feat(voice): add async selectable summaries
This commit is contained in:
@@ -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 字限制。
|
||||
|
||||
### 创建学生档案
|
||||
|
||||
|
||||
@@ -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 维护方式
|
||||
|
||||
55
server/migrations/0005_feedback_summary_runs.sql
Normal file
55
server/migrations/0005_feedback_summary_runs.sql
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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```"),
|
||||
"反馈正文。"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
308
server/src/summary_worker.rs
Normal file
308
server/src/summary_worker.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user