From e6814762c0999f87b7c3cb80f0910f925d9c6474 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 12:37:20 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=B4=E8=AF=9D?= =?UTF-8?q?=E4=BA=BA=E9=87=87=E6=A0=B7=E9=85=8D=E9=A2=9D=E5=85=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1481d-411a-78b2-bce7-3089f4f4ac06 --- config/config.example.jsonc | 4 +- .../llm/core/speaker_inferencer.py | 34 ++++---- tests/unit/test_speaker_inferencer.py | 85 +++++++++++++++++++ 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/config/config.example.jsonc b/config/config.example.jsonc index 68f3c835..baab0491 100644 --- a/config/config.example.jsonc +++ b/config/config.example.jsonc @@ -367,8 +367,8 @@ // 避免把低置信度的猜测当作确定结论展示给用户 // ------------------------------------------------------ "speaker_inference": { - "samples_per_speaker": 3, // 每个说话人采样的发言条数上限 - "max_chars_per_speaker": 400, // 每个说话人采样文本的总字符上限 + "samples_per_speaker": 3, // 普通说话人的采样条数基准;发言量 top2 至少 8 条;设为 0 关闭全部采样 + "max_chars_per_speaker": 400, // 普通说话人的采样字符基准;发言量 top2 至少 1200 字符;设为 0 关闭全部采样 "context_dialogs": 2, // 说话人首次出场前,采集他人发言作为上下文的条数 "confidence_threshold": 0.6, // 低于此置信度时不采用推断姓名,降级为"说话人N" "max_total_sample_chars": 8000 // 所有说话人采样文本合计的全局字符上限, diff --git a/src/video_transcript_api/llm/core/speaker_inferencer.py b/src/video_transcript_api/llm/core/speaker_inferencer.py index 7026c9d7..1ca22a5f 100644 --- a/src/video_transcript_api/llm/core/speaker_inferencer.py +++ b/src/video_transcript_api/llm/core/speaker_inferencer.py @@ -139,11 +139,9 @@ class SpeakerInferencer: # 单条采样文本的截断上限(字符数)。未纳入外部配置——过细粒度,固定即可。 _MAX_CHARS_PER_SAMPLE = 120 - # 发言量 top2 说话人的采样加额(card-vta-261015-speaker-evidence 锁定 - # 决策 6):按发言条数取前两名,采样上限提高到 8 条/1200 字符,其余 - # 维持 samples_per_speaker/max_chars_per_speaker 默认 3 条/400。目的: - # 保证对话内称呼片段(生产案例「胖哥」称呼未被 3 条采样覆盖)有更高 - # 概率进入模型输入。全局预算逻辑不变(_apply_global_sample_budget)。 + # 发言量 top2 说话人的采样配额下限:普通说话人配置是基准,top2 的条数 + # 与字符数分别取 max(基准, 此下限)。任一基准为 0 时全员停用采样并走 + # identity fallback。提高默认基准之上的召回,保留全局预算逻辑不变。 _TOP_SPEAKER_QUOTA_SLOTS = 2 _TOP_SPEAKER_SAMPLE_COUNT = 8 _TOP_SPEAKER_MAX_CHARS = 1200 @@ -316,8 +314,8 @@ def __init__( cache_manager: 缓存管理器(可选) model: 使用的模型 reasoning_effort: reasoning effort 参数 - samples_per_speaker: 每个说话人采样的发言条数上限(默认 3) - max_chars_per_speaker: 每个说话人采样文本的总字符上限(默认 400) + samples_per_speaker: 普通说话人的采样条数基准(默认 3);top2 至少 8,0 关闭全部采样 + max_chars_per_speaker: 普通说话人的采样字符基准(默认 400);top2 至少 1200,0 关闭全部采样 context_dialogs: 说话人首次出场前,采集他人发言作为上下文的条数(默认 2) confidence_threshold: 置信度阈值,低于此值不采用推断姓名(默认 0.6) max_total_sample_chars: 所有说话人采样文本合计的全局字符上限(默认 8000)。 @@ -393,6 +391,10 @@ def infer( logger.warning("Speaker list is empty, skipping inference") return self._identity_fallback(speakers) + if self.samples_per_speaker == 0 or self.max_chars_per_speaker == 0: + logger.info("Speaker sampling disabled by zero quota; using identity labels") + return self._identity_fallback(speakers) + input_fingerprint = self.input_fingerprint( speakers, dialogs, title=title, author=author, description=description ) @@ -637,14 +639,16 @@ def _extract_sample_dialogs( candidate_dialogs[speaker].append(text) + # candidate_dialogs 按 speakers 的首次出现顺序初始化,因此稳定排序会 + # 在发言条数并列时保留既有顺序。只排序一次,供下面每位说话人复用。 + top_speakers = self._top_sample_speakers(candidate_dialogs) + for speaker in speakers: candidates = candidate_dialogs[speaker] - # 发言量 top2 加额:按发言条数降序取前两名(并列按 speakers 列表 - # 顺序即首次出现顺序),其配额改用加额常量;其余说话人维持构造 - # 参数不变。 - if speaker in self._top_sample_speakers(candidate_dialogs, speakers): - quota_count = self._TOP_SPEAKER_SAMPLE_COUNT - quota_chars = self._TOP_SPEAKER_MAX_CHARS + # top2 各自采用配置基准与加额下限的较大值,普通说话人沿用配置。 + if speaker in top_speakers: + quota_count = max(self.samples_per_speaker, self._TOP_SPEAKER_SAMPLE_COUNT) + quota_chars = max(self.max_chars_per_speaker, self._TOP_SPEAKER_MAX_CHARS) else: quota_count = self.samples_per_speaker quota_chars = self.max_chars_per_speaker @@ -697,9 +701,7 @@ def _extract_sample_dialogs( return self._apply_global_sample_budget(sample_groups) - def _top_sample_speakers( - self, candidate_dialogs: Dict[str, List[str]], speakers: List[str] - ) -> set: + def _top_sample_speakers(self, candidate_dialogs: Dict[str, List[str]]) -> set: """按发言条数选出采样加额的说话人集合(发言量 top2)。 排序键:发言条数降序,并列按首次出现顺序(candidate_dialogs 的键 diff --git a/tests/unit/test_speaker_inferencer.py b/tests/unit/test_speaker_inferencer.py index e050c74b..40dcb677 100644 --- a/tests/unit/test_speaker_inferencer.py +++ b/tests/unit/test_speaker_inferencer.py @@ -200,6 +200,65 @@ def test_top_speaker_char_boost_to_1200(self): self.assertLessEqual(total_a, 1200) self.assertEqual(total_a, 8 * 120) # 8 条 x 120 字符/条 = 960 <= 1200 + def test_top_speaker_quota_preserves_larger_configured_baselines(self): + """top2 配额分别取配置基准与 8 条/1200 字符下限中的较大值。""" + speakers = ["A", "B", "C"] + dialogs = [] + for speaker in speakers: + for index in range(12): + marker = f"{speaker}-{index:02d}-END" + text = "x" * (120 - len(marker)) + marker + dialogs.append(_make_dialog(speaker, text, start_time=float(len(dialogs)))) + + inferencer = _make_inferencer( + samples_per_speaker=12, + max_chars_per_speaker=2000, + context_dialogs=0, + ) + inferencer.llm_client.call.return_value = _mock_llm_response( + speaker_mapping={speaker: speaker for speaker in speakers}, + confidence={speaker: 0.9 for speaker in speakers}, + ) + + inferencer.infer(speakers=speakers, dialogs=dialogs, title="t") + + prompt = inferencer.llm_client.call.call_args.kwargs["user_prompt"] + # 12 条各 120 字符,共 1440 字符;末尾标记确认整条样本都进入了 + # 真实请求。即使配置基准超过 top2 默认值,也不得缩回 8 条/1200 字符。 + for speaker in speakers: + for index in range(12): + self.assertIn(f"{speaker}-{index:02d}-END", prompt) + + def test_top_speaker_uses_configured_count_and_minimum_char_boost(self): + """较大的条数基准与 top2 字符加额各自生效,不互相覆盖。""" + speakers = ["A", "B", "C"] + dialogs = [] + for speaker in speakers: + for index in range(12): + marker = f"{speaker}-{index:02d}-END" + text = "x" * (100 - len(marker)) + marker + dialogs.append(_make_dialog(speaker, text, start_time=float(len(dialogs)))) + + inferencer = _make_inferencer( + samples_per_speaker=12, + max_chars_per_speaker=400, + context_dialogs=0, + ) + inferencer.llm_client.call.return_value = _mock_llm_response( + speaker_mapping={speaker: speaker for speaker in speakers}, + confidence={speaker: 0.9 for speaker in speakers}, + ) + + inferencer.infer(speakers=speakers, dialogs=dialogs, title="t") + + prompt = inferencer.llm_client.call.call_args.kwargs["user_prompt"] + # top2 的 12 x 100 字符恰好用满 1200;普通说话人的 4 x 100 + # 字符用满配置的 400。标记位于样本文本末尾,证明整个样本进入请求。 + for speaker, expected_count in (("A", 12), ("B", 12), ("C", 4)): + for index in range(expected_count): + self.assertIn(f"{speaker}-{index:02d}-END", prompt) + self.assertNotIn(f"{speaker}-{expected_count:02d}-END", prompt) + def test_single_sample_truncated_to_120_chars(self): inferencer = _make_inferencer(samples_per_speaker=1, max_chars_per_speaker=10000) very_long = "y" * 500 @@ -1187,6 +1246,32 @@ def test_no_valid_samples_falls_back_to_identity(self): self.assertFalse(result["meta"]["Speaker1"]["applied"]) self.assertEqual(result["source"], "identity_fallback") + def test_zero_sampling_quota_returns_identity_without_calling_llm(self): + dialogs = [_make_dialog("Speaker1", "hello", 0.0)] + for quota in ("samples_per_speaker", "max_chars_per_speaker"): + with self.subTest(quota=quota): + cache_manager = MagicMock() + cache_manager.get_speaker_mapping.return_value = { + "mapping": {"Speaker1": "Cached Name"}, + "meta": { + "Speaker1": { + "name": "Cached Name", + "confidence": 0.95, + "applied": True, + } + }, + "low_confidence": [], + } + inferencer = _make_inferencer(cache_manager=cache_manager, **{quota: 0}) + + result = inferencer.infer( + speakers=["Speaker1"], dialogs=dialogs, title="t" + ) + + self.assertEqual(result["mapping"], {"Speaker1": "Speaker1"}) + self.assertEqual(result["source"], "identity_fallback") + inferencer.llm_client.call.assert_not_called() + def test_llm_call_exception_falls_back_to_identity(self): """Local codex review round 6, G4: a transient LLM failure (network blip, rate limit, timeout...) must be tagged "identity_fallback", From dbf79e67effb30010a63c0913c8845cc2b82e39d Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Sat, 17 Oct 2026 12:55:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=AF=B4=E8=AF=9D?= =?UTF-8?q?=E4=BA=BA=E9=85=8D=E9=A2=9D=E5=A4=8D=E6=A0=B8=E8=A3=81=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-lead Agent-Session: 01a1481d-411a-78b2-bce7-3089f4f4ac06 --- .../reviews/B-speaker-sampling-verdict.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/sessions/261017-issue-cleanup/reviews/B-speaker-sampling-verdict.md diff --git a/docs/sessions/261017-issue-cleanup/reviews/B-speaker-sampling-verdict.md b/docs/sessions/261017-issue-cleanup/reviews/B-speaker-sampling-verdict.md new file mode 100644 index 00000000..8cb4cb50 --- /dev/null +++ b/docs/sessions/261017-issue-cleanup/reviews/B-speaker-sampling-verdict.md @@ -0,0 +1,40 @@ + + +## 后续裁决([codex]) + +**按原卡校准:不阻断交付。** 零配额只停用本轮新采样与新说话人 LLM 调用;不清除与输入指纹匹配的已验证历史姓名,原有 `identity_fallback` 姓名恢复与分层缓存行为保持不变。 + +下方独审的历史姓名恢复事实与原 P1 判定均保留。阻断来自 review 卡额外加入、原实施卡和设计均未要求的“不从历史缓存恢复名字”,不构成原需求缺陷;原 `p1-found` 由此裁决更正。 + +- 主脑前置 OCR 意见 1(低):`infer()` / `_identity_fallback()` docstring 未列零配额来源;接受不修。 +- 主脑前置 OCR 意见 2(低):私有 `_extract_sample_dialogs()` 零配额直调仍可能 top2 采样,但唯一生产调用方 `infer()` 已早退、无生产缺陷;接受不修。 +- 两项来自主脑前置 OCR;下方独审自跑 OCR 为 `findings=[]`,来源不同,均如实保留。 +- 原实施卡:`docs/sessions/261017-issue-cleanup/tasks/B-issue244-speaker-sampling.md`;PR [#262](https://github.com/zlxlabs/VideoTranscriptAPI/pull/262)。 + +failure-visibility: clean + +## 结论 + +**P1,阻断交付。** 固定范围内的采样配额计算、top2 稳定排序和内容预算符合卡面要求;但零配额只在 `SpeakerInferencer.infer()` 内生效,真实缓存与落盘调用链仍能把历史推断姓名放回结果,未满足核心不变式 2。 + +## Findings + +### P1 — 零配额仍会恢复历史说话人姓名 + +- **违反不变式:** 2(任一配额为 0 时须返回 identity fallback、不调用 LLM,且不从历史推断缓存恢复名字)。 +- **证据:** 新增分支在 `src/video_transcript_api/llm/core/speaker_inferencer.py:394` 返回 identity fallback;但正常落盘路径的 `src/video_transcript_api/api/services/llm_ops.py:2112` 会在 `speaker_inference_source == "identity_fallback"` 时调用姓名恢复。该恢复函数用同输入指纹验证 `speaker_mapping.json` 后,在 `src/video_transcript_api/api/services/llm_ops.py:1905` 将验证过的缓存姓名写回 dialogs 和 speaker_mapping。现有集成用例也将这条恢复行为作为 identity fallback 的预期结果锁定(`tests/integration/test_llm_ops_identity_fallback_name_restoration.py:244-297`)。另外,分层缓存预检在 `src/video_transcript_api/api/services/transcription.py:1391` 按历史映射决定是否需要说话人推断,并可在所有层命中时于 1520-1528 直接返回缓存结果;此路径也不检查采样配额。 +- **复现:** 先用非零配额处理一个媒体,留下与当前转录指纹匹配的 `speaker_mapping.json` 和带姓名的结构化结果;随后将任一配额设为 0,并对同一输入触发校对/补层。若进入推断器,新分支虽不调 LLM,`_save_llm_results` 仍会从匹配的 `speaker_mapping.json` 恢复旧姓名;若全层缓存命中,分层预检会直接返回既有含姓名结果。 +- **风险:** 用户显式关闭说话人采样后,输出仍展示历史模型推断出的姓名;核心停用语义无法兑现,因此阻断交付。 + +## 其余不变式 + +- **1:通过。** top2 的条数和字符配额分别取配置基准与 8/1200 的最大值,较大的显式基准不会被下调;示例注释表达了基准及加额关系。 +- **3:通过。** `_extract_sample_dialogs` 每轮只计算一次 top2;稳定排序保留候选字典顺序;helper 的未使用 `speakers` 参数已移除。 +- **4:通过。** 单条 120 字符上限和全局 `max_total_sample_chars` 预算处理未改变。 +- **5:通过。** 本次未新增负数校验、抽象或无关修复。 + +## 验证 + +- `git diff --check 29f24581a8532605413c0aab22faec028deb5e58..e6814762c0999f87b7c3cb80f0910f925d9c6474`:通过。 +- OCR 前置扫描:`status=reviewed`、`coverage=complete`、`findings=[]`;无 finding,因此二次 verifier 状态为 `skipped`。该结果不改变上述直接调用链 finding。 +- 未运行测试;本卡指定的验证命令为 `git diff --check`。