Files
UmbraAudio/umbraaudio/analysis/character_discovery.py
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00

1253 lines
48 KiB
Python

from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from umbraaudio.config import LLMConfig
logger = logging.getLogger(__name__)
from umbraaudio.voices.lmstudio_gate import queued_chat_completion
PROMPT_PATH = Path(__file__).parent / "CHARACTER_DISCOVERY_PROMPT.md"
SCENE_BREAK = "<<<>>>"
@dataclass
class Demographics:
type: str = "Human"
gender: str = "Unknown"
def to_dict(self) -> dict:
return {"type": self.type, "gender": self.gender}
@classmethod
def from_dict(cls, data: dict) -> Demographics:
return cls(type=data.get("type", "Human"), gender=data.get("gender", "Unknown"))
@dataclass
class CommunicationStyle:
primary_register: str = "Casual"
style_shifts_or_notes: str = ""
def to_dict(self) -> dict:
return {
"primary_register": self.primary_register,
"style_shifts_or_notes": self.style_shifts_or_notes,
}
@classmethod
def from_dict(cls, data: dict) -> CommunicationStyle:
return cls(
primary_register=data.get("primary_register", "Casual"),
style_shifts_or_notes=data.get("style_shifts_or_notes", ""),
)
@dataclass
class Speaker:
primary_name: str
aliases: list[str] = field(default_factory=list)
demographics: Demographics = field(default_factory=Demographics)
vocal_traits: list[str] = field(default_factory=list)
communication_style: CommunicationStyle = field(default_factory=CommunicationStyle)
is_narrator: bool = False
is_group: bool = False
description: str = ""
sample_dialogue: str = ""
chapters_appeared: list[int] = field(default_factory=list)
unidentified: bool = False
unidentified_reason: str = ""
alias_evidence: list[str] = field(default_factory=list)
@property
def all_names(self) -> list[str]:
return [self.primary_name] + self.aliases
def to_dict(self) -> dict:
return {
"primary_name": self.primary_name,
"aliases": self.aliases,
"demographics": self.demographics.to_dict(),
"vocal_traits": self.vocal_traits,
"communication_style": self.communication_style.to_dict(),
"is_narrator": self.is_narrator,
"is_group": self.is_group,
"description": self.description,
"sample_dialogue": self.sample_dialogue,
"chapters_appeared": self.chapters_appeared,
"unidentified": self.unidentified,
"unidentified_reason": self.unidentified_reason,
"alias_evidence": self.alias_evidence,
}
@classmethod
def from_dict(cls, data: dict) -> Speaker:
demos = data.get("demographics", {})
comm = data.get("communication_style", {})
return cls(
primary_name=data.get("primary_name", ""),
aliases=data.get("aliases", []),
demographics=Demographics.from_dict(demos),
vocal_traits=data.get("vocal_traits", []),
communication_style=CommunicationStyle.from_dict(comm),
is_narrator=data.get("is_narrator", False),
is_group=data.get("is_group", False),
description=data.get("description", ""),
sample_dialogue=data.get("sample_dialogue", ""),
chapters_appeared=data.get("chapters_appeared", []),
unidentified=data.get("unidentified", False),
unidentified_reason=data.get("unidentified_reason", ""),
alias_evidence=data.get("alias_evidence", []),
)
@dataclass
class ChapterSpeakers:
chapter_index: int
chapter_title: str
speaking_characters: list[dict]
non_speaking_characters: list[dict]
validation_notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"chapter_index": self.chapter_index,
"chapter_title": self.chapter_title,
"speaking_characters": self.speaking_characters,
"non_speaking_characters": self.non_speaking_characters,
"validation_notes": self.validation_notes,
}
@dataclass
class DiscoveryResult:
speakers: list[Speaker]
chapter_results: list[ChapterSpeakers] = field(default_factory=list)
raw_responses: list[str] = field(default_factory=list)
book_title: str = ""
total_chapters: int = 0
total_words: int = 0
validation_log: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"speakers": [s.to_dict() for s in self.speakers],
"chapter_results": [cr.to_dict() for cr in self.chapter_results],
"book_title": self.book_title,
"total_chapters": self.total_chapters,
"total_words": self.total_words,
"validation_log": self.validation_log,
}
def save(self, output_path: Path) -> None:
has_narrator = any(s.is_narrator for s in self.speakers)
if not has_narrator:
narrator = Speaker(
primary_name="Narrator",
aliases=["narrator", "the narrator"],
demographics=Demographics(type="Human", gender="Male"),
vocal_traits=["deep", "resonant", "calm", "authoritative"],
communication_style=CommunicationStyle(
primary_register="Formal",
style_shifts_or_notes="Steady, even-toned throughout. Never rushes.",
),
description=(
"Deep, resonant male voice. Calm, authoritative tone. "
"Rich baritone timbre, slow and deliberate pacing, "
"like a late-night radio host."
),
is_narrator=True,
)
self.speakers.insert(0, narrator)
logger.info("Added default narrator speaker")
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
logger.info("Saved discovery results to %s", output_path)
@classmethod
def load(cls, path: Path) -> DiscoveryResult:
with open(path) as f:
data = json.load(f)
return cls(
speakers=[Speaker.from_dict(s) for s in data.get("speakers", [])],
chapter_results=[
ChapterSpeakers(
chapter_index=cr.get("chapter_index", 0),
chapter_title=cr.get("chapter_title", ""),
speaking_characters=cr.get("speaking_characters", []),
non_speaking_characters=cr.get("non_speaking_characters", []),
validation_notes=cr.get("validation_notes", []),
)
for cr in data.get("chapter_results", [])
],
book_title=data.get("book_title", ""),
total_chapters=data.get("total_chapters", 0),
total_words=data.get("total_words", 0),
validation_log=data.get("validation_log", []),
)
class CharacterDiscoverer:
def __init__(self, llm_config: LLMConfig, data_dir: Path | None = None) -> None:
self.config = llm_config
self._client = None
self._prompt_template: str | None = None
self._active_model: str | None = None
self.data_dir = data_dir or Path(".umbra")
def _load_prompt(self) -> str:
if self._prompt_template is None:
self._prompt_template = PROMPT_PATH.read_text()
return self._prompt_template
def _get_client(self) -> Any:
if self._client is not None:
return self._client
if self.config.provider in ("lmstudio", "auto"):
try:
import httpx
resp = httpx.get(f"{self.config.lmstudio_base_url}/models", timeout=5)
if resp.status_code == 200:
data = resp.json()
models = data.get("data", [])
embedding_models = {
"text-embedding-nomic-embed-text-v1.5",
"text-embedding-ada-002",
}
chat_models = [
m["id"] for m in models if m.get("id") and m["id"] not in embedding_models
]
if chat_models:
preferred = [
self.config.discovery_model or "google/gemma-4-e4b",
"google/gemma-4-e4b",
"qwen3.5-9b",
]
self._active_model = next(
(m for m in preferred if m in chat_models),
chat_models[0],
)
logger.info("Discovery using LM Studio model: %s", self._active_model)
from openai import OpenAI
self._client = OpenAI(
base_url=self.config.lmstudio_base_url,
api_key="lm-studio",
timeout=600,
)
return self._client
except Exception:
pass
if self.config.provider in ("openai", "auto"):
from openai import OpenAI
self._client = OpenAI()
self._active_model = self.config.openai_model
return self._client
raise RuntimeError("No LLM provider available")
def _call_llm(
self,
text: str,
context: str = "",
max_chars: int = 40000,
known_speakers: str = "",
pass_context: str = "",
on_retry: Callable[[int], None] | None = None,
) -> dict:
if len(text) > max_chars:
logger.info("Truncating chapter text from %d to %d chars", len(text), max_chars)
text = text[:max_chars]
prompt_template = self._load_prompt()
prompt = prompt_template.replace("{book_text}", text)
if known_speakers:
prompt = prompt.replace("{known_speakers_section}", known_speakers)
else:
prompt = prompt.replace("{known_speakers_section}", "")
if pass_context:
prompt = prompt.replace("{pass_context}", pass_context)
else:
prompt = prompt.replace("{pass_context}", "")
if context:
prompt = f"{context}\n\n{prompt}"
client = self._get_client()
model = self._active_model or self.config.openai_model
max_attempts = 6
response = None
for attempt in range(max_attempts):
try:
response = queued_chat_completion(
client,
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=8192,
timeout=600,
)
break
except Exception as e:
err = str(e)
logger.warning(
"LLM call failed (attempt %d/%d): %s", attempt + 1, max_attempts, err
)
if on_retry is not None:
try:
on_retry(attempt + 1)
except Exception:
pass
if attempt < max_attempts - 1:
time.sleep(min(60, 5 * (attempt + 1)))
else:
logger.error("LLM call failed after %d attempts: %s", max_attempts, err)
raise
assert response is not None
content = response.choices[0].message.content or ""
content = content.strip()
if not content:
logger.warning("Empty LLM response")
return {"speaking_characters": [], "non_speaking_characters": []}
code_block = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", content, re.DOTALL)
if code_block:
content = code_block.group(1).strip()
try:
return json.loads(content)
except json.JSONDecodeError as e:
logger.warning("JSON parse error: %s", e)
json_start = content.find("{")
json_end = content.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
candidate = content[json_start:json_end]
try:
return json.loads(candidate)
except json.JSONDecodeError:
pass
for _ in range(5):
open_braces = candidate.count("{") - candidate.count("}")
open_brackets = candidate.count("[") - candidate.count("]")
close = "}" * open_braces + "]" * open_brackets
fixed = candidate + close
try:
return json.loads(fixed)
except json.JSONDecodeError:
last_brace = candidate.rfind("}")
last_bracket = candidate.rfind("]")
last_comma = candidate.rfind(",")
cut = max(last_brace, last_bracket, last_comma)
if cut > 0:
candidate = candidate[:cut]
if candidate.endswith(","):
candidate = candidate[:-1]
else:
break
logger.error("Failed to parse LLM response")
return {"speaking_characters": [], "non_speaking_characters": []}
def _parse_scenes(self, chapter_text: str) -> list[str]:
if SCENE_BREAK in chapter_text:
return [s.strip() for s in chapter_text.split(SCENE_BREAK) if s.strip()]
paragraphs = re.split(r"\n\s*\n", chapter_text)
scenes: list[str] = []
current = ""
for para in paragraphs:
if len(current) + len(para) > 3000:
if current.strip():
scenes.append(current.strip())
current = para
else:
current = current + "\n\n" + para if current else para
if current.strip():
scenes.append(current.strip())
return scenes if scenes else [chapter_text]
def _find_speakers_in_text(self, text: str, known_names: list[str]) -> list[str]:
found: list[str] = []
sorted_names = sorted(known_names, key=len, reverse=True)
pattern = re.compile(
r"\b(" + "|".join(re.escape(n) for n in sorted_names) + r")\b",
re.IGNORECASE,
)
for match in pattern.finditer(text):
matched = match.group(0)
for name in sorted_names:
if name.lower() == matched.lower():
if name not in found:
found.append(name)
break
return found
TITLE_WORDS = {
"captain", "princess", "prince", "king", "queen", "doctor", "dr",
"professor", "prof", "aunt", "auntie", "uncle", "sir", "lady",
"lord", "mr", "mrs", "ms", "miss", "master", "sister", "brother",
"father", "mother", "grandfather", "grandmother", "grandma", "grandpa",
"chief", "commander", "major", "colonel", "general", "admiral",
"sergeant", "lieutenant", "corporal", "private", "ensign", "mister",
"madam", "madame", "dame", "saint", "emperor", "empress",
"duke", "duchess", "count", "countess", "baron", "baroness",
"ambassador", "senator", "governor", "judge", "justice",
"reverend", "pastor", "bishop", "cardinal", "rabbi",
"imam", "sheikh", "sultan",
}
def _has_two_proper_names(self, name_a: str, name_b: str) -> bool:
a_lower = name_a.lower()
b_lower = name_b.lower()
if a_lower in b_lower or b_lower in a_lower:
return False
def strip_titles(name: str) -> list[str]:
return [w for w in name.split() if w.lower() not in self.TITLE_WORDS]
words_a = strip_titles(name_a)
words_b = strip_titles(name_b)
if not words_a or not words_b:
return False
all_proper_a = all(w[0].isupper() and len(w) > 1 for w in words_a)
all_proper_b = all(w[0].isupper() and len(w) > 1 for w in words_b)
if not (all_proper_a and all_proper_b):
return False
a_word_set = set(w.lower() for w in words_a)
b_word_set = set(w.lower() for w in words_b)
if a_word_set & b_word_set:
return False
for wa in words_a:
for wb in words_b:
wa_l = wa.lower()
wb_l = wb.lower()
min_len = min(len(wa_l), len(wb_l))
if min_len >= 4 and wa_l[:min_len] == wb_l[:min_len]:
return False
return True
@staticmethod
def _alias_has_evidence(char_data: dict[str, Any], alias: str) -> bool:
alias_lower = alias.lower()
return any(
alias_lower in ev.lower()
for ev in char_data.get("alias_evidence", [])
)
def validate_aliases(
self, chapter_text: str, chapter_title: str, characters: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], list[str]]:
scenes = self._parse_scenes(chapter_text)
all_names: list[str] = []
for char in characters:
name = char.get("primary_name", "")
if name and name not in all_names:
all_names.append(name)
for alias in char.get("aliases", []):
if alias not in all_names:
all_names.append(alias)
invalid_aliases: list[tuple[str, str, str]] = []
scene_speakers: list[list[str]] = []
for _scene_idx, scene_text in enumerate(scenes):
speakers_in_scene = self._find_speakers_in_text(scene_text, all_names)
scene_speakers.append(speakers_in_scene)
for char in characters:
primary = char.get("primary_name", "")
aliases = list(char.get("aliases", []))
valid_aliases: list[str] = []
for alias in aliases:
is_invalid = False
if not is_invalid:
for scene_idx, speakers in enumerate(scene_speakers):
if (
primary in speakers
and alias in speakers
and primary.lower() != alias.lower()
):
invalid_aliases.append(
(
primary,
alias,
f"Both appear in scene {scene_idx + 1}",
)
)
is_invalid = True
break
if not is_invalid:
valid_aliases.append(alias)
char["aliases"] = valid_aliases
validation_notes: list[str] = []
for primary, alias, reason in invalid_aliases:
note = f"Removed alias '{alias}' from '{primary}': {reason}"
validation_notes.append(note)
logger.info("Alias validation: %s", note)
for char in characters:
if not char.get("alias_evidence"):
char["alias_evidence"] = []
primary = char.get("primary_name", "")
for alias in char.get("aliases", []):
has_evidence = any(
primary.lower() in e.lower() and alias.lower() in e.lower()
for e in char["alias_evidence"]
)
if not has_evidence:
char["alias_evidence"].append(
f"'{alias}' validated by text co-occurrence check"
)
return characters, validation_notes
def _merge_chapter_results(
self, chapter_results: list[ChapterSpeakers], book_title: str, total_words: int
) -> list[Speaker]:
merged: dict[str, Speaker] = {}
alias_frequency: dict[str, dict[str, set[int]]] = {}
for cr in chapter_results:
chapter_num = cr.chapter_index + 1
for char_data in cr.speaking_characters:
primary = char_data.get("primary_name", "").strip()
if not primary:
continue
if primary in merged:
existing = merged[primary]
existing.chapters_appeared.append(chapter_num)
for alias in char_data.get("aliases", []):
if alias.lower() == primary.lower():
continue
alias_frequency.setdefault(primary, {}).setdefault(
alias, set()
).add(chapter_num)
if (
alias not in existing.aliases
and (
not self._has_two_proper_names(primary, alias)
or self._alias_has_evidence(char_data, alias)
)
):
existing.aliases.append(alias)
for trait in char_data.get("vocal_traits", []):
if trait not in existing.vocal_traits:
existing.vocal_traits.append(trait)
demos = char_data.get("demographics", {})
if demos.get("type") and demos["type"] != "Human":
existing.demographics.type = demos["type"]
if demos.get("gender") and demos["gender"] != "Unknown":
existing.demographics.gender = demos["gender"]
comm = char_data.get("communication_style", {})
if (
comm.get("style_shifts_or_notes")
and comm["style_shifts_or_notes"]
not in existing.communication_style.style_shifts_or_notes
):
existing.communication_style.style_shifts_or_notes = (
f"{existing.communication_style.style_shifts_or_notes} | "
f"Ch.{chapter_num}: {comm['style_shifts_or_notes']}"
if existing.communication_style.style_shifts_or_notes
else f"Ch.{chapter_num}: {comm['style_shifts_or_notes']}"
)
for ev in char_data.get("alias_evidence", []):
if ev not in existing.alias_evidence:
existing.alias_evidence.append(ev)
if char_data.get("unidentified") and not existing.unidentified:
existing.unidentified = True
existing.unidentified_reason = char_data.get("unidentified_reason", "")
else:
demos = char_data.get("demographics", {})
comm = char_data.get("communication_style", {})
raw_aliases = char_data.get("aliases", [])
filtered_aliases = [
a for a in raw_aliases
if a.lower() == primary.lower()
or not self._has_two_proper_names(primary, a)
or self._alias_has_evidence(char_data, a)
]
merged[primary] = Speaker(
primary_name=primary,
aliases=filtered_aliases,
demographics=Demographics.from_dict(demos),
vocal_traits=char_data.get("vocal_traits", []),
communication_style=CommunicationStyle.from_dict(comm),
description=char_data.get("description", ""),
chapters_appeared=[chapter_num],
unidentified=char_data.get("unidentified", False),
unidentified_reason=char_data.get("unidentified_reason", ""),
alias_evidence=char_data.get("alias_evidence", []),
)
for char_data in cr.non_speaking_characters:
primary = char_data.get("primary_name", "").strip()
if not primary:
continue
if primary in merged:
existing = merged[primary]
if chapter_num not in existing.chapters_appeared:
existing.chapters_appeared.append(chapter_num)
for primary_name, speaker in list(merged.items()):
chapters_count = len(speaker.chapters_appeared)
if chapters_count >= 3 and primary_name in alias_frequency:
freq = alias_frequency[primary_name]
speaker.aliases = [
a for a in speaker.aliases
if a.lower() == primary_name.lower()
or len(freq.get(a, set())) > 1
or a.lower() not in {n.lower() for n in freq}
]
merged = self._deduplicate_by_aliases(merged)
return sorted(merged.values(), key=lambda s: len(s.chapters_appeared), reverse=True)
def _deduplicate_by_aliases(self, merged: dict[str, Speaker]) -> dict[str, Speaker]:
changed = True
while changed:
changed = False
names = list(merged.keys())
for i, name_a in enumerate(names):
if name_a not in merged:
continue
speaker_a = merged[name_a]
for name_b in names[i + 1 :]:
if name_b not in merged:
continue
speaker_b = merged[name_b]
name_a_lower = name_a.lower()
name_b_lower = name_b.lower()
if name_a_lower == name_b_lower:
self._absorb_into_richer(merged, name_a, name_b)
changed = True
if name_a not in merged:
break
continue
b_aliases_lower = [a.lower() for a in speaker_b.aliases]
a_aliases_lower = [a.lower() for a in speaker_a.aliases]
b_has_a = name_a_lower in b_aliases_lower
a_has_b = name_b_lower in a_aliases_lower
shared_specific = self._shared_specific_alias(speaker_a, speaker_b)
if (b_has_a or a_has_b) and self._should_merge_by_context(
speaker_a, speaker_b
):
self._absorb_into_richer(merged, name_a, name_b)
changed = True
if name_a not in merged:
break
elif shared_specific:
logger.info(
"Dedup: merging '%s' and '%s' (shared specific alias)",
name_a, name_b,
)
self._absorb_into_richer(merged, name_a, name_b)
changed = True
if name_a not in merged:
break
return merged
def _shared_specific_alias(self, speaker_a: Speaker, speaker_b: Speaker) -> bool:
def is_specific(name: str) -> bool:
words = [w for w in name.split() if w.lower() not in self.TITLE_WORDS]
return bool(words)
a_names = {
n.lower()
for n in [speaker_a.primary_name] + speaker_a.aliases
if is_specific(n)
}
b_names = {
n.lower()
for n in [speaker_b.primary_name] + speaker_b.aliases
if is_specific(n)
}
return bool(a_names & b_names)
def _absorb_into_richer(
self, merged: dict[str, Speaker], name_a: str, name_b: str
) -> None:
if name_a not in merged or name_b not in merged:
return
speaker_a = merged[name_a]
speaker_b = merged[name_b]
if len(speaker_b.chapters_appeared) > len(speaker_a.chapters_appeared):
self._absorb_speaker(speaker_b, speaker_a)
merged.pop(name_a, None)
else:
self._absorb_speaker(speaker_a, speaker_b)
merged.pop(name_b, None)
def _should_merge_by_context(self, speaker_a: Speaker, speaker_b: Speaker) -> bool:
all_a = [speaker_a.primary_name] + speaker_a.aliases
all_b = [speaker_b.primary_name] + speaker_b.aliases
for name_a in all_a:
for name_b in all_b:
if self._has_two_proper_names(name_a, name_b):
logger.info(
"Skipping merge: '%s' and '%s' have distinct proper names",
name_a,
name_b,
)
return False
return True
def _absorb_speaker(self, target: Speaker, source: Speaker) -> None:
for ch in source.chapters_appeared:
if ch not in target.chapters_appeared:
target.chapters_appeared.append(ch)
for alias in source.aliases:
if alias not in target.aliases:
target.aliases.append(alias)
for trait in source.vocal_traits:
if trait not in target.vocal_traits:
target.vocal_traits.append(trait)
for ev in source.alias_evidence:
if ev not in target.alias_evidence:
target.alias_evidence.append(ev)
if not target.description and source.description:
target.description = source.description
if not target.sample_dialogue and source.sample_dialogue:
target.sample_dialogue = source.sample_dialogue
if source.demographics.gender != "Unknown" and target.demographics.gender == "Unknown":
target.demographics.gender = source.demographics.gender
if source.demographics.type != "Human" and target.demographics.type == "Human":
target.demographics.type = source.demographics.type
if not target.communication_style.style_shifts_or_notes:
target.communication_style = source.communication_style
target.is_group = target.is_group or source.is_group
target.unidentified = target.unidentified or source.unidentified
if not target.unidentified_reason and source.unidentified_reason:
target.unidentified_reason = source.unidentified_reason
def _save_temp(self, data: dict[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
logger.info("Saved temporary data to %s", path)
def _load_temp(self, path: Path) -> dict[str, Any] | None:
if not path.exists():
return None
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to load temp file %s: %s", path, e)
return None
async def _run_pass1(
self,
chapters: list[dict[str, Any]],
book_title: str,
max_chars: int,
temp_dir: Path,
) -> DiscoveryResult:
chapter_results: list[ChapterSpeakers] = []
raw_responses: list[str] = []
known_speakers: list[dict[str, Any]] = []
logger.info("Pass 1: Chapter-by-chapter discovery in %d chapters", len(chapters))
ckpt_path = temp_dir / "discovery_pass1_chapters.json"
start_idx = 0
if ckpt_path.exists():
ckpt = self._load_temp(ckpt_path)
if ckpt:
chapter_results = [
ChapterSpeakers(
chapter_index=cr.get("chapter_index", 0),
chapter_title=cr.get("chapter_title", ""),
speaking_characters=cr.get("speaking_characters", []),
non_speaking_characters=cr.get("non_speaking_characters", []),
validation_notes=cr.get("validation_notes", []),
)
for cr in ckpt.get("chapter_results", [])
]
raw_responses = ckpt.get("raw_responses", [])
known_speakers = ckpt.get("known_speakers", [])
start_idx = len(chapter_results)
logger.info(
"Pass 1: resuming from checkpoint with %d/%d chapters done",
start_idx,
len(chapters),
)
for i, ch in enumerate(chapters):
if i < start_idx:
continue
ch_text = ch.get("source_text", "")
ch_title = ch.get("title", "") or f"Chapter {i + 1}"
logger.info(
"Pass 1 — Chapter %d/%d: %s (%d chars)",
i + 1,
len(chapters),
ch_title,
len(ch_text),
)
known_section = ""
if known_speakers:
lines = [
"## Previously Discovered Speakers",
(
"The following speakers have been identified in earlier "
"chapters. Use this list to avoid creating duplicates."
),
"",
]
for ks in known_speakers:
name = ks.get("primary_name", "")
aliases = ks.get("aliases", [])
demos = ks.get("demographics", {})
gender = demos.get("gender", "Unknown")
stype = demos.get("type", "Human")
alias_str = f" (aliases: {', '.join(aliases)})" if aliases else ""
lines.append(f"- **{name}**{alias_str} — {gender}, {stype}")
lines.append("")
known_section = "\n".join(lines)
chapter_prompt = f"--- Chapter {i + 1}: {ch_title} ---\n\n"
data = self._call_llm(
ch_text,
context=chapter_prompt,
max_chars=max_chars,
known_speakers=known_section,
)
raw_responses.append(json.dumps(data))
chapter_results.append(
ChapterSpeakers(
chapter_index=i,
chapter_title=ch_title,
speaking_characters=data.get("speaking_characters", []),
non_speaking_characters=data.get("non_speaking_characters", []),
validation_notes=data.get("validation_notes", []),
)
)
for char_data in data.get("speaking_characters", []):
primary = char_data.get("primary_name", "").strip()
if not primary:
continue
exists = any(k["primary_name"] == primary for k in known_speakers)
if not exists:
known_speakers.append(
{
"primary_name": primary,
"aliases": char_data.get("aliases", []),
"demographics": char_data.get("demographics", {}),
}
)
import asyncio
await asyncio.sleep(1)
self._save_temp(
{
"chapter_results": [cr.to_dict() for cr in chapter_results],
"raw_responses": raw_responses,
"known_speakers": known_speakers,
},
ckpt_path,
)
logger.info("Pass 1: Merging results from %d chapters", len(chapter_results))
merged_speakers = self._merge_chapter_results(chapter_results, book_title, 0)
total_words = sum(len(ch.get("source_text", "").split()) for ch in chapters)
result = DiscoveryResult(
speakers=merged_speakers,
chapter_results=chapter_results,
raw_responses=raw_responses,
book_title=book_title,
total_chapters=len(chapters),
total_words=total_words,
)
pass1_data = result.to_dict()
self._save_temp(pass1_data, temp_dir / "discovery_pass1.json")
if ckpt_path.exists():
ckpt_path.unlink()
logger.info("Pass 1: checkpoint cleared after full run")
logger.info(
"Pass 1 complete: %d speakers across %d chapters",
len(merged_speakers),
len(chapters),
)
return result
async def _run_reconciliation(
self,
pass1_result: DiscoveryResult,
chapters: list[dict[str, Any]],
max_chars: int,
temp_dir: Path,
) -> DiscoveryResult:
if len(pass1_result.speakers) < 3:
logger.info("Reconciliation skipped: only %d speakers", len(pass1_result.speakers))
return pass1_result
logger.info(
"Reconciliation: checking %d speakers for duplicates", len(pass1_result.speakers)
)
speaker_list_lines: list[str] = []
for s in pass1_result.speakers:
aliases_str = f" (aliases: {', '.join(s.aliases)})" if s.aliases else ""
ch_str = f" [chapters: {s.chapters_appeared}]" if s.chapters_appeared else ""
speaker_list_lines.append(
f"- {s.primary_name}{aliases_str}{ch_str}: "
f"{s.demographics.gender}, {s.description[:80]}"
)
speaker_list = "\n".join(speaker_list_lines)
excerpt_parts: list[str] = []
total_chars = 0
for ch in chapters:
text = ch.get("source_text", "")
title = ch.get("title", "")
if not text:
continue
sample = text[:2000] if len(text) > 2000 else text
excerpt_parts.append(f"--- {title} (excerpt) ---\n{sample}")
total_chars += len(sample)
if total_chars > max_chars:
break
excerpts = "\n\n".join(excerpt_parts)
prompt = f"""You are analyzing a list of characters discovered in a book to find DUPLICATE entries.
Two speaker entries should be MERGED if they refer to the SAME person, even if called by different names.
For example: "Robert" and "Bob" in different chapters are the same person if context confirms it.
Two entries should NOT be merged if:
- They have different proper names AND both appear as speakers in the same scene
- One is a title shared by multiple people ("Captain" in different contexts)
- There is no textual evidence they are the same person
## Discovered Speakers
{speaker_list}
## Chapter Excerpts (for context)
{excerpts}
## Instructions
Return a JSON object with a single key "merges" containing a list of merge operations.
Each merge: {{"keep": "<primary_name to keep>", "absorb": "<primary_name to remove>", "reason": "<brief explanation>"}}
If no merges are needed, return {{"merges": []}}.
Return ONLY valid JSON."""
client = self._get_client()
model = self._active_model or self.config.openai_model
try:
response = queued_chat_completion(
client,
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=4096,
)
content = response.choices[0].message.content or ""
content = content.strip()
code_block = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", content, re.DOTALL)
if code_block:
content = code_block.group(1).strip()
data = json.loads(content)
merges = data.get("merges", [])
except Exception as e:
logger.warning("Reconciliation LLM call failed: %s", e)
return pass1_result
if not merges:
logger.info("Reconciliation: no merges recommended")
return pass1_result
speaker_map: dict[str, Speaker] = {
s.primary_name: s for s in pass1_result.speakers
}
merged_count = 0
for merge in merges:
keep_name = merge.get("keep", "")
absorb_name = merge.get("absorb", "")
reason = merge.get("reason", "")
if keep_name not in speaker_map or absorb_name not in speaker_map:
continue
if keep_name == absorb_name:
continue
keep_speaker = speaker_map[keep_name]
absorb_speaker = speaker_map[absorb_name]
if not self._should_merge_by_context(keep_speaker, absorb_speaker):
logger.info(
"Reconciliation: skipping merge %s -> %s (context check failed)",
absorb_name, keep_name,
)
continue
self._absorb_speaker(keep_speaker, absorb_speaker)
if absorb_name not in keep_speaker.aliases:
keep_speaker.aliases.append(absorb_name)
del speaker_map[absorb_name]
merged_count += 1
logger.info(
"Reconciliation: merged '%s' into '%s' — %s",
absorb_name, keep_name, reason,
)
pass1_result.speakers = sorted(
speaker_map.values(), key=lambda s: len(s.chapters_appeared), reverse=True
)
pass1b_data = pass1_result.to_dict()
self._save_temp(pass1b_data, temp_dir / "discovery_pass1b_reconciliation.json")
logger.info(
"Reconciliation complete: %d merges applied, %d speakers remaining",
merged_count, len(pass1_result.speakers),
)
return pass1_result
async def _run_pass2(
self,
pass1_result: DiscoveryResult,
chapters: list[dict[str, Any]],
max_chars: int,
temp_dir: Path,
) -> DiscoveryResult:
logger.info("Pass 2: Alias validation across %d chapters", len(chapters))
validation_log: list[str] = []
for cr in pass1_result.chapter_results:
chapter_idx = cr.chapter_index
if chapter_idx >= len(chapters):
continue
ch_text = chapters[chapter_idx].get("source_text", "")
ch_title = cr.chapter_title
validated_chars, notes = self.validate_aliases(
ch_text, ch_title, cr.speaking_characters
)
cr.speaking_characters = validated_chars
cr.validation_notes = notes
validation_log.extend(notes)
if notes:
logger.info(
"Pass 2 — Chapter %d (%s): %d alias corrections",
chapter_idx + 1,
ch_title,
len(notes),
)
import asyncio
await asyncio.sleep(0.1)
pass1_result.validation_log = validation_log
logger.info("Pass 2: Re-merging with validated aliases")
merged_speakers = self._merge_chapter_results(
pass1_result.chapter_results,
pass1_result.book_title,
pass1_result.total_words,
)
pass1_result.speakers = merged_speakers
pass2_data = pass1_result.to_dict()
self._save_temp(pass2_data, temp_dir / "discovery_pass2.json")
removed_count = sum(1 for n in validation_log if "Removed alias" in n)
logger.info(
"Pass 2 complete: %d alias corrections applied, %d speakers",
removed_count,
len(merged_speakers),
)
return pass1_result
async def _run_pass3(
self,
pass2_result: DiscoveryResult,
chapters: list[dict[str, Any]],
max_chars: int,
temp_dir: Path,
) -> DiscoveryResult:
logger.info("Pass 3: Cross-chapter reconciliation")
unidentified = [s for s in pass2_result.speakers if s.unidentified]
if unidentified:
logger.info("Pass 3: %d unidentified speakers flagged for review", len(unidentified))
for s in unidentified:
logger.info(
" Unidentified: '%s' — %s",
s.primary_name,
s.unidentified_reason,
)
known_by_name: dict[str, Speaker] = {}
for speaker in pass2_result.speakers:
known_by_name[speaker.primary_name.lower()] = speaker
for alias in speaker.aliases:
if alias.lower() not in known_by_name:
known_by_name[alias.lower()] = speaker
suspicious_merges: list[str] = []
for speaker in pass2_result.speakers:
all_names = [speaker.primary_name] + speaker.aliases
bad_aliases: set[str] = set()
for i, name_a in enumerate(all_names):
for name_b in all_names[i + 1 :]:
if not self._has_two_proper_names(name_a, name_b):
continue
if self._alias_has_evidence({"alias_evidence": speaker.alias_evidence}, name_b):
continue
msg = (
f"'{speaker.primary_name}': removed alias "
f"'{name_b}' (distinct proper names: '{name_a}' vs '{name_b}')"
)
suspicious_merges.append(msg)
if name_b != speaker.primary_name:
bad_aliases.add(name_b)
if name_a != speaker.primary_name:
bad_aliases.add(name_a)
if bad_aliases:
speaker.aliases = [a for a in speaker.aliases if a not in bad_aliases]
logger.warning(
"Pass 3: split %d bad aliases from '%s': %s",
len(bad_aliases), speaker.primary_name, list(bad_aliases),
)
for msg in suspicious_merges:
logger.warning("Pass 3: %s", msg)
pass2_result.validation_log.extend(suspicious_merges)
pass3_data = pass2_result.to_dict()
self._save_temp(pass3_data, temp_dir / "discovery_pass3.json")
logger.info(
"Pass 3 complete: %d speakers, %d unidentified, %d suspicious merges",
len(pass2_result.speakers),
len(unidentified),
len(suspicious_merges),
)
return pass2_result
async def discover_speakers(
self,
chapters: list[dict[str, Any]],
book_title: str = "",
max_chars: int = 40000,
) -> DiscoveryResult:
data_dir = self.data_dir
temp_dir = data_dir / "discovery"
logger.info("Starting 4-pass character discovery for %d chapters", len(chapters))
pass1_result = await self._run_pass1(chapters, book_title, max_chars, temp_dir)
pass1b_result = await self._run_reconciliation(pass1_result, chapters, max_chars, temp_dir)
pass2_result = await self._run_pass2(pass1b_result, chapters, max_chars, temp_dir)
final_result = await self._run_pass3(pass2_result, chapters, max_chars, temp_dir)
logger.info(
"Discovery complete: %d speakers across %d chapters",
len(final_result.speakers),
len(chapters),
)
return final_result
def get_speaker_names(self, result: DiscoveryResult) -> list[str]:
names = []
for speaker in result.speakers:
names.append(speaker.primary_name)
names.extend(speaker.aliases)
return list(set(names))
def get_canonical_for_name(self, name: str, result: DiscoveryResult) -> str | None:
name_lower = name.lower()
for speaker in result.speakers:
if speaker.primary_name.lower() == name_lower:
return speaker.primary_name
for alias in speaker.aliases:
if alias.lower() == name_lower:
return speaker.primary_name
return None
def get_speaker_by_canonical(
self, canonical_name: str, result: DiscoveryResult
) -> Speaker | None:
for speaker in result.speakers:
if speaker.primary_name == canonical_name:
return speaker
return None