use std::collections::HashSet; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use crate::config::SummaryConfig; const MAX_FEEDBACK_CHARS: usize = 2000; #[derive(Clone)] pub struct SummaryService { config: Option, client: reqwest::Client, } #[derive(Debug)] pub struct SummaryResult { pub content: String, pub method: &'static str, } #[derive(Serialize)] struct ChatRequest<'a> { model: &'a str, messages: Vec>, temperature: f32, } #[derive(Serialize)] struct ChatMessage<'a> { role: &'a str, content: &'a str, } #[derive(Deserialize)] struct ChatResponse { choices: Vec, } #[derive(Deserialize)] struct ChatChoice { message: ChatChoiceMessage, } #[derive(Deserialize)] struct ChatChoiceMessage { content: String, } impl SummaryService { pub fn new(config: Option) -> Self { Self { config, client: reqwest::Client::new(), } } pub fn configured(&self) -> bool { self.config.is_some() } pub async fn summarize( &self, existing_content: &str, lesson_transcripts: &[String], ) -> Result { let Some(config) = &self.config else { return Ok(SummaryResult { content: extractive_summary(existing_content, lesson_transcripts), method: "extractive", }); }; let transcript_text = lesson_transcripts .iter() .enumerate() .map(|(index, transcript)| format!("第{}节课:\n{}", index + 1, transcript)) .collect::>() .join("\n\n"); let user_prompt = format!( "教师已填写内容:\n{}\n\n新增课堂转录:\n{}", existing_content.trim(), transcript_text ); let system_prompt = "你是教学反馈整理助手。请把教师已有内容与课堂转录合并成一份面向家长的中文反馈,保留具体事实,按课堂表现、进步、问题和后续建议组织。不要虚构,不要提及录音或转录,不超过2000个汉字,只输出反馈正文。"; let request = ChatRequest { model: &config.model, messages: vec![ ChatMessage { role: "system", content: system_prompt, }, ChatMessage { role: "user", content: &user_prompt, }, ], temperature: 0.2, }; let mut builder = self .client .post(&config.api_url) .header(CONTENT_TYPE, "application/json") .json(&request); if let Some(api_key) = &config.api_key { builder = builder.header(AUTHORIZATION, format!("Bearer {api_key}")); } let response = builder .send() .await .map_err(|error| format!("反馈汇总服务连接失败:{error}"))?; let status = response.status(); if !status.is_success() { return Err(format!("反馈汇总服务返回 HTTP {status}")); } let result: ChatResponse = response .json() .await .map_err(|error| format!("反馈汇总响应格式无效:{error}"))?; let content = result .choices .into_iter() .next() .map(|choice| choice.message.content.trim().to_owned()) .filter(|content| !content.is_empty()) .ok_or_else(|| "反馈汇总服务未返回内容".to_owned())?; Ok(SummaryResult { content: content.chars().take(MAX_FEEDBACK_CHARS).collect(), method: "llm", }) } } fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) -> String { let existing = existing_content.trim(); let mut result = existing .chars() .take(MAX_FEEDBACK_CHARS) .collect::(); let mut seen = HashSet::new(); let mut selected = Vec::new(); for transcript in lesson_transcripts { for sentence in transcript.split_inclusive(['。', '!', '?', '\n']) { let sentence = sentence.trim(); if sentence.chars().count() < 4 || !seen.insert(sentence.to_owned()) { continue; } selected.push(sentence.to_owned()); } } if !selected.is_empty() && result.chars().count() < MAX_FEEDBACK_CHARS { if !result.is_empty() { result.push_str("\n\n"); } result.push_str("课堂记录汇总:\n"); for sentence in selected { let prefix = "- "; let remaining = MAX_FEEDBACK_CHARS.saturating_sub(result.chars().count()); if remaining <= prefix.chars().count() + 4 { break; } result.push_str(prefix); result.extend( sentence .chars() .take(remaining - prefix.chars().count() - 1), ); result.push('\n'); } } result.trim().to_owned() } #[cfg(test)] mod tests { use super::extractive_summary; #[test] fn extractive_summary_preserves_manual_content_and_deduplicates() { let result = extractive_summary( "手动备注。", &["完成了构图练习。完成了构图练习。色彩更大胆。".to_owned()], ); assert!(result.starts_with("手动备注。")); assert_eq!(result.matches("完成了构图练习。").count(), 1); assert!(result.contains("色彩更大胆。")); } }