Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrpipe/refine.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

489 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)