deploy standalone ASR pipeline on tools-pve

This commit is contained in:
Codex
2026-09-18 02:32:14 -07:00
parent f30f72b295
commit b23f00f41a
38 changed files with 182773 additions and 0 deletions
@@ -0,0 +1,468 @@
#!/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++ 分角色 + 声纹自动命名",
"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)