diff --git a/docs/sessions/vta-speaker-naming-261030/progress/vta-261030-self-intro-verifier-progress.md b/docs/sessions/vta-speaker-naming-261030/progress/vta-261030-self-intro-verifier-progress.md
new file mode 100644
index 00000000..d48f74cf
--- /dev/null
+++ b/docs/sessions/vta-speaker-naming-261030/progress/vta-261030-self-intro-verifier-progress.md
@@ -0,0 +1,19 @@
+# 本人样本自我介绍独立复核进度
+
+## 里程碑 1:规则改独立复核并完成窄测(2026-10-30)
+- 当前阶段:implementing,窄范围测试已通过。
+- 本段结论:删除了 `name_not_self_intro` 的正则句式清单;只出现在本人发言样本里的 dialog_address 改为每次 `infer()` 最多一次独立 LLM 复核。Prompt 与 schema 放在新文件,未增加 `prompts/__init__.py` 行数。
+- 关键决策与已否决方案:复核请求只含 `{id, name, quote}`;缺 id 或域外分类记 `unclear` 并判 `name_not_self_intro`,调用异常沿用 `infer()` 现有路径。不在首轮推断里让模型自报 address_kind。
+- 下一步唯一动作:跑 `make test`、编译检查与 scope 检查。
+
+## 里程碑 2:全量回归与红验通过(2026-10-30)
+- 当前阶段:implementing 收尾,准备交付。
+- 本段结论:`make test` 退出码 0;`compileall` 退出码 0;缺 id 判 `name_not_self_intro` 与规则版本 6 两条红验均为 AssertionError。`--commit-range 5cdd6bb4..HEAD` 的 scope 检查通过。
+- 关键决策与已否决方案:无。
+- 下一步唯一动作:提交本段进度、推送分支并写执行器报告。
+
+## 里程碑 3:行数闸续修,超限老文件净增压到 ≤ 0(2026-10-30)
+- 当前阶段:implementing 续修第 1 轮。
+- 本段结论:复核编排迁入 `apply_self_intro_review`,`speaker_inferencer.py` 只留调用点。本卡新增用例集中在 `tests/unit/test_speaker_self_intro_verifier.py`。相对 `5cdd6bb4` 四个超限文件 numstat 均为新增-删除 ≤ 0。
+- 关键决策与已否决方案:不新建共享辅助文件,分发 mock 放在新测试文件里供导入。本仓没有 `scripts/lint/file_size_gate.py`,无法 `--update-baseline`;棘轮以 git numstat 相对基线为准。
+- 下一步唯一动作:红验、`make test`、推送分支。
diff --git a/docs/sessions/vta-speaker-naming-261030/progress/vta-261030-speaker-evidence-fix-progress.md b/docs/sessions/vta-speaker-naming-261030/progress/vta-261030-speaker-evidence-fix-progress.md
new file mode 100644
index 00000000..0636a278
--- /dev/null
+++ b/docs/sessions/vta-speaker-naming-261030/progress/vta-261030-speaker-evidence-fix-progress.md
@@ -0,0 +1,13 @@
+# 说话人证据闸门修复进度
+
+## 里程碑 1:完成规则实现与窄测(2026-10-30)
+- 当前阶段:实现完成,窄范围验证通过。
+- 本段结论:静态行候选名已绑定引用文本;本人发言样本只有命中明确自我介绍句式才通过,出场前他人上下文与第三人呼叫-回应旁证保持可用。系统提示已区分标题与发布账号署名。
+- 关键决策与已否决方案:核对侧从同一 `sample_groups` 拆取原始 `context_before` / `own_samples`,不改用户提示的渲染文本;不采信模型自报的证据类别或主持人身份。
+- 下一步唯一动作:运行 `make test`、编译检查与 scope 检查,修复本卡范围内的问题。
+
+## 里程碑 2:全量回归通过(2026-10-30)
+- 当前阶段:全量测试与编译验证通过,准备交付。
+- 本段结论:首次 `make test` 暴露一个过严的提示词基线比较和两个仍用普通本人发言冒充称呼的旧测试夹具;修正为只容许本卡三处提示词变化,并将夹具改为真实自我介绍后,完整 `make test` 通过。
+- 关键决策与已否决方案:按批准的 scope seq=1 修改两个回归测试文件;保留原测试目的,只更新与本卡新契约冲突的提示词基线和证据句。
+- 下一步唯一动作:提交回归测试适配并推送本分支,随后核对远端分支状态。
diff --git a/src/video_transcript_api/llm/core/speaker_inferencer.py b/src/video_transcript_api/llm/core/speaker_inferencer.py
index ec35f1ea..123ea2f9 100644
--- a/src/video_transcript_api/llm/core/speaker_inferencer.py
+++ b/src/video_transcript_api/llm/core/speaker_inferencer.py
@@ -17,6 +17,7 @@
from .llm_client import LLMClient
from .cache_manager import CacheManager
from .key_info_extractor import KeyInfo
+from .speaker_self_intro_verifier import apply_self_intro_review
from ..prompts import (
SPEAKER_INFERENCE_SYSTEM_PROMPT,
build_speaker_inference_user_prompt,
@@ -29,16 +30,9 @@
logger = setup_logger(__name__)
-# 证据裁决规则版本(card-vta-261015-speaker-evidence)。纳入 input_fingerprint:
-# 裁决规则升级后旧缓存自然失效重算,不会用旧规则的 mapping 冒充新规则的产物。
-# 唯一定义处,与 SpeakerInferencer.input_fingerprint 同文件维护。
-# v2 = 证据裁决上线;v3 = 复合显示名带「推测」标注(gate 修复轮第 3 轮:
-# 标题名与对话称呼是否同一人属模型推测,mapping 值必须就地自证推测属性,
-# 含原始说话人编号 + 推测标注 + 两条来源名);v4 = 平台先验段 + 第三人提及
-# 旁证区(gate 修复轮第 5 轮:prompt 新增 twitter_space 平台背景先验、采样
-# 阶段新增呼叫-回应旁证收集与 dialog_address 旁证引用判据)。显示格式与
-# 证据可达性都属于裁决规则的一部分,bump 版本让旧格式缓存经指纹自然失效重算。
-SPEAKER_EVIDENCE_RULES_VERSION = 4
+# 证据裁决规则版本,纳入 input_fingerprint;升级后旧缓存失效重算。
+# 唯一定义处。v2-v5 见历史;v6 = 本人样本自我介绍改由独立 LLM 复核。
+SPEAKER_EVIDENCE_RULES_VERSION = 6
def build_speaker_risk_flags(
@@ -498,24 +492,20 @@ def infer(
platform=platform,
corroboration_snippets=corroboration_snippets,
)
- # quote 核对分区(gate 审查修复②):与 user_prompt 同参同源构建,
- # 核对收窄到 evidence_type 对应分区,防止「仅出现在描述参会名单里的
- # 名字被误标为 dialog_address」的绕行。
+ # Metadata sections share the prompt builder; dialog sources below split
+ # the same sampled text by context_before versus own_samples.
quote_sections = build_speaker_quote_sections(
video_title=title,
author=author,
description=description,
context_snippets=context_snippets,
)
- # gate 修复轮第 2 轮(correctness-speaker-evidence-quote-scope·跨说话人
- # 借用):dialog_address 的核对对象从「全部说话人片段拼接的转录区」
- # 收窄到「该说话人自己的采样片段与出场上下文」。per-speaker 片段由
- # _render_speaker_segment 渲染——与拼进 prompt 的 context_snippets 是
- # 同一份渲染逻辑("\n\n".join 不改片段内容),逐字同源;其他说话人
- # 文本中的称呼不构成该说话人的 dialog_address 证据(fixture 上
- # Speaker1 片段里的「黄兰姐」不能让它冒领 Speaker2 的「兰姐」称呼)。
+ # Keep raw evidence sources separate; prompt rendering remains unchanged.
dialog_sections_by_speaker = {
- speaker: self._render_speaker_segment(speaker, info)
+ speaker: {
+ "context_before": "\n".join(text for _, text in info["context_before"]),
+ "own_samples": "\n".join(info["own_samples"]),
+ }
for speaker, info in sample_groups.items()
}
@@ -1221,45 +1211,18 @@ def _verify_candidates(
candidates,
quote_sections: Optional[Dict[str, str]] = None,
corroboration_entries: Optional[List[Dict]] = None,
- ) -> Tuple[List[Dict], List[str]]:
- """逐条核对 LLM 候选的引用,返回 (核对通过的候选, 作废原因列表)。
-
- 核对规则(fail-closed,任一不满足即作废):
- - name 非空字符串
- - evidence_type 在域值内(SPEAKER_EVIDENCE_TYPES),域外类型一律作废
- - evidence_quote 含实质内容:去首尾空白后长度 >= 2 且至少含一个
- 字母/数字/汉字(isalnum 语义)——纯空白/纯标点(全角空格、中文
- 引号、单个逗号等)即使逐字存在于输入中也无证据价值
- (gate 审查修复①:correctness-whitespace-evidence-quote)
- - evidence_quote 逐字存在于其 evidence_type 对应分区(
- build_speaker_quote_sections,与 user prompt 组装同源),不做
- strip/归一化——LLM 摇抄不逐字就是证据不合格;分区缺失或为空串
- 时任何 quote 都核对不过
- (gate 审查修复②:correctness-speaker-evidence-quote-scope)。
- gate 修复轮第 5 轮②扩展:dialog_address 的 quote 允许来自第三人
- 提及旁证条目(corroboration_entries,本人片段分区核对不到时的
- 第二通道)——quote 必须逐字出现在本说话人的某条旁证条目文本内,
- 且该条目在收集时已被程序证明「呼叫后紧邻本人应答」;quote 与
- 呼叫对象的一致性由条目归属(只传入本说话人的条目)保证。通道
- 只对 dialog_address 开放,静态行分类仍以自身分区为唯一核对对象。
- - dialog_address 的 evidence_quote 必须包含候选名本身(英文忽略
- 大小写)——本人片段与旁证两条通道同样适用;归属核对只证明文本
- 属于该说话人,不证明文本支持该姓名
- (gate 审查修复④:correctness-dialog-candidate-name-not-bound-
- to-quote)。静态行(title_credit/author_host/description_roster)
- 核对不变:复合名侧已有双证据 + 推测标注兜住关联性。
-
- 作废原因(quote_missing / name_not_in_quote / invalid_candidate /
- unknown_evidence_type)返回给调用方写入 meta 供诊断;候选级原因中
- quote_missing 是轴表点名的必须可观测的一项。旁证通道核对通过的
- 候选附带 corroboration 元数据(来源说话人、段号、时间)——evidence
- meta 如实标注 quote 来源(gate 修复轮第 5 轮②)。
- """
+ *,
+ dialog_sources: Dict[str, str],
+ ) -> Tuple[List[Dict], List[str], List[Dict]]:
+ """核对引用与名字绑定;本人样本 dialog_address 放入 pending 待复核。"""
verified: List[Dict] = []
rejected: List[str] = []
+ pending: List[Dict] = []
sections = quote_sections or {}
+ context_before = dialog_sources.get("context_before", "")
+ own_samples = dialog_sources.get("own_samples", "")
if not isinstance(candidates, list):
- return verified, rejected
+ return verified, rejected, pending
for cand in candidates:
if not isinstance(cand, dict):
rejected.append("invalid_candidate")
@@ -1280,45 +1243,58 @@ def _verify_candidates(
if len(stripped) < 2 or not any(ch.isalnum() for ch in stripped):
rejected.append("quote_missing")
continue
+ corroboration_entry = next(
+ (
+ entry
+ for entry in (corroboration_entries or [])
+ if evidence_type == "dialog_address"
+ and isinstance(entry, dict)
+ and quote in (entry.get("text") or "")
+ ),
+ None,
+ )
corroboration_meta: Optional[Dict] = None
- if quote not in sections.get(evidence_type, ""):
- # 本人/静态分区核对不过:仅 dialog_address 允许第二通道
- # (第三人提及旁证条目),其余类型直接作废。
- if evidence_type != "dialog_address":
- rejected.append("quote_missing")
- continue
- entry = next(
- (
- e
- for e in (corroboration_entries or [])
- if isinstance(e, dict) and quote in (e.get("text") or "")
- ),
- None,
- )
- if entry is None:
+ if evidence_type == "dialog_address":
+ section_hit = quote in context_before or quote in own_samples
+ else:
+ section_hit = quote in sections.get(evidence_type, "")
+ if not section_hit:
+ if evidence_type != "dialog_address" or corroboration_entry is None:
rejected.append("quote_missing")
continue
corroboration_meta = {
- "source_speaker": str(entry.get("speaker", "")),
- "segment_index": entry.get("index"),
- "quote_time": entry.get("time"),
+ "source_speaker": str(corroboration_entry.get("speaker", "")),
+ "segment_index": corroboration_entry.get("index"),
+ "quote_time": corroboration_entry.get("time"),
}
- # gate 审查修复④:dialog_address 的名字绑定判据(见 docstring),
- # 本人片段与旁证两条通道同样适用。
- if evidence_type == "dialog_address":
- bound_name = name.strip()
- if bound_name.casefold() not in quote.casefold():
- rejected.append("name_not_in_quote")
- continue
+ bound_name = name.strip()
+ if bound_name.casefold() not in quote.casefold():
+ rejected.append("name_not_in_quote")
+ continue
+ if (
+ evidence_type == "dialog_address"
+ and quote in own_samples
+ and quote not in context_before
+ and corroboration_entry is None
+ ):
+ pending.append(
+ {
+ "name": bound_name,
+ "evidence_type": evidence_type,
+ "evidence_quote": quote,
+ "source": cand,
+ }
+ )
+ continue
verified_candidate = {
- "name": name.strip(),
+ "name": bound_name,
"evidence_type": evidence_type,
"evidence_quote": quote,
}
if corroboration_meta is not None:
verified_candidate["corroboration"] = corroboration_meta
verified.append(verified_candidate)
- return verified, rejected
+ return verified, rejected, pending
@staticmethod
def _decide_display_name(
@@ -1382,40 +1358,17 @@ def _apply_evidence_gate(
sampled: Optional[Dict[str, bool]] = None,
quote_sections: Optional[Dict[str, str]] = None,
*,
- dialog_sections_by_speaker: Dict[str, str],
+ dialog_sections_by_speaker: Dict[str, Dict[str, str]],
corroboration_by_speaker: Optional[Dict[str, List[Dict]]] = None,
) -> Dict:
- """证据裁决:按核对通过的引用决定显示名,confidence 只入 meta。
-
- 与 _apply_confidence_gate(仍服务 v1 缓存重判)的关键差异:
- - applied 与否完全由引用核对结果决定;LLM 自报 confidence 不参与。
- - 未采样的 speaker 保留原始标签(同 v1 语义)。
- - meta 新增 candidates(LLM 原始候选,供审计)/ verified_candidates
- (核对通过的候选,applied 映射据此可指回已核对引用)/
- rejection_reason(未采用时的作废原因)。
+ """按核对通过的证据裁决显示名;confidence 只记录到 meta。
- Args:
- dialog_sections_by_speaker: speaker→自身采样片段(含出场上下文)
- 的渲染文本(gate 修复轮第 2 轮,correctness-speaker-evidence-
- quote-scope 跨说话人借用)。dialog_address 的 quote 对该说话人
- 只核对这个分区,必传 keyword——映射缺某 speaker 时按空分区
- fail-closed(该 speaker 的 dialog_address 候选一律核对不过),
- 与静态分区缺失的既有语义一致。title_credit/author_host/
- description_roster 三类静态行核对不受影响(与说话人无关)。
- corroboration_by_speaker: speaker→第三人提及旁证条目列表
- (gate 修复轮第 5 轮②,_collect_call_response_evidence 的
- 输出)。dialog_address 的 quote 在本人片段分区核对不过时,
- 允许逐字命中本说话人的旁证条目(收集时已程序核实呼叫-回应);
- 核对通过后候选附带 corroboration 元数据(来源说话人/段号)。
- None 或映射缺失 speaker 时按空列表 fail-closed。
-
- Returns:
- {"mapping", "meta", "low_confidence"};low_confidence 语义
- 在 v2 下是「证据核对未通过/未采样的 label 列表」。
+ 本人样本 dialog_address 经 apply_self_intro_review 一次打包复核。
"""
mapping: Dict[str, str] = {}
meta: Dict[str, Dict] = {}
low_confidence: List[str] = []
+ prepared: List[Tuple] = []
for speaker in speakers:
was_sampled = True if sampled is None else sampled.get(speaker, True)
@@ -1444,29 +1397,35 @@ def _apply_evidence_gate(
candidates = candidates_by_speaker.get(speaker)
if not isinstance(candidates, list):
candidates = []
- # dialog_address 分区按说话人收窄(跨说话人借用修复);静态行
- # 分区原样透传。映射缺失的 speaker 得到空串 → fail-closed。
- effective_sections = dict(quote_sections or {})
- effective_sections["dialog_address"] = dialog_sections_by_speaker.get(
- speaker, ""
- )
- verified, rejected = self._verify_candidates(
+ dialog_sources = dialog_sections_by_speaker.get(speaker, {})
+ verified, rejected, pending = self._verify_candidates(
candidates,
- effective_sections,
+ quote_sections,
corroboration_entries=(corroboration_by_speaker or {}).get(speaker),
+ dialog_sources=dialog_sources,
+ )
+ prepared.append(
+ (speaker, confidence, candidates, verified, rejected, pending)
)
+
+ apply_self_intro_review(
+ self.llm_client, self.model, self.reasoning_effort, prepared
+ )
+
+ for speaker, confidence, candidates, verified, rejected, _pending in prepared:
display_name, reason = self._decide_display_name(verified, speaker, speakers)
inferred_name = raw_mapping.get(speaker, speaker)
if display_name is None:
- # 全部候选都因引用核对作废(quote_missing / name_not_in_quote,
- # 修复④新增后者)且无任何核对通过时,作废原因取核对层原因
- # (quote_missing 是轴表点名的可观测项,混合时优先),否则用
- # 裁决规则给出的原因。
if not verified and rejected and all(
- r in ("quote_missing", "name_not_in_quote") for r in rejected
+ r in ("quote_missing", "name_not_in_quote", "name_not_self_intro")
+ for r in rejected
):
- reason = "quote_missing" if "quote_missing" in rejected else "name_not_in_quote"
+ reason = next(
+ r
+ for r in ("quote_missing", "name_not_in_quote", "name_not_self_intro")
+ if r in rejected
+ )
fallback_label = self._build_fallback_label(speaker, speakers)
mapping[speaker] = fallback_label
low_confidence.append(speaker)
@@ -1516,21 +1475,10 @@ def _build_fallback_label(speaker: str, speakers: List[str]) -> str:
return f"说话人{n}"
def _identity_fallback(self, speakers: List[str]) -> Dict:
- """无法推断时的兜底:所有说话人使用原始标签,标记为未采信
-
- meta 里附带 "sampled": False,与 _apply_confidence_gate 的同名字段
- 保持形状一致——虽然这两条路径的结果目前都不会被写入缓存(调用方
- 没有传 platform/media_id 走到这里,或者是异常兜底),但形状统一
- 能避免未来读取 meta 的代码要额外处理"某些路径没有这个 key"。
-
- 统一在这里标记 "source": "identity_fallback"(本地 codex review
- 第 6 轮 G4),而不是让 infer() 的每个调用点各自补一遍:这个方法是
- speakers 为空、allow_llm=False、无有效采样样本、LLM 调用异常这
- 四条路径唯一共用的出口,集中标记既不遗漏也不会几处代码各写一遍、
- 将来漂移。调用方(llm_ops._refresh_speaker_names_in_existing_
- structured_artifact)据此拒绝用这类没有真实推断依据的结果覆盖既有
- 展示产物——尤其是"LLM 调用异常"这条路径,瞬时故障不该把已经展示的
- 真名替换成占位符。
+ """无法推断时的兜底:所有说话人使用原始标签,标记为未采信。
+
+ meta 带 sampled=False,与 confidence gate 同形。source 固定为
+ identity_fallback,供调用方拒绝用无真实推断的结果覆盖已有产物。
"""
return {
"mapping": {speaker: speaker for speaker in speakers},
diff --git a/src/video_transcript_api/llm/core/speaker_self_intro_verifier.py b/src/video_transcript_api/llm/core/speaker_self_intro_verifier.py
new file mode 100644
index 00000000..4dd41126
--- /dev/null
+++ b/src/video_transcript_api/llm/core/speaker_self_intro_verifier.py
@@ -0,0 +1,166 @@
+"""Independent LLM review of own-sample dialog_address quotes.
+
+One call per infer() packs every quote that only appears in the speaker's
+own samples. The first-round inference output is not sent. Missing or
+out-of-domain classifications are recorded as unclear; call failures
+propagate to the caller.
+"""
+
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from ...utils.logging import setup_logger
+from ..prompts.schemas.speaker_self_intro import (
+ SELF_INTRO_CLASSIFICATIONS,
+ SPEAKER_SELF_INTRO_SCHEMA,
+)
+from ..prompts.speaker_self_intro import (
+ SPEAKER_SELF_INTRO_SYSTEM_PROMPT,
+ build_speaker_self_intro_user_prompt,
+)
+from .llm_client import LLMClient
+
+logger = setup_logger(__name__)
+
+SPEAKER_SELF_INTRO_TASK_TYPE = "speaker_self_intro"
+SELF_INTRO_PASS = "self_introduction"
+SELF_INTRO_UNCLEAR = "unclear"
+
+
+class SpeakerSelfIntroVerifier:
+ """Classify own-sample quotes with one structured LLM call."""
+
+ def __init__(
+ self,
+ llm_client: LLMClient,
+ model: str,
+ reasoning_effort: Optional[str] = None,
+ ) -> None:
+ self.llm_client = llm_client
+ self.model = model
+ self.reasoning_effort = reasoning_effort
+
+ def verify(self, items: Sequence[Dict[str, str]]) -> Dict[str, str]:
+ """Return id -> classification for every requested item.
+
+ Requested ids missing from the response, or mapped to a value
+ outside SELF_INTRO_CLASSIFICATIONS, are recorded as unclear.
+ """
+ requested_ids = [str(item["id"]) for item in items]
+ if not requested_ids:
+ return {}
+
+ payload_items = [
+ {"id": str(item["id"]), "name": str(item["name"]), "quote": str(item["quote"])}
+ for item in items
+ ]
+ user_prompt = build_speaker_self_intro_user_prompt(payload_items)
+ response = self.llm_client.call(
+ model=self.model,
+ system_prompt=SPEAKER_SELF_INTRO_SYSTEM_PROMPT,
+ user_prompt=user_prompt,
+ response_schema=SPEAKER_SELF_INTRO_SCHEMA,
+ reasoning_effort=self.reasoning_effort,
+ task_type=SPEAKER_SELF_INTRO_TASK_TYPE,
+ )
+ parsed = _parse_verdicts(response.structured_output, requested_ids)
+ passed = sum(1 for value in parsed.values() if value == SELF_INTRO_PASS)
+ logger.info(
+ f"Self-intro verification completed: candidates={len(requested_ids)} passed={passed}"
+ )
+ return parsed
+
+
+def apply_self_intro_review(
+ llm_client: LLMClient,
+ model: str,
+ reasoning_effort: Optional[str],
+ prepared: Sequence[Tuple],
+) -> None:
+ """Pack pending own-sample quotes into one review call and attach verdicts.
+
+ Each ``prepared`` row is
+ ``(speaker, confidence, candidates, verified, rejected, pending)``.
+ ``verified`` and ``rejected`` are mutated in place. No pending rows
+ means the LLM is not called.
+ """
+ pending_items: List[Dict[str, str]] = []
+ refs_by_row: List[List[Tuple[str, Dict[str, Any]]]] = []
+ for row in prepared:
+ refs: List[Tuple[str, Dict[str, Any]]] = []
+ for item in row[5]:
+ item_id = f"si-{len(pending_items)}"
+ pending_items.append(
+ {
+ "id": item_id,
+ "name": item["name"],
+ "quote": item["evidence_quote"],
+ }
+ )
+ refs.append((item_id, item))
+ refs_by_row.append(refs)
+ verdicts: Dict[str, str] = {}
+ if pending_items:
+ verdicts = SpeakerSelfIntroVerifier(
+ llm_client, model, reasoning_effort
+ ).verify(pending_items)
+ for row, refs in zip(prepared, refs_by_row):
+ attach_self_intro_verdicts(refs, verdicts, row[3], row[4])
+
+
+def attach_self_intro_verdicts(
+ pending_refs: Sequence[Tuple[str, Dict[str, Any]]],
+ verdicts: Dict[str, str],
+ verified: List[Dict[str, Any]],
+ rejected: List[str],
+) -> None:
+ """Apply review classifications onto pending own-sample candidates.
+
+ Mutates the original candidate dict (``source``) with self_intro_verdict.
+ Only self_introduction is appended to verified; every other class is
+ rejected as name_not_self_intro.
+ """
+ for item_id, pending_cand in pending_refs:
+ classification = verdicts.get(item_id, SELF_INTRO_UNCLEAR)
+ if classification not in SELF_INTRO_CLASSIFICATIONS:
+ classification = SELF_INTRO_UNCLEAR
+ source = pending_cand.get("source")
+ if isinstance(source, dict):
+ source["self_intro_verdict"] = classification
+ if classification == SELF_INTRO_PASS:
+ verified.append(
+ {
+ "name": pending_cand["name"],
+ "evidence_type": pending_cand["evidence_type"],
+ "evidence_quote": pending_cand["evidence_quote"],
+ "self_intro_verdict": classification,
+ }
+ )
+ else:
+ rejected.append("name_not_self_intro")
+
+
+def _parse_verdicts(payload: Any, requested_ids: Sequence[str]) -> Dict[str, str]:
+ """Map each requested id to a domain classification; gaps become unclear."""
+ found: Dict[str, str] = {}
+ if isinstance(payload, dict):
+ entries = payload.get("verdicts")
+ if isinstance(entries, list):
+ for entry in entries:
+ if not isinstance(entry, dict):
+ continue
+ item_id = entry.get("id")
+ classification = entry.get("classification")
+ if not isinstance(item_id, str) or item_id in found:
+ continue
+ if item_id not in requested_ids:
+ continue
+ if (
+ isinstance(classification, str)
+ and classification in SELF_INTRO_CLASSIFICATIONS
+ ):
+ found[item_id] = classification
+ else:
+ found[item_id] = SELF_INTRO_UNCLEAR
+ return {
+ item_id: found.get(item_id, SELF_INTRO_UNCLEAR) for item_id in requested_ids
+ }
diff --git a/src/video_transcript_api/llm/prompts/__init__.py b/src/video_transcript_api/llm/prompts/__init__.py
index e3218891..e5badc7e 100644
--- a/src/video_transcript_api/llm/prompts/__init__.py
+++ b/src/video_transcript_api/llm/prompts/__init__.py
@@ -860,9 +860,9 @@ def build_validation_user_prompt(
## 证据类型(candidates[].evidence_type,只能取以下四个值)
-- `dialog_address`:对话内其他人对该说话人的称呼,或该说话人的自我介绍。最强的证据,单条核对通过即可被采用为显示名。引用可来自:该说话人本人片段/出场上下文,或第三人提及旁证区中呼叫对象为该说话人的句子(旁证句的呼叫-回应关系已由程序核实,采用时程序会在 meta 里记录来源说话人与段号)。
-- `title_credit`:视频标题/专场名/作者名。仅当另有该说话人的 `dialog_address` 佐证(表明该说话人是专场主角/主持人)时才会被采用;标题名与对话称呼是否同一人属于推测,采用时显示名会保留原始说话人编号并以「标题关联推测」等标注形式同时带上两条来源名。
-- `author_host`:作者署名即主持人身份。与 `title_credit` 同规则。
+- `dialog_address`:对话内其他人对该说话人的称呼,或本人明确自我介绍。最强证据,单条核对通过即可采用。引用可来自出场前他人发言或第三人提及旁证;本人发言中仅提及姓名不算称呼,只有符合自我介绍句式才算证据。旁证句的呼叫-回应关系已由程序核实,采用时会记录来源说话人与段号。
+- `title_credit`:视频标题/专场名。引用必须来自标题。仅当另有该说话人的 `dialog_address` 佐证时才会被采用;标题名与对话称呼是否同一人属于推测,采用时显示名会保留原始说话人编号并以「标题关联推测」标注两条来源名。
+- `author_host`:发布账号的署名。署名不等于出镜者或主持人,转载、搬运、剪辑账号尤其如此。仅当另有该说话人的 `dialog_address` 佐证时才会被采用;采用时以「作者关联推测」标注两条来源名。
- `description_roster`:描述中的参会名单。名单只是名单,核对通过也**不会单独**让名字被采用,仅作为佐证记录;不要把它当作「实际发言人名单」。
## 硬性要求
diff --git a/src/video_transcript_api/llm/prompts/schemas/speaker_self_intro.py b/src/video_transcript_api/llm/prompts/schemas/speaker_self_intro.py
new file mode 100644
index 00000000..4b7fef58
--- /dev/null
+++ b/src/video_transcript_api/llm/prompts/schemas/speaker_self_intro.py
@@ -0,0 +1,36 @@
+"""Self-introduction review JSON schema.
+
+Independent of the first-round speaker inference schema: this call only
+classifies already-checked own-sample quotes.
+"""
+
+SELF_INTRO_CLASSIFICATIONS: tuple[str, ...] = (
+ "self_introduction",
+ "addressed_by_other",
+ "third_party_mention",
+ "unclear",
+)
+
+SPEAKER_SELF_INTRO_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "verdicts": {
+ "type": "array",
+ "description": "One classification per reviewed quote",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string", "minLength": 1},
+ "classification": {
+ "type": "string",
+ "enum": list(SELF_INTRO_CLASSIFICATIONS),
+ },
+ },
+ "required": ["id", "classification"],
+ "additionalProperties": False,
+ },
+ }
+ },
+ "required": ["verdicts"],
+ "additionalProperties": False,
+}
diff --git a/src/video_transcript_api/llm/prompts/speaker_self_intro.py b/src/video_transcript_api/llm/prompts/speaker_self_intro.py
new file mode 100644
index 00000000..6e2d8db8
--- /dev/null
+++ b/src/video_transcript_api/llm/prompts/speaker_self_intro.py
@@ -0,0 +1,52 @@
+"""Prompt for the independent self-introduction review call.
+
+Kept out of prompts/__init__.py so that module's line budget stays at zero.
+The request body is only {id, name, quote}; first-round inference output
+must never appear here.
+"""
+
+import json
+from typing import Dict, List, Sequence
+
+SPEAKER_SELF_INTRO_SYSTEM_PROMPT = """You classify whether a quoted utterance is the speaker introducing themselves.
+
+You receive only a candidate name and a verbatim quote. Do not use any other context. Do not reuse or invent first-round inference fields such as reasoning, confidence, or evidence_type.
+
+Classify each item as exactly one of:
+- self_introduction: the speaker is presenting their own name or identity.
+- addressed_by_other: someone else is addressing or naming this person.
+- third_party_mention: the quote talks about this name as a third person.
+- unclear: the quote is not enough to tell.
+
+Positive examples (self_introduction):
+- name="Bob", quote="Bob here"
+- name="柠姐", quote="柠姐在这儿"
+- name="某某", quote="大家好,这里是某某"
+- name="Alice", quote="Hi, I’m Alice"
+- name="Bob", quote="大家都叫我 Bob"
+
+Negative examples:
+- name="John Collison", quote="John Collison wrote about this" → third_party_mention
+- name="张三", quote="欢迎张三发言" → addressed_by_other
+- name="Bob", quote="I forwarded this to Bob yesterday" → third_party_mention
+
+Return JSON only: {"verdicts": [{"id": "...", "classification": "..."}, ...]}.
+Every input id should appear at most once. Use only the four classifications above.
+"""
+
+
+def build_speaker_self_intro_user_prompt(items: Sequence[Dict[str, str]]) -> str:
+ """Build the user prompt from program-generated {id, name, quote} items."""
+ payload: List[Dict[str, str]] = [
+ {
+ "id": str(item["id"]),
+ "name": str(item["name"]),
+ "quote": str(item["quote"]),
+ }
+ for item in items
+ ]
+ encoded = json.dumps(payload, ensure_ascii=False)
+ return (
+ "Classify each item. Use only the quote and the candidate name.\n"
+ f"\n{encoded}\n"
+ )
diff --git a/tests/unit/i18n_llm/test_prompt_selection.py b/tests/unit/i18n_llm/test_prompt_selection.py
index b4c53372..8bb86517 100644
--- a/tests/unit/i18n_llm/test_prompt_selection.py
+++ b/tests/unit/i18n_llm/test_prompt_selection.py
@@ -68,10 +68,27 @@ def _git_show(path: str) -> bytes:
)
-def test_chinese_prompt_module_matches_base_byte_for_byte():
- """Chinese prompt constants stay in prompts/__init__.py and must not drift."""
+def test_chinese_prompt_module_matches_base_except_speaker_evidence_definitions():
+ baseline = _git_show(CHINESE_PROMPT_MODULE)
+ changes = (
+ (
+ "- `dialog_address`:对话内其他人对该说话人的称呼,或该说话人的自我介绍。最强的证据,单条核对通过即可被采用为显示名。引用可来自:该说话人本人片段/出场上下文,或第三人提及旁证区中呼叫对象为该说话人的句子(旁证句的呼叫-回应关系已由程序核实,采用时程序会在 meta 里记录来源说话人与段号)。",
+ "- `dialog_address`:对话内其他人对该说话人的称呼,或本人明确自我介绍。最强证据,单条核对通过即可采用。引用可来自出场前他人发言或第三人提及旁证;本人发言中仅提及姓名不算称呼,只有符合自我介绍句式才算证据。旁证句的呼叫-回应关系已由程序核实,采用时会记录来源说话人与段号。",
+ ),
+ (
+ "- `title_credit`:视频标题/专场名/作者名。仅当另有该说话人的 `dialog_address` 佐证(表明该说话人是专场主角/主持人)时才会被采用;标题名与对话称呼是否同一人属于推测,采用时显示名会保留原始说话人编号并以「标题关联推测」等标注形式同时带上两条来源名。",
+ "- `title_credit`:视频标题/专场名。引用必须来自标题。仅当另有该说话人的 `dialog_address` 佐证时才会被采用;标题名与对话称呼是否同一人属于推测,采用时显示名会保留原始说话人编号并以「标题关联推测」标注两条来源名。",
+ ),
+ (
+ "- `author_host`:作者署名即主持人身份。与 `title_credit` 同规则。",
+ "- `author_host`:发布账号的署名。署名不等于出镜者或主持人,转载、搬运、剪辑账号尤其如此。仅当另有该说话人的 `dialog_address` 佐证时才会被采用;采用时以「作者关联推测」标注两条来源名。",
+ ),
+ )
+ for old, new in changes:
+ assert baseline.count(old.encode()) == 1
+ baseline = baseline.replace(old.encode(), new.encode(), 1)
current = (PROJECT_ROOT / CHINESE_PROMPT_MODULE).read_bytes()
- assert current == _git_show(CHINESE_PROMPT_MODULE)
+ assert current == baseline
def test_chinese_summary_builder_matches_base_when_language_is_zh_cn(language_config):
diff --git a/tests/unit/test_llm_schemas.py b/tests/unit/test_llm_schemas.py
index 524ae9af..c39a0722 100644
--- a/tests/unit/test_llm_schemas.py
+++ b/tests/unit/test_llm_schemas.py
@@ -510,8 +510,8 @@ def test_validation_system_prompt_not_empty(self):
def test_unified_validation_system_prompt_not_empty(self):
assert len(UNIFIED_VALIDATION_SYSTEM_PROMPT) > 50
- def test_speaker_inference_system_prompt_not_empty(self):
- assert len(SPEAKER_INFERENCE_SYSTEM_PROMPT) > 50
+ def test_speaker_inference_system_prompt_evidence_definitions(self):
+ assert all(text in SPEAKER_INFERENCE_SYSTEM_PROMPT for text in ("视频标题/专场名。", "发布账号的署名。署名不等于出镜者或主持人", "转载、搬运、剪辑账号尤其如此", "本人发言中仅提及姓名不算称呼"))
def test_key_info_system_prompt_not_empty(self):
assert len(KEY_INFO_SYSTEM_PROMPT) > 50
diff --git a/tests/unit/test_speaker_artifact.py b/tests/unit/test_speaker_artifact.py
index bb4ffb21..3d49a05a 100644
--- a/tests/unit/test_speaker_artifact.py
+++ b/tests/unit/test_speaker_artifact.py
@@ -10,11 +10,11 @@
from video_transcript_api.llm.prompts.schemas.speaker_mapping import SPEAKER_MAPPING_SCHEMA_VERSION
from video_transcript_api.llm.core.speaker_inferencer import SpeakerInferencer
from video_transcript_api.llm.coordinator import LLMCoordinator
+from tests.unit.test_speaker_self_intro_verifier import assert_inference_and_review, speaker_llm_side_effect
def _dialogs():
- # 文本含说话人自报名:gate 修复轮第 4 轮起 dialog_address 的 quote 必须
- # 包含候选名本身,候选引用取此处的逐字子串。
+ # dialog_address quote 必须是候选名的逐字子串。
return [
{"speaker": "Speaker1", "text": "Hello, I'm Alice", "start_time": 0.0},
{"speaker": "Speaker2", "text": "World, Bob here", "start_time": 1.0},
@@ -496,19 +496,21 @@ def test_deep_malformed_artifact_falls_back_to_real_inference_not_a_task_failure
)
llm = MagicMock()
- llm.call.return_value = SimpleNamespace(structured_output={
- "speaker_mapping": {"Speaker1": "Alice", "Speaker2": "Bob"},
- "confidence": {"Speaker1": 0.95, "Speaker2": 0.95},
- # schema v2:引用取自采样文本(_dialogs 的逐字原文)且含候选名本身,核对可过。
- "candidates": {
- "Speaker1": [
- {"name": "Alice", "evidence_type": "dialog_address", "evidence_quote": "I'm Alice"}
- ],
- "Speaker2": [
- {"name": "Bob", "evidence_type": "dialog_address", "evidence_quote": "Bob here"}
- ],
- },
- })
+ llm.call.side_effect = speaker_llm_side_effect(
+ SimpleNamespace(structured_output={
+ "speaker_mapping": {"Speaker1": "Alice", "Speaker2": "Bob"},
+ "confidence": {"Speaker1": 0.95, "Speaker2": 0.95},
+ # schema v2:引用取自采样文本(_dialogs 的逐字原文)且含候选名本身,核对可过。
+ "candidates": {
+ "Speaker1": [
+ {"name": "Alice", "evidence_type": "dialog_address", "evidence_quote": "I'm Alice"}
+ ],
+ "Speaker2": [
+ {"name": "Bob", "evidence_type": "dialog_address", "evidence_quote": "Bob here"}
+ ],
+ },
+ })
+ )
inferencer = SpeakerInferencer(llm, cache_manager=cache)
result = inferencer.infer(
@@ -518,7 +520,7 @@ def test_deep_malformed_artifact_falls_back_to_real_inference_not_a_task_failure
# The corrupted cache must not have been treated as a hit -- the real
# inference path (LLM call) must have taken over instead of crashing.
- llm.call.assert_called_once()
+ assert_inference_and_review(llm.call)
assert result["mapping"] == {"Speaker1": "Alice", "Speaker2": "Bob"}
@@ -741,14 +743,7 @@ def test_all_processing_features_disabled_make_zero_llm_calls(tmp_path):
def test_infer_speaker_names_alone_still_calls_llm_for_name_inference(tmp_path):
- """Matrix case locking in already-decided semantics: calibrate=False +
- summarize=False must NOT silence infer_speaker_names=True. Name inference
- is a "transcription" deliverable, independent from calibration/summary,
- so it must still reach the LLM while calibration and summary make zero
- calls. Real coordinator + real processors; only the LLM boundary is
- mocked (same style as test_all_processing_features_disabled_make_zero_llm_calls
- above, which locks the opposite corner of this matrix).
- """
+ """calibrate/summary off must not silence infer_speaker_names=True."""
coordinator = LLMCoordinator(
config_dict={
"llm": {
@@ -784,6 +779,8 @@ def _fake_llm_call(**kwargs):
})
if task_type == "speaker_contradiction_scan":
return SimpleNamespace(structured_output={"contradictions": []})
+ if task_type == "speaker_self_intro":
+ return speaker_llm_side_effect(None)(**kwargs)
raise AssertionError(
f"unexpected LLM call while calibration/summary are disabled: "
f"task_type={task_type!r}"
@@ -808,8 +805,7 @@ def _fake_llm_call(**kwargs):
for call in coordinator.llm_client.call.call_args_list
}
assert called_task_types <= {
- "key_info",
- "speaker_inference",
+ "key_info", "speaker_inference", "speaker_self_intro",
"speaker_contradiction_scan",
}
# ... and calibration/summary made zero calls (would have raised above
diff --git a/tests/unit/test_speaker_call_response.py b/tests/unit/test_speaker_call_response.py
index 9a9ac240..0200f1d5 100644
--- a/tests/unit/test_speaker_call_response.py
+++ b/tests/unit/test_speaker_call_response.py
@@ -19,6 +19,7 @@
from unittest.mock import MagicMock
from video_transcript_api.llm.core.speaker_inferencer import SpeakerInferencer
+from tests.unit.test_speaker_self_intro_verifier import speaker_llm_side_effect
FIXTURE_PATH = (
Path(__file__).parent.parent / "fixtures" / "call_response_host" / "transcript_funasr.json"
@@ -73,13 +74,15 @@ def _run_infer(candidates, confidence, speakers=None):
inferencer = _make_inferencer()
mapping = {speaker: speaker for speaker in (speakers or ALL_SPEAKERS)}
inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm(
- {
- "speaker_mapping": mapping,
- "confidence": confidence,
- "candidates": candidates,
- "reasoning": "mock",
- }
+ side_effect=speaker_llm_side_effect(
+ _mock_llm(
+ {
+ "speaker_mapping": mapping,
+ "confidence": confidence,
+ "candidates": candidates,
+ "reasoning": "mock",
+ }
+ )
)
)
result = inferencer.infer(
@@ -385,10 +388,11 @@ def _entries(self):
]
def test_corroboration_quote_verified_with_meta(self):
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected, _pending = SpeakerInferencer._verify_candidates(
[_candidate("江哥", "dialog_address", CALL_QUOTE_1)],
quote_sections={"dialog_address": "[Speaker1](本人片段,不含旁证句)"},
corroboration_entries=self._entries(),
+ dialog_sources={"context_before": "", "own_samples": ""},
)
self.assertEqual(rejected, [])
self.assertEqual(len(verified), 1)
@@ -396,44 +400,43 @@ def test_corroboration_quote_verified_with_meta(self):
self.assertEqual(verified[0]["corroboration"]["segment_index"], 1)
def test_other_speaker_corroboration_entry_rejected(self):
- """quote 只在他人的旁证条目里(错误目标)→ quote_missing。"""
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected, _pending = SpeakerInferencer._verify_candidates(
[_candidate("江哥", "dialog_address", WRONG_TARGET_QUOTE)],
quote_sections={"dialog_address": "[Speaker1](本人片段,不含旁证句)"},
corroboration_entries=self._entries(),
+ dialog_sources={"context_before": "", "own_samples": ""},
)
self.assertEqual(verified, [])
self.assertEqual(rejected, ["quote_missing"])
def test_corroboration_name_not_in_quote_rejected(self):
- """旁证路径同样执行 name-in-quote 判据:quote 不含候选名 → 作废。"""
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected, _pending = SpeakerInferencer._verify_candidates(
[_candidate("林知遥", "dialog_address", CALL_QUOTE_1)],
quote_sections={"dialog_address": "[Speaker1](本人片段,不含旁证句)"},
corroboration_entries=self._entries(),
+ dialog_sources={"context_before": "", "own_samples": ""},
)
self.assertEqual(verified, [])
self.assertEqual(rejected, ["name_not_in_quote"])
def test_own_section_quote_still_takes_priority(self):
- """本人片段路径优先且不带 corroboration meta(既有行为不变)。"""
- own_section = f"[Speaker1]: {CALL_QUOTE_1}"
- verified, rejected = SpeakerInferencer._verify_candidates(
- [_candidate("江哥", "dialog_address", CALL_QUOTE_1)],
- quote_sections={"dialog_address": own_section},
- corroboration_entries=self._entries(),
+ own_quote = "我是江哥"
+ verified, rejected, _pending = SpeakerInferencer._verify_candidates(
+ [_candidate("江哥", "dialog_address", own_quote)],
+ quote_sections={"dialog_address": own_quote},
+ corroboration_entries=[{"speaker": "Guest", "text": own_quote}],
+ dialog_sources={"context_before": "", "own_samples": own_quote},
)
self.assertEqual(rejected, [])
self.assertEqual(len(verified), 1)
self.assertNotIn("corroboration", verified[0])
def test_non_dialog_type_has_no_corroboration_channel(self):
- """旁证通道只对 dialog_address 开放:静态行 quote 不在自身分区时
- 不允许借旁证条目通过。"""
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected, _pending = SpeakerInferencer._verify_candidates(
[_candidate("江哥", "title_credit", CALL_QUOTE_1)],
quote_sections={"title_credit": "- 标题:某标题", "dialog_address": ""},
corroboration_entries=self._entries(),
+ dialog_sources={"context_before": "", "own_samples": ""},
)
self.assertEqual(verified, [])
self.assertEqual(rejected, ["quote_missing"])
diff --git a/tests/unit/test_speaker_evidence_gate.py b/tests/unit/test_speaker_evidence_gate.py
index 4a90cd99..95ce3735 100644
--- a/tests/unit/test_speaker_evidence_gate.py
+++ b/tests/unit/test_speaker_evidence_gate.py
@@ -1,16 +1,4 @@
-"""说话人显示名证据裁决的验收与回归测试(card-vta-261015-speaker-evidence)。
-
-覆盖:
-- 真实 fixture 形态的验收测试(tests/fixtures/twitter_space_1wxWjlzQVwDJQ/
- 为人工编写的合成对话,见该目录 README.md;mock LLM,不打真网):作者名
- 主持 applied、dialog_address+title_credit 双证据带标注复合名、伪造引用
- 作废降级、低 confidence 不再拦截。
-- input_fingerprint 扩展(title/author/description/rules_version)。
-- 裁决规则的确定性分支(description_roster 不独立生效、无佐证标题名拒绝、
- 未知证据类型作废)。
-- v1(confidence 门控)旧产物缓存重放走旧降级语义;v2 产物缓存重放直接采用。
-- gate 修复轮第 3 轮①:复合名三要素语义与网页/导出下游断言。
-"""
+"""说话人证据闸门回归测试:核对、缓存、复合显示名与下游语义。"""
import json
import inspect
@@ -22,6 +10,7 @@
SPEAKER_EVIDENCE_RULES_VERSION,
SpeakerInferencer,
)
+from tests.unit.test_speaker_self_intro_verifier import install_speaker_llm
FIXTURE_PATH = (
Path(__file__).parent.parent
@@ -55,16 +44,26 @@ def _candidate(name, evidence_type, quote):
return {"name": name, "evidence_type": evidence_type, "evidence_quote": quote}
-def _run_infer_with(candidates, confidence, dialogs=None, speakers=None):
- """以 mock LLM 跑完整 infer 链路(真实 fixture 采样 + 证据裁决)。
+def _verify_in_context(candidates, sections):
+ verified, rejected, _pending = SpeakerInferencer._verify_candidates(
+ candidates,
+ sections,
+ dialog_sources={
+ "context_before": sections.get("dialog_address", ""),
+ "own_samples": "",
+ },
+ )
+ return verified, rejected
+
- 返回 (inferencer, result),供需要检查 inferencer 内部状态的用例使用。
- """
+def _run_infer_with(candidates, confidence, dialogs=None, speakers=None, title=FIXTURE_TITLE):
+ """以 mock LLM 跑完整推断链路,返回 inferencer 与结果。"""
dialogs = dialogs if dialogs is not None else _load_fixture_dialogs()
speakers = speakers if speakers is not None else ["Speaker1", "Speaker2"]
inferencer = _make_inferencer()
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm(
+ install_speaker_llm(
+ inferencer,
+ _mock_llm(
{
"speaker_mapping": {
"Speaker1": "林晚风",
@@ -74,12 +73,12 @@ def _run_infer_with(candidates, confidence, dialogs=None, speakers=None):
"candidates": candidates,
"reasoning": "mock",
}
- )
+ ),
)
result = inferencer.infer(
speakers=speakers,
dialogs=dialogs,
- title=FIXTURE_TITLE,
+ title=title,
author=FIXTURE_AUTHOR,
description=FIXTURE_DESCRIPTION,
)
@@ -127,9 +126,7 @@ def _run_infer(self, candidates, confidence):
return _run_infer_with(candidates, confidence, self.dialogs, self.speakers)[1]
def test_host_author_name_applied_not_placeholder(self):
- """验收①:主持人形态——author_host + dialog_address 引用均取自输入
- 真实文本 → applied,mapping["Speaker1"] != 说话人1(合成角色:主持人
- =作者林晚风,对话中称「风哥」;对应原生产案例被低置信隐藏的主持人)。"""
+ """author_host 与本人自我介绍可共同生成推测标注名。"""
# dialog_address 的 quote 取 Speaker1 真实采样文本的首条(必然逐字
# 在 prompt 中);name 是 LLM 对话称呼的声称值,程序只核对 quote 存在性。
speaker1_sample = self.sample_groups["Speaker1"]["own_samples"][0]
@@ -154,9 +151,7 @@ def test_host_author_name_applied_not_placeholder(self):
)
def test_soul_lan_composite_display_name(self):
- """验收②:「柠姐/青柠」案例形态——Speaker2 的 dialog_address(柠姐,
- quote 真实在采样覆盖的对话文本中)+ title_credit(青柠,quote 取自
- 标题)双候选 → 「说话人2|标题关联推测:青柠|对话称:柠姐」。"""
+ """Speaker2 的柠姐对话证据与青柠标题证据生成复合名。"""
prompt_text = self._prompt_text()
# 「柠姐」必须真实被采样覆盖(top2 加额后在本 fixture 上成立),
# 否则此测试的前提即失效——称呼片段进不了输入正是本卡要修的根因。
@@ -175,9 +170,24 @@ def test_soul_lan_composite_display_name(self):
)
self.assertTrue(result["meta"]["Speaker2"]["applied"])
+ def test_static_quote_must_bind_candidate_name(self):
+ candidates = {
+ "Speaker2": [
+ _candidate("Invented Person", "title_credit", "OpenAI"),
+ _candidate("Invented Person", "description_roster", "小满"),
+ _candidate("柠姐", "dialog_address", "大家好,我是柠姐,麦能听到了吗?"),
+ ],
+ }
+ result = _run_infer_with(
+ candidates, {"Speaker1": 0.0, "Speaker2": 0.0}, title="OpenAI"
+ )[1]
+
+ verified = result["meta"]["Speaker2"]["verified_candidates"]
+ self.assertEqual([candidate["name"] for candidate in verified], ["柠姐"])
+ self.assertEqual(result["mapping"]["Speaker2"], "柠姐")
+
def test_forged_quote_rejected_to_placeholder(self):
- """验收③:引用伪造(quote 不在输入中)→ 候选作废、显示「说话人N」,
- meta 保留作废原因 quote_missing。"""
+ """不在输入中的引用作废并降级。"""
candidates = {
"Speaker2": [
_candidate("某大V", "dialog_address", "这句引用根本不在输入文本中"),
@@ -194,8 +204,7 @@ def test_forged_quote_rejected_to_placeholder(self):
self.assertEqual(result["meta"]["Speaker2"]["verified_candidates"], [])
def test_low_confidence_does_not_gate_application(self):
- """验收④:confidence=0.1 但证据核对通过 → 仍 applied(confidence 已
- 退出裁决,只入 meta)。"""
+ """低 confidence 不改变通过证据的裁决。"""
candidates = {
"Speaker2": [
_candidate("柠姐", "dialog_address", "柠姐"),
@@ -210,10 +219,7 @@ def test_low_confidence_does_not_gate_application(self):
self.assertEqual(result["mapping"]["Speaker1"], "说话人1")
def test_whitespace_or_punctuation_quote_rejected(self):
- """审查修复①(correctness-whitespace-evidence-quote):空白/纯标点
- quote 无实质证据内容——即便逐字存在于输入也按 quote_missing 作废。
- quote 取真实采样区里出现过的单个全角逗号(生产 LLM 摇抄标点的真实
- 形态),旧判据会放行导致无实质证据的候选 applied。"""
+ """真实采样区中的纯标点引用必须作废。"""
quote = ","
prompt_text = self._prompt_text()
# 前提自证:该 quote 确实逐字存在于本轮输入(排除假红)。
@@ -230,10 +236,7 @@ def test_whitespace_or_punctuation_quote_rejected(self):
self.assertEqual(result["meta"]["Speaker2"]["rejection_reason"], "quote_missing")
def test_cross_section_quote_rejected(self):
- """审查修复②(correctness-speaker-evidence-quote-scope):quote 只
- 出现在另一分区(描述参会名单)却声称 dialog_address → 核对收窄到
- evidence_type 对应分区后作废,堵住「名单里的名字标成对话称呼」的
- 绕行路径。"""
+ """描述名单中的名字不能充当对话称呼证据。"""
# 望舒只出现在描述参会名单,从不出现在任何发言采样里(前提已探针)。
prompt_text = self._prompt_text()
self.assertIn("望舒", prompt_text)
@@ -249,8 +252,7 @@ def test_cross_section_quote_rejected(self):
self.assertEqual(result["meta"]["Speaker2"]["rejection_reason"], "quote_missing")
def test_v1_cached_artifact_replays_with_legacy_confidence_semantics(self):
- """验收⑤:v1 旧产物(仅 confidence 无 candidates)读侧兼容不崩,
- 走旧降级语义(conf 重判),报告选定「走旧降级语义」选项。"""
+ """v1 缓存仍走 confidence 重判。"""
cached_v1 = {
"mapping": {"Speaker1": "张主持", "Speaker2": "李主讲"},
"meta": {
@@ -280,8 +282,7 @@ def test_v1_cached_artifact_replays_with_legacy_confidence_semantics(self):
self.assertIn("Speaker2", result["low_confidence"])
def test_v2_cached_artifact_replays_verdict_directly(self):
- """v2 产物缓存命中:证据裁决结论无法离线重核(输入全文不在缓存),
- 直接采用缓存 mapping/applied,不发起 LLM 调用。"""
+ """v2 缓存命中直接复用已裁决结果。"""
cached_v2 = {
"mapping": {
"Speaker1": "说话人1|作者关联推测:林晚风|对话称:风哥",
@@ -333,10 +334,7 @@ def test_v2_cached_artifact_replays_verdict_directly(self):
self.assertTrue(result["meta"]["Speaker2"]["applied"])
def test_dialog_quote_without_name_rejected(self):
- """gate 修复轮第 4 轮(correctness-dialog-candidate-name-not-bound-to-
- quote):归属核对只证明 quote 属于该说话人,不证明 quote 支持该姓名。
- quote 真实存在于说话人自身片段但不含候选名 → 候选作废、降级说话人N,
- 作废原因记 name_not_in_quote。"""
+ """本人片段引用不含候选名时作废。"""
quote = "先说结论:三十天拉新一万这件事,没有捷径,全是重复劳动。"
# 前提自证:quote 真实存在于 Speaker2 自身片段(排除假红——若采样没
# 覆盖该句,候选会挂在分区核对 quote_missing 而非本判据)。
@@ -358,8 +356,7 @@ def test_dialog_quote_without_name_rejected(self):
)
def test_dialog_quote_containing_name_still_applies(self):
- """第 4 轮对照:quote 含候选名本身(合成 fixture 中「柠姐」在 Speaker2
- 片段内的自我介绍句)→ applied 保持,新判据不误伤真证据。"""
+ """本人自我介绍仍然通过核对。"""
quote = "大家好,我是柠姐,麦能听到了吗?"
candidates = {
"Speaker2": [_candidate("柠姐", "dialog_address", quote)],
@@ -371,9 +368,7 @@ def test_dialog_quote_containing_name_still_applies(self):
class TestGateRound2QuoteScope(unittest.TestCase):
- """gate 修复轮第 2 轮:dialog_address 的 quote 核对收窄到该说话人自身的
- 采样片段与出场上下文(correctness-speaker-evidence-quote-scope 跨说话人
- 借用 + correctness-static-label-evidence-quote 联合兜住)。"""
+ """说话人对话证据的来源范围回归。"""
def setUp(self):
self.dialogs = _load_fixture_dialogs()
@@ -384,11 +379,7 @@ def setUp(self):
self.seg2 = inferencer._render_speaker_segment("Speaker2", self.sample_groups["Speaker2"])
def test_dialog_quote_borrowed_from_other_speaker_rejected(self):
- """跨说话人借用:他人出场上下文里的称呼引用不得作为本说话人的
- dialog_address 证据。fixture 上「柠姐,我邀请你做共同主持人了」只
- 出现在 Speaker2 的出场上下文(Speaker1 说出的、归属 Speaker2 的称呼
- 证据);Speaker1 自身片段里虽含「陈柠姐」字样但无此句(前提自证,
- 排除假红)。收窄前 quote 对全转录区核对会放行,Speaker1 被误标「柠姐」。"""
+ """不允许借用别的说话人的上下文称呼。"""
quote = "柠姐,我邀请你做共同主持人了"
self.assertIn(quote, self.seg2)
self.assertNotIn(quote, self.seg1)
@@ -408,11 +399,7 @@ def test_dialog_quote_borrowed_from_other_speaker_rejected(self):
self.assertTrue(result["meta"]["Speaker2"]["applied"])
def test_static_evidence_only_never_applies(self):
- """联合兜住:三类静态行证据全部核对通过(quote 逐字存在于对应静态行,
- 防幻觉核对保持)、无任何 dialog_address → 永不 applied。静态行核对与
- per-speaker 收窄正交:title_credit/author_host/description_roster 仍按
- 静态分区核对,静态证据永远不足以单独支撑显示名(不变式轴表:无佐证的
- 标题名不得 applied)。"""
+ """三类静态证据通过核对也不能单独命名。"""
candidates = {
"Speaker2": [
_candidate("青柠", "title_credit", "青柠专场"),
@@ -434,9 +421,7 @@ def test_static_evidence_only_never_applies(self):
class _FingerprintAwareCache:
- """按 (platform, media_id, input_fingerprint) 精确模拟真实 CacheManager 的
- 指纹敏感命中行为:v2 起 input_fingerprint 是缓存 payload 字段,值变即 miss
- (真实读侧见 CacheManager.get_speaker_mapping 的指纹比对)。"""
+ """模拟按真实 input_fingerprint 命中缓存。"""
def __init__(self):
self.store = {}
@@ -455,10 +440,7 @@ def save_speaker_mapping(self, platform, media_id, result, input_fingerprint="",
class TestMetadataChangeMissesCache(unittest.TestCase):
- """correctness-metadata-fingerprint-order(第 2 轮):元数据变 → 指纹变 →
- 旧映射不命中重算,锁为 spec(实现侧已两次被主审驳回改动,按门禁规则以
- 回归测试锁定预期行为:指纹含 title/author/description,元数据变化必须
- 触发缓存 miss 与 LLM 重算)。"""
+ """元数据变化必须改变缓存指纹并重算。"""
def _make(self):
inferencer = _make_inferencer()
@@ -519,7 +501,7 @@ class TestEvidenceGateRules(unittest.TestCase):
def test_description_roster_alone_never_applies(self):
"""参会名单核对通过也不得单独 applied(名单≠发言人)。"""
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("小满", "description_roster", "小满")],
{"description_roster": "描述:…小满…"},
)
@@ -530,32 +512,44 @@ def test_description_roster_alone_never_applies(self):
self.assertIsNone(name)
self.assertEqual(reason, "description_roster_not_standalone")
- def test_title_credit_without_dialog_support_rejected(self):
- verified, _ = SpeakerInferencer._verify_candidates(
- [_candidate("青柠", "title_credit", "青柠专场")],
- {"title_credit": "标题:…青柠专场…"},
- )
- name, reason = SpeakerInferencer._decide_display_name(
- verified, "Speaker2", ["Speaker2"]
- )
- self.assertIsNone(name)
- self.assertEqual(reason, "title_credit_without_dialog_support")
+ def test_static_name_binding_keeps_matching_author_without_support(self):
+ for evidence_type, name, quote, section, label in (
+ ("title_credit", "青柠", "青柠专场", "title_credit", "标题"),
+ ("author_host", "John Collison", "John Collison", "author_host", "作者"),
+ ("author_host", " John Collison ", "JOHN COLLISON", "author_host", "作者"),
+ ):
+ verified, rejected = _verify_in_context(
+ [_candidate(name, evidence_type, quote)],
+ {section: f"- {label}:{quote}"},
+ )
+ self.assertEqual(rejected, [])
+ self.assertEqual([candidate["name"] for candidate in verified], [name.strip()])
+ display, reason = SpeakerInferencer._decide_display_name(
+ verified, "Speaker2", ["Speaker2"]
+ )
+ self.assertIsNone(display)
+ self.assertEqual(reason, "title_credit_without_dialog_support")
- def test_dialog_address_alone_applies_directly(self):
- verified, _ = SpeakerInferencer._verify_candidates(
- [_candidate("柠姐", "dialog_address", "今天是柠姐的专场")],
- {"dialog_address": "…今天是柠姐的专场…"},
- )
- name, reason = SpeakerInferencer._decide_display_name(
- verified, "Speaker2", ["Speaker2"]
- )
- self.assertEqual(name, "柠姐")
- self.assertIsNone(reason)
+ def test_context_before_address_remains_valid(self):
+ quote = "今天是青柠姐的专场"
+ inferencer = _make_inferencer()
+ dialogs = _load_fixture_dialogs()
+ speakers = ["Speaker1", "Speaker2"]
+ samples = inferencer._extract_sample_dialogs(dialogs, speakers)
+ context = "\n".join(text for _, text in samples["Speaker2"]["context_before"])
+ own = "\n".join(samples["Speaker2"]["own_samples"])
+ self.assertIn(quote, context)
+ self.assertNotIn(quote, own)
+ result = _run_infer_with(
+ {"Speaker2": [_candidate("青柠姐", "dialog_address", quote)]},
+ {"Speaker1": 0.0, "Speaker2": 0.0},
+ )[1]
+ self.assertEqual(result["mapping"]["Speaker2"], "青柠姐")
def test_composite_format_is_exact(self):
"""title_credit + dialog_address → 带标注复合名(gate 修复轮第 3 轮①:
三要素缺一不可——说话人编号、「推测」字样、两条来源名)。"""
- verified, _ = SpeakerInferencer._verify_candidates(
+ verified, _ = _verify_in_context(
[
_candidate("青柠", "title_credit", "青柠专场"),
_candidate("柠姐", "dialog_address", "柠姐"),
@@ -569,7 +563,7 @@ def test_composite_format_is_exact(self):
self.assertEqual(name, "说话人2|标题关联推测:青柠|对话称:柠姐")
def test_unknown_evidence_type_rejected(self):
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("张三", "vibes", "张三")],
{"dialog_address": "张三"},
)
@@ -578,7 +572,7 @@ def test_unknown_evidence_type_rejected(self):
def test_partial_quote_substring_is_enough(self):
"""quote 是输入的逐字子串即可(不要求整行/整段)。"""
- verified, _ = SpeakerInferencer._verify_candidates(
+ verified, _ = _verify_in_context(
[_candidate("柠姐", "dialog_address", "柠姐,我邀请你")],
{"dialog_address": "开场:柠姐,我邀请你做共同主持人了……"},
)
@@ -588,14 +582,14 @@ def test_whitespace_and_punctuation_only_quotes_all_rejected(self):
"""修复①边界值:纯空白/纯标点(含长度>=2 的多标点串)一律
quote_missing;带实质字符的才放行。"""
for quote in ("\u3000\u3000", "\u201c\u201d", ",", ",。", " 。 "):
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("张三", "dialog_address", quote)],
{"dialog_address": f"对话{quote}后文"},
)
self.assertEqual(verified, [], f"quote={quote!r} should be rejected")
self.assertEqual(rejected, ["quote_missing"])
# 对照:去首尾空白后有实质内容的 quote 放行(逐字存在性仍用原文核对)。
- verified, _ = SpeakerInferencer._verify_candidates(
+ verified, _ = _verify_in_context(
[_candidate("张三", "dialog_address", " 张三 ")],
{"dialog_address": "对话 张三 后文"},
)
@@ -605,14 +599,14 @@ def test_dialog_name_binding_case_insensitive_for_english(self):
"""修复④(第 4 轮)边界:dialog_address 的 quote 必须包含候选名本身,
英文名忽略大小写(ADRIAN PUNK 支持 Adrian Punk);不含名的真实
quote 对该名字无证据力 → name_not_in_quote。"""
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("Adrian Punk", "dialog_address", "welcome, ADRIAN PUNK speaking")],
{"dialog_address": "welcome, ADRIAN PUNK speaking"},
)
self.assertEqual(len(verified), 1)
self.assertEqual(rejected, [])
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("张三", "dialog_address", "今天天气不错")],
{"dialog_address": "今天天气不错"},
)
@@ -621,14 +615,14 @@ def test_dialog_name_binding_case_insensitive_for_english(self):
def test_quote_in_wrong_section_rejected(self):
"""修复②边界:quote 只存在于另一分区/分区缺失时,逐字存在也不放行。"""
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("望舒", "dialog_address", "望舒")],
{"description_roster": "- 描述:…望舒…"},
)
self.assertEqual(verified, [])
self.assertEqual(rejected, ["quote_missing"])
# 分区缺失(如空描述不进 prompt)→ fail-closed。
- verified, rejected = SpeakerInferencer._verify_candidates(
+ verified, rejected = _verify_in_context(
[_candidate("青柠", "title_credit", "青柠专场")],
{},
)
@@ -694,19 +688,14 @@ def test_fingerprint_is_stable_and_default_compatible(self):
class TestCompositeNameSemantics(unittest.TestCase):
- """gate 修复轮第 3 轮①:复合显示名语义显式化。
-
- 契约(主脑续修指令锁定):含 title_credit/author_host 的复合 mapping 值
- 三要素缺一不可——原始说话人编号、「推测」字样、两条来源名;标题名与
- 对话称呼是否同一人是模型推测,程序只核实引用存在与分区归属,因此
- 复合名必须就地自证推测属性,禁止裸标题名/作者名。仅 dialog_address
- 的 mapping 值保持对话称呼原文(可证实事实,不标注)。
- """
+ """复合名必须包含说话人、推测标记和两条来源。"""
def _verified(self, credit_type="title_credit"):
- verified, rejected = SpeakerInferencer._verify_candidates(
+ credit_name = "青柠" if credit_type == "title_credit" else "林晚风"
+ credit_quote = "青柠专场" if credit_type == "title_credit" else "林晚风"
+ verified, rejected = _verify_in_context(
[
- _candidate("青柠" if credit_type == "title_credit" else "林晚风", credit_type, "原文引用"),
+ _candidate(credit_name, credit_type, credit_quote),
_candidate("柠姐", "dialog_address", "柠姐"),
],
{
@@ -749,8 +738,7 @@ def test_composite_never_bare_credit_name(self):
self.assertNotEqual(name, "柠姐")
def test_non_numeric_label_uses_position_number(self):
- """原始标签无数字时(如 FunASR 通道的「说话人A」),复合名按
- speakers 列表位次编号,与降级占位符同一套编号规则。"""
+ """非数字说话人标签按列表位次编号。"""
verified = self._verified("title_credit")
name, _ = SpeakerInferencer._decide_display_name(
verified, "说话人B", ["说话人A", "说话人B"]
@@ -758,11 +746,9 @@ def test_non_numeric_label_uses_position_number(self):
self.assertTrue(name.startswith("说话人2|"), name)
def test_wrong_credit_association_still_annotated_end_to_end(self):
- """反例(主脑续修指令①):真实引用 + 错误关联——dialog_address 真
- (Guest 的对话称呼),title_credit 标给无标题关联的普通说话人。
- 程序无法验证语义关联,因此显示名必须带「推测」标注而非裸名。"""
+ """真实但错误关联的两条证据仍须显示推测标注。"""
dialogs = [
- {"speaker": "Speaker1", "text": "欢迎来到今晚的直播", "start_time": 0.0},
+ {"speaker": "Speaker1", "text": "欢迎小张来到今晚的直播", "start_time": 0.0},
{"speaker": "Speaker2", "text": "大家好我是小张,今天来串门", "start_time": 1.0},
{"speaker": "Speaker1", "text": "小张请坐", "start_time": 2.0},
]
@@ -774,7 +760,7 @@ def test_wrong_credit_association_still_annotated_end_to_end(self):
"confidence": {"Speaker1": 0.9, "Speaker2": 0.9},
"candidates": {
"Speaker2": [
- _candidate("小张", "dialog_address", "小张"),
+ _candidate("小张", "dialog_address", "欢迎小张"),
# 语义错误关联:小张与标题专场毫无关系,但 quote 逐字真实。
_candidate("青柠", "title_credit", "青柠专场"),
],
@@ -799,14 +785,12 @@ def test_wrong_credit_association_still_annotated_end_to_end(self):
class TestCompositeNameDownstreamSurfaces(unittest.TestCase):
- """gate 修复轮第 3 轮①:复合名是单一事实源——归一化对话(网页渲染、
- 导出文本、LLM 后续输入的共同上游)原样携带复合名,渲染层不二次加工。"""
+ """复合名在网页与归一化结果中保持不变。"""
COMPOSITE = "说话人2|标题关联推测:青柠|对话称:柠姐"
def test_web_html_contains_all_composite_elements(self):
- """网页渲染:结构化数据里 dialogs.speaker 已是复合名,HTML 必须
- 同时含「说话人2」「推测」「青柠」「柠姐」四要素(转义不改中文字面)。"""
+ """网页 HTML 保留复合名的四个组成部分。"""
import tempfile
from video_transcript_api.utils.rendering.dialog_renderer import DialogRenderer
@@ -837,8 +821,7 @@ def test_web_html_contains_all_composite_elements(self):
self.assertNotIn('data-speaker-status="pending"', html_out)
def test_normalized_dialogs_carry_composite_verbatim(self):
- """导出/后续输入上游:processor 归一化对话原样携带复合名,
- speaker_id 保留原始标签(映射关系可追溯)。"""
+ """归一化对话保留复合名与原始 speaker_id。"""
from video_transcript_api.llm.processors.speaker_aware_processor import (
SpeakerAwareProcessor,
)
@@ -861,13 +844,7 @@ def test_normalized_dialogs_carry_composite_verbatim(self):
class TestPlatformSpeakerPriors(unittest.TestCase):
- """gate 修复轮第 5 轮③:平台说话人先验。
-
- 契约:表内平台(twitter_space)在视频信息区追加先验段;表外平台/未传
- platform 与旧输出逐字一致(零变化);先验段只进 prompt 展示、不进任何
- 引用分区(从先验文案摘 quote 会被逐字核对 fail-closed 作废);规则版本
- 随先验上线 bump 3→4。
- """
+ """平台背景只进入提示词,不参与证据引用核对。"""
def _build(self, platform=""):
from video_transcript_api.llm.prompts import (
@@ -891,8 +868,7 @@ def test_prior_injected_for_known_platform(self):
self.assertIn(priors["twitter_space"], prompt.split("**转录内容片段**")[0])
def test_unknown_or_missing_platform_output_unchanged(self):
- """表外平台零变化:不传 platform、传空串、传未知平台三种形态的
- prompt 与旧(无先验)输出逐字一致。"""
+ """未配置先验的平台保持旧 prompt 文本。"""
from video_transcript_api.llm.prompts import build_speaker_inference_user_prompt
legacy = build_speaker_inference_user_prompt(
@@ -910,8 +886,7 @@ def test_unknown_or_missing_platform_output_unchanged(self):
)
def test_prior_never_enters_quote_sections(self):
- """先验段不是证据:不进 build_speaker_quote_sections 任何分区,
- 从先验文案摘 quote 会被 fail-closed 作废。"""
+ """平台先验不进入任何证据引用分区。"""
from video_transcript_api.llm.prompts import (
PLATFORM_SPEAKER_PRIORS,
build_speaker_quote_sections,
@@ -928,9 +903,6 @@ def test_prior_never_enters_quote_sections(self):
self.assertNotIn(prior, text)
self.assertNotIn("平台背景", text)
- def test_rules_version_bumped_to_4(self):
- """先验与旁证上线属裁决规则变化:v3→v4,旧缓存经指纹自然失效。"""
- self.assertEqual(SPEAKER_EVIDENCE_RULES_VERSION, 4)
if __name__ == "__main__":
diff --git a/tests/unit/test_speaker_inferencer.py b/tests/unit/test_speaker_inferencer.py
index 8117661b..af0679ba 100644
--- a/tests/unit/test_speaker_inferencer.py
+++ b/tests/unit/test_speaker_inferencer.py
@@ -4,12 +4,8 @@
Covers the "per-speaker sampling + confidence downgrade" refactor:
- Late-appearing speakers still get sampled with first-appearance context.
- Per-speaker sample count / char caps are enforced.
-- Low-confidence mappings are downgraded to a placeholder label ("SpeakerN"),
- not applied as a real name.
-- Missing/malformed confidence defaults to 0.0 (low confidence, downgraded).
-- Cache is only reused when it covers the current speaker set AND carries
- real confidence data; legacy (flat dict, pre-confidence-gating) cache
- format is treated as unusable and forces re-inference (ci-gate review).
+- Evidence rules, not the model's confidence, decide whether names are applied.
+- Legacy confidence-only caches are re-inferred; cache hits must cover every speaker.
- Prompt sample groups are ordered by first-appearance time.
- The prompt's raw speaker-label list ("original_speakers") is cropped to
the speakers that survive the global sample budget, so it can't blow up
@@ -20,7 +16,8 @@
import unittest
from unittest.mock import MagicMock, patch
-from video_transcript_api.llm.core.speaker_inferencer import SpeakerInferencer
+from video_transcript_api.llm.core.speaker_inferencer import SPEAKER_EVIDENCE_RULES_VERSION, SpeakerInferencer
+from tests.unit.test_speaker_self_intro_verifier import assert_inference_and_review, install_speaker_llm
def _make_dialog(speaker, text, start_time=None):
@@ -718,10 +715,9 @@ class TestConfidenceGating(unittest.TestCase):
def _run_infer(self, speaker_mapping, confidence, candidates=None, threshold=0.6):
inferencer = _make_inferencer(confidence_threshold=threshold)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping, confidence, candidates=candidates
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(speaker_mapping, confidence, candidates=candidates),
)
dialogs = [
_make_dialog("Speaker1", "hello there,我是张三", start_time=0.0),
@@ -785,7 +781,7 @@ def test_missing_confidence_defaults_to_zero_for_diagnostics_only(self):
),
"reasoning": "no confidence field at all",
}
- inferencer.llm_client.call = MagicMock(return_value=response)
+ install_speaker_llm(inferencer, response)
dialogs = [
_make_dialog("Speaker1", "hello there,我是张三", start_time=0.0),
_make_dialog("Speaker2", "hi", start_time=1.0),
@@ -860,14 +856,15 @@ def test_fallback_label_uses_numeric_suffix_of_original_label(self):
"""The placeholder number must come from the label's own digit (Speaker7 -> 7),
not from its position in the speakers list (which would be 2nd -> 2)."""
inferencer = _make_inferencer(confidence_threshold=0.6)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"Speaker1": "张三", "Speaker7": "李四"},
- confidence={"Speaker1": 0.9, "Speaker7": 0.1},
- candidates=_auto_candidates(
- {"Speaker1": [("张三", "我是张三")]}
- ),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"Speaker1": "张三", "Speaker7": "李四"},
+ confidence={"Speaker1": 0.9, "Speaker7": 0.1},
+ candidates=_auto_candidates(
+ {"Speaker1": [("张三", "我是张三")]}
+ ),
+ )
)
dialogs = [
_make_dialog("Speaker1", "hello,我是张三", start_time=0.0),
@@ -881,14 +878,15 @@ def test_fallback_label_uses_numeric_suffix_of_original_label(self):
def test_fallback_label_falls_back_to_position_when_no_digit(self):
inferencer = _make_inferencer(confidence_threshold=0.6)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"alpha": "张三", "beta": "李四"},
- confidence={"alpha": 0.9, "beta": 0.1},
- candidates=_auto_candidates(
- {"alpha": [("张三", "我是张三")]}
- ),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"alpha": "张三", "beta": "李四"},
+ confidence={"alpha": 0.9, "beta": 0.1},
+ candidates=_auto_candidates(
+ {"alpha": [("张三", "我是张三")]}
+ ),
+ )
)
dialogs = [
_make_dialog("alpha", "hello,我是张三", start_time=0.0),
@@ -902,15 +900,7 @@ def test_fallback_label_falls_back_to_position_when_no_digit(self):
class TestUnsampledSpeakerIdentityFallback(unittest.TestCase):
- """A speaker excluded from sampling entirely (global budget, or simply no
- valid dialog) must keep its ORIGINAL label -- not get downgraded to a
- "SpeakerN" placeholder. Regression guard: fixing "missing confidence
- defaults to low" (so a speaker the LLM was asked about but didn't answer
- for gets downgraded) must not also sweep in speakers the LLM was never
- asked about at all -- those have zero inference signal and belong to the
- pre-existing identity-fallback path, not the low-confidence-downgrade
- path. ci-gate review: "应将未采样标签与 LLM 返回的低置信度猜测区分开,
- 前者保留原标签"."""
+ """Unsampled speakers keep their original label, not a placeholder."""
def test_budget_excluded_speaker_keeps_original_label_not_placeholder(self):
speakers = ["alpha", "beta", "gamma"]
@@ -947,17 +937,18 @@ def test_budget_excluded_speaker_keeps_original_label_not_placeholder(self):
# The LLM is only ever prompted with "alpha"/"beta" context (gamma
# was excluded before the prompt was built), so a realistic mock
# response has no opinion on "gamma" at all.
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"alpha": "张三", "beta": "李四"},
- confidence={"alpha": 0.9, "beta": 0.9},
- candidates=_auto_candidates(
- {
- "alpha": [("张三", "我是张三")],
- "beta": [("李四", "我是李四")],
- }
- ),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"alpha": "张三", "beta": "李四"},
+ confidence={"alpha": 0.9, "beta": 0.9},
+ candidates=_auto_candidates(
+ {
+ "alpha": [("张三", "我是张三")],
+ "beta": [("李四", "我是李四")],
+ }
+ ),
+ )
)
result = inferencer.infer(speakers=speakers, dialogs=dialogs, title="t")
@@ -978,12 +969,13 @@ def test_speaker_with_no_valid_dialog_keeps_original_label_alongside_sampled_pee
same call get sampled and sent to the LLM normally. That speaker
must also keep its original label, not a fabricated placeholder."""
inferencer = _make_inferencer(confidence_threshold=0.6)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"alpha": "张三"},
- confidence={"alpha": 0.9},
- candidates=_auto_candidates({"alpha": [("张三", "我是张三")]}),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"alpha": "张三"},
+ confidence={"alpha": 0.9},
+ candidates=_auto_candidates({"alpha": [("张三", "我是张三")]}),
+ )
)
dialogs = [
_make_dialog("alpha", "hello there,我是张三", start_time=0.0),
@@ -1010,12 +1002,13 @@ def test_fresh_inference_persists_sampled_flag_for_excluded_speaker(self):
cache_manager = MagicMock()
cache_manager.get_speaker_mapping.return_value = None
inferencer = _make_inferencer(cache_manager=cache_manager, confidence_threshold=0.6)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"alpha": "张三"},
- confidence={"alpha": 0.9},
- candidates=_auto_candidates({"alpha": [("张三", "我是张三")]}),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"alpha": "张三"},
+ confidence={"alpha": 0.9},
+ candidates=_auto_candidates({"alpha": [("张三", "我是张三")]}),
+ )
)
dialogs = [
_make_dialog("alpha", "hello there,我是张三", start_time=0.0),
@@ -1076,6 +1069,9 @@ def test_cache_replay_preserves_unsampled_identity_across_threshold_changes(self
class TestCacheCoverageValidation(unittest.TestCase):
"""Cached mapping must cover the current speaker set to be reused."""
+ def test_evidence_rules_version_bumped_to_6(self):
+ self.assertEqual(SPEAKER_EVIDENCE_RULES_VERSION, 6)
+
def test_cache_hit_when_covers_current_speakers(self):
cache_manager = MagicMock()
cache_manager.get_speaker_mapping.return_value = {
@@ -1140,16 +1136,17 @@ def test_cache_miss_when_missing_a_speaker_triggers_reinference(self):
# Legacy flat-dict cache only covers Speaker1; Speaker2 is new this run.
cache_manager.get_speaker_mapping.return_value = {"Speaker1": "张三"}
inferencer = _make_inferencer(cache_manager=cache_manager)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"Speaker1": "张三", "Speaker2": "李四"},
- confidence={"Speaker1": 0.9, "Speaker2": 0.9},
- candidates=_auto_candidates(
- {
- "Speaker2": [("李四", "我是李四")],
- }
- ),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"Speaker1": "张三", "Speaker2": "李四"},
+ confidence={"Speaker1": 0.9, "Speaker2": 0.9},
+ candidates=_auto_candidates(
+ {
+ "Speaker2": [("李四", "我是李四")],
+ }
+ ),
+ )
)
result = inferencer.infer(
@@ -1163,7 +1160,7 @@ def test_cache_miss_when_missing_a_speaker_triggers_reinference(self):
media_id="m",
)
- inferencer.llm_client.call.assert_called_once()
+ assert_inference_and_review(inferencer.llm_client.call)
self.assertEqual(result["mapping"]["Speaker2"], "李四")
# Re-inferred result must be persisted back (overwriting the stale cache).
cache_manager.save_speaker_mapping.assert_called_once()
@@ -1187,14 +1184,15 @@ def test_legacy_flat_cache_is_ignored_and_triggers_reinference(self):
"Speaker2": "李四",
}
inferencer = _make_inferencer(cache_manager=cache_manager)
- inferencer.llm_client.call = MagicMock(
- return_value=_mock_llm_response(
- speaker_mapping={"Speaker1": "张三", "Speaker2": "王五"},
- confidence={"Speaker1": 0.95, "Speaker2": 0.3},
- candidates=_auto_candidates(
- {"Speaker1": [("张三", "我是张三")]}
- ),
- )
+ install_speaker_llm(
+ inferencer,
+ _mock_llm_response(
+ speaker_mapping={"Speaker1": "张三", "Speaker2": "王五"},
+ confidence={"Speaker1": 0.95, "Speaker2": 0.3},
+ candidates=_auto_candidates(
+ {"Speaker1": [("张三", "我是张三")]}
+ ),
+ )
)
result = inferencer.infer(
@@ -1210,7 +1208,7 @@ def test_legacy_flat_cache_is_ignored_and_triggers_reinference(self):
# Legacy cache must not short-circuit inference, even though it
# nominally "covers" both current speakers.
- inferencer.llm_client.call.assert_called_once()
+ assert_inference_and_review(inferencer.llm_client.call)
# Fresh, confidence-aware result -- Speaker2's low confidence (0.3)
# correctly triggers the downgrade the legacy cache was hiding.
self.assertEqual(result["mapping"]["Speaker1"], "张三")
diff --git a/tests/unit/test_speaker_self_intro_verifier.py b/tests/unit/test_speaker_self_intro_verifier.py
new file mode 100644
index 00000000..d822cada
--- /dev/null
+++ b/tests/unit/test_speaker_self_intro_verifier.py
@@ -0,0 +1,467 @@
+"""Independent self-intro review: payload isolation, verdicts, and infer() axes."""
+
+import json
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock
+
+from video_transcript_api.llm.core.speaker_inferencer import SpeakerInferencer
+from video_transcript_api.llm.core.speaker_self_intro_verifier import (
+ SELF_INTRO_PASS,
+ SELF_INTRO_UNCLEAR,
+ SPEAKER_SELF_INTRO_TASK_TYPE,
+ SpeakerSelfIntroVerifier,
+ apply_self_intro_review,
+)
+from video_transcript_api.llm.prompts.schemas.speaker_self_intro import (
+ SELF_INTRO_CLASSIFICATIONS,
+ SPEAKER_SELF_INTRO_SCHEMA,
+)
+from video_transcript_api.llm.prompts.speaker_self_intro import (
+ SPEAKER_SELF_INTRO_SYSTEM_PROMPT,
+ build_speaker_self_intro_user_prompt,
+)
+
+FIXTURE_PATH = (
+ Path(__file__).parent.parent
+ / "fixtures"
+ / "twitter_space_1wxWjlzQVwDJQ"
+ / "transcript_funasr.json"
+)
+CALL_RESPONSE_PATH = (
+ Path(__file__).parent.parent / "fixtures" / "call_response_host" / "transcript_funasr.json"
+)
+FIXTURE_TITLE = "夜航电台之青柠专场:独立开发者的播客实验,三十天拉新一万"
+FIXTURE_AUTHOR = "林晚风"
+FIXTURE_DESCRIPTION = (
+ "Twitter Space participated by 星野, 长风万里, 阿澈, 小满, 铁蛋, "
+ "数码君日常, 阿凯, 夜行者, 望舒, 青山不改, 路人甲, 拾光者"
+)
+
+
+def parse_self_intro_items(user_prompt: str):
+ start = user_prompt.index("") + len("")
+ end = user_prompt.index("")
+ return json.loads(user_prompt[start:end].strip())
+
+
+def speaker_llm_side_effect(
+ inference_response,
+ classification=SELF_INTRO_PASS,
+ by_name=None,
+ raise_on_verify=None,
+):
+ """Dispatch first-round inference vs self-intro review by task_type."""
+
+ def fake(*args, **kwargs):
+ if kwargs.get("task_type") != SPEAKER_SELF_INTRO_TASK_TYPE:
+ return inference_response
+ if raise_on_verify is not None:
+ raise raise_on_verify
+ items = parse_self_intro_items(kwargs["user_prompt"])
+ verdicts = []
+ for item in items:
+ chosen = classification
+ if by_name is not None and item["name"] in by_name:
+ chosen = by_name[item["name"]]
+ if chosen is None:
+ continue
+ verdicts.append({"id": item["id"], "classification": chosen})
+ response = MagicMock()
+ response.structured_output = {"verdicts": verdicts}
+ return response
+
+ return fake
+
+
+def install_speaker_llm(inferencer, inference_response, **kwargs):
+ inferencer.llm_client.call = MagicMock(
+ side_effect=speaker_llm_side_effect(inference_response, **kwargs)
+ )
+
+
+def assert_inference_and_review(call_mock):
+ types = [c.kwargs.get("task_type") for c in call_mock.call_args_list]
+ if types.count("speaker_inference") != 1 or types.count("speaker_self_intro") != 1:
+ raise AssertionError("unexpected task_types=%r" % (types,))
+
+
+def _mock_llm(structured_output):
+ response = MagicMock()
+ response.structured_output = structured_output
+ return response
+
+
+def _candidate(name, evidence_type, quote):
+ return {"name": name, "evidence_type": evidence_type, "evidence_quote": quote}
+
+
+def _make_inferencer(cached_mapping=None):
+ cache_manager = MagicMock()
+ cache_manager.get_speaker_mapping.return_value = cached_mapping
+ return SpeakerInferencer(
+ llm_client=MagicMock(),
+ cache_manager=cache_manager,
+ model="test-model",
+ reasoning_effort="low",
+ )
+
+
+def _inference_output(candidates, confidence, mapping=None, reasoning="first-round-reasoning"):
+ mapping = mapping or {speaker: speaker for speaker in candidates}
+ return _mock_llm(
+ {
+ "speaker_mapping": mapping,
+ "confidence": confidence,
+ "candidates": candidates,
+ "reasoning": reasoning,
+ }
+ )
+
+
+def _load_fixture_dialogs():
+ data = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
+ return [
+ {
+ "speaker": seg.get("speaker") or "",
+ "text": seg.get("text") or "",
+ "start_time": seg.get("start_time"),
+ }
+ for seg in data["segments"]
+ ]
+
+
+def _load_call_response_dialogs():
+ data = json.loads(CALL_RESPONSE_PATH.read_text(encoding="utf-8"))
+ return [
+ {
+ "speaker": seg.get("speaker") or "",
+ "text": seg.get("text") or "",
+ "start_time": seg.get("start_time"),
+ }
+ for seg in data["segments"]
+ ]
+
+
+class TestSelfIntroPromptIsolation(unittest.TestCase):
+ def test_user_prompt_contains_only_id_name_quote(self):
+ prompt = build_speaker_self_intro_user_prompt(
+ [{"id": "si-0", "name": "Bob", "quote": "Bob here"}]
+ )
+ self.assertEqual(
+ parse_self_intro_items(prompt),
+ [{"id": "si-0", "name": "Bob", "quote": "Bob here"}],
+ )
+ for forbidden in ("reasoning", "confidence", "evidence_type", "speaker_mapping"):
+ self.assertNotIn(forbidden, prompt)
+
+ def test_schema_classifications_are_closed(self):
+ enum_values = SPEAKER_SELF_INTRO_SCHEMA["properties"]["verdicts"]["items"][
+ "properties"
+ ]["classification"]["enum"]
+ self.assertEqual(tuple(enum_values), SELF_INTRO_CLASSIFICATIONS)
+
+
+class TestApplySelfIntroReview(unittest.TestCase):
+ def test_empty_pending_does_not_call_llm(self):
+ llm = MagicMock()
+ verified: list = []
+ rejected: list = []
+ apply_self_intro_review(
+ llm,
+ "test-model",
+ None,
+ [("Speaker1", 0.9, [], verified, rejected, [])],
+ )
+ llm.call.assert_not_called()
+ self.assertEqual(verified, [])
+ self.assertEqual(rejected, [])
+
+
+class TestVerifierCallContract(unittest.TestCase):
+ def test_verify_sends_schema_task_type_and_isolated_prompt(self):
+ llm_client = MagicMock()
+ llm_client.call.return_value = _mock_llm(
+ {"verdicts": [{"id": "si-0", "classification": SELF_INTRO_PASS}]}
+ )
+ result = SpeakerSelfIntroVerifier(
+ llm_client, model="test-model", reasoning_effort="low"
+ ).verify([{"id": "si-0", "name": "Bob", "quote": "Bob here"}])
+ self.assertEqual(result, {"si-0": SELF_INTRO_PASS})
+ kwargs = llm_client.call.call_args.kwargs
+ self.assertEqual(kwargs["task_type"], SPEAKER_SELF_INTRO_TASK_TYPE)
+ self.assertEqual(kwargs["response_schema"], SPEAKER_SELF_INTRO_SCHEMA)
+ self.assertEqual(kwargs["model"], "test-model")
+ self.assertEqual(kwargs["reasoning_effort"], "low")
+ self.assertEqual(kwargs["system_prompt"], SPEAKER_SELF_INTRO_SYSTEM_PROMPT)
+ items = parse_self_intro_items(kwargs["user_prompt"])
+ self.assertEqual(items[0]["name"], "Bob")
+ self.assertEqual(items[0]["quote"], "Bob here")
+ self.assertNotIn("reasoning", kwargs["user_prompt"])
+
+ def test_empty_items_do_not_call_llm(self):
+ llm_client = MagicMock()
+ self.assertEqual(SpeakerSelfIntroVerifier(llm_client, "test-model").verify([]), {})
+ llm_client.call.assert_not_called()
+
+
+class TestInferSelfIntroAxes(unittest.TestCase):
+ """Axis table locked from SpeakerInferencer.infer(); only llm_client.call is stubbed."""
+
+ def _run(self, candidates, dialogs, speakers, side_effect, **infer_kwargs):
+ inferencer = _make_inferencer()
+ inferencer.llm_client.call = MagicMock(side_effect=side_effect)
+ result = inferencer.infer(
+ speakers=speakers,
+ dialogs=dialogs,
+ title=infer_kwargs.get("title", "t"),
+ author=infer_kwargs.get("author", ""),
+ description=infer_kwargs.get("description", ""),
+ platform=infer_kwargs.get("platform", "p"),
+ media_id=infer_kwargs.get("media_id", "m"),
+ )
+ return inferencer, result
+
+ def _own_sample_run(self, quote, name, classification=SELF_INTRO_PASS):
+ candidates = {"Speaker1": [_candidate(name, "dialog_address", quote)]}
+ return self._run(
+ candidates,
+ [{"speaker": "Speaker1", "text": quote}],
+ ["Speaker1"],
+ speaker_llm_side_effect(
+ _inference_output(candidates, {"Speaker1": 0.9}, {"Speaker1": name}),
+ classification=classification,
+ ),
+ )
+
+ def test_own_sample_address_classifications(self):
+ cases = [
+ ("Bob here", "Bob", "self_introduction", True),
+ ("柠姐在这儿", "柠姐", "self_introduction", True),
+ ("John Collison wrote about this", "John Collison", "third_party_mention", False),
+ ("欢迎张三发言", "张三", "addressed_by_other", False),
+ ]
+ for quote, name, classification, is_intro in cases:
+ with self.subTest(quote=quote):
+ inferencer, result = self._own_sample_run(
+ quote, name, classification=classification
+ )
+ expected = name if is_intro else "说话人1"
+ self.assertEqual(result["mapping"]["Speaker1"], expected)
+ self.assertEqual(result["meta"]["Speaker1"]["applied"], is_intro)
+ self.assertEqual(
+ result["meta"]["Speaker1"]["candidates"][0]["self_intro_verdict"],
+ classification,
+ )
+ if not is_intro:
+ self.assertEqual(
+ result["meta"]["Speaker1"]["rejection_reason"],
+ "name_not_self_intro",
+ )
+ if is_intro:
+ self.assertEqual(inferencer.llm_client.call.call_count, 2)
+
+ def test_missing_or_illegal_classification_is_unclear(self):
+ _, missing = self._own_sample_run("Bob here", "Bob", classification=None)
+ self.assertEqual(missing["meta"]["Speaker1"]["rejection_reason"], "name_not_self_intro")
+ self.assertEqual(
+ missing["meta"]["Speaker1"]["candidates"][0]["self_intro_verdict"],
+ SELF_INTRO_UNCLEAR,
+ )
+ _, illegal = self._own_sample_run("Bob here", "Bob", classification="not_a_class")
+ self.assertEqual(illegal["mapping"]["Speaker1"], "说话人1")
+ self.assertEqual(
+ illegal["meta"]["Speaker1"]["candidates"][0]["self_intro_verdict"],
+ SELF_INTRO_UNCLEAR,
+ )
+
+ def test_context_before_quote_skips_review(self):
+ dialogs = [
+ {"speaker": "Speaker1", "text": "欢迎柠姐", "start_time": 0.0},
+ {"speaker": "Speaker2", "text": "大家好", "start_time": 1.0},
+ ]
+ candidates = {"Speaker2": [_candidate("柠姐", "dialog_address", "欢迎柠姐")]}
+ inferencer, result = self._run(
+ candidates,
+ dialogs,
+ ["Speaker1", "Speaker2"],
+ speaker_llm_side_effect(
+ _inference_output(
+ candidates,
+ {"Speaker1": 0.9, "Speaker2": 0.9},
+ {"Speaker1": "Speaker1", "Speaker2": "柠姐"},
+ )
+ ),
+ )
+ self.assertEqual(result["mapping"]["Speaker2"], "柠姐")
+ self.assertEqual(inferencer.llm_client.call.call_count, 1)
+ self.assertEqual(
+ inferencer.llm_client.call.call_args.kwargs["task_type"], "speaker_inference"
+ )
+
+ def test_corroboration_quote_skips_review(self):
+ quote = "江哥,开场白你先来,我们洗耳恭听。"
+ candidates = {
+ "Speaker1": [_candidate("江哥", "dialog_address", quote)],
+ "Speaker2": [],
+ "Speaker3": [],
+ "Speaker4": [],
+ }
+ inferencer, result = self._run(
+ candidates,
+ _load_call_response_dialogs(),
+ ["Speaker1", "Speaker2", "Speaker3", "Speaker4"],
+ speaker_llm_side_effect(
+ _inference_output(candidates, {s: 0.9 for s in candidates}, {"Speaker1": "江哥"})
+ ),
+ title="远航夜话之江远专场:小团队出海避坑实录",
+ author="江远",
+ description="直播 participated by 沈舟, 阿北, 老白, 阿慧, 木木, 阿岛",
+ )
+ self.assertEqual(result["mapping"]["Speaker1"], "江哥")
+ self.assertEqual(inferencer.llm_client.call.call_count, 1)
+ verified = result["meta"]["Speaker1"]["verified_candidates"]
+ self.assertTrue(any("corroboration" in entry for entry in verified))
+
+ def test_no_own_sample_candidate_calls_llm_once(self):
+ candidates = {"Speaker1": [_candidate("林晚风", "author_host", FIXTURE_AUTHOR)]}
+ inferencer, result = self._run(
+ candidates,
+ _load_fixture_dialogs(),
+ ["Speaker1", "Speaker2"],
+ speaker_llm_side_effect(
+ _inference_output(
+ candidates,
+ {"Speaker1": 0.9, "Speaker2": 0.9},
+ {"Speaker1": "林晚风", "Speaker2": "Speaker2"},
+ )
+ ),
+ title=FIXTURE_TITLE,
+ author=FIXTURE_AUTHOR,
+ description=FIXTURE_DESCRIPTION,
+ )
+ self.assertEqual(inferencer.llm_client.call.call_count, 1)
+ self.assertEqual(
+ result["meta"]["Speaker1"]["rejection_reason"],
+ "title_credit_without_dialog_support",
+ )
+
+ def test_two_own_sample_candidates_share_one_review_call(self):
+ dialogs = [
+ {"speaker": "Speaker1", "text": "Bob here", "start_time": 0.0},
+ {"speaker": "Speaker2", "text": "柠姐在这儿", "start_time": 1.0},
+ ]
+ candidates = {
+ "Speaker1": [_candidate("Bob", "dialog_address", "Bob here")],
+ "Speaker2": [_candidate("柠姐", "dialog_address", "柠姐在这儿")],
+ }
+ inferencer, result = self._run(
+ candidates,
+ dialogs,
+ ["Speaker1", "Speaker2"],
+ speaker_llm_side_effect(
+ _inference_output(
+ candidates,
+ {"Speaker1": 0.9, "Speaker2": 0.9},
+ {"Speaker1": "Bob", "Speaker2": "柠姐"},
+ )
+ ),
+ )
+ self.assertEqual(result["mapping"], {"Speaker1": "Bob", "Speaker2": "柠姐"})
+ self.assertEqual(inferencer.llm_client.call.call_count, 2)
+ review = inferencer.llm_client.call.call_args_list[1]
+ self.assertEqual(review.kwargs["task_type"], SPEAKER_SELF_INTRO_TASK_TYPE)
+ items = parse_self_intro_items(review.kwargs["user_prompt"])
+ self.assertEqual({item["name"] for item in items}, {"Bob", "柠姐"})
+
+ def test_review_exception_uses_identity_fallback(self):
+ _, result = self._run(
+ {"Speaker1": [_candidate("Bob", "dialog_address", "Bob here")]},
+ [{"speaker": "Speaker1", "text": "Bob here"}],
+ ["Speaker1"],
+ speaker_llm_side_effect(
+ _inference_output(
+ {"Speaker1": [_candidate("Bob", "dialog_address", "Bob here")]},
+ {"Speaker1": 0.9},
+ {"Speaker1": "Bob"},
+ ),
+ raise_on_verify=RuntimeError("review boom"),
+ ),
+ )
+ self.assertEqual(result["source"], "identity_fallback")
+ self.assertEqual(result["mapping"]["Speaker1"], "Speaker1")
+
+ def test_cache_hit_skips_review(self):
+ cached = {
+ "mapping": {"Speaker1": "Bob"},
+ "meta": {
+ "Speaker1": {
+ "name": "Bob",
+ "confidence": 0.9,
+ "applied": True,
+ "sampled": True,
+ "candidates": [
+ {
+ "name": "Bob",
+ "evidence_type": "dialog_address",
+ "evidence_quote": "Bob here",
+ "self_intro_verdict": SELF_INTRO_PASS,
+ }
+ ],
+ "verified_candidates": [
+ {
+ "name": "Bob",
+ "evidence_type": "dialog_address",
+ "evidence_quote": "Bob here",
+ "self_intro_verdict": SELF_INTRO_PASS,
+ }
+ ],
+ }
+ },
+ "low_confidence": [],
+ }
+ inferencer = _make_inferencer(cached_mapping=cached)
+ inferencer.llm_client.call = MagicMock(side_effect=AssertionError("must not call LLM"))
+ result = inferencer.infer(
+ speakers=["Speaker1"],
+ dialogs=[{"speaker": "Speaker1", "text": "Bob here"}],
+ title="t",
+ platform="p",
+ media_id="m",
+ )
+ inferencer.llm_client.call.assert_not_called()
+ self.assertEqual(result["source"], "cache_hit")
+ self.assertEqual(result["mapping"]["Speaker1"], "Bob")
+
+ def test_e2e_review_prompt_has_quote_not_first_round_reasoning(self):
+ quote = "大家好,我是柠姐,麦能听到了吗?"
+ candidates = {"Speaker2": [_candidate("柠姐", "dialog_address", quote)]}
+ inferencer, result = self._run(
+ candidates,
+ _load_fixture_dialogs(),
+ ["Speaker1", "Speaker2"],
+ speaker_llm_side_effect(
+ _inference_output(
+ candidates,
+ {"Speaker1": 0.9, "Speaker2": 0.9},
+ {"Speaker1": "Speaker1", "Speaker2": "柠姐"},
+ reasoning="first-round-reasoning-secret",
+ )
+ ),
+ title=FIXTURE_TITLE,
+ author=FIXTURE_AUTHOR,
+ description=FIXTURE_DESCRIPTION,
+ )
+ self.assertEqual(result["mapping"]["Speaker2"], "柠姐")
+ first, review = inferencer.llm_client.call.call_args_list
+ self.assertEqual(first.kwargs["task_type"], "speaker_inference")
+ self.assertEqual(review.kwargs["task_type"], SPEAKER_SELF_INTRO_TASK_TYPE)
+ self.assertEqual(review.kwargs["response_schema"], SPEAKER_SELF_INTRO_SCHEMA)
+ user_prompt = review.kwargs["user_prompt"]
+ items = parse_self_intro_items(user_prompt)
+ self.assertEqual(items[0]["name"], "柠姐")
+ self.assertEqual(items[0]["quote"], quote)
+ self.assertNotIn("first-round-reasoning-secret", user_prompt)
+ self.assertNotIn("reasoning", user_prompt)
+ self.assertNotIn(FIXTURE_TITLE, user_prompt)
+ self.assertNotIn(FIXTURE_AUTHOR, user_prompt)