feat: add local voice transcription pipeline
This commit is contained in:
193
src/summary.rs
Normal file
193
src/summary.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
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("色彩更大胆。"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user