Skip to content

Commit fe0dc28

Browse files
authored
Merge pull request #26 from zlxlabs/fix/summary-retry-and-resummarize
修复总结空响应无重试 + 新增 /api/resummarize 总结层独立重跑入口
2 parents b7a622e + e4c5dc4 commit fe0dc28

9 files changed

Lines changed: 1140 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ curl -X GET "http://localhost:8000/api/task/{task_id}" \
152152
|------|------|------|
153153
| `/api/transcribe` | POST | 提交转录任务(支持 `processing_options` 处理深度开关) |
154154
| `/api/task/{task_id}` | GET | 查询任务状态 |
155-
| `/api/recalibrate` | POST | 重新校对(唯一强制重做例外,忽略分层缓存保护) |
155+
| `/api/recalibrate` | POST | 重新校对(唯一强制重做例外,忽略分层缓存保护;总结缺失时仍会自动补跑,但单独重跑总结请用 `/api/resummarize`) |
156+
| `/api/resummarize` | POST | 只重新生成总结(跳过下载、转录、校对和章节,复用已有校对文本) |
156157
| `/api/audit/stats` | GET | 调用统计,含 LLM token 用量聚合(按阶段汇总 prompt/completion/total tokens) |
157158
| `/api/audit/calls` | GET | 调用记录 |
158159
| `/api/audit/history` | GET | audit.db 独立终态任务历史(状态仅支持 `success`、`failed`、`all`;支持过滤、分页、关键词搜索),含处理状态与 `content_expired` |

‎config/config.example.jsonc‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,16 @@
325325
"max_retries": 2,
326326
// 是否启用模式降级(false 时强制使用 json_schema,用于调试)
327327
"enable_fallback": true
328+
},
329+
330+
// ------------------------------------------------------
331+
// 纯文本输出配置
332+
// 用于总结等 text 模式的 LLM 调用
333+
// ------------------------------------------------------
334+
"text_output": {
335+
// "HTTP 200 但 content 为空"时的最大重发次数(思考模型偶发,
336+
// 多因思考预算耗尽);重试时自动强制 reasoning_effort=disabled
337+
"max_retries": 2
328338
}
329339
},
330340

‎src/video_transcript_api/api/routes/tasks.py‎

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
from .audit import check_view_token_ownership
1818
from ..services.transcription import (
1919
RecalibrateRequest,
20+
ResummarizeRequest,
2021
TranscribeRequest,
2122
TranscribeResponse,
2223
normalize_processing_options,
2324
verify_token,
2425
)
26+
from ...utils.llm_status import SummaryStatus
2527
from ...utils.notifications import send_view_link_wechat, get_notification_router
2628
from ...utils.task_status import TaskStatus, http_code_for_status
2729

