Skip to main content

archivist_core/
debug.rs

1//! Automatic LLM-assisted debugging for failed search requests.
2//!
3//! When a search/ask/body command fails (transport error, JSON parse error,
4//! API error), the bot records what happened and asks the local Ollama
5//! instance (same host as FicHub, thinkcentre) to diagnose the root cause.
6//! The diagnosis is logged at `warn!` level and echoed to the user as a
7//! short hint so they know the failure is being looked at.
8//!
9//! Config (all optional):
10//!   - `FANFIC_ARCHIVIST_OLLAMA_URL`  default `http://localhost:11434`
11//!   - `FANFIC_ARCHIVIST_LLM_MODEL`   default `ornith:9b`
12//!   - `FANFIC_ARCHIVIST_LLM_DEBUG`   set `0` to disable entirely
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::Result;
17
18/// Input describing a failed API call.
19#[derive(Debug, Clone, Serialize)]
20pub struct DebugContext {
21    /// Command that failed ("search" | "ask" | "body").
22    pub command: String,
23    /// The user's raw query.
24    pub query: String,
25    /// Full URL (or query string) that was requested.
26    pub url: String,
27    /// HTTP status (0 when transport failed).
28    pub status: u16,
29    /// Raw response body snippet (may be HTML or partial JSON; truncated).
30    pub body_snippet: String,
31    /// The exact error the bot surfaced.
32    pub error: String,
33}
34
35#[derive(Debug, Deserialize)]
36struct OllamaGenerateResp {
37    response: String,
38}
39
40/// Ask the local Ollama to diagnose a failed search. Best-effort: any failure
41/// returns `Ok(None)` so the caller can continue without the diagnosis.
42pub 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
93/// Build a short user-facing hint from a diagnosis (keep it under embed limits).
94pub 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}