Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/config.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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 // 所有说话人采样文本合计的全局字符上限,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!-- delegate-outcome: succeeded -->

## 后续裁决([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`。
34 changes: 18 additions & 16 deletions src/video_transcript_api/llm/core/speaker_inferencer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)。
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 的键
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/test_speaker_inferencer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading