Files
UmbraAudio/scripts/reattribute_all.py
B.D. Murphy b09e324431 Multi-pass attribution + web voice-maintenance banner + qwen demo branding
Attribution (from prior session, now committed):
- llm_attributor: mechanical narration evidence, unnamed-role rules,
  wider context (12->20 lines, 8->40 utterances), uncertain flag,
  verify pass with 20-item batches and invented-name gate
- reattribute_all: --min-id/--max-id, --dry-run, --no-verify,
  --verify-rounds, qa_accepted skip guard, per-chapter elapsed times
- test_multipass_attribution: 19 tests

Web banner:
- voice_maintenance module: pid-liveness-checked marker file so a
  crashed run self-heals the banner away
- routes/base.html: amber banner on every page while the LLM
  re-attribution run has Qwen3-TTS offline
- reattribute_all: sets banner before switching models, clears in
  finally (also on failure)
- test_voice_maintenance: 8 tests

qwen3-tts submodule: UmbraAudio Voice Studio branding in web demo
(icon data-uri + favicon logo).
2026-09-22 19:12:50 -07:00

284 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Re-run LLM speaker attribution on existing utterances without re-segmenting.
Preserves utterance IDs and mechanical segmentation; only updates speaker_name.
"""
from __future__ import annotations
import json
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.analysis.attribution_qa import (
check_attribution,
verify_conversation_flow,
)
from umbraaudio.analysis.llm_attributor import LLMAttributor
from umbraaudio.config import load_config
from umbraaudio.voice_maintenance import (
clear_voice_maintenance,
set_voice_maintenance,
)
from umbraaudio.voices.model_coordinator import ModelCoordinator, PipelineStage
def _build_alias_map(speakers_path: Path) -> dict[str, str]:
if not speakers_path.exists():
return {}
with open(speakers_path) as f:
data = json.load(f)
alias_map: dict[str, str] = {}
for s in data.get("speakers", []):
primary = s.get("primary_name", "")
if not primary:
continue
alias_map[primary.lower()] = primary
for alias in s.get("aliases", []):
if alias:
alias_map[alias.lower()] = primary
return alias_map
def _qa_issue_count(utt_list: list[dict], chapter_id: int, alias_map: dict) -> int:
qa = check_attribution(utt_list, chapter_id, alias_map)
flow = verify_conversation_flow(utt_list, chapter_id, alias_map)
return len(qa.issues) + len(flow.issues)
def _report_dry_run(attributed: list[dict], original: list[dict]) -> None:
"""Print speaker changes the run would make (no DB writes)."""
orig_by_id = {u["id"]: u for u in original}
changed = 0
shown = 0
for u in attributed:
o = orig_by_id.get(u["id"])
if o is None:
continue
before = o.get("speaker_name") or "narrator"
after = u.get("speaker_name") or "narrator"
if (before or "").lower() == (after or "").lower():
continue
changed += 1
if shown < 5:
text = (u.get("text") or "")[:70]
print(f" utt {u.get('utterance_index')}: {before} -> {after} {text!r}")
shown += 1
print(f" dry-run: {changed} speaker change(s) (sample above)")
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
"--project",
type=str,
default=None,
help="Project name or path",
)
parser.add_argument(
"--chapters",
type=str,
default=None,
help="Comma-separated chapter IDs (e.g. '5,6,7')",
)
parser.add_argument(
"--min-id",
type=int,
default=None,
help="Only chapters with id >= this value (e.g. 11 = 'chapters 11 to the end')",
)
parser.add_argument(
"--max-id",
type=int,
default=None,
help="Only chapters with id <= this value",
)
parser.add_argument(
"--model",
type=str,
default="google/gemma-4-e4b",
help="LLM model name",
)
parser.add_argument(
"--sleep",
type=float,
default=1.0,
help="Seconds to sleep between LLM batches",
)
parser.add_argument(
"--no-verify",
action="store_true",
help="Skip the multi-pass verification round (pass 3)",
)
parser.add_argument(
"--verify-rounds",
type=int,
default=2,
help="Max verification rounds for flagged lines (default 2)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Attribute in memory and report vs DB, but write NOTHING",
)
args = parser.parse_args()
cfg = load_config(args.project)
config = cfg.llm
coordinator = ModelCoordinator(cfg)
if not coordinator.switch_to(
PipelineStage.CHAPTER_ANALYSIS, lm_model=args.model
).get("ok"):
print("Failed to switch models to CHAPTER_ANALYSIS stage")
sys.exit(1)
set_voice_maintenance(
"LLM speaker re-attribution is running — Qwen3-TTS is offline "
"for the analysis. Voice re-generation is paused until the run finishes."
)
try:
db_path = cfg.db_path
if not db_path.exists():
print(f"Database not found: {db_path}")
return
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
alias_map = _build_alias_map(cfg.data_dir / "speakers.json")
if args.chapters:
chapter_ids = [int(x.strip()) for x in args.chapters.split(",")]
placeholders = ",".join("?" * len(chapter_ids))
chapters = db.execute(
f"SELECT id, title, chapter_index FROM chapters "
f"WHERE id IN ({placeholders}) ORDER BY chapter_index",
chapter_ids,
).fetchall()
else:
chapters = db.execute(
"SELECT id, title, chapter_index FROM chapters ORDER BY chapter_index"
).fetchall()
if args.min_id is not None or args.max_id is not None:
chapters = [
ch
for ch in chapters
if (args.min_id is None or ch["id"] >= args.min_id)
and (args.max_id is None or ch["id"] <= args.max_id)
]
print(f"Found {len(chapters)} chapters to attribute")
print(f"Model: {args.model}")
print(f"Verify pass: {'ON' if not args.no_verify else 'OFF'} "
f"(up to {args.verify_rounds} round(s))")
if args.dry_run:
print("DRY RUN: no changes will be written")
attributor = LLMAttributor(config, model=args.model, data_dir=cfg.data_dir)
total_dialogue = 0
total_narrator_left = 0
total_qa_before = 0
total_qa_after = 0
for ch in chapters:
ch_id = ch["id"]
title = ch["title"] or f"Chapter {ch['chapter_index']}"
ch_start = time.time()
utts = db.execute(
"SELECT id, utterance_index, text, type, speaker_name, "
"qa_accepted, qa_accepted_sig "
"FROM utterances WHERE chapter_id=? ORDER BY utterance_index",
(ch_id,),
).fetchall()
if not utts:
print(f" {title}: no utterances, skipping")
continue
utt_list = [dict(u) for u in utts]
dialogue_before = sum(1 for u in utt_list if u.get("type") == "dialogue")
narrator_before = sum(
1
for u in utt_list
if u.get("type") == "dialogue"
and (u.get("speaker_name") or "narrator").lower() == "narrator"
)
qa_before = _qa_issue_count(utt_list, ch_id, alias_map)
attributed = attributor.attribute_speakers(
utt_list,
title,
ch_id,
verify=not args.no_verify,
verify_rounds=args.verify_rounds,
)
time.sleep(0.5)
ch_elapsed = time.time() - ch_start
accepted_skipped = 0
for u in attributed:
if u.get("qa_accepted"):
accepted_skipped += 1
continue
if args.dry_run:
continue
db.execute(
"UPDATE utterances SET speaker_name=?, attribution_confidence=?, "
"attribution_reasoning=? WHERE id=?",
(
u.get("speaker_name", "Narrator"),
u.get("attribution_confidence", u.get("confidence", 0.5)),
u.get("attribution_reasoning", "") or "",
u["id"],
),
)
if not args.dry_run:
db.commit()
if args.dry_run:
_report_dry_run(attributed, utt_list)
narrator_after = sum(
1
for u in attributed
if u.get("type") == "dialogue"
and (u.get("speaker_name") or "narrator").lower() == "narrator"
)
qa_after = _qa_issue_count(attributed, ch_id, alias_map)
total_dialogue += dialogue_before
total_narrator_left += narrator_after
total_qa_before += qa_before
total_qa_after += qa_after
print(
f" {title} (id={ch_id}): dialogue {dialogue_before}, "
f"narrator-left {narrator_before} -> {narrator_after}, "
f"QA issues {qa_before} -> {qa_after}"
f" ({ch_elapsed:.0f}s)"
+ (f", accepted-skipped {accepted_skipped}" if accepted_skipped else "")
)
db.close()
print(
f"\nTotal: {total_dialogue} dialogue utterances, "
f"{total_narrator_left} still attributed to narrator, "
f"QA issues {total_qa_before} -> {total_qa_after}"
)
finally:
clear_voice_maintenance()
coordinator.switch_to(PipelineStage.IDLE)
print("Done!")
if __name__ == "__main__":
main()