deploy standalone ASR pipeline on tools-pve
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user