1use crate::models::AtomicNote;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum CloudError {
9 #[error("The cloud provider rejected the API key. It might have expired or been disabled.")]
10 ApiKey,
11
12 #[error("The cloud model returned a response that could not be understood.")]
13 ParseError,
14
15 #[error("The cloud provider returned an unexpected error: {status}: {text}")]
16 ApiError { status: u16, text: String },
17
18 #[error(transparent)]
19 RequestError(#[from] reqwest::Error),
20}
21
22const SYNTHESIZER_PROMPT: &str = r#"You are an expert-level AI Synthesizer. Your task is to answer the user's prompt by generating a concise, "atomic note" of knowledge.
23
24CRITICAL OUTPUT CONSTRAINTS:
25
26Header (Metadata): You MUST generate a set of 3-5 semantic keywords or tags that capture the absolute essence of the topic. These tags are for a knowledge graph.
27
28Body (Content): The main response MUST be a maximum of four (4) sentences. It must be a dense, self-contained summary of the most critical information.
29
30OUTPUT FORMAT (JSON):
31Your final output MUST be a single, valid JSON object with two keys: header_tags and body_text.
32
33{
34 "header_tags": ["keyword1", "keyword2", "keyword3"],
35 "body_text": "Your concise, 3-4 sentence summary goes here."
36}
37
38USER PROMPT:
39{prompt}
40"#;
41
42#[derive(Serialize)]
43struct ResponseFormat {
44 r#type: String,
45}
46
47#[derive(Serialize)]
48struct OpenRouterRequest<'a> {
49 model: String,
50 messages: Vec<ChatMessage<'a>>,
51 max_tokens: u32,
52 response_format: ResponseFormat,
53}
54
55#[derive(Serialize)]
56struct ChatMessage<'a> {
57 role: String,
58 content: &'a str,
59}
60
61#[derive(Deserialize)]
62struct OpenRouterResponse {
63 choices: Vec<Choice>,
64}
65
66#[derive(Deserialize)]
67struct Choice {
68 message: Message,
69}
70
71#[derive(Deserialize)]
72struct Message {
73 content: String,
74}
75
76pub async fn call_cloud_model(
77 api_key: &str,
78 model: &str,
79 prompt: &str,
80) -> Result<AtomicNote, CloudError> {
81 let client = Client::builder().timeout(Duration::from_secs(30)).build()?;
82
83 let synthesizer_prompt = SYNTHESIZER_PROMPT.replace("{prompt}", prompt);
84
85 std::fs::write("/tmp/debug_synthesis_prompt.txt", &synthesizer_prompt).ok();
87
88 let request_body = OpenRouterRequest {
89 model: model.to_string(),
90 messages: vec![ChatMessage {
91 role: "user".to_string(),
92 content: &synthesizer_prompt,
93 }],
94 max_tokens: 1024,
95 response_format: ResponseFormat {
96 r#type: "json_object".to_string(),
97 },
98 };
99
100 let response = client
101 .post("https://openrouter.ai/api/v1/chat/completions")
102 .header("Authorization", format!("Bearer {}", api_key))
103 .header("Content-Type", "application/json")
104 .json(&request_body)
105 .send()
106 .await?;
107
108 if !response.status().is_success() {
109 let status = response.status();
110 if status == 401 {
111 return Err(CloudError::ApiKey);
112 }
113 let error_text = response.text().await.unwrap_or_default();
114 return Err(CloudError::ApiError {
115 status: status.as_u16(),
116 text: error_text,
117 });
118 }
119
120 let response_text = response.text().await?;
121
122 std::fs::write("/tmp/debug_cloud_response.txt", &response_text).ok();
124
125 let openrouter_response: OpenRouterResponse = match serde_json::from_str(&response_text) {
126 Ok(res) => res,
127 Err(e) => {
128 let debug_info = format!(
129 "Cloud API Response Parse Error: {}\nRaw Response: {}",
130 e, response_text
131 );
132 std::fs::write("/tmp/debug_cloud_api_error.txt", &debug_info).ok();
133 return Err(CloudError::ParseError);
134 }
135 };
136
137 let message_content = openrouter_response
138 .choices
139 .first()
140 .map(|choice| &choice.message.content)
141 .ok_or(CloudError::ParseError)?;
142
143 parse_atomic_note_with_fallbacks(message_content)
145}
146
147fn parse_atomic_note_with_fallbacks(message_content: &str) -> Result<AtomicNote, CloudError> {
148 let clean_content = extract_json_from_cloud_markdown(message_content);
150
151 std::fs::write("/tmp/debug_synthesis_json.txt", clean_content).ok();
153
154 if let Ok(note) = serde_json::from_str::<AtomicNote>(clean_content) {
156 return Ok(note);
157 }
158
159 if let Some(json_start) = clean_content.find("{") {
161 let json_str = &clean_content[json_start..];
162 if let Some(json_end) = json_str.rfind("}") {
163 let json_only = &json_str[..=json_end];
164 if let Ok(note) = serde_json::from_str::<AtomicNote>(json_only) {
165 return Ok(note);
166 }
167 }
168 }
169
170 if let Some(note) = try_extract_atomic_note_fields(message_content) {
172 return Ok(note);
173 }
174
175 let debug_info = format!(
177 "All cloud synthesis parsing strategies failed\nRaw Message: {}\nCleaned Content: {}",
178 message_content, clean_content
179 );
180 std::fs::write("/tmp/debug_synthesis_parse_failure.txt", &debug_info).ok();
181
182 Err(CloudError::ParseError)
183}
184
185fn extract_json_from_cloud_markdown(content: &str) -> &str {
186 if content.contains("```json") {
188 if let Some(json_start) = content.find("```json") {
189 let after_start = &content[json_start + 7..];
190 if let Some(json_end) = after_start.find("```") {
191 return after_start[..json_end].trim();
192 }
193 }
194 }
195
196 if content.contains("```") {
198 if let Some(first_tick) = content.find("```") {
199 let after_first = &content[first_tick + 3..];
200 if let Some(second_tick) = after_first.find("```") {
201 let content = after_first[..second_tick].trim();
202 if let Some(newline) = content.find('\n') {
204 let potential_json = content[newline..].trim();
205 if potential_json.starts_with('{') {
206 return potential_json;
207 }
208 }
209 return content;
210 }
211 }
212 }
213
214 content
215}
216
217fn try_extract_atomic_note_fields(content: &str) -> Option<AtomicNote> {
218 let mut header_tags = Vec::new();
219 let mut body_text = String::new();
220
221 if let Some(tags_start) = content.find("\"header_tags\"") {
223 let after_tags = &content[tags_start..];
224 if let Some(array_start) = after_tags.find('[') {
225 if let Some(array_end) = after_tags.find(']') {
226 let array_content = &after_tags[array_start + 1..array_end];
227 for tag in array_content.split(',') {
229 let cleaned_tag = tag.trim().trim_matches('"').trim();
230 if !cleaned_tag.is_empty() {
231 header_tags.push(cleaned_tag.to_string());
232 }
233 }
234 }
235 }
236 }
237
238 if let Some(body_start) = content.find("\"body_text\"") {
240 let after_body = &content[body_start..];
241 if let Some(quote_start) = after_body.find('"') {
242 let after_quote = &after_body[quote_start + 1..];
243 if let Some(quote_end) = after_quote.find('"') {
244 body_text = after_quote[..quote_end].to_string();
245 }
246 }
247 }
248
249 if !header_tags.is_empty() && !body_text.is_empty() && body_text.len() > 10 {
251 Some(AtomicNote {
252 header_tags,
253 body_text,
254 })
255 } else {
256 None
257 }
258}