1use serde::{Deserialize, Serialize};
15
16use crate::error::Result;
17
18#[derive(Debug, Clone, Serialize)]
20pub struct DebugContext {
21 pub command: String,
23 pub query: String,
25 pub url: String,
27 pub status: u16,
29 pub body_snippet: String,
31 pub error: String,
33}
34
35#[derive(Debug, Deserialize)]
36struct OllamaGenerateResp {
37 response: String,
38}
39
40pub async fn diagnose_with_llm(ctx: &DebugContext) -> Result<Option<String>> {
43 if std::env::var("FANFIC_ARCHIVIST_LLM_DEBUG")
44 .map(|v| v == "0" || v.eq_ignore_ascii_case("false"))
45 .unwrap_or(false)
46 {
47 return Ok(None);
48 }
49
50 let base = std::env::var("FANFIC_ARCHIVIST_OLLAMA_URL")
51 .unwrap_or_else(|_| "http://localhost:11434".to_string());
52 let model = std::env::var("FANFIC_ARCHIVIST_LLM_MODEL")
53 .unwrap_or_else(|_| "ornith:9b".to_string());
54
55 let prompt = format!(
56 "You are diagnosing a failed Discord bot request to a fanfiction \
57 archive API. The bot called the API and it failed.\n\n\
58 Command: {cmd}\nQuery: {q}\nURL requested: {url}\nHTTP status: {status}\n\
59 Raw body (first 800 chars): {body}\nBot error: {err}\n\n\
60 Diagnose the most likely root cause in 2-3 sentences. Be concrete: \
61 name the likely bug (wrong URL path, HTML returned instead of JSON, \
62 rate limit, missing endpoint, etc). Suggest the fix. No markdown.",
63 cmd = ctx.command,
64 q = ctx.query,
65 url = ctx.url,
66 status = ctx.status,
67 body = ctx.body_snippet,
68 err = ctx.error,
69 );
70
71 let client = reqwest::Client::new();
72 let resp: OllamaGenerateResp = client
73 .post(format!("{base}/api/generate"))
74 .json(&serde_json::json!({
75 "model": model,
76 "prompt": prompt,
77 "stream": false,
78 }))
79 .timeout(std::time::Duration::from_secs(30))
80 .send()
81 .await?
82 .error_for_status()?
83 .json()
84 .await?;
85
86 let diag = resp.response.trim().to_string();
87 if diag.is_empty() {
88 return Ok(None);
89 }
90 Ok(Some(diag))
91}
92
93pub fn short_hint(diag: &str) -> String {
95 let first = diag.split('\n').next().unwrap_or("").trim();
96 let mut hint = first.to_string();
97 if hint.chars().count() > 200 {
98 hint.truncate(200);
99 hint.push('…');
100 }
101 hint
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn short_hint_takes_first_line_and_truncates() {
110 let d = "This is the first line.\nSecond line should be dropped.";
111 assert_eq!(short_hint(d), "This is the first line.");
112 let long = "x".repeat(300);
113 assert_eq!(short_hint(&long).chars().count(), 201);
114 }
115}