archivist-core 0.2.0

Platform-neutral core for the FicHub companion bot: FicHub REST API client, search/recommendation/download command logic, intent classification, pagination cache, and the PlatformMessage IR. Shared by every platform adapter (Discord, Telegram, Matrix, Slack, IRC, fediverse, CLI, web).
Documentation
//! Automatic LLM-assisted debugging for failed search requests.
//!
//! When a search/ask/body command fails (transport error, JSON parse error,
//! API error), the bot records what happened and asks the local Ollama
//! instance (same host as FicHub, thinkcentre) to diagnose the root cause.
//! The diagnosis is logged at `warn!` level and echoed to the user as a
//! short hint so they know the failure is being looked at.
//!
//! Config (all optional):
//!   - `FANFIC_ARCHIVIST_OLLAMA_URL`  default `http://localhost:11434`
//!   - `FANFIC_ARCHIVIST_LLM_MODEL`   default `ornith:9b`
//!   - `FANFIC_ARCHIVIST_LLM_DEBUG`   set `0` to disable entirely

use serde::{Deserialize, Serialize};

use crate::error::Result;

/// Input describing a failed API call.
#[derive(Debug, Clone, Serialize)]
pub struct DebugContext {
    /// Command that failed ("search" | "ask" | "body").
    pub command: String,
    /// The user's raw query.
    pub query: String,
    /// Full URL (or query string) that was requested.
    pub url: String,
    /// HTTP status (0 when transport failed).
    pub status: u16,
    /// Raw response body snippet (may be HTML or partial JSON; truncated).
    pub body_snippet: String,
    /// The exact error the bot surfaced.
    pub error: String,
}

#[derive(Debug, Deserialize)]
struct OllamaGenerateResp {
    response: String,
}

/// Ask the local Ollama to diagnose a failed search. Best-effort: any failure
/// returns `Ok(None)` so the caller can continue without the diagnosis.
pub async fn diagnose_with_llm(ctx: &DebugContext) -> Result<Option<String>> {
    if std::env::var("FANFIC_ARCHIVIST_LLM_DEBUG")
        .map(|v| v == "0" || v.eq_ignore_ascii_case("false"))
        .unwrap_or(false)
    {
        return Ok(None);
    }

    let base = std::env::var("FANFIC_ARCHIVIST_OLLAMA_URL")
        .unwrap_or_else(|_| "http://localhost:11434".to_string());
    let model = std::env::var("FANFIC_ARCHIVIST_LLM_MODEL")
        .unwrap_or_else(|_| "ornith:9b".to_string());

    let prompt = format!(
        "You are diagnosing a failed Discord bot request to a fanfiction \
         archive API. The bot called the API and it failed.\n\n\
         Command: {cmd}\nQuery: {q}\nURL requested: {url}\nHTTP status: {status}\n\
         Raw body (first 800 chars): {body}\nBot error: {err}\n\n\
         Diagnose the most likely root cause in 2-3 sentences. Be concrete: \
         name the likely bug (wrong URL path, HTML returned instead of JSON, \
         rate limit, missing endpoint, etc). Suggest the fix. No markdown.",
        cmd = ctx.command,
        q = ctx.query,
        url = ctx.url,
        status = ctx.status,
        body = ctx.body_snippet,
        err = ctx.error,
    );

    let client = reqwest::Client::new();
    let resp: OllamaGenerateResp = client
        .post(format!("{base}/api/generate"))
        .json(&serde_json::json!({
            "model": model,
            "prompt": prompt,
            "stream": false,
        }))
        .timeout(std::time::Duration::from_secs(30))
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    let diag = resp.response.trim().to_string();
    if diag.is_empty() {
        return Ok(None);
    }
    Ok(Some(diag))
}

/// Build a short user-facing hint from a diagnosis (keep it under embed limits).
pub fn short_hint(diag: &str) -> String {
    let first = diag.split('\n').next().unwrap_or("").trim();
    let mut hint = first.to_string();
    if hint.chars().count() > 200 {
        hint.truncate(200);
        hint.push('');
    }
    hint
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn short_hint_takes_first_line_and_truncates() {
        let d = "This is the first line.\nSecond line should be dropped.";
        assert_eq!(short_hint(d), "This is the first line.");
        let long = "x".repeat(300);
        assert_eq!(short_hint(&long).chars().count(), 201);
    }
}