194 lines
5.7 KiB
Rust
194 lines
5.7 KiB
Rust
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<SummaryConfig>,
|
||
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<ChatMessage<'a>>,
|
||
temperature: f32,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct ChatMessage<'a> {
|
||
role: &'a str,
|
||
content: &'a str,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ChatResponse {
|
||
choices: Vec<ChatChoice>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ChatChoice {
|
||
message: ChatChoiceMessage,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ChatChoiceMessage {
|
||
content: String,
|
||
}
|
||
|
||
impl SummaryService {
|
||
pub fn new(config: Option<SummaryConfig>) -> 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<SummaryResult, String> {
|
||
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::<Vec<_>>()
|
||
.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::<String>();
|
||
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("色彩更大胆。"));
|
||
}
|
||
}
|