1use fetch_kit::Client;
8use serde::{Deserialize, Serialize};
9
10use crate::error::{Result, TrendError};
11
12const USER_AGENT: &str = "trendkit/0.3";
13const MAX_INPUT_CHARS: usize = 6_000;
15
16#[derive(Debug, Clone)]
18pub struct AiDigestConfig {
19 pub base_url: String,
21 pub api_key: String,
23 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
68pub 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
115pub 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}