489 lines
21 KiB
Python
489 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
"""Stage orchestration: transcript text -> rough-outline draft.
|
||
|
||
S0 segment collapse ASR degeneration, split into speaker-aware chunks
|
||
S1 flag phonetic + term-bank candidates, Hindsight in-play recall
|
||
S1c keyword LLM keyword extraction -> Hindsight recall (restores context
|
||
for backends that emit no speaker tags)
|
||
S2 clean per-chunk 去口语化 + per-sentence candidate adjudication
|
||
S3 gate deterministic fidelity check, strict retry, fallback to source
|
||
S4 draft one strong-model call, structure + provenance labelling only
|
||
|
||
Design invariant: **no stage may silently lose content**. S3 is what makes that
|
||
true; without it, per-chunk cleaning dropped up to 22% of English terms and 11% of
|
||
numbers on the evaluation set, and no amount of prompt wording fixed it.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from . import context as context_compactor
|
||
from . import fidelity, flagger, numbers as number_norm, prompts, segment
|
||
from .config import CFG
|
||
from .llm import PartialFailure, Usage, chat, chat_many
|
||
from .log import log
|
||
|
||
SOURCE_DETAIL = {
|
||
"qwen3cam": "Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹自动命名",
|
||
"soniox": "Soniox stt-async-v5 线上分角色转写",
|
||
"gemini": "Gemini 3.5 Transcribe (LiteLLM)",
|
||
"openai": "OpenAI GPT-4o Transcribe (LiteLLM)",
|
||
"grok": "Grok Voice Transcribe 2.0",
|
||
"vibevoice": "VibeVoice 分角色",
|
||
"qwen3-plain": "Qwen3-ASR 1.7B",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class Result:
|
||
draft: str
|
||
cleaned: str
|
||
meta: dict
|
||
usage: dict
|
||
timings: dict
|
||
stats: dict
|
||
flags: list = field(default_factory=list)
|
||
context: str = ""
|
||
evidence: str = ""
|
||
|
||
|
||
# ------------------------------------------------------------------ S1c
|
||
def keyword_recall(raw: str, usage: Usage) -> tuple[set[str], list[str], str]:
|
||
"""LLM keyword extraction, then Hindsight recall on those keywords.
|
||
|
||
Needed because the phonetic flagger seeds its in-play entity set from speaker
|
||
tags: on `qwen3-plain` transcripts there are none, and measurement showed
|
||
in_play=0 for every such fixture — no vault person or company could ever be
|
||
offered as a candidate. Reading names out of the text restores that signal and
|
||
was the single biggest quality lever (term-correction recall 0.21 -> 0.40).
|
||
"""
|
||
head, mid = raw[:3000], raw[len(raw) // 2: len(raw) // 2 + 3000]
|
||
try:
|
||
out = chat(CFG.keyword_model,
|
||
prompts.KEYWORD_PROMPT.format(sample=head + "\n…\n" + mid),
|
||
max_tokens=300, usage=usage)
|
||
except Exception as e:
|
||
log(f"keyword recall failed, continuing without it: {e}", level="WARN")
|
||
return set(), [], ""
|
||
words = [w.strip("、,,。;;::·") for w in re.split(r"[\s,、]+", out) if w.strip()]
|
||
words = [w for w in words if 2 <= len(w) <= 12][:25]
|
||
names, texts = flagger.hindsight_recall(words, "会议 背景 相关人物 公司 项目")
|
||
return names, texts, " ".join(words)
|
||
|
||
|
||
def build_context(fa: dict, extra_texts: list[str], limit: int = 6) -> str:
|
||
names = fa.get("hindsight_names") or []
|
||
texts = (fa.get("hindsight_texts") or []) + (extra_texts or [])
|
||
try:
|
||
from . import lexicon
|
||
texts += lexicon.relationship_card(set(names), limit=12)
|
||
except Exception:
|
||
pass
|
||
compacted = context_compactor.compact(names, texts, per_entity=2,
|
||
total=max(6, limit * 2))
|
||
head = "相关实体:" + "、".join(names[:30]) if names else ""
|
||
return "\n".join(x for x in (head, compacted) if x)
|
||
|
||
|
||
# ------------------------------------------------------------------ S2 + S3
|
||
def clean_chunks(chunks, usage: Usage) -> tuple[list[str], list[dict], dict]:
|
||
vocab = fidelity.domain_terms() if CFG.use_termbank else set()
|
||
stats = {"skipped_trivial": 0, "retried": 0, "fallback": 0, "gate_failed": 0}
|
||
|
||
def trivial(c) -> bool:
|
||
stripped = re.sub(r"\[…重复噪声已折叠:.*?…\]|[\s【】]", "", c.body)
|
||
return len(stripped) < 40
|
||
|
||
prompt_list, target_idx = [], []
|
||
for c in chunks:
|
||
if trivial(c):
|
||
continue
|
||
parts = [prompts.CLEAN_RULES]
|
||
if c.overlap_prefix:
|
||
parts.append("上一片段结尾(只读上下文,**不要**重复输出这部分):\n"
|
||
+ c.overlap_prefix)
|
||
parts.append("待清洗片段:\n" + c.body)
|
||
prompt_list.append("\n\n".join(parts))
|
||
target_idx.append(c.idx)
|
||
stats["skipped_trivial"] = len(chunks) - len(prompt_list)
|
||
|
||
max_tok = min(16000, 1200 + 3 * max((c.chars for c in chunks), default=600))
|
||
outs: list[str] = []
|
||
if prompt_list:
|
||
try:
|
||
outs = chat_many(CFG.clean_model, prompt_list,
|
||
system=prompts.CLEAN_SYS, max_tokens=max_tok, usage=usage)
|
||
except PartialFailure as pf:
|
||
# Keep what succeeded; failed indices fall back to source text below.
|
||
outs = pf.outputs
|
||
log(f"{len(pf.failed)} chunk(s) unrecoverable, using source text",
|
||
level="WARN")
|
||
|
||
by_idx = dict(zip(target_idx, outs))
|
||
prompt_by_idx = dict(zip(target_idx, prompt_list))
|
||
|
||
finals, verdicts = [], []
|
||
for c in chunks:
|
||
out = by_idx.get(c.idx) or c.body
|
||
p = prompt_by_idx.get(c.idx)
|
||
v = fidelity.check(c.body, out, vocab=vocab,
|
||
min_ratio=CFG.gate_min_ratio, max_ratio=CFG.gate_max_ratio)
|
||
if CFG.gate_enabled and not v.ok and p is not None:
|
||
stats["retried"] += 1
|
||
strict = (p + "\n\n**上一次输出不合格**:" + v.brief()
|
||
+ "\n请重做。逐句改写,不要合并、不要省略、不要总结;"
|
||
"数字、英文、说话人标签必须保留;听不懂的词原样保留。")
|
||
try:
|
||
out2 = chat(CFG.clean_model, strict, system=prompts.CLEAN_SYS,
|
||
max_tokens=min(16000, 1500 + 3 * c.chars), usage=usage)
|
||
v2 = fidelity.check(c.body, out2, vocab=vocab,
|
||
min_ratio=CFG.gate_min_ratio,
|
||
max_ratio=CFG.gate_max_ratio)
|
||
if v2.ok or v2.ratio > v.ratio:
|
||
out, v = out2, v2
|
||
except Exception as e:
|
||
log(f"strict retry failed for chunk {c.idx}: {e}", level="WARN")
|
||
if not v.ok:
|
||
# A visibly unclean chunk beats one with facts deleted.
|
||
out = c.body
|
||
v.reasons.append("两次失败→保留原文")
|
||
stats["fallback"] += 1
|
||
if not v.ok:
|
||
stats["gate_failed"] += 1
|
||
finals.append(out)
|
||
verdicts.append(v.as_dict())
|
||
return finals, verdicts, stats
|
||
|
||
|
||
def _apply_settled(text: str, flags: list[dict]) -> tuple[str, list[dict]]:
|
||
"""Apply unambiguous human-confirmed mappings without asking an LLM."""
|
||
chosen: dict[str, str] = {}
|
||
for g in flags:
|
||
if not g.get("settled") or g.get("common_word") or not g.get("candidates"):
|
||
continue
|
||
chosen[g["span"]] = g["candidates"][0]["target"]
|
||
markers: dict[str, str] = {}
|
||
out = text
|
||
for i, (surface, target) in enumerate(
|
||
sorted(chosen.items(), key=lambda x: len(x[0]), reverse=True)):
|
||
if surface not in out or surface == target:
|
||
continue
|
||
marker = f"\uFFF0ASR{i}\uFFF1"
|
||
out = out.replace(surface, marker)
|
||
markers[marker] = target
|
||
for marker, target in markers.items():
|
||
out = out.replace(marker, target)
|
||
decisions = [{"surface": s, "target": t, "mode": "deterministic"}
|
||
for s, t in chosen.items() if s in text]
|
||
return out, decisions
|
||
|
||
|
||
def _common_lexical_context(span: str, sentence: str,
|
||
allowlist: set[str]) -> bool:
|
||
"""Reject a candidate that cuts through an ordinary Chinese word."""
|
||
if span in {"很好"}:
|
||
return True
|
||
for match in re.finditer(re.escape(span), sentence):
|
||
start, end = match.span()
|
||
lo, hi = max(0, start - 4), min(len(sentence), end + 4)
|
||
for left in range(lo, end):
|
||
for right in range(max(left + 2, start + 1),
|
||
min(hi, left + 6) + 1):
|
||
if right <= start or left >= end:
|
||
continue
|
||
word = sentence[left:right]
|
||
if (word in allowlist
|
||
and re.fullmatch(r"[\u3400-\u9fff]+", word)
|
||
and (left < start or right > end)):
|
||
return True
|
||
if (len(span) == 2 and span[-1] in "总哥姐"
|
||
and start > 0
|
||
and re.fullmatch(r"[\u3400-\u9fff]", sentence[start - 1])
|
||
and sentence[start - 1] not in "问跟和找请让给叫说"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _queue_ambiguous(recording: str, groups: list[dict],
|
||
normalized: str = "") -> int:
|
||
"""Queue only ambiguities the context adjudicator explicitly left unresolved."""
|
||
try:
|
||
from . import review
|
||
except Exception:
|
||
return 0
|
||
markers = re.findall(r"\[\?([^\]]+)\]", normalized)
|
||
if not markers:
|
||
return 0
|
||
allowlist = flagger.load_allowlist()
|
||
n = 0
|
||
for g in groups:
|
||
cs = g.get("candidates") or []
|
||
if g.get("settled") or g.get("common_word") or not cs:
|
||
continue
|
||
if _common_lexical_context(
|
||
g["span"], str(g.get("sentence", "")), allowlist):
|
||
continue
|
||
choices = [c["target"] for c in cs[:3]]
|
||
if not any(g["span"] in marker
|
||
or any(choice in marker for choice in choices)
|
||
for marker in markers):
|
||
continue
|
||
top = cs[0]
|
||
score = float(top.get("score", 0))
|
||
if not (CFG.review_min_score <= score <= CFG.review_max_score):
|
||
continue
|
||
rule = top.get("rule", "")
|
||
source = str(top.get("source", ""))
|
||
supported = bool(top.get("boost") or top.get("support"))
|
||
curated = rule == "alias" and source.startswith(
|
||
("alias-card", "alias-learned", "alias-glossary", "alias-review"))
|
||
consistent = rule == "consistency" and source == "consistency-alias"
|
||
contextual_short = (rule in {"honorific", "org-short", "given"}
|
||
and supported and score >= 0.90)
|
||
via = str(top.get("via", ""))
|
||
observed_neighbour = (
|
||
rule == "observed" and supported and score >= 0.95
|
||
and len(g["span"]) >= 3 and via
|
||
and (g["span"][0] == via[0] or score >= 0.99))
|
||
# A broad phonetic channel is useful for recall but is not, by itself,
|
||
# sufficient reason to interrupt a human. "也说→叶硕" and "们七→范梦奇"
|
||
# are representative false positives from that channel.
|
||
if not (curated or consistent or contextual_short or observed_neighbour):
|
||
continue
|
||
if len(choices) == 1:
|
||
q = f"“{g['span']}”是不是指“{choices[0]}”?"
|
||
else:
|
||
q = f"“{g['span']}”更可能指哪一个:{' / '.join(choices)}?"
|
||
review.enqueue(recording, q, {
|
||
"surface": g["span"], "candidates": choices,
|
||
"sentence": g.get("sentence", ""), "kind": top.get("kind", "")})
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def normalize_chunks(chunks, cleaned_parts: list[str], flags: list[dict],
|
||
context: str, recording: str,
|
||
usage: Usage, *,
|
||
queue_review: bool = True) -> tuple[list[str], list[dict], dict]:
|
||
"""S3: deterministic confirmed mappings, then narrow LLM adjudication."""
|
||
prompts_by_idx: dict[int, str] = {}
|
||
base_by_idx: dict[int, str] = {}
|
||
decisions: list[dict] = []
|
||
number_decisions: list[dict] = []
|
||
unresolved_by_idx: dict[int, list[dict]] = {}
|
||
queued = 0
|
||
for c, cleaned in zip(chunks, cleaned_parts):
|
||
cf = [g for g in flags if g["span"] in c.body]
|
||
base, done = _apply_settled(cleaned, cf)
|
||
decisions.extend({**d, "chunk": c.idx} for d in done)
|
||
base, nums = number_norm.normalize(base)
|
||
number_decisions.extend({**d, "chunk": c.idx} for d in nums)
|
||
unresolved = [g for g in cf if not g.get("settled")]
|
||
unresolved_by_idx[c.idx] = unresolved
|
||
uncertain_numbers = [d for d in nums if d["confidence"] < 0.8]
|
||
base_by_idx[c.idx] = base
|
||
if not unresolved and not uncertain_numbers:
|
||
continue
|
||
parts = [prompts.NORMALIZE_RULES]
|
||
if unresolved:
|
||
parts.append(prompts.JUDGE_RULES.format(
|
||
candidates=flagger.render_for_prompt(unresolved, 20)))
|
||
if context:
|
||
parts.append(prompts.CONTEXT_FOR_CLEAN.format(context=context))
|
||
if uncertain_numbers:
|
||
lines = "\n".join(
|
||
f"- 原值「{d['raw']}」|上下文:{d['context']}"
|
||
for d in uncertain_numbers[:12])
|
||
parts.append("以下中文数字类型不明确。不能可靠确定时原样保留并标待确认:\n"
|
||
+ lines)
|
||
parts.append("待规范化文本:\n" + base)
|
||
prompts_by_idx[c.idx] = "\n\n".join(parts)
|
||
|
||
idxs = list(prompts_by_idx)
|
||
outs = []
|
||
if idxs:
|
||
try:
|
||
outs = chat_many(CFG.clean_model, [prompts_by_idx[i] for i in idxs],
|
||
system=prompts.NORMALIZE_SYS, max_tokens=6000, usage=usage)
|
||
except PartialFailure as pf:
|
||
outs = pf.outputs
|
||
normalized = dict(zip(idxs, outs))
|
||
finals = []
|
||
fallback = 0
|
||
for c in chunks:
|
||
base = base_by_idx[c.idx]
|
||
out = normalized.get(c.idx) or base
|
||
ratio = len(out) / max(1, len(base))
|
||
src_speakers = set(fidelity.SPEAKER.findall(base))
|
||
out_speakers = set(fidelity.SPEAKER.findall(out))
|
||
if not (0.75 <= ratio <= 1.25) or not src_speakers.issubset(out_speakers):
|
||
out = base
|
||
fallback += 1
|
||
elif queue_review:
|
||
queued += _queue_ambiguous(
|
||
recording, unresolved_by_idx.get(c.idx, []), out)
|
||
finals.append(out)
|
||
return finals, decisions, {
|
||
"normalize_calls": len(idxs), "normalize_fallback": fallback,
|
||
"review_queued": queued, "deterministic_corrections": len(decisions),
|
||
"number_decisions": number_decisions,
|
||
"numbers_normalized": sum(
|
||
1 for d in number_decisions if d["raw"] != d["normalized"]),
|
||
"numbers_uncertain": sum(
|
||
1 for d in number_decisions if d["confidence"] < 0.8),
|
||
}
|
||
|
||
|
||
def scrub_internal_wording(text: str) -> str:
|
||
"""Keep pipeline jargon out of text a person will read."""
|
||
for src, dst in (
|
||
("卡片未提供", "记录未写明"),
|
||
("卡片提及", "录音中提到"),
|
||
("事实卡片", "整理要点"),
|
||
("粗整理草稿", "整理稿"),
|
||
):
|
||
text = text.replace(src, dst)
|
||
return text
|
||
|
||
|
||
def extract_evidence(parts: list[str], usage: Usage) -> str:
|
||
jobs = [prompts.EVIDENCE_PROMPT.format(chunk_id=i, body=body)
|
||
for i, body in enumerate(parts)]
|
||
if not jobs:
|
||
return ""
|
||
try:
|
||
outs = chat_many(CFG.clean_model, jobs, system=prompts.EVIDENCE_SYS,
|
||
max_tokens=1400, usage=usage)
|
||
except PartialFailure as pf:
|
||
outs = pf.outputs
|
||
return scrub_internal_wording("\n".join(x for x in outs if x.strip()))
|
||
|
||
|
||
# ------------------------------------------------------------------ S4
|
||
def write_draft(cleaned: str, meta: dict, context: str, date: str,
|
||
transcript_path: str, usage: Usage) -> str:
|
||
extra = ""
|
||
intake = meta.get("intake") or {}
|
||
if intake.get("job_id"):
|
||
extra += f"intake_job: {intake['job_id']}\n"
|
||
if meta.get("voiceprint_session"):
|
||
extra += f"voiceprint_session: {meta['voiceprint_session']}\n"
|
||
named = meta.get("voiceprint_named") or {}
|
||
if named:
|
||
extra += "voiceprint_speakers:\n"
|
||
for k, v in named.items():
|
||
extra += f" {k}: {v}\n"
|
||
extra += ("# 说明:voiceprint_speakers 只写声纹已确认命名的;你据内容推断的匿名"
|
||
"说话人不要当确认名写进来(会误导回注册),留正文「疑似」即可。\n")
|
||
user_context = str(intake.get("context") or "").strip()
|
||
if user_context:
|
||
supplied = (
|
||
"用户在上传录音时明确提供的背景(可用于辨识本场主题和实体;"
|
||
"不能把其中未在录音出现的内容冒充为本场发言):\n"
|
||
+ user_context)
|
||
context = supplied + ("\n\n" + context if context else "")
|
||
ctx = prompts.CONTEXT_FOR_CLEAN.format(context=context) if context else ""
|
||
if ctx:
|
||
ctx = "本场背景(来自既有笔记与长期记忆;引用其中内容时必须按上面第 3 条加 〔背景〕):\n" \
|
||
+ context
|
||
backend = meta.get("backend") or meta.get("asr_provider") or ""
|
||
model_name = meta.get("asr_model") or ""
|
||
detail = SOURCE_DETAIL.get(backend, "ASR")
|
||
if model_name and backend in ("soniox", "gemini", "openai", "grok") and model_name not in detail:
|
||
detail = f"{detail} ({model_name})"
|
||
prompt = prompts.DRAFT_PROMPT.format(
|
||
speaker_note=prompts.SPEAKER_NOTE.get(backend, ""),
|
||
context=ctx, date=date, source_detail=detail,
|
||
transcript=transcript_path, extra_fm=extra, body=cleaned)
|
||
return scrub_internal_wording(chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage))
|
||
|
||
|
||
# ------------------------------------------------------------------ driver
|
||
def refine(raw: str, *, date: str, transcript_path: str,
|
||
queue_review: bool = True) -> Result:
|
||
usage = Usage()
|
||
timings: dict = {}
|
||
t_all = time.time()
|
||
|
||
t = time.time()
|
||
chunks, meta = segment.chunk(raw, CFG.chunk_budget, CFG.chunk_overlap,
|
||
collapse_noise=CFG.collapse_noise)
|
||
timings["s0_segment"] = round(time.time() - t, 2)
|
||
if meta.get("degenerate_chars"):
|
||
ev = meta["degenerate_events"]
|
||
log(f"S0 collapsed {meta['degenerate_chars']} chars of ASR degeneration "
|
||
f"({len(ev)} run(s), e.g. {ev[0]['unit']!r}x{ev[0]['repeats']})")
|
||
|
||
# S1c runs *before* S1 so its entity names can seed the candidate space. Doing
|
||
# it the other way round meant the recalled names only ever reached the prompt
|
||
# as background prose, never as phonetic candidates — which is why transcripts
|
||
# without speaker labels produced almost no flags.
|
||
t = time.time()
|
||
kw, extra_texts, names = "", [], set()
|
||
if CFG.use_keyword_recall and CFG.use_hindsight:
|
||
names, extra_texts, kw = keyword_recall(raw, usage)
|
||
timings["s1c_recall"] = round(time.time() - t, 2)
|
||
|
||
t = time.time()
|
||
fa = {"flags": [], "hindsight_names": [], "hindsight_texts": [], "in_play": []}
|
||
if CFG.use_termbank or CFG.use_hindsight:
|
||
try:
|
||
fa = flagger.analyze(raw, use_hindsight=CFG.use_hindsight,
|
||
use_termbank=CFG.use_termbank,
|
||
extra_in_play=names)
|
||
except Exception as e:
|
||
log(f"flagger failed, continuing without candidates: {e}", level="WARN")
|
||
if names:
|
||
fa["hindsight_names"] = sorted(set(fa.get("hindsight_names", [])) | names)
|
||
timings["s1_flag"] = round(time.time() - t, 2)
|
||
|
||
context = build_context(fa, extra_texts) if CFG.use_hindsight else ""
|
||
|
||
t = time.time()
|
||
cleaned_parts, verdicts, stats = clean_chunks(chunks, usage)
|
||
cleaned = "\n\n".join(cleaned_parts)
|
||
timings["s2_clean"] = round(time.time() - t, 2)
|
||
|
||
t = time.time()
|
||
normalized_parts = cleaned_parts
|
||
if CFG.use_normalize:
|
||
normalized_parts, decisions, nstats = normalize_chunks(
|
||
chunks, cleaned_parts, fa["flags"], context, transcript_path, usage,
|
||
queue_review=queue_review)
|
||
stats.update(nstats)
|
||
stats["normalization_decisions"] = decisions
|
||
normalized = "\n\n".join(normalized_parts)
|
||
timings["s3_normalize"] = round(time.time() - t, 2)
|
||
|
||
t = time.time()
|
||
evidence = extract_evidence(normalized_parts, usage) if CFG.use_evidence else normalized
|
||
timings["s4_evidence"] = round(time.time() - t, 2)
|
||
|
||
t = time.time()
|
||
draft = write_draft(evidence, meta, context, date, transcript_path, usage)
|
||
timings["s4_draft"] = round(time.time() - t, 2)
|
||
|
||
src_body = "\n\n".join(c.body for c in chunks)
|
||
stats.update({
|
||
"raw_chars": len(raw),
|
||
"source_chars": len(src_body),
|
||
"cleaned_chars": len(cleaned),
|
||
"normalized_chars": len(normalized),
|
||
"evidence_chars": len(evidence),
|
||
"draft_chars": len(draft),
|
||
"n_chunks": len(chunks),
|
||
"n_turns": meta.get("n_turns", 0),
|
||
"n_flags": len(fa["flags"]),
|
||
"n_hindsight": len(fa.get("hindsight_names", [])),
|
||
"keywords": kw,
|
||
"degenerate_chars": meta.get("degenerate_chars", 0),
|
||
"fidelity": fidelity.score_document(src_body, cleaned),
|
||
"wall_s": round(time.time() - t_all, 2),
|
||
})
|
||
return Result(draft=draft, cleaned=normalized, meta=meta, usage=usage.summary(),
|
||
timings=timings, stats=stats, flags=fa["flags"], context=context,
|
||
evidence=evidence)
|