|
17 | 17 | from .audit import check_view_token_ownership |
18 | 18 | from ..services.transcription import ( |
19 | 19 | RecalibrateRequest, |
| 20 | + ResummarizeRequest, |
20 | 21 | TranscribeRequest, |
21 | 22 | TranscribeResponse, |
22 | 23 | normalize_processing_options, |
23 | 24 | verify_token, |
24 | 25 | ) |
| 26 | +from ...utils.llm_status import SummaryStatus |
25 | 27 | from ...utils.notifications import send_view_link_wechat, get_notification_router |
26 | 28 | from ...utils.task_status import TaskStatus, http_code_for_status |
27 | 29 |
|
@@ -780,3 +782,277 @@ async def recalibrate( |
780 | 782 | finally: |
781 | 783 | if registration_owned: |
782 | 784 | 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) |
0 commit comments