Skip to main content

trendkit_core/
ai.rs

1//! AI-summarized digest via any OpenAI-compatible chat endpoint.
2//!
3//! Defaults target DeepSeek — the commodity-model wave means a daily
4//! briefing costs fractions of a cent. No key configured: the digest is
5//! skipped, never fatal.
6
7use fetch_kit::Client;
8use serde::{Deserialize, Serialize};
9
10use crate::error::{Result, TrendError};
11
12const USER_AGENT: &str = "trendkit/0.3";
13/// Report text fed to the model, to bound prompt cost.
14const MAX_INPUT_CHARS: usize = 6_000;
15
16/// Endpoint + credentials for an OpenAI-compatible chat API.
17#[derive(Debug, Clone)]
18pub struct AiDigestConfig {
19    /// e.g. `https://api.deepseek.com` (no trailing slash).
20    pub base_url: String,
21    /// Bearer token.
22    pub api_key: String,
23    /// Chat model id, e.g. `deepseek-chat`.
24    pub model: String,
25}
26
27#[derive(Debug, Serialize)]
28struct ChatRequest<'a> {
29    model: &'a str,
30    messages: [ChatMessage<'a>; 2],
31    max_tokens: u32,
32    temperature: f32,
33}
34
35#[derive(Debug, Serialize)]
36struct ChatMessage<'a> {
37    role: &'a str,
38    content: &'a str,
39}
40
41#[derive(Debug, Deserialize)]
42struct ChatResponse {
43    #[serde(default)]
44    choices: Vec<Choice>,
45}
46
47#[derive(Debug, Deserialize)]
48struct Choice {
49    #[serde(default)]
50    message: Option<Message>,
51}
52
53#[derive(Debug, Deserialize)]
54struct Message {
55    #[serde(default)]
56    content: Option<String>,
57}
58
59const SYSTEM_PROMPT: &str = "You are a senior developer-trends analyst. \
60You receive a trendkit velocity report: GitHub repos / HN stories / \
61crates.io crates / HuggingFace models with score deltas per day. \
62Write a compact briefing: 3-4 bullets naming what is genuinely rising \
63(distinguish signal from big-but-static), then one final bullet \
64recommending which single trend best fits a developer who builds in \
65Rust, runs docs sites, and ships developer tooling. No preamble, no \
66closing remarks, markdown bullets only, under 160 words total.";
67
68/// Summarize a rendered report into a short briefing.
69///
70/// # Errors
71/// Fails on transport errors, malformed responses, or empty choices;
72/// Telegram/digest callers are expected to treat this as optional.
73pub async fn summarize(config: &AiDigestConfig, report_text: &str) -> Result<String> {
74    let trimmed: String = if report_text.chars().count() > MAX_INPUT_CHARS {
75        report_text.chars().take(MAX_INPUT_CHARS).collect()
76    } else {
77        report_text.to_owned()
78    };
79    let client = Client::builder().user_agent(USER_AGENT).build();
80    let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
81    let response: ChatResponse = client
82        .post_json(
83            &url,
84            &ChatRequest {
85                model: &config.model,
86                messages: [
87                    ChatMessage {
88                        role: "system",
89                        content: SYSTEM_PROMPT,
90                    },
91                    ChatMessage {
92                        role: "user",
93                        content: &trimmed,
94                    },
95                ],
96                max_tokens: 500,
97                temperature: 0.3,
98            },
99        )
100        .await?;
101    let content = response
102        .choices
103        .first()
104        .and_then(|c| c.message.as_ref())
105        .and_then(|m| m.content.clone())
106        .filter(|c| !c.trim().is_empty());
107    match content {
108        Some(text) => Ok(text),
109        None => Err(TrendError::Other(
110            "ai digest: model returned no content".to_owned(),
111        )),
112    }
113}
114
115/// Parse an OpenAI-compatible chat-completions payload (exposed for
116/// tests and offline callers).
117///
118/// # Errors
119/// Fails when the payload is not a chat-completions response.
120pub fn parse_chat_json(payload: &str) -> Result<String> {
121    let response: ChatResponse = serde_json::from_str(payload)
122        .map_err(|e| TrendError::Config(format!("invalid chat payload: {e}")))?;
123    response
124        .choices
125        .first()
126        .and_then(|c| c.message.as_ref())
127        .and_then(|m| m.content.clone())
128        .filter(|c| !c.trim().is_empty())
129        .ok_or_else(|| TrendError::Other("no content in chat payload".to_owned()))
130}
131
132#[cfg(test)]
133mod tests {
134    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
135
136    use super::*;
137
138    const SAMPLE: &str = r#"{
139        "id": "chatcmpl-1",
140        "choices": [
141            {"index": 0, "message": {"role": "assistant", "content": "- trend one\n- trend two"}, "finish_reason": "stop"}
142        ]
143    }"#;
144
145    #[test]
146    fn parse_extracts_content() {
147        let out = parse_chat_json(SAMPLE).unwrap();
148        assert!(out.contains("- trend one"));
149        assert!(out.contains("trend two"));
150    }
151
152    #[test]
153    fn parse_rejects_empty_choices() {
154        let err = parse_chat_json(r#"{"choices": []}"#).unwrap_err();
155        assert!(err.to_string().contains("no content"));
156    }
157
158    #[test]
159    fn parse_rejects_garbage() {
160        assert!(parse_chat_json("not json").is_err());
161    }
162}