@@ -780,3 +782,277 @@ async def recalibrate(
780782
finally:
781783
if registration_owned:
782784
inflight_registry.release("llm", task_id)
785+
786+
787+
@router.post("/resummarize", response_model=TranscribeResponse)
788+
async def resummarize(
789+
request_body: ResummarizeRequest,
790+
request: Request,
791+
user_info: dict = Depends(verify_token),
792+
):
793+
"""重新生成总结接口
794+
795+
只重跑总结层(跳过下载、转录、校对和章节),复用缓存里已有的
796+
llm_calibrated 校对文本作为总结输入。权限复用 "recalibrate"——
797+
两者的爆炸半径同级(消耗 LLM 配额、写共享产物),不新增权限名,
798+
避免 users.json 迁移。llm_task 构造镜像 transcription.py 分层缓存
799+
部分命中分支的 else 路径(calibrate=False + transcription_data=None +
800+
cached_speaker_count 回传),校对层文件/状态均不动,只写总结层。
801+
"""
802+
view_token = request_body.view_token
803+
user_id = user_info.get("user_id")
804+
api_key = user_info.get("api_key")
805+
806+
start_time = datetime.datetime.now()
807+
808+
audit_logger.log_api_call(
809+
api_key=api_key,
810+
user_id=user_id,
811+
endpoint="/api/resummarize",
812+
user_agent=request.headers.get("User-Agent"),
813+
remote_ip=request.client.host if request.client else None,
814+
)
815+
816+
# 权限检查:与 recalibrate 共用同一个权限名(见 docstring),user_manager
817+
# 同样走 runtime 优先的 DI 来源,与 verify_token 鉴权用同一个实例。
818+
if not user_manager.check_permission(user_info, "recalibrate"):
819+
logger.warning(f"用户 {user_id} 无 recalibrate 权限")
820+
raise HTTPException(status_code=403, detail="无重新生成总结权限")
821+
822+
# 通过 view_token 获取缓存数据
823+
cache_data = cache_manager.get_cache_by_view_token(view_token)
824+
if not cache_data:
825+
logger.warning(f"view_token 对应的缓存不存在: {view_token}")
826+
raise HTTPException(status_code=404, detail="未找到对应的转录数据")
827+
828+
# 验证有转录数据(llm_calibrated 取不到时要回退到原始转录)
829+
transcript_data = cache_data.get("transcript_data")
830+
if not transcript_data:
831+
logger.warning(f"缓存中无转录数据: {view_token}")
832+
raise HTTPException(status_code=400, detail="缓存中没有转录数据,无法重新生成总结")
833+
834+
task_info = cache_data.get("task_info", {})
835+
836+
# 归属校验:与 recalibrate 完全同一套判定(check_view_token_ownership +
837+
# asyncio.to_thread,fail-closed)——resummarize 同样会创建新任务、
838+
# 消耗 LLM 配额并覆盖共享媒体的总结产物,不能对公开分享的 view_token
839+
# 一律放行。
840+
original_task_id = task_info.get("task_id")
841+
if original_task_id:
842+
try:
843+
owned = await asyncio.to_thread(
844+
check_view_token_ownership,
845+
view_token, original_task_id, user_id, cache_manager, audit_logger,
846+
)
847+
except Exception as e:
848+
logger.error(f"resummarize 归属校验暂时不可用(fail-closed 拒绝): {e}")
849+
raise HTTPException(status_code=503, detail="归属校验暂时不可用,请稍后重试")
850+
if not owned:
851+
logger.warning(
852+
f"用户 {user_id} 无权对 view_token={view_token} 发起 resummarize"
853+
f"(非该任务的权威提交者)"
854+
)
855+
raise HTTPException(status_code=403, detail="无权对该任务发起重新生成总结")
856+
857+
# 前置校验(在归属校验之后,避免向非任务归属方泄露总结层状态):总结
858+
# 已真实生成过(文件非空且状态为 generated)时直接 400,防止误点重复
859+
# 消耗 LLM 配额;failed/pending/缺失等情况放行,这正是本接口要补的场景。
860+
existing_summary = (cache_data.get("llm_summary") or "").strip()
861+
existing_summary_status = (cache_data.get("llm_status") or {}).get("summary_status")
862+
if existing_summary and existing_summary_status == SummaryStatus.GENERATED:
863+
logger.warning(f"总结已存在,拒绝重复生成: {view_token}")
864+
raise HTTPException(status_code=400, detail="总结已存在,无需重新生成")
865+
866+
platform = cache_data.get("platform")
867+
media_id = cache_data.get("media_id")
868+
use_speaker_recognition = cache_data.get("use_speaker_recognition", False)
869+
video_title = cache_data.get("title", "")
870+
author = cache_data.get("author", "")
871+
description = cache_data.get("description", "")
872+
cache_file_path = cache_data.get("file_path")
873+
874+
# resummarize 与 RecalibrateRequest 一样没有请求级 processing_options
875+
# 字段:管线语义固定为"只跑总结层"——校对层不动(calibrate=False,供
876+
# llm_ops._save_llm_results 的 suppress_calibration 保护已有校对产物)、
877+
# 章节不动(chapters=False,llm_status 合并语义保留旧值)、不做二次说话
878+
# 人姓名推断(infer_speaker_names=False)。INSERT 落库的值与下面交给
879+
# llm_ops 的 llm_task 里的值同源,保证创建行与终态快照语义一致。
880+
resummarize_processing_options = {
881+
"calibrate": False,
882+
"summarize": True,
883+
"infer_speaker_names": False,
884+
"chapters": False,
885+
}
886+
887+
# 创建新任务(复用原 view_token)。准入即登记:与 recalibrate 相同的
888+
# inflight registry 容量语义(llm 桶,容量取 LLM_QUEUE_MAXSIZE),
889+
# 满载拒绝不落库。
890+
from ..context import get_inflight_registry, get_llm_queue
891+
892+
task_id = cache_manager.generate_task_id()
893+
inflight_registry = get_inflight_registry()
894+
if not inflight_registry.try_register("llm", task_id):
895+
logger.warning("在途 LLM 任务已达受理上限,拒绝重新生成总结任务: %s", view_token)
896+
raise HTTPException(status_code=503, detail="任务处理已达上限,请稍后重试")
897+
898+
# 登记表配额从这里开始"归属"这次 HTTP 请求,直到下面 llm_queue.
899+
# put_nowait 成功把任务交给消费者——中途任何异常都必须在最下面的
900+
# finally 里释放,否则名额永久占用。
901+
registration_owned = True
902+
try:
903+
try:
904+
with cache_manager._get_cursor() as cursor:
905+
cursor.execute('''
906+
INSERT INTO task_status
907+
(task_id, view_token, url, platform, media_id,
908+
use_speaker_recognition, status, title, author,
909+
processing_options, submitted_by)
910+
VALUES (?, ?, ?, ?, ?, ?, 'processing', ?, ?, ?, ?)
911+
''', (
912+
task_id, view_token, task_info.get("url", ""),
913+
platform, media_id, use_speaker_recognition,
914+
video_title, author,
915+
json.dumps(resummarize_processing_options, sort_keys=True),
916+
user_id,
917+
))
918+
logger.info(f"重新生成总结任务创建成功: {task_id}, view_token: {view_token}")
919+
except Exception as e:
920+
logger.error(f"创建重新生成总结任务失败: {e}")
921+
raise HTTPException(status_code=500, detail=f"创建重新生成总结任务失败: {e}")
922+
923+
# 状态以 DB 为唯一真相源;上方 INSERT 已写入 status='processing'
924+
925+
# 说话人识别任务:读缓存里已落盘的结构化产物(llm_processed.json)
926+
# 把真实说话人数回传给协调器,供总结环节选择正确的多/单说话人
927+
# Prompt——下面强制走纯文本路径(transcription_data=None),协调器
928+
# 的自动推断必然判成单说话人,必须用这个缓存值覆盖(与
929+
# transcription.py 分层缓存 else 分支同款逻辑)。
930+
cached_speaker_count = None
931+
if use_speaker_recognition:
932+
cached_structured = cache_data.get("llm_processed") or {}
933+
cached_speaker_mapping = cached_structured.get("speaker_mapping")
934+
if cached_speaker_mapping:
935+
cached_speaker_count = len(cached_speaker_mapping)
936+
937+
# 总结输入优先取已有校对文本(质量更高);取不到时回退原始转录
938+
# (覆盖"原任务未启用校对"的场景),funasr 格式化处理与 recalibrate
939+
# 同款,且同样必须在"落库成功、交接成功前"的窗口内 try/except 收口
940+
# 成 failed 终态,否则客户端会永久轮询一个不会被消费的任务。
941+
try:
942+
transcript_text = cache_data.get("llm_calibrated")
943+
if not transcript_text:
944+
if cache_data.get("transcript_type") == "funasr":
945+
from ...transcriber import FunASRSpeakerClient
946+
funasr_client = FunASRSpeakerClient()
947+
transcript_text = funasr_client.format_transcript_with_speakers(
948+
transcript_data
949+
)
950+
else:
951+
transcript_text = transcript_data
952+
except Exception as format_exc:
953+
logger.exception(
954+
f"重新生成总结任务转录数据格式化失败: {task_id}, 错误: {format_exc}"
955+
)
956+
terminal_write_ok = _fail_task_after_creation(
957+
task_id, f"转录数据格式化失败: {format_exc}",
958+
log_context="转录数据格式化失败后写入 failed 终态失败",
959+
)
960+
detail = f"重新生成总结任务转录数据格式化失败: {format_exc}"
961+
if not terminal_write_ok:
962+
detail += _TERMINAL_WRITE_FAILURE_NOTE
963+
raise HTTPException(status_code=500, detail=detail)
964+
965+
# Build per-channel webhooks
966+
resum_webhooks = {}
967+
wechat_wh = (
968+
request_body.wechat_webhook
969+
or user_info.get("wechat_webhook")
970+
or config.get("wechat", {}).get("webhook")
971+
)
972+
if wechat_wh:
973+
resum_webhooks["wechat"] = wechat_wh
974+
feishu_wh = (
975+
user_info.get("feishu_webhook")
976+
or config.get("feishu", {}).get("webhook")
977+
)
978+
if feishu_wh:
979+
resum_webhooks["feishu"] = feishu_wh
980+
981+
# 放入 LLM 队列
982+
llm_queue = get_llm_queue()
983+
984+
llm_task = {
985+
"task_id": task_id,
986+
"url": task_info.get("url", ""),
987+
"display_url": task_info.get("url", ""),
988+
"platform": platform,
989+
"media_id": media_id,
990+
"video_title": video_title,
991+
"author": author,
992+
"description": description,
993+
"transcript": transcript_text,
994+
"use_speaker_recognition": use_speaker_recognition,
995+
# 强制纯文本路由(transcription_data=None),避免二次说话人推断
996+
# 浪费 LLM 调用;真实说话人数由上面的 cached_speaker_count 回传。
997+
"transcription_data": None,
998+
"cached_speaker_count": cached_speaker_count,
999+
"is_generic": False,
1000+
"wechat_webhook": resum_webhooks.get("wechat"),
1001+
"notification_webhooks": resum_webhooks,
1002+
# 不传 calibrate_only:它不是"重新校对"语义,避免走 llm_ops 的
1003+
# "保留总结"分支;层选择完全由 processing_options 表达。
1004+
"processing_options": resummarize_processing_options,
1005+
}
1006+
1007+
try:
1008+
# put_nowait 而非阻塞 put:与 recalibrate 同款收口——同步
1009+
# queue.Queue 在 async 路由里裸 put 会阻塞整个事件循环;满了
1010+
# 抛 queue.Full 转成下面的 503,同时把已落库为 processing 的
1011+
# 任务行 CAS 成 failed。
1012+
llm_queue.put_nowait(llm_task)
1013+
registration_owned = False
1014+
logger.info(f"重新生成总结任务已加入 LLM 队列: {task_id}")
1015+
except queue.Full:
1016+
logger.warning("LLM 队列已满,拒绝重新生成总结任务: %s", task_id)
1017+
terminal_write_ok = _fail_task_after_creation(
1018+
task_id, "LLM 队列已满,重新生成总结提交被拒绝",
1019+
log_context="LLM 队列已满后写入 failed 终态失败",
1020+
)
1021+
detail = "LLM 队列已满,请稍后重试"
1022+
if not terminal_write_ok:
1023+
detail += _TERMINAL_WRITE_FAILURE_NOTE
1024+
raise HTTPException(status_code=503, detail=detail)
1025+
except Exception as e:
1026+
logger.error(f"重新生成总结任务加入队列失败: {e}")
1027+
terminal_write_ok = _fail_task_after_creation(
1028+
task_id, f"任务加入队列失败: {e}",
1029+
log_context="任务加入队列失败后写入 failed 终态失败",
1030+
)
1031+
detail = f"任务加入队列失败: {e}"
1032+
if not terminal_write_ok:
1033+
detail += _TERMINAL_WRITE_FAILURE_NOTE
1034+
raise HTTPException(status_code=500, detail=detail)
1035+
1036+
processing_time_ms = int(
1037+
(datetime.datetime.now() - start_time).total_seconds() * 1000
1038+
)
1039+
audit_logger.log_api_call(
1040+
api_key=api_key,
1041+
user_id=user_id,
1042+
endpoint="/api/resummarize",
1043+
processing_time_ms=processing_time_ms,
1044+
status_code=202,
1045+
task_id=task_id,
1046+
user_agent=request.headers.get("User-Agent"),
1047+
remote_ip=request.client.host if request.client else None,
1048+
wechat_webhook=resum_webhooks.get("wechat"),
1049+
)
1050+
1051+
return TranscribeResponse(
1052+
code=202,
1053+
message="重新生成总结任务已提交",
1054+
data={"task_id": task_id, "view_token": view_token},
1055+
)
1056+
finally:
1057+
if registration_owned:
1058+
inflight_registry.release("llm", task_id)

‎src/video_transcript_api/api/services/transcription.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,28 @@ def validate_webhook_url(cls, v):
193193
return v
194194

195195

196+
class ResummarizeRequest(BaseModel):
197+
"""重新生成总结请求数据模型(字段与 RecalibrateRequest 完全相同)"""
198+
199+
view_token: str = Field(..., description="查看页面的 view_token")
200+
wechat_webhook: Optional[str] = Field(
201+
None, description="企业微信webhook地址,用于发送通知"
202+
)
203+
204+
@field_validator("wechat_webhook")
205+
@classmethod
206+
def validate_webhook_url(cls, v):
207+
"""验证 webhook URL 安全性(防止 SSRF)"""
208+
if v is None or v.strip() == "":
209+
return v
210+
from ...utils.url_validator import validate_url_safe, URLValidationError
211+
try:
212+
validate_url_safe(v)
213+
except URLValidationError as e:
214+
raise ValueError(f"webhook URL is not allowed: {e}")
215+
return v
216+
217+
196218
class TranscribeResponse(BaseModel):
197219
"""转录响应数据模型"""
198220

0 commit comments

Comments
 (0)