feat: add local voice transcription pipeline

This commit is contained in:
2026-07-20 10:49:41 +08:00
parent f1e5fd8793
commit 8a88706ff9
16 changed files with 2389 additions and 28 deletions

193
src/summary.rs Normal file
View 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("色彩更大胆。"));
}
}