#!/usr/bin/env python3 """S0 — deterministic pre-processing and chunking. No LLM, no cost. Handles the three transcript shapes the pipeline actually produces: qwen3cam `【谭凯】…` / `【speaker_3】…` turns, blank-line separated, plus a `` header line. qwen3-plain `--- 第N段 [00:00 - 09:00] ---` segment banners then a wall of text with no speaker information at all. vibevoice `[mm:ss] S0: …` lines, with `[复读截断]` / `·` noise artifacts. Chunking rule: never split inside a speaker turn if avoidable, because the cleaning model needs the whole turn to tell a filler word from a real answer. Turns longer than the budget are split on sentence boundaries. Each chunk carries `overlap_prefix` — the tail of the previous chunk, given to the model as read-only context so it can resolve a pronoun or a name that started earlier without being asked to re-emit it. """ from __future__ import annotations import json import re from dataclasses import dataclass, field VOICEPRINT_HDR = re.compile(r"^"): try: meta["intake"] = json.loads( ln[len("")].strip()) except (TypeError, ValueError): pass continue if VOICEPRINT_HDR.match(ln): m = re.search(r"voiceprint_session:\s*(\S+)", ln) if m: meta["voiceprint_session"] = m.group(1) m = re.search(r"clusters:\s*(\d+)", ln) if m: meta["clusters"] = int(m.group(1)) continue if ln.startswith("# 转写"): for pair in re.findall(r"(speaker_\d+)=([^,\s]+)", ln): meta["voiceprint_named"][pair[0]] = pair[1] continue if SEGMENT_BANNER.match(ln): meta["banners"].append(ln.strip()) continue body_lines.append(ln) backend = detect_backend(raw) meta["backend"] = backend turns: list[Turn] = [] if backend == "vibevoice": for ln in body_lines: m = VIBE_TAG.match(ln.strip()) if m: txt = NOISE.sub("", m.group(3)).strip() if txt: turns.append(Turn(m.group(2), txt, len(turns), m.group(1))) return turns, meta blocks = [b.strip() for b in re.split(r"\n\s*\n", "\n".join(body_lines)) if b.strip()] for b in blocks: m = TURN_TAG.match(b) if m: txt = NOISE.sub("", m.group(2)).strip() if txt: turns.append(Turn(m.group(1), txt, len(turns))) else: txt = NOISE.sub("", b).strip() if txt: turns.append(Turn("", txt, len(turns))) return turns, meta def _pack(pieces: list[str], budget: int) -> list[str]: out, cur = [], "" for s in pieces: if len(cur) + len(s) > budget and cur: out.append(cur) cur = s else: cur += s if cur: out.append(cur) return out def _split_long(text: str, budget: int) -> list[str]: """Break an over-long single turn, escalating through split granularities. A hard character cut is the final fallback. It is ugly, but an oversized chunk is worse: it is exactly the condition that makes the cleaning model start summarizing instead of rewriting, which is the failure mode this whole pipeline exists to prevent. """ out = _pack([s for s in SENT_END.split(text) if s], budget) if all(len(p) <= budget for p in out): return out or [text] stage2: list[str] = [] for p in out: if len(p) <= budget: stage2.append(p) else: stage2 += _pack([s for s in SOFT_BREAK.split(p) if s], budget) final: list[str] = [] for p in stage2: if len(p) <= budget: final.append(p) else: final += [p[i:i + budget] for i in range(0, len(p), budget)] return final or [text] def chunk(raw: str, budget: int = 1800, overlap_chars: int = 200, collapse_noise: bool = True) -> tuple[list[Chunk], dict]: events: list[dict] = [] if collapse_noise: raw, events = collapse_degenerate(raw) turns, meta = parse(raw) meta["degenerate_events"] = events meta["degenerate_chars"] = sum(e["chars"] for e in events) chunks: list[Chunk] = [] cur: list[Turn] = [] size = 0 def flush(): nonlocal cur, size if cur: chunks.append(Chunk(len(chunks), cur, meta={})) cur, size = [], 0 for t in turns: if len(t.text) > budget: flush() for i, piece in enumerate(_split_long(t.text, budget)): chunks.append(Chunk(len(chunks), [Turn(t.speaker, piece, t.idx, t.ts)], meta={"split_turn": True, "part": i})) continue if size + len(t.text) > budget and cur: flush() cur.append(t) size += len(t.text) flush() if overlap_chars: for i in range(1, len(chunks)): prev = chunks[i - 1].body chunks[i].overlap_prefix = prev[-overlap_chars:] meta["n_turns"] = len(turns) meta["n_chunks"] = len(chunks) meta["speakers"] = sorted({t.speaker for t in turns if t.speaker}) return chunks, meta if __name__ == "__main__": import json import sys from pathlib import Path for p in sys.argv[1:]: raw = Path(p).read_text(errors="ignore") for budget in (900, 1800, 3600): cs, meta = chunk(raw, budget) sizes = [c.chars for c in cs] print(f"{Path(p).name[:40]:<42} budget={budget:<5} chunks={len(cs):<4} " f"turns={meta['n_turns']:<5} spk={len(meta['speakers']):<3} " f"backend={meta['backend']:<12} " f"size min/avg/max={min(sizes)}/{sum(sizes)//len(sizes)}/{max(sizes)}")