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
Original file line number Diff line number Diff line change
@@ -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`、推送分支。
Original file line number Diff line number Diff line change
@@ -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 修改两个回归测试文件;保留原测试目的,只更新与本卡新契约冲突的提示词基线和证据句。
- 下一步唯一动作:提交回归测试适配并推送本分支,随后核对远端分支状态。
226 changes: 87 additions & 139 deletions src/video_transcript_api/llm/core/speaker_inferencer.py

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions src/video_transcript_api/llm/core/speaker_self_intro_verifier.py
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 3 additions & 3 deletions src/video_transcript_api/llm/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`:描述中的参会名单。名单只是名单,核对通过也**不会单独**让名字被采用,仅作为佐证记录;不要把它当作「实际发言人名单」。

## 硬性要求
Expand Down
36 changes: 36 additions & 0 deletions src/video_transcript_api/llm/prompts/schemas/speaker_self_intro.py
Original file line number Diff line number Diff line change
@@ -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,
}
52 changes: 52 additions & 0 deletions src/video_transcript_api/llm/prompts/speaker_self_intro.py
Original file line number Diff line number Diff line change
@@ -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"<items>\n{encoded}\n</items>"
)
23 changes: 20 additions & 3 deletions tests/unit/i18n_llm/test_prompt_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_llm_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading