#!/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 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 "\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 detail = SOURCE_DETAIL.get(meta.get("backend", ""), "ASR") prompt = prompts.DRAFT_PROMPT.format( speaker_note=prompts.SPEAKER_NOTE.get(meta.get("backend", ""), ""), context=ctx, date=date, source_detail=detail, transcript=transcript_path, extra_fm=extra, body=cleaned) return 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)