Skip to main content

llm_manager/backend/
web_context.rs

1use tracing::info;
2use uuid::Uuid;
3
4use crate::backend::web_search;
5
6const WEB_SEARCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
7
8/// Result of building an injected prompt with web search context.
9pub struct InjectedPrompt {
10    /// The modified message content with web context prepended.
11    pub content: String,
12    /// Whether web search was actually performed.
13    pub performed: bool,
14}
15
16fn log(cb: &std::sync::Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>, msg: String) {
17    if let Some(c) = cb.lock().unwrap().as_ref() {
18        c(msg);
19    }
20}
21
22/// Build the full prompt to send to llama-server, including web search context injection.
23/// Returns the original request if no web search is needed or the preset doesn't match.
24pub async fn build_injected_prompt(
25    preset_name: &str,
26    messages: &serde_json::Value,
27    web_search_enabled: bool,
28    web_search_engine: &str,
29    web_search_engine_url: &str,
30    web_search_api_key: &str,
31    log_callback: &std::sync::Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>,
32) -> InjectedPrompt {
33    log(
34        log_callback,
35        format!(
36            "Web search: preset='{}', enabled={}",
37            preset_name, web_search_enabled
38        ),
39    );
40    if !web_search_enabled {
41        log(
42            log_callback,
43            "Web search: disabled in config, skipping".into(),
44        );
45        return InjectedPrompt {
46            content: String::new(),
47            performed: false,
48        };
49    }
50
51    let messages_array = match messages.get("messages").and_then(|m| m.as_array()) {
52        Some(m) => {
53            info!("Web search: found {} messages", m.len());
54            log(
55                log_callback,
56                format!("Web search: found {} messages", m.len()),
57            );
58            m
59        }
60        None => {
61            info!("Web search: no messages array in request");
62            log(
63                log_callback,
64                "Web search: no messages array in request".into(),
65            );
66            return InjectedPrompt {
67                content: String::new(),
68                performed: false,
69            };
70        }
71    };
72
73    if messages_array.is_empty() {
74        log(log_callback, "Web search: empty messages array".into());
75        return InjectedPrompt {
76            content: String::new(),
77            performed: false,
78        };
79    }
80
81    let last_msg = messages_array.last().unwrap();
82    let user_content = last_msg.get("content");
83    let content = match user_content {
84        Some(serde_json::Value::String(s)) => {
85            info!("Web search: content is String ({} chars)", s.len());
86            log(
87                log_callback,
88                format!("Web search: content is String ({} chars)", s.len()),
89            );
90            s.clone()
91        }
92        Some(serde_json::Value::Array(parts)) => {
93            info!("Web search: content is Array with {} parts", parts.len());
94            let text_parts: Vec<&str> = parts
95                .iter()
96                .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
97                .collect();
98            if text_parts.is_empty() {
99                info!("Web search: no text parts in array");
100                log(log_callback, "Web search: no text parts in array".into());
101                return InjectedPrompt {
102                    content: String::new(),
103                    performed: false,
104                };
105            }
106            let joined = text_parts.join(" ");
107            info!("Web search: joined content ({} chars)", joined.len());
108            log(
109                log_callback,
110                format!("Web search: joined content ({} chars)", joined.len()),
111            );
112            joined
113        }
114        _ => {
115            info!(
116                "Web search: content type is {:?}",
117                user_content.map(|v| {
118                    match v {
119                        serde_json::Value::Null => "null",
120                        serde_json::Value::Bool(_) => "bool",
121                        serde_json::Value::Number(_) => "number",
122                        serde_json::Value::String(_) => "string",
123                        serde_json::Value::Array(_) => "array",
124                        serde_json::Value::Object(_) => "object",
125                    }
126                })
127            );
128            log(log_callback, "Web search: unsupported content type".into());
129            return InjectedPrompt {
130                content: String::new(),
131                performed: false,
132            };
133        }
134    };
135
136    let needs = web_search::needs_search(&content);
137    info!(
138        "Web search: needs_search={} for '{}'",
139        needs,
140        &content[..content
141            .char_indices()
142            .nth(80)
143            .map(|(i, _)| i)
144            .unwrap_or(content.len())]
145    );
146    log(
147        log_callback,
148        format!(
149            "Web search: needs_search={} for '{}'",
150            needs,
151            &content[..content
152                .char_indices()
153                .nth(80)
154                .map(|(i, _)| i)
155                .unwrap_or(content.len())]
156        ),
157    );
158    if !needs {
159        log(
160            log_callback,
161            "Web search: no search keywords found, skipping".into(),
162        );
163        return InjectedPrompt {
164            content: String::new(),
165            performed: false,
166        };
167    }
168
169    info!(
170        "Web search: triggering for message: {}",
171        &content[..content
172            .char_indices()
173            .nth(100)
174            .map(|(i, _)| i)
175            .unwrap_or(content.len())]
176    );
177    log(
178        log_callback,
179        format!(
180            "Web search: triggering for: '{}'",
181            &content[..content
182                .char_indices()
183                .nth(100)
184                .map(|(i, _)| i)
185                .unwrap_or(content.len())]
186        ),
187    );
188
189    let query = content.to_string();
190    let engine = web_search_engine.to_string();
191    let engine_url = web_search_engine_url.to_string();
192    let api_key = web_search_api_key.to_string();
193    log(
194        log_callback,
195        format!("Web search: engine={}, url={}", engine, engine_url),
196    );
197    let search_handle = tokio::spawn(async move {
198        web_search::gather_search_context(&query, &engine, &engine_url, &api_key).await
199    });
200
201    let search_result = match tokio::time::timeout(WEB_SEARCH_TIMEOUT, search_handle).await {
202        Ok(Ok(Ok((ctx, sources)))) => {
203            info!("Web search: gathered context ({} chars)", ctx.len());
204            log(
205                log_callback,
206                format!(
207                    "Web search: gathered {} chars, {} sources",
208                    ctx.len(),
209                    sources.len()
210                ),
211            );
212            (ctx, sources)
213        }
214        Ok(Ok(Err(e))) => {
215            info!("Web search failed: {}", e);
216            log(log_callback, format!("Web search failed: {}", e));
217            return InjectedPrompt {
218                content: String::new(),
219                performed: false,
220            };
221        }
222        Ok(Err(e)) => {
223            info!("Web search task panicked: {}", e);
224            log(log_callback, format!("Web search task panicked: {}", e));
225            return InjectedPrompt {
226                content: String::new(),
227                performed: false,
228            };
229        }
230        Err(_) => {
231            info!("Web search timed out");
232            log(log_callback, "Web search timed out".into());
233            return InjectedPrompt {
234                content: String::new(),
235                performed: false,
236            };
237        }
238    };
239
240    let (search_context, sources) = search_result;
241
242    // Build sources list
243    let sources_section = if sources.is_empty() {
244        String::new()
245    } else {
246        let sources_list: String = sources
247            .iter()
248            .enumerate()
249            .map(|(i, url)| format!("{}. {}", i + 1, url))
250            .collect::<Vec<_>>()
251            .join("\n");
252        format!(
253            "\n\n---\n\n**Sources:**\n{}\n\n**When using information from these sources, display the original URL as a reference.**",
254            sources_list
255        )
256    };
257
258    info!(
259        "Web search: gathered context ({} chars)",
260        search_context.len()
261    );
262
263    let ctx_id = Uuid::new_v4();
264    let new_content = format!(
265        "[WEB-CTX-{}]\nINSTRUCTION: Cite sources using inline markdown links in your answer. Format: [source name](URL). Place links directly after the facts they support. If you find PDF link, add them to the list with brief description. Do NOT include claims you cannot verify.\n\n{}\n[/WEB-CTX-{}]\n\n{}\n\n---\n\n{}",
266        ctx_id, search_context, sources_section, ctx_id, content
267    );
268
269    if let Some(cb) = log_callback.lock().unwrap().as_ref() {
270        cb(format!(
271            "Web search: results injected ({} chars)",
272            search_context.len()
273        ));
274    }
275
276    InjectedPrompt {
277        content: new_content,
278        performed: true,
279    }
280}