feat(voice): add async selectable summaries

This commit is contained in:
zhangheng
2026-07-22 19:13:55 +08:00
parent 108e280b3f
commit 0a2509d923
17 changed files with 1530 additions and 159 deletions

View File

@@ -52,7 +52,7 @@ TENCENT_CLOUD_ASR_SECRET_KEY=your-secret-key
TENCENT_CLOUD_ASR_ENGINE_TYPE=16k_zh
```
反馈汇总支持可选的 OpenAI 兼容 Chat Completions 接口。未配置时使用本地抽取式汇总,录音转录和保存流程仍可工作:
反馈汇总支持可选的 OpenAI 兼容 Chat Completions 接口。配置后,后台任务按“片段事实提取 -> 单次录音压缩 -> 全部所选录音统一改写”处理,最终只返回一份不按录音次数分栏的正文。未配置时使用本地抽取式汇总,录音转录和保存流程仍可工作:
```text
FEEDBACK_SUMMARY_API_URL=https://api.example.com/v1/chat/completions
@@ -103,7 +103,12 @@ Authorization: Bearer <access-token>
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记该节转写已手动加入反馈。 |
| `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 |
| `DELETE` | `/api/v1/audio-segments/{segment_id}` | 放弃失败片段;空课堂会一并删除。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/summary-runs` | 选择已就绪录音并创建异步总结任务。 |
| `GET` | `/api/v1/feedback-summary-runs/{run_id}` | 查询总结状态和预览正文。 |
| `POST` | `/api/v1/feedback-summary-runs/{run_id}/apply` | 确认后用预览正文替换反馈草稿。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 旧版同步汇总接口,仅保留客户端兼容。 |
总结任务使用请求中的 `idempotency_key` 防止重复点击创建重复任务。`queued``processing` 状态由 API 内的持久化 worker 自动恢复;生成完成进入 `ready`,此时尚未修改反馈草稿。只有调用 `/apply` 后才进入 `applied` 并替换草稿。原始转录不限制为 2000 字,只有最终反馈正文和输入框受 2000 字限制。
### 创建学生档案

View File

@@ -128,6 +128,8 @@ Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新
失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。
已就绪录音的总结流程为:调用 `POST /api/v1/feedback-sessions/{session_id}/summary-runs` 并传入 `lesson_ids`、当前输入框内容和客户端幂等键;轮询 `GET /api/v1/feedback-summary-runs/{run_id}` 直到状态为 `ready`;检查 `content` 后再调用 `POST /api/v1/feedback-summary-runs/{run_id}/apply`。创建和查询不会修改输入框,只有最后的应用操作会替换草稿。
活动反馈批次中的每节语音记录会返回按片段顺序合并的 `transcript`。小程序将语音记录作为可复用素材保存,教师可以逐节查看并按需要加入反馈输入框;录音转写完成后不会自动改写教师正在编辑的正文。
## 6. OpenAPI 维护方式

View File

@@ -0,0 +1,55 @@
CREATE TABLE feedback_summary_runs (
id UUID PRIMARY KEY,
feedback_session_id UUID NOT NULL REFERENCES feedback_sessions(id) ON DELETE CASCADE,
idempotency_key VARCHAR(100) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
existing_content TEXT NOT NULL DEFAULT '',
source_hash VARCHAR(64),
content TEXT,
method VARCHAR(24),
model TEXT,
prompt_version VARCHAR(32) NOT NULL DEFAULT 'summary-v1',
character_count INTEGER,
error_class VARCHAR(64),
error_message TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
processing_started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
applied_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT feedback_summary_runs_status_check
CHECK (status IN ('queued', 'processing', 'ready', 'failed', 'applied')),
CONSTRAINT feedback_summary_runs_idempotency_key_check
CHECK (char_length(idempotency_key) BETWEEN 1 AND 100),
CONSTRAINT feedback_summary_runs_existing_content_check
CHECK (char_length(existing_content) <= 2000),
CONSTRAINT feedback_summary_runs_content_check
CHECK (content IS NULL OR char_length(content) <= 2000),
CONSTRAINT feedback_summary_runs_character_count_check
CHECK (character_count IS NULL OR character_count BETWEEN 0 AND 2000),
CONSTRAINT feedback_summary_runs_attempts_check
CHECK (attempts >= 0),
CONSTRAINT feedback_summary_runs_result_check
CHECK (
(status IN ('ready', 'applied') AND content IS NOT NULL AND character_count IS NOT NULL AND completed_at IS NOT NULL)
OR
(status NOT IN ('ready', 'applied') AND content IS NULL AND character_count IS NULL)
)
);
CREATE TABLE feedback_summary_run_lessons (
summary_run_id UUID NOT NULL REFERENCES feedback_summary_runs(id) ON DELETE CASCADE,
lesson_session_id UUID NOT NULL REFERENCES lesson_sessions(id) ON DELETE CASCADE,
PRIMARY KEY (summary_run_id, lesson_session_id)
);
CREATE INDEX idx_feedback_summary_runs_session_created
ON feedback_summary_runs(feedback_session_id, created_at DESC);
CREATE INDEX idx_feedback_summary_runs_queue
ON feedback_summary_runs(created_at, id)
WHERE status IN ('queued', 'processing');
CREATE INDEX idx_feedback_summary_run_lessons_lesson
ON feedback_summary_run_lessons(lesson_session_id);

View File

@@ -5,6 +5,7 @@ mod recording_routes;
mod routes;
mod speech;
mod summary;
mod summary_worker;
mod transcription_worker;
use std::{path::PathBuf, sync::Arc, time::Duration};
@@ -96,8 +97,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
};
if let Some(pool) = state.pool.clone() {
if speech.configured() {
transcription_worker::spawn(pool, speech.clone(), log_context.clone());
transcription_worker::spawn(pool.clone(), speech.clone(), log_context.clone());
}
summary_worker::spawn(pool, state.summary.clone(), log_context.clone());
}
let app = build_app(
state,

View File

@@ -1,4 +1,7 @@
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use axum::{
Json,
@@ -36,6 +39,9 @@ pub fn router() -> OpenApiRouter<SharedState> {
.routes(routes!(mark_lesson_applied))
.routes(routes!(retry_audio_segment))
.routes(routes!(discard_audio_segment))
.routes(routes!(create_feedback_summary_run))
.routes(routes!(get_feedback_summary_run))
.routes(routes!(apply_feedback_summary_run))
.routes(routes!(generate_feedback_content))
}
@@ -130,6 +136,7 @@ struct FeedbackSessionResponse {
failed_segment_count: usize,
needs_generation: bool,
lessons: Vec<LessonSummaryResponse>,
latest_summary_run: Option<SummaryRunResponse>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
@@ -191,6 +198,49 @@ struct GenerateFeedbackResponse {
method: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct CreateSummaryRunInput {
lesson_ids: Vec<Uuid>,
#[serde(default)]
existing_content: String,
idempotency_key: String,
}
#[derive(Debug, FromRow)]
struct SummaryRunRow {
id: Uuid,
feedback_session_id: Uuid,
status: String,
content: Option<String>,
method: Option<String>,
model: Option<String>,
prompt_version: String,
character_count: Option<i32>,
error_message: Option<String>,
attempts: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
applied_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, ToSchema)]
struct SummaryRunResponse {
id: Uuid,
feedback_session_id: Uuid,
lesson_ids: Vec<Uuid>,
status: String,
content: Option<String>,
method: Option<String>,
model: Option<String>,
prompt_version: String,
character_count: Option<i32>,
error_message: Option<String>,
attempts: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
applied_at: Option<DateTime<Utc>>,
}
#[derive(Debug, ToSchema)]
#[allow(dead_code)]
struct AudioSegmentUpload {
@@ -851,6 +901,252 @@ async fn discard_audio_segment(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/feedback-sessions/{session_id}/summary-runs",
tag = "语音反馈",
summary = "创建反馈总结任务",
description = "选择一节或多节已完成转录的录音,异步生成一份统一反馈正文。重复的幂等键返回同一个任务。",
params(
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("session_id" = Uuid, Path, description = "反馈批次 ID")
),
request_body(content = CreateSummaryRunInput, content_type = "application/json"),
responses(
(status = 202, description = "已创建或恢复的总结任务", body = SummaryRunResponse),
(status = 400, description = "所选录音不可用于总结", body = ErrorBody),
(status = 404, description = "反馈批次不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn create_feedback_summary_run(
State(state): State<SharedState>,
headers: HeaderMap,
Path(session_id): Path<Uuid>,
Json(input): Json<CreateSummaryRunInput>,
) -> Result<(StatusCode, Json<SummaryRunResponse>), ApiError> {
let owner_id = state
.auth
.authenticate(state.pool.as_ref(), &headers)
.await?
.user_id;
let pool = pool(&state)?;
assert_session_owner(pool, owner_id, session_id).await?;
validate_draft_content(&input.existing_content)?;
let idempotency_key = input.idempotency_key.trim();
if idempotency_key.is_empty() || idempotency_key.chars().count() > 100 {
return Err(ApiError::BadRequest(
"idempotency_key must contain 1 to 100 characters".to_owned(),
));
}
if input.lesson_ids.is_empty() || input.lesson_ids.len() > 100 {
return Err(ApiError::BadRequest(
"请选择 1 到 100 节已就绪的语音记录".to_owned(),
));
}
let unique_lesson_ids = input.lesson_ids.iter().copied().collect::<HashSet<_>>();
if unique_lesson_ids.len() != input.lesson_ids.len() {
return Err(ApiError::BadRequest("所选语音记录不能重复".to_owned()));
}
if let Some(existing) =
load_summary_run_by_idempotency(pool, owner_id, session_id, idempotency_key).await?
{
return Ok((StatusCode::ACCEPTED, Json(existing)));
}
let ready_lesson_ids = sqlx::query_scalar::<_, Uuid>(
"SELECT l.id FROM lesson_sessions l \
WHERE l.feedback_session_id = $1 AND l.id = ANY($2) AND l.status = 'ready' \
AND EXISTS (\
SELECT 1 FROM audio_segments s \
WHERE s.lesson_session_id = l.id AND s.status = 'ready' \
AND s.transcript IS NOT NULL AND char_length(btrim(s.transcript)) > 0\
)",
)
.bind(session_id)
.bind(&input.lesson_ids)
.fetch_all(pool)
.await?;
let ready_lesson_ids = ready_lesson_ids.into_iter().collect::<HashSet<_>>();
if ready_lesson_ids != unique_lesson_ids {
return Err(ApiError::BadRequest(
"所选语音记录中有内容尚未转录完成,请刷新后重新选择".to_owned(),
));
}
let run_id = Uuid::new_v4();
let mut transaction = pool.begin().await?;
let inserted_id = sqlx::query_scalar::<_, Uuid>(
"INSERT INTO feedback_summary_runs (\
id, feedback_session_id, idempotency_key, existing_content, prompt_version\
) VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (idempotency_key) DO NOTHING RETURNING id",
)
.bind(run_id)
.bind(session_id)
.bind(idempotency_key)
.bind(&input.existing_content)
.bind(crate::summary::PROMPT_VERSION)
.fetch_optional(&mut *transaction)
.await?;
if inserted_id.is_none() {
transaction.rollback().await?;
let existing = load_summary_run_by_idempotency(pool, owner_id, session_id, idempotency_key)
.await?
.ok_or_else(|| ApiError::BadRequest("该幂等键已被其他总结任务使用".to_owned()))?;
return Ok((StatusCode::ACCEPTED, Json(existing)));
}
sqlx::query(
"INSERT INTO feedback_summary_run_lessons (summary_run_id, lesson_session_id) \
SELECT $1, lesson_id FROM UNNEST($2::uuid[]) AS selected(lesson_id)",
)
.bind(run_id)
.bind(&input.lesson_ids)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
tracing::info!(
event = "summary_job_queued",
summary_run_id = %run_id,
feedback_session_id = %session_id,
recording_count = input.lesson_ids.len(),
existing_character_count = input.existing_content.chars().count(),
prompt_version = crate::summary::PROMPT_VERSION,
status = "queued",
"feedback summary job queued"
);
let response = load_summary_run(pool, run_id)
.await?
.ok_or(ApiError::Internal)?;
Ok((StatusCode::ACCEPTED, Json(response)))
}
#[utoipa::path(
get,
path = "/api/v1/feedback-summary-runs/{run_id}",
tag = "语音反馈",
summary = "查询反馈总结任务",
description = "读取异步总结任务的状态、所选录音和预览正文;页面可据此恢复轮询。",
params(
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("run_id" = Uuid, Path, description = "总结任务 ID")
),
responses(
(status = 200, description = "总结任务状态和预览内容", body = SummaryRunResponse),
(status = 404, description = "总结任务不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn get_feedback_summary_run(
State(state): State<SharedState>,
headers: HeaderMap,
Path(run_id): Path<Uuid>,
) -> Result<Json<SummaryRunResponse>, ApiError> {
let owner_id = state
.auth
.authenticate(state.pool.as_ref(), &headers)
.await?
.user_id;
let response = load_summary_run_for_owner(pool(&state)?, owner_id, run_id)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(response))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-summary-runs/{run_id}/apply",
tag = "语音反馈",
summary = "应用反馈总结",
description = "确认预览后,用总结正文替换当前反馈草稿。重复应用同一任务是幂等的。",
params(
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("run_id" = Uuid, Path, description = "总结任务 ID")
),
responses(
(status = 200, description = "应用总结后的反馈批次", body = FeedbackSessionResponse),
(status = 400, description = "总结任务尚未生成完成", body = ErrorBody),
(status = 404, description = "总结任务不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn apply_feedback_summary_run(
State(state): State<SharedState>,
headers: HeaderMap,
Path(run_id): Path<Uuid>,
) -> Result<Json<FeedbackSessionResponse>, ApiError> {
let owner_id = state
.auth
.authenticate(state.pool.as_ref(), &headers)
.await?
.user_id;
let pool = pool(&state)?;
let mut transaction = pool.begin().await?;
let run = sqlx::query_as::<_, SummaryRunRow>(
"SELECT run.id, run.feedback_session_id, run.status, run.content, run.method, \
run.model, run.prompt_version, run.character_count, run.error_message, \
run.attempts, run.created_at, run.completed_at, run.applied_at \
FROM feedback_summary_runs run \
JOIN feedback_sessions session ON session.id = run.feedback_session_id \
WHERE run.id = $1 AND session.owner_id = $2 AND session.status = 'active' \
FOR UPDATE OF run",
)
.bind(run_id)
.bind(owner_id)
.fetch_optional(&mut *transaction)
.await?
.ok_or(ApiError::NotFound)?;
if run.status != "ready" && run.status != "applied" {
return Err(ApiError::BadRequest(match run.status.as_str() {
"failed" => "总结生成失败,请重新生成".to_owned(),
_ => "总结仍在生成中,请稍后再应用".to_owned(),
}));
}
if run.status == "ready" {
let content = run.content.as_deref().ok_or(ApiError::Internal)?;
sqlx::query("UPDATE feedback_sessions SET content = $2, updated_at = now() WHERE id = $1")
.bind(run.feedback_session_id)
.bind(content)
.execute(&mut *transaction)
.await?;
sqlx::query(
"UPDATE lesson_sessions SET included_in_generation = TRUE, updated_at = now() \
WHERE id IN (\
SELECT lesson_session_id FROM feedback_summary_run_lessons WHERE summary_run_id = $1\
)",
)
.bind(run_id)
.execute(&mut *transaction)
.await?;
sqlx::query(
"UPDATE feedback_summary_runs \
SET status = 'applied', applied_at = now(), updated_at = now() WHERE id = $1",
)
.bind(run_id)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
tracing::info!(
event = "summary_job_applied",
summary_run_id = %run_id,
feedback_session_id = %run.feedback_session_id,
result_character_count = run.character_count.unwrap_or(0),
method = run.method.as_deref().unwrap_or("unknown"),
model = run.model.as_deref().unwrap_or("none"),
prompt_version = %run.prompt_version,
status = "applied",
"feedback summary applied to draft"
);
let session = load_feedback_session_row(pool, owner_id, run.feedback_session_id)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(load_session_response(pool, session).await?))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-sessions/{session_id}/generate",
@@ -956,6 +1252,113 @@ async fn generate_feedback_content(
}))
}
async fn load_summary_run(
pool: &PgPool,
run_id: Uuid,
) -> Result<Option<SummaryRunResponse>, ApiError> {
let row = sqlx::query_as::<_, SummaryRunRow>(
"SELECT id, feedback_session_id, status, content, method, model, prompt_version, \
character_count, error_message, attempts, created_at, completed_at, applied_at \
FROM feedback_summary_runs WHERE id = $1",
)
.bind(run_id)
.fetch_optional(pool)
.await?;
summary_run_response(pool, row).await
}
async fn load_summary_run_for_owner(
pool: &PgPool,
owner_id: Uuid,
run_id: Uuid,
) -> Result<Option<SummaryRunResponse>, ApiError> {
let row = sqlx::query_as::<_, SummaryRunRow>(
"SELECT run.id, run.feedback_session_id, run.status, run.content, run.method, run.model, \
run.prompt_version, run.character_count, run.error_message, run.attempts, \
run.created_at, run.completed_at, run.applied_at \
FROM feedback_summary_runs run \
JOIN feedback_sessions session ON session.id = run.feedback_session_id \
WHERE run.id = $1 AND session.owner_id = $2",
)
.bind(run_id)
.bind(owner_id)
.fetch_optional(pool)
.await?;
summary_run_response(pool, row).await
}
async fn load_summary_run_by_idempotency(
pool: &PgPool,
owner_id: Uuid,
session_id: Uuid,
idempotency_key: &str,
) -> Result<Option<SummaryRunResponse>, ApiError> {
let row = sqlx::query_as::<_, SummaryRunRow>(
"SELECT run.id, run.feedback_session_id, run.status, run.content, run.method, run.model, \
run.prompt_version, run.character_count, run.error_message, run.attempts, \
run.created_at, run.completed_at, run.applied_at \
FROM feedback_summary_runs run \
JOIN feedback_sessions session ON session.id = run.feedback_session_id \
WHERE run.idempotency_key = $1 AND run.feedback_session_id = $2 \
AND session.owner_id = $3",
)
.bind(idempotency_key)
.bind(session_id)
.bind(owner_id)
.fetch_optional(pool)
.await?;
summary_run_response(pool, row).await
}
async fn summary_run_response(
pool: &PgPool,
row: Option<SummaryRunRow>,
) -> Result<Option<SummaryRunResponse>, ApiError> {
let Some(row) = row else {
return Ok(None);
};
let lesson_ids = sqlx::query_scalar::<_, Uuid>(
"SELECT lesson_session_id FROM feedback_summary_run_lessons selected \
JOIN lesson_sessions lesson ON lesson.id = selected.lesson_session_id \
WHERE selected.summary_run_id = $1 ORDER BY lesson.sequence",
)
.bind(row.id)
.fetch_all(pool)
.await?;
Ok(Some(SummaryRunResponse {
id: row.id,
feedback_session_id: row.feedback_session_id,
lesson_ids,
status: row.status,
content: row.content,
method: row.method,
model: row.model,
prompt_version: row.prompt_version,
character_count: row.character_count,
error_message: row.error_message,
attempts: row.attempts,
created_at: row.created_at,
completed_at: row.completed_at,
applied_at: row.applied_at,
}))
}
async fn load_feedback_session_row(
pool: &PgPool,
owner_id: Uuid,
session_id: Uuid,
) -> Result<Option<FeedbackSessionRow>, ApiError> {
Ok(sqlx::query_as::<_, FeedbackSessionRow>(
"SELECT id, profile_id, feedback_date, content, status, created_at, updated_at \
FROM feedback_sessions \
WHERE id = $1 AND owner_id = $2 AND status = 'active'",
)
.bind(session_id)
.bind(owner_id)
.fetch_optional(pool)
.await?)
}
async fn load_session_response(
pool: &PgPool,
session: FeedbackSessionRow,
@@ -977,6 +1380,16 @@ async fn load_session_response(
.bind(session.id)
.fetch_all(pool)
.await?;
let latest_summary_run_row = sqlx::query_as::<_, SummaryRunRow>(
"SELECT id, feedback_session_id, status, content, method, model, prompt_version, \
character_count, error_message, attempts, created_at, completed_at, applied_at \
FROM feedback_summary_runs WHERE feedback_session_id = $1 \
ORDER BY created_at DESC, id DESC LIMIT 1",
)
.bind(session.id)
.fetch_optional(pool)
.await?;
let latest_summary_run = summary_run_response(pool, latest_summary_run_row).await?;
let processing_segment_count = segments
.iter()
.filter(|segment| segment.status == "processing")
@@ -1050,6 +1463,7 @@ async fn load_session_response(
failed_segment_count,
needs_generation,
lessons: lesson_responses,
latest_summary_run,
created_at: session.created_at,
updated_at: session.updated_at,
})

View File

@@ -924,7 +924,10 @@ mod tests {
.filter(|operation| operation.get("responses").is_some())
.collect::<Vec<_>>();
assert_eq!(operations.len(), 24);
assert_eq!(operations.len(), 27);
assert!(paths.contains_key("/api/v1/feedback-sessions/{session_id}/summary-runs"));
assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}"));
assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}/apply"));
for operation in &operations {
assert_non_empty(operation, "summary");
assert_non_empty(operation, "description");

View File

@@ -1,11 +1,14 @@
use std::collections::HashSet;
use std::{collections::HashSet, time::Duration};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use crate::config::SummaryConfig;
const MAX_FEEDBACK_CHARS: usize = 2000;
pub const MAX_FEEDBACK_CHARS: usize = 2000;
pub const PROMPT_VERSION: &str = "summary-v1";
const MAX_SEGMENT_SUMMARY_CHARS: usize = 800;
const MAX_RECORDING_SUMMARY_CHARS: usize = 1200;
#[derive(Clone)]
pub struct SummaryService {
@@ -17,6 +20,13 @@ pub struct SummaryService {
pub struct SummaryResult {
pub content: String,
pub method: &'static str,
pub model: Option<String>,
}
#[derive(Debug)]
pub struct RecordingTranscript {
pub sequence: i32,
pub segments: Vec<String>,
}
#[derive(Serialize)]
@@ -51,7 +61,10 @@ impl SummaryService {
pub fn new(config: Option<SummaryConfig>) -> Self {
Self {
config,
client: reqwest::Client::new(),
client: reqwest::Client::builder()
.timeout(Duration::from_secs(180))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
}
}
@@ -63,26 +76,96 @@ impl SummaryService {
&self,
existing_content: &str,
lesson_transcripts: &[String],
) -> Result<SummaryResult, String> {
let recordings = lesson_transcripts
.iter()
.enumerate()
.map(|(index, transcript)| RecordingTranscript {
sequence: i32::try_from(index + 1).unwrap_or(i32::MAX),
segments: vec![transcript.clone()],
})
.collect::<Vec<_>>();
self.summarize_hierarchical(existing_content, &recordings)
.await
}
pub async fn summarize_hierarchical(
&self,
existing_content: &str,
recordings: &[RecordingTranscript],
) -> Result<SummaryResult, String> {
let Some(config) = &self.config else {
let transcripts = recordings
.iter()
.flat_map(|recording| recording.segments.iter().cloned())
.collect::<Vec<_>>();
return Ok(SummaryResult {
content: extractive_summary(existing_content, lesson_transcripts),
content: extractive_summary(existing_content, &transcripts),
method: "extractive",
model: None,
});
};
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{}",
let mut recording_summaries = Vec::with_capacity(recordings.len());
for recording in recordings {
let mut segment_facts = Vec::with_capacity(recording.segments.len());
for transcript in &recording.segments {
let facts = self
.chat(
config,
"你是教学课堂事实提取助手。从转录中提取可用于教学反馈的事实、表现、进步、困难和建议线索。忽略口头语和重复内容,不推测、不评价录音质量,不提及录音或转录,只输出简洁中文事实。",
transcript,
)
.await?;
segment_facts.push(limit_chars(&facts, MAX_SEGMENT_SUMMARY_CHARS));
}
let recording_prompt = segment_facts
.iter()
.enumerate()
.map(|(index, facts)| format!("片段{}{}", index + 1, facts))
.collect::<Vec<_>>()
.join("\n");
let recording_summary = self
.chat(
config,
"将同一次课堂记录的片段事实合并为一份紧凑的课堂摘要。去重并保留具体事实,不使用固定栏目,不提及片段、录音或转录,不虚构,只输出中文摘要正文。",
&recording_prompt,
)
.await?;
recording_summaries.push(format!(
"{}次课堂:{}",
recording.sequence,
limit_chars(&recording_summary, MAX_RECORDING_SUMMARY_CHARS)
));
}
let final_prompt = format!(
"教师当前输入(可为空):\n{}\n\n所选课堂事实摘要:\n{}",
existing_content.trim(),
transcript_text
recording_summaries.join("\n\n")
);
let system_prompt = "你是教学反馈整理助手。请把教师已有内容与课堂转录合并成一份面向家长的中文反馈保留具体事实按课堂表现、进步、问题和后续建议组织。不要虚构不要提及录音或转录不超过2000个汉字只输出反馈正文。";
let content = self
.chat(
config,
"你是教学反馈整理助手。请把教师当前输入与所有课堂事实合并、去重并改写为一份面向家长的连贯中文反馈。最终内容是整体总结不按录音次数分段不使用固定栏目标题不提及第几次课堂、录音、片段或转录。保留具体事实包含整体表现、关键进步、仍需关注的问题和可执行建议但不要虚构。只输出可直接使用的正文最多2000个汉字。",
&final_prompt,
)
.await?;
Ok(SummaryResult {
content: limit_chars(&content, MAX_FEEDBACK_CHARS),
method: "llm_hierarchical",
model: Some(config.model.clone()),
})
}
async fn chat(
&self,
config: &SummaryConfig,
system_prompt: &str,
user_prompt: &str,
) -> Result<String, String> {
let request = ChatRequest {
model: &config.model,
messages: vec![
@@ -92,7 +175,7 @@ impl SummaryService {
},
ChatMessage {
role: "user",
content: &user_prompt,
content: user_prompt,
},
],
temperature: 0.2,
@@ -117,29 +200,37 @@ impl SummaryService {
.json()
.await
.map_err(|error| format!("反馈汇总响应格式无效:{error}"))?;
let content = result
result
.choices
.into_iter()
.next()
.map(|choice| choice.message.content.trim().to_owned())
.map(|choice| normalize_model_output(&choice.message.content))
.filter(|content| !content.is_empty())
.ok_or_else(|| "反馈汇总服务未返回内容".to_owned())?;
Ok(SummaryResult {
content: content.chars().take(MAX_FEEDBACK_CHARS).collect(),
method: "llm",
})
.ok_or_else(|| "反馈汇总服务未返回内容".to_owned())
}
}
fn limit_chars(content: &str, limit: usize) -> String {
content.trim().chars().take(limit).collect()
}
fn normalize_model_output(content: &str) -> String {
let trimmed = content.trim();
if !trimmed.starts_with("```") {
return trimmed.to_owned();
}
let mut lines = trimmed.lines();
lines.next();
let mut remaining = lines.collect::<Vec<_>>();
if remaining.last().is_some_and(|line| line.trim() == "```") {
remaining.pop();
}
remaining.join("\n").trim().to_owned()
}
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 result = limit_chars(existing_content, MAX_FEEDBACK_CHARS);
let mut seen = HashSet::new();
let mut selected = Vec::new();
for transcript in lesson_transcripts {
for sentence in transcript.split_inclusive(['。', '', '', '\n']) {
@@ -147,28 +238,13 @@ fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) ->
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 separator = if result.is_empty() { "" } else { "\n" };
let remaining = MAX_FEEDBACK_CHARS.saturating_sub(result.chars().count());
if remaining <= prefix.chars().count() + 4 {
break;
if remaining <= separator.chars().count() + 4 {
return result.trim().to_owned();
}
result.push_str(prefix);
result.extend(
sentence
.chars()
.take(remaining - prefix.chars().count() - 1),
);
result.push('\n');
result.push_str(separator);
result.extend(sentence.chars().take(remaining - separator.chars().count()));
}
}
@@ -177,7 +253,10 @@ fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) ->
#[cfg(test)]
mod tests {
use super::extractive_summary;
use super::{
MAX_FEEDBACK_CHARS, RecordingTranscript, SummaryService, extractive_summary,
normalize_model_output,
};
#[test]
fn extractive_summary_preserves_manual_content_and_deduplicates() {
@@ -189,5 +268,41 @@ mod tests {
assert!(result.starts_with("手动备注。"));
assert_eq!(result.matches("完成了构图练习。").count(), 1);
assert!(result.contains("色彩更大胆。"));
assert!(!result.contains("课堂记录汇总"));
}
#[tokio::test]
async fn local_hierarchical_summary_uses_every_recording_and_caps_output() {
let service = SummaryService::new(None);
let result = service
.summarize_hierarchical(
"教师备注。",
&[
RecordingTranscript {
sequence: 1,
segments: vec!["第一节完成了构图练习。".to_owned()],
},
RecordingTranscript {
sequence: 2,
segments: vec![format!("第二节色彩更大胆。{}", "补充表现。".repeat(500))],
},
],
)
.await
.expect("local summary should succeed");
assert!(result.content.contains("第一节完成了构图练习。"));
assert!(result.content.contains("第二节色彩更大胆。"));
assert!(result.content.chars().count() <= MAX_FEEDBACK_CHARS);
assert_eq!(result.method, "extractive");
assert!(result.model.is_none());
}
#[test]
fn normalize_model_output_removes_code_fence() {
assert_eq!(
normalize_model_output("```text\n反馈正文。\n```"),
"反馈正文。"
);
}
}

View File

@@ -0,0 +1,308 @@
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use sqlx::{FromRow, PgPool};
use tracing::Instrument;
use uuid::Uuid;
use crate::{
LogContext,
summary::{PROMPT_VERSION, RecordingTranscript, SummaryService},
};
const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(750);
const UNAVAILABLE_POLL_INTERVAL: Duration = Duration::from_secs(5);
const JOB_LEASE_SECONDS: i64 = 30 * 60;
#[derive(Debug, FromRow)]
struct SummaryJob {
id: Uuid,
feedback_session_id: Uuid,
existing_content: String,
created_at: DateTime<Utc>,
attempts: i32,
}
#[derive(Debug, FromRow)]
struct SummarySourceRow {
lesson_id: Uuid,
lesson_sequence: i32,
segment_id: Uuid,
segment_sequence: i32,
transcript: String,
}
pub fn spawn(pool: PgPool, summary: SummaryService, log_context: LogContext) {
let span = tracing::info_span!(
"summary_worker",
service = "api",
environment = %log_context.environment,
app_version = %log_context.app_version,
git_sha = %log_context.git_sha,
);
tokio::spawn(async move { run(pool, summary).await }.instrument(span));
}
async fn run(pool: PgPool, summary: SummaryService) {
loop {
match claim_next_job(&pool).await {
Ok(Some(job)) => {
if process_job(&pool, &summary, &job).await.is_err() {
tracing::error!(
event = "summary_job_update_failed",
summary_run_id = %job.id,
feedback_session_id = %job.feedback_session_id,
error_class = "database_update_failed",
"summary job update failed"
);
tokio::time::sleep(IDLE_POLL_INTERVAL).await;
}
}
Ok(None) => tokio::time::sleep(IDLE_POLL_INTERVAL).await,
Err(_) => {
tracing::error!(
event = "summary_job_claim_failed",
error_class = "database_query_failed",
"failed to claim summary job"
);
tokio::time::sleep(UNAVAILABLE_POLL_INTERVAL).await;
}
}
}
}
async fn claim_next_job(pool: &PgPool) -> Result<Option<SummaryJob>, sqlx::Error> {
sqlx::query_as::<_, SummaryJob>(
"WITH next_job AS (\
SELECT id FROM feedback_summary_runs \
WHERE status = 'queued' \
OR (status = 'processing' AND processing_started_at < now() - ($1 * interval '1 second')) \
ORDER BY created_at, id \
FOR UPDATE SKIP LOCKED LIMIT 1\
) \
UPDATE feedback_summary_runs AS run \
SET status = 'processing', processing_started_at = now(), attempts = attempts + 1, \
error_class = NULL, error_message = NULL, updated_at = now() \
FROM next_job \
WHERE run.id = next_job.id \
RETURNING run.id, run.feedback_session_id, run.existing_content, run.created_at, run.attempts",
)
.bind(JOB_LEASE_SECONDS)
.fetch_optional(pool)
.await
}
async fn process_job(
pool: &PgPool,
summary: &SummaryService,
job: &SummaryJob,
) -> Result<(), sqlx::Error> {
let started = Instant::now();
let rows = sqlx::query_as::<_, SummarySourceRow>(
"SELECT l.id AS lesson_id, l.sequence AS lesson_sequence, \
s.id AS segment_id, s.sequence AS segment_sequence, s.transcript \
FROM feedback_summary_run_lessons selected \
JOIN lesson_sessions l ON l.id = selected.lesson_session_id \
JOIN audio_segments s ON s.lesson_session_id = l.id \
WHERE selected.summary_run_id = $1 AND l.status = 'ready' \
AND s.status = 'ready' AND s.transcript IS NOT NULL \
AND char_length(btrim(s.transcript)) > 0 \
ORDER BY l.sequence, s.sequence",
)
.bind(job.id)
.fetch_all(pool)
.await?;
let expected_lesson_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM feedback_summary_run_lessons WHERE summary_run_id = $1",
)
.bind(job.id)
.fetch_one(pool)
.await?;
let source_lesson_count = rows
.iter()
.map(|row| row.lesson_id)
.collect::<std::collections::HashSet<_>>()
.len();
if rows.is_empty() || source_lesson_count != usize::try_from(expected_lesson_count).unwrap_or(0)
{
fail_job(
pool,
job,
"summary_source_unavailable",
"所选录音尚未全部转录完成,请重新选择后生成",
elapsed_ms(started),
)
.await?;
return Ok(());
}
let source_character_count = rows
.iter()
.map(|row| row.transcript.chars().count())
.sum::<usize>();
let source_segment_count = rows.len();
let mut hasher = Sha256::new();
hasher.update(job.existing_content.as_bytes());
let mut recordings = Vec::<RecordingTranscript>::new();
for row in rows {
hasher.update(row.lesson_id.as_bytes());
hasher.update(row.segment_id.as_bytes());
hasher.update(row.segment_sequence.to_be_bytes());
hasher.update(row.transcript.as_bytes());
match recordings.last_mut() {
Some(recording) if recording.sequence == row.lesson_sequence => {
recording.segments.push(row.transcript);
}
_ => recordings.push(RecordingTranscript {
sequence: row.lesson_sequence,
segments: vec![row.transcript],
}),
}
}
let source_hash = format!("{:x}", hasher.finalize());
let queue_wait_ms = elapsed_since(job.created_at);
tracing::info!(
event = "summary_job_started",
summary_run_id = %job.id,
feedback_session_id = %job.feedback_session_id,
attempt = job.attempts,
queue_wait_ms,
recording_count = recordings.len(),
source_segment_count,
source_character_count,
estimated_model_request_count = if summary.configured() {
source_segment_count + recordings.len() + 1
} else {
0
},
prompt_version = PROMPT_VERSION,
provider = if summary.configured() { "openai_compatible" } else { "local" },
status = "processing",
"generating feedback summary"
);
match summary
.summarize_hierarchical(&job.existing_content, &recordings)
.await
{
Ok(result) if !result.content.trim().is_empty() => {
let character_count = result.content.chars().count();
let latency_ms = elapsed_ms(started);
sqlx::query(
"UPDATE feedback_summary_runs \
SET status = 'ready', source_hash = $2, content = $3, method = $4, model = $5, \
prompt_version = $6, character_count = $7, processing_started_at = NULL, \
completed_at = now(), updated_at = now() \
WHERE id = $1 AND status = 'processing'",
)
.bind(job.id)
.bind(&source_hash)
.bind(&result.content)
.bind(result.method)
.bind(&result.model)
.bind(PROMPT_VERSION)
.bind(i32::try_from(character_count).unwrap_or(i32::MAX))
.execute(pool)
.await?;
tracing::info!(
event = "summary_job_finished",
summary_run_id = %job.id,
feedback_session_id = %job.feedback_session_id,
attempt = job.attempts,
recording_count = recordings.len(),
source_segment_count,
source_character_count,
model_request_count = if summary.configured() {
source_segment_count + recordings.len() + 1
} else {
0
},
result_character_count = character_count,
latency_ms,
method = result.method,
model = result.model.as_deref().unwrap_or("none"),
prompt_version = PROMPT_VERSION,
status = "ready",
"feedback summary generated"
);
}
Ok(_) => {
fail_job(
pool,
job,
"summary_empty_result",
"未能从所选录音生成有效总结",
elapsed_ms(started),
)
.await?;
}
Err(message) => {
fail_job(
pool,
job,
summary_error_class(&message),
&message,
elapsed_ms(started),
)
.await?;
}
}
Ok(())
}
async fn fail_job(
pool: &PgPool,
job: &SummaryJob,
error_class: &str,
message: &str,
latency_ms: u64,
) -> Result<(), sqlx::Error> {
let error_message = message.chars().take(1000).collect::<String>();
sqlx::query(
"UPDATE feedback_summary_runs \
SET status = 'failed', error_class = $2, error_message = $3, \
processing_started_at = NULL, completed_at = now(), updated_at = now() \
WHERE id = $1 AND status = 'processing'",
)
.bind(job.id)
.bind(error_class)
.bind(&error_message)
.execute(pool)
.await?;
tracing::warn!(
event = "summary_job_failed",
summary_run_id = %job.id,
feedback_session_id = %job.feedback_session_id,
attempt = job.attempts,
latency_ms,
error_class,
status = "failed",
error = %error_message,
"feedback summary generation failed"
);
Ok(())
}
fn summary_error_class(message: &str) -> &'static str {
if message.contains("连接失败") {
"summary_service_connection_failed"
} else if message.contains("HTTP") {
"summary_service_http_error"
} else if message.contains("格式无效") {
"summary_response_invalid"
} else {
"summary_generation_failed"
}
}
fn elapsed_since(timestamp: DateTime<Utc>) -> i64 {
Utc::now()
.signed_duration_since(timestamp)
.num_milliseconds()
.max(0)
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}