"""Speaker-attribution audit for one chapter. Runs inside manga-orchestrator (reads /data/manga.db). Answers the three questions asked of the 2026-08-11 rerun: 1. what happens on a multi-character panel whose people have no identity, 2. whether a known character gets mis-named, 3. what the named-speaker share actually is now. """ import collections import json import sqlite3 import sys CHAPTER = sys.argv[1] if len(sys.argv) > 1 else "7c944dd4-e972-42c7-ba60-9f6939548e80" SPEECH = {"speech", "shout", "thought"} c = sqlite3.connect("/data/manga.db") c.row_factory = sqlite3.Row manga_id = c.execute("SELECT manga_id FROM chapters WHERE chapter_id=?", (CHAPTER,)).fetchone()[0] reg = {r["character_id"]: dict(r) for r in c.execute( "SELECT character_id, name, aliases, description FROM characters WHERE manga_id=?", (manga_id,))} # duplicate registry rows make a correct name unresolvable: an answer matching two rows is ambiguous. by_name = collections.defaultdict(list) for cid, r in reg.items(): for n in [r["name"], *json.loads(r["aliases"] or "[]")]: if n: by_name[str(n).strip().casefold()].append(cid) dupes = {n: ids for n, ids in by_name.items() if len(ids) > 1} panels = c.execute("SELECT panel_id, panel_index, page_index FROM panels WHERE chapter_id=? ORDER BY \"order\"", (CHAPTER,)).fetchall() methods, kinds, per_char = collections.Counter(), collections.Counter(), collections.Counter() speech = named = 0 crowded_speech = crowded_named = 0 solo_speech = solo_named = 0 unresolved_names = collections.Counter() ambiguous = [] no_identity_crowd = 0 # a line attributed to a real present local_id that simply has no identity row: attribution succeeded # and the name is lost anyway. This is the identity-coverage wall, not an attribution failure. attributed_but_unassigned = collections.Counter() for p in panels: pid = p["panel_id"] row = c.execute("SELECT result_json FROM vision_results WHERE panel_id=?", (pid,)).fetchone() if not row: continue v = json.loads(row["result_json"]) if "dialogue" not in v: continue people = [ch for ch in (v.get("characters") or []) if ch.get("local_id")] assigned = {a["local_id"] for a in c.execute( "SELECT local_id FROM identity_assignments WHERE panel_id=?", (pid,))} crowd = len(people) > 1 if crowd and not assigned: no_identity_crowd += 1 for d in v["dialogue"]: if d.get("type", "speech") not in SPEECH: continue speech += 1 ref = d.get("speaker_ref") or {} kind = ref.get("kind") methods[d.get("speaker_method")] += 1 kinds[kind] += 1 if crowd: crowded_speech += 1 else: solo_speech += 1 if kind == "character_id": named += 1 per_char[reg.get(ref["value"], {}).get("name") or ref["value"]] += 1 if crowd: crowded_named += 1 else: solo_named += 1 elif kind == "unknown": # normalize_dialogue nulls the flat `speaker` unless it resolved, but speaker_ref keeps the # unresolved local_id as its value. raw = str(ref.get("value") or "").strip() local_ids = {ch["local_id"] for ch in people} if raw in local_ids and raw not in assigned: attributed_but_unassigned[d.get("speaker_method")] += 1 elif kind == "name": unresolved_names[ref.get("value")] += 1 if ref.get("candidates"): ambiguous.append((pid, ref.get("value"), ref["candidates"])) print(f"panels with dialogue: {sum(1 for p in panels if (lambda r: r and 'dialogue' in json.loads(r['result_json']))(c.execute('SELECT result_json FROM vision_results WHERE panel_id=?', (p['panel_id'],)).fetchone()))}/{len(panels)}") print(f"speech lines: {speech} named (character_id): {named} = {100*named/max(speech,1):.0f}%") print(f" multi-character panels: {crowded_named}/{crowded_speech} named") print(f" single-character panels: {solo_named}/{solo_speech} named") print(f"multi-character panels with NO identity at all: {no_identity_crowd}") print(f"attributed to a present local_id with NO identity row: {dict(attributed_but_unassigned)}") print(f"speaker_method: {dict(methods)}") print(f"speaker_ref kind: {dict(kinds)}") print(f"named per character: {dict(per_char)}") print(f"unresolved name refs: {dict(unresolved_names)}") print(f"registry duplicate names (block a correct bind): " f"{ {n: [reg[i]['name'] for i in ids] for n, ids in dupes.items()} }") if ambiguous: print("ambiguous binds:") for pid, val, cands in ambiguous[:20]: print(f" {pid} {val!r} -> {[reg.get(x, {}).get('name') for x in cands]}")