Squashes ~3 weeks of uncommitted work (2026-09-03 → 09-21). The repo is the
only durable copy; a single accidental `git checkout` on routes.py nearly lost
a month of work (recovered from an editor snapshot, not git). See AGENTS.md →
"Commit Cadence" — commit every Sunday.
Attribution & QA
- New umbraaudio/analysis/attribution_qa.py: mechanical check_attribution /
fix_high_confidence / verify_conversation_flow (speech-tag mismatch, narrator
dialogue, role-tag guessing) plus scripts/qa_attribution.py CLI.
- llm_attributor prompt rules 3e-3h (unnamed roles, pronoun coreference,
repeated lines) with wider scene/position context.
- Per-utterance QA box in chapter audio detail UI: hover issues, Recheck
(auto-fix loop) and Accept (persisted qa_accepted* + text/speaker signature,
auto re-surfaces when either changes). New POST /api/utterance/{id}/recheck
and /api/utterance/{id}/qa-accept. _ensure_utterance_columns migration fixes
legacy DBs that lacked the columns.
Generation
- Dramatic utterance variations: 26 emotions + pace + custom instruct,
composed into every TTS voice mode (clone/icl/ref/embedding) and all
generation sites; markers record the instruct so edits re-trigger.
- Staleness markers are JSON {speaker,text}; legacy plain-text markers always
stale.
- Scene-break segmentation (generation/scene_break.py) + resegment merge.
- AR package exporter (assembly/ar_export.py) with tests.
- Voice ref resolution: rglob refs, canonical alias / first-word fallback.
- Edge-TTS fallback visibility: durable voice_engines.json ledger + UI badge.
GPU / TTS server
- qwen_tts_server: instruct + clone/icl support (voice_description →
instruct_ids prepended to the talker input).
- qwen_tts.py remote path sends voice_description alongside voice_ref/slug.
Config / ops
- .env: UMBRA_TTS__QWEN_TTS_URL=http://localhost:8881.
- .gitignore: book projects, tmp_out, logs, one-off samples A/B demo.
- AGENTS.md + README.md: Commit Cadence (commit at least weekly, on Sunday).
New tests: test_qa_acceptance, test_variations, test_ar_export,
test_scene_break, test_resegment_merge, test_vocals_project. Suite: 327 pass
(6 pre-existing voice-profile accent-wording failures, unrelated).
386 lines
18 KiB
Markdown
386 lines
18 KiB
Markdown
# Spec: Two-Phase Segmentation (Pass 2)
|
||
|
||
## Overview
|
||
|
||
Pass 2 segments chapter text into utterances (narration and dialogue) and attributes speakers using a two-phase approach: a mechanical segmenter handles text splitting, and an LLM attributes speakers to all dialogue. This produces a 0.05% dialogue-as-narrator error rate (1/1,882 dialogue utterances) across all 35 chapters.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Raw chapter text
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ PHASE 1: MECHANICAL SEGMENTER │
|
||
│ mapping/segmenter.py │
|
||
│ │
|
||
│ - Sentence splitting at .!? (not commas) │
|
||
│ - Dialogue detection via quotes ("'""') │
|
||
│ - Tag-based attribution (forward/backward) │
|
||
│ - Scene break (<<<>>>) handling │
|
||
│ - Chapter title detection │
|
||
└─────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ PHASE 2: LLM SPEAKER ATTRIBUTION │
|
||
│ analysis/llm_attributor.py │
|
||
│ │
|
||
│ - Attributes ALL dialogue (overrides guesses) │
|
||
│ - Resolves pronouns using context │
|
||
│ - Uses character profiles from speakers.json │
|
||
│ - Batches of 40 utterances for efficiency │
|
||
│ - Normalizes names (aliases/first-word, │
|
||
│ narrator lowercase, pronoun guard) │
|
||
└─────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────┐
|
||
│ OUTPUT: Utterances table │
|
||
│ 4,339 utterances across 35 chapters │
|
||
│ 0.05% dialogue-as-narrator (1/1,882) │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## Components
|
||
|
||
### 1. Mechanical Segmenter (`mapping/segmenter.py`)
|
||
|
||
#### Class: `Segmenter`
|
||
|
||
```python
|
||
class Segmenter:
|
||
def segment(
|
||
self,
|
||
text: str,
|
||
chapter_id: int,
|
||
character_names: list[str] | None = None,
|
||
) -> list[dict[str, Any]]
|
||
```
|
||
|
||
**Returns:**
|
||
```python
|
||
[{
|
||
"index": int,
|
||
"text": str,
|
||
"type": "narration" | "dialogue",
|
||
"character_name": str | None,
|
||
}, ...]
|
||
```
|
||
|
||
**Segmentation Pipeline:**
|
||
|
||
```
|
||
Raw text
|
||
│
|
||
├── 1. _split_segments(text) → list[str]
|
||
│ Character-by-character parser
|
||
│ Detects quote boundaries and sentence endings
|
||
│
|
||
├── 2. _build_utterances(segments) → list[dict]
|
||
│ Tags each segment as "dialogue" or "narration"
|
||
│ based on leading quote character
|
||
│
|
||
└── 3. _attribute_speakers(utterances, character_names) → list[dict]
|
||
Forward-tracking speaker attribution
|
||
Regex matching against character names
|
||
```
|
||
|
||
**Quote handling:** a quoted dialogue span is extracted whole and kept as one utterance even
|
||
with multiple sentences. `_restore_quote_wrap` re-wraps it with typographic double quotes and
|
||
only treats double-quote closes (`"`, `\u201d`) as outer closes — trailing **inner
|
||
single-quotes** (`’`) are preserved (e.g. `...named ‘Nanny.’`). Previously the inner text was
|
||
split back into sentences and each was re-quoted, which fragmented multi-sentence speech and
|
||
mangled inner single-quotes.
|
||
|
||
**Quote Detection:**
|
||
- Open: `"`, `'`, `\u201c` (left double), `\u2018` (left single)
|
||
- Close: `"`, `'`, `\u201d` (right double), `\u2019` (right single)
|
||
- Matching pairs: `"` → `"`, `\u201c` → `\u201d`, etc.
|
||
|
||
**Sentence Splitting:**
|
||
- Split at `.`, `!`, `?` followed by whitespace/quote/dash
|
||
- Minimum 2 words per segment (avoids fragments)
|
||
- Quote boundaries always create segment breaks
|
||
|
||
**Speaker Attribution Algorithm:**
|
||
|
||
```python
|
||
def _attribute_speakers(self, utterances, character_names) -> list[dict]
|
||
```
|
||
|
||
1. **Tag Detection**: Find `NAME said/asked/replied` patterns
|
||
2. **Forward Tracking**: When tag found, set `next_speaker = NAME`
|
||
3. **Backward Reference**: When no tag, look back for `said NAME` patterns
|
||
4. **Name Normalization**: Case-insensitive matching, alias resolution
|
||
|
||
**Speech Verbs (42 total):**
|
||
```
|
||
said, asked, replied, whispered, shouted, muttered, called,
|
||
exclaimed, answered, began, continued, added, finished, noted,
|
||
stated, remarked, yelled, screamed, laughed, sighed, says,
|
||
tells, told, orders, ordered, demands, demanded, insists,
|
||
insisted, begs, begged, pleads, pleaded, commands, commanded,
|
||
snapped, snarled, hissed, growled, barked, roared, croaked,
|
||
grunted, murmured, purred, intoned, declared, announced,
|
||
proclaimed, responds, repeats, mentions, offers
|
||
```
|
||
|
||
### 2. LLM Speaker Attribution (`analysis/llm_attributor.py`)
|
||
|
||
#### Class: `LLMAttributor`
|
||
|
||
```python
|
||
class LLMAttributor:
|
||
def __init__(self, config: LLMConfig, model: str | None = None) -> None
|
||
|
||
def attribute_speakers(
|
||
self,
|
||
utterances: list[dict[str, Any]],
|
||
chapter_title: str,
|
||
chapter_id: int,
|
||
) -> list[dict[str, Any]]
|
||
```
|
||
|
||
**Key Insight:** The LLM attributes **all** dialogue utterances. The mechanical attribution from the segmenter is preserved only as a `current_speaker` hint (Rule 8) — the LLM's verdict takes precedence so that ambiguous or incorrectly guessed lines can be corrected. This differs from the original design where only `speaker_name == "narrator"` dialogue was attributed.
|
||
|
||
**Attribution Flow:**
|
||
|
||
1. **Filter**: Collect all dialogue utterances (type == "dialogue")
|
||
2. **Batch**: Group into batches of 40 utterances
|
||
3. **Context**: Split into scenes (`<<<>>>`); include per-scene narration names + last-12 narration lines as context
|
||
4. **LLM Call**: Send batch with character profiles and scene context to LLM
|
||
5. **Parse Response**: Extract JSON array with index → speaker mapping
|
||
6. **Post-Process**: Strip speech verb suffixes ("says", "said", etc.); run `_normalize_speaker` (aliases + first-word-of-primary → primary; narrator/the-narrator → lowercase `narrator`; pronouns → `narrator` guard)
|
||
7. **Update**: Apply LLM verdicts to all dialogue utterances
|
||
8. **Fallback**: Re-attribute any dialogue still un-attributed via speech-tag regex on the 3 preceding utterances, then speaker carry-forward (scene breaks reset the carried speaker)
|
||
|
||
**LLM Prompt (inline):**
|
||
```
|
||
You are a dialogue attribution expert for audiobook production.
|
||
|
||
Given utterances that need speaker attribution, identify who is speaking each line.
|
||
|
||
## Character Profiles
|
||
{character_profiles}
|
||
|
||
## Chapter Title
|
||
{chapter_title}
|
||
|
||
## Scene Context
|
||
This chapter has {scene_count} scene(s) separated by scene breaks (<<<>>>).
|
||
You are attributing dialogue for: {scene_label}
|
||
|
||
{scene_speakers_context}
|
||
|
||
## Utterances to Attribute
|
||
{utterances_json}
|
||
|
||
## Rules
|
||
1. Each utterance is already correctly split — do NOT merge or split them
|
||
2. EVERY utterance MUST be attributed to a speaker. Return EXACTLY one entry per utterance index. Do not skip any.
|
||
3. ALL these utterances are dialogue (in quotes) — they are spoken by a CHARACTER, almost never "Narrator". Only use "Narrator" if the line is truly un-attributable.
|
||
3b. STRONG HINT: when a preceding narration line says a character IS telling/explaining/speaking to someone (e.g. "Nanny is telling the princess about what will happen."), the following quoted lines are that character's dialogue, until another character is indicated.
|
||
3c. A multi-line speech block from one character continues until dialogue tags, action, or the responder's lines indicate a change.
|
||
3d. A single quoted dialogue utterance may contain MULTIPLE sentences (e.g. one paragraph of speech) — every sentence within it belongs to the SAME speaker. Do not split the speaker across sentences within one utterance.
|
||
4. Use dialogue tags (X says/replies/asks) to identify speakers
|
||
5. Use context: who is present, who was speaking before, who is addressed
|
||
6. Resolve pronouns: "she says" refers to the most recently mentioned female character
|
||
7. If a character has aliases, use their EXACT full primary_name shown in the profiles (e.g. "Kainda Walters Foster Murphy", NOT "Kainda" or "Princess Kainda")
|
||
8. The "current_speaker" field shows who the mechanical segmenter guessed — use it as a hint but correct if wrong
|
||
9. In a conversation, speakers usually alternate. If one line is Character A, the next line is likely a different character (often the one addressed).
|
||
10. CRITICAL — only return the EXACT utterance indices provided in the input. Do NOT invent or hallucinate extra indices.
|
||
11. CRITICAL — every returned speaker must be the ACTUAL character speaking. Assigning the wrong speaker ruins the audiobook.
|
||
12. AI-1 is a specific AI character voice. Only attribute dialogue to AI-1 if the text explicitly indicates AI-1 is speaking. Do NOT default undetermined utterances to AI-1.
|
||
|
||
## Scene Attribution Rules (CRITICAL)
|
||
- Chapters can contain multiple scenes separated by scene breaks (<<<>>>)
|
||
- Each scene is an independent unit. Speakers identified in one scene may NOT appear in other scenes
|
||
- Within a scene, once a speaker is identified, subsequent dialogue by the same person continues until another speaker is identified
|
||
- "captain" in one scene may refer to a different person than "captain" in another scene — there can be multiple characters with the same title
|
||
- Only attribute a speaker to dialogue if there is evidence within THAT SCENE that they are speaking
|
||
- If a scene introduces new characters via narration, those characters are candidates for dialogue in that scene
|
||
- A speaker who appears in scene 1 may be entirely absent from scene 2 — do NOT carry speakers across scene boundaries
|
||
- When ambiguous ("he says", "she says"), resolve using the most recent speaker mention within the SAME SCENE
|
||
|
||
## Output Format
|
||
Return ONLY a JSON array of objects, one per utterance, each with:
|
||
- "index": the utterance index (number)
|
||
- "speaker": the correct speaker name (use primary_name from profiles, MUST be a character name, NOT "Narrator")
|
||
|
||
Return ONLY the JSON array, no other text.
|
||
```
|
||
|
||
**Performance:**
|
||
- ~5 seconds per batch of 40 utterances with `google/gemma-4-12b-qat` (~34 tok/s on the remote LM Studio server)
|
||
- The model must be loaded with `context_length: 8192` (default 262k-token context requests ~44.9 GB of memory and blocks loading)
|
||
- ~20-30 minutes for all 35 chapters; every dialogue utterance is processed (not just unattributed ones)
|
||
|
||
### 3. Scene Break Handling
|
||
|
||
**Scene Breaks (`<<<>>>`):**
|
||
- 51 scene breaks across the book
|
||
- Marked as special utterances: `{"speaker": "scene_break", "text": "<<<>>>"}`
|
||
- Within a scene: track which characters are present
|
||
- At a scene break: re-evaluate present characters
|
||
- Chapter context carries across scene breaks
|
||
|
||
**LLM Context:**
|
||
- Full chapter text with `<<<>>>` preserved
|
||
- LLM maintains context across scene breaks
|
||
- Can alter character presence between scenes
|
||
|
||
### 4. Sentence Splitting Rules
|
||
|
||
**Correct Behavior:**
|
||
- Split at sentence endings: `.` `!` `?` (narration only)
|
||
- Do NOT split at commas
|
||
- Narration: each sentence = separate utterance
|
||
- Quoted dialogue: the whole quoted span = ONE utterance, even when it contains multiple sentences (all sentences share the same speaker — Rule 3d)
|
||
|
||
**Examples:**
|
||
```
|
||
"I'm on a yacht." → ["I'm on a yacht."]
|
||
"That is not something..." → ["That is not something..."]
|
||
"Help! Open the latch." → ["Help! Open the latch."] # one utterance, one speaker
|
||
```
|
||
|
||
### 5. Speaker Name Resolution
|
||
|
||
Every LLM-returned speaker passes through `_normalize_speaker` before being stored:
|
||
|
||
**Aliases → Primary Names:**
|
||
- "Princess", "Kainda", "the princess" → "Kainda Walters Foster Murphy"
|
||
- "Captain", "Captain Wake" → "Willamina Wake"
|
||
- "Hunter", "Ship AI", "The Deal Hunter" → "Deal Hunter" (the ship's AI — one speaker)
|
||
|
||
**First-word-of-primary mapping:** the first word of each primary name maps to the full
|
||
primary name when it is not already an alias (e.g. "kainda" → "Kainda Walters Foster Murphy").
|
||
|
||
**Narrator lowercase:** "narrator" / "the narrator" → canonical lowercase `narrator` (the DB
|
||
and generation code check this exact case).
|
||
|
||
**Pronoun guard:** pronouns ("she", "he", "they", "her", etc.) → `narrator`; a bare pronoun
|
||
is an LLM failure, never a character.
|
||
|
||
**LLM Post-Processing:**
|
||
- Strip speech verb suffixes: "Kainda says" → "Kainda"
|
||
- Handle group speakers: "Management Group", "Guard Detail Members"
|
||
- Handle AI speakers: "Deal Hunter" (alias "Hunter"/"Ship AI"), "AI-1"
|
||
- Unknown names are kept as-is and surfaced on the orphaned-utterances page for review
|
||
|
||
## Output
|
||
|
||
Utterances are saved to the `utterances` table in `project.db`:
|
||
```sql
|
||
INSERT INTO utterances (
|
||
chapter_id,
|
||
utterance_index,
|
||
text,
|
||
type, -- "narration" | "dialogue"
|
||
speaker_name -- "narrator" | "Princess Kainda" | "scene_break" | etc.
|
||
) VALUES (?, ?, ?, ?, ?)
|
||
```
|
||
|
||
## Quality Metrics
|
||
|
||
| Metric | Value |
|
||
|--------|-------|
|
||
| Total utterances | 4,339 |
|
||
| Dialogue utterances | 1,882 |
|
||
| Narration utterances | 2,453 |
|
||
| Dialogue-as-narrator errors | 1 (0.05%) |
|
||
| Scene breaks | 51 |
|
||
| Chapters processed | 35 |
|
||
|
||
## Error Analysis
|
||
|
||
**Remaining 0.05% Error:**
|
||
- 1 dialogue utterance still attributed to narrator (ch35 "About the author" back matter — expected, no speaker is present)
|
||
- Edge cases where the LLM and mechanical segmenter both fail
|
||
- Acceptable for production use
|
||
|
||
**Common Failure Modes:**
|
||
1. Missing dialogue tags with no context
|
||
2. Multiple speakers in rapid succession
|
||
3. Internal thoughts (should be narrator, sometimes misattributed)
|
||
4. Indirect speech ("she said that...") vs direct speech
|
||
5. Bare pronoun speakers ("She") — caught by the pronoun guard, which maps them to `narrator`
|
||
|
||
## Validation
|
||
|
||
Run after any re-segment or re-attribute to confirm the quality gates hold:
|
||
|
||
```sql
|
||
-- Dialogue left as narrator (expect only the ch35 back-matter exception)
|
||
SELECT chapter_id, COUNT(*) FROM utterances
|
||
WHERE type = 'dialogue' AND lower(speaker_name) = 'narrator'
|
||
GROUP BY chapter_id;
|
||
|
||
-- Pronoun speakers (must be 0 — guard maps these to narrator)
|
||
SELECT COUNT(*) FROM utterances
|
||
WHERE type = 'dialogue' AND lower(speaker_name) IN
|
||
('i','me','my','you','your','he','him','his','she','her',
|
||
'they','them','their','we','us','our','it','its','one');
|
||
|
||
-- Narrator case (must be lowercase 'narrator', never 'Narrator')
|
||
SELECT COUNT(*) FROM utterances WHERE speaker_name = 'Narrator';
|
||
|
||
-- Unknown speakers (cross-check against speakers.json aliases/first-words;
|
||
-- leftovers appear on the /orphaned-utterances page)
|
||
SELECT DISTINCT speaker_name FROM utterances
|
||
WHERE type = 'dialogue' AND lower(speaker_name) != 'narrator';
|
||
```
|
||
|
||
Additional checks:
|
||
- **ch13 golden reference**: 44 utterances — 15 Kainda / 15 Nanny / 14 narrator (verified against the manuscript, e.g. "Fifty light years" is Kainda, "Don't help Princess Kainda" is Nanny)
|
||
- **Multi-sentence quotes**: spot-check that a paragraph of quoted speech is ONE dialogue utterance with a single speaker (Rule 3d)
|
||
- **Alternation**: dialogue should alternate between the characters present; a long run of one speaker with no tag is suspicious
|
||
|
||
## Scripts
|
||
|
||
### resegment_all.py
|
||
|
||
```bash
|
||
# Run on all chapters with LLM attribution
|
||
python scripts/resegment_all.py --model google/gemma-4-12b-qat
|
||
|
||
# Run on specific chapters
|
||
python scripts/resegment_all.py --chapters 5,6,7 --model google/gemma-4-12b-qat
|
||
|
||
# Mechanical-only (no LLM)
|
||
python scripts/resegment_all.py --mechanical-only
|
||
```
|
||
|
||
### reattribute_all.py
|
||
|
||
Re-runs LLM speaker attribution on existing utterances **without re-segmenting** (preserves utterance IDs and mechanical segmentation; only updates `speaker_name`):
|
||
|
||
```bash
|
||
# All chapters (--sleep paces requests to the remote server)
|
||
python scripts/reattribute_all.py --model google/gemma-4-12b-qat --sleep 0.2
|
||
|
||
# Specific chapters
|
||
python scripts/reattribute_all.py --chapters 5,6,7 --model google/gemma-4-12b-qat
|
||
```
|
||
|
||
**Note:** before running attribution, ensure the remote LM Studio server has the model loaded with `context_length: 8192`:
|
||
|
||
```bash
|
||
curl -X POST http://192.168.123.14:1234/api/v1/models/load \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"model": "google/gemma-4-12b-qat", "context_length": 8192, "flash_attention": true, "echo_load_config": true}'
|
||
```
|
||
|
||
## Key Design Decisions
|
||
|
||
1. **Two-phase over one-phase**: Mechanical segmenter handles text splitting reliably; LLM only does attribution
|
||
2. **LLM attributes all dialogue**: The LLM's verdict overrides the mechanical `current_speaker` guess so ambiguous lines get corrected (not just the unattributed ones)
|
||
3. **Quotes kept whole**: Multi-sentence quoted speech is one utterance; splitting it loses the single-speaker evidence
|
||
4. **Inner-quote preservation**: `_restore_quote_wrap` only recognizes double-quote closes so trailing inner single-quotes (`’`) survive re-wrapping
|
||
5. **Name normalization**: Aliases and first-word-of-primary map to canonical primary names; narrator stays lowercase; pronouns map to narrator (no fabricated speakers)
|
||
6. **Deterministic fallback**: Speech-tag regex + speaker carry-forward guarantee un-attributed dialogue still gets a speaker
|
||
7. **Batch processing**: 40 utterances per LLM call for efficiency
|
||
8. **Scene break preservation**: `<<<>>>` markers maintained as special utterances; they reset the carried speaker
|
||
9. **google/gemma-4-12b-qat model**: ~34 tok/s on the remote LM Studio server, must be loaded with `context_length: 8192` (~44.9 GB context otherwise blocks loading)
|