Mechanical speech-evidence gate: catches the ch6 utt29 miss (spec 24 A)

The operator's chapter 6 review found a line attributed to Hallr that the text
gives to a scum boy, and no part of the system had flagged it. Spec 24 §2
diagnosed why: the duplicate scan only compares names that already exist, so it
cannot see a line given to the wrong EXISTING speaker. Spec 24 §4A proposed a
mechanical evidence gate. This is that gate, measured.

New umbraaudio/analysis/speech_evidence.py, model-free, plus
scripts/report_speech_evidence.py (read-only) and 44 tests.

For every dialogue line: extract the speaker the narration actually names, build
the candidate set from the cast rather than from the guess, and return
supported / contradicted / unsupported / unconstrained.

Last Dwarf, all 49 chapters, 3,918 dialogue lines:
  supported       1,844  47.06%
  unconstrained   2,032  51.86%
  contradicted       34   0.87%
  unsupported         8   0.20%
  review queue      42   1.07%

It catches the chapter 6 defect exactly: "The leader on the right for Hallr
says," / "We want to be the ones to claim we killed the last dwarf" assigned to
Hallr, with the two leaders who could own it. attribution_qa reports 11 findings
book-wide and this one is not among them.

Two design decisions carried the precision, and both were forced by measurement.
First, "no tag" is not a finding: most dialogue has no tag because a tag
introduces only the first line of a conversation, so unconstrained and
unsupported are separate verdicts. The first working version queued 60% of the
book for this reason, which is the same as having no gate. Second, "ambiguous"
was removed as a verdict - it hid the case that matters, since a line assigned to
Hallr under "The leader on the right" is wrong no matter how many leaders exist.
Candidate count now decides whether a suggested fix is confident or a question.

Two corrections to spec 24, both from measurement. The 20 role-tag violations
reported in section 3 are really 11; my ad-hoc probe was worse than the checker
it was meant to replace. And identity coverage is already 100% - every dialogue
line resolves to a profile or alias - so the defect is never a missing speaker,
it is a line given to the wrong existing one.

Six extractor bugs the measurement exposed are now regression tests: a role
phrase with a spatial qualifier produced no tag at all; a coordinated predicate
spoke as its first subject instead of its last; a possessive voice resolved to a
profile called Voice; irregular plurals and superlatives never matched; and the
subject was read off trailing participial clauses. Two generalisable rules came
out of them - a speech tag's subject is a noun phrase and a prepositional
qualifier is still part of it, and a subordinating conjunction opens a clause of
its own ("The dragon swallows when Hallr says" is Hallr).

Reports carry chapter_id AND title because chapter_id and chapter_index differ
by one in every project here; a bare "ch8 utt264" sent the operator to the wrong
chapter, and the line they asked about is chapter 9 utt264.

Full suite 619 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
This commit is contained in:
B.D. Murphy
2026-10-04 20:50:17 -07:00
parent 60564cc056
commit f19ada75fb
4 changed files with 1613 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
"""Report mechanical speech-evidence support over a project's dialogue lines.
Read-only. Prints the verdict mix, then every contradicted line with the narration the
reviewer needs to read to judge it. Chapter references always carry BOTH the database id
and the title, because ``chapter_id`` and ``chapter_index`` differ by one in every project
in this repo and a bare "ch8" sent the operator to the wrong chapter once already.
Usage::
python scripts/report_speech_evidence.py --project "Last Dwarf"
python scripts/report_speech_evidence.py --project "Last Dwarf" --chapter-id 6
python scripts/report_speech_evidence.py --project "Last Dwarf" --verdict contradicted
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from collections import Counter
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from umbraaudio.analysis.speech_evidence import ( # noqa: E402
nearest_tag,
support,
)
MAX_LISTED = 60
def load_cast(project_dir: Path) -> dict[str, dict[str, Any]]:
path = project_dir / "speakers.json"
if not path.exists():
raise SystemExit(f"no speakers.json in {project_dir}")
data = json.loads(path.read_text())
cast: dict[str, dict[str, Any]] = {}
for profile in data.get("speakers", []):
canonical = profile.get("primary_name") or ""
if not canonical:
continue
cast[canonical.casefold()] = profile
for alias in profile.get("aliases") or []:
cast.setdefault(str(alias).casefold(), profile)
return cast
def report(project_dir: Path, chapter_id: int | None, wanted: str | None) -> int:
cast = load_cast(project_dir)
db = project_dir / "project.db"
con = sqlite3.connect(f"file:{db.resolve()}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
query = (
"SELECT id, chapter_index, title FROM chapters"
+ (" WHERE id = ?" if chapter_id else "")
+ " ORDER BY chapter_index"
)
chapters = con.execute(query, (chapter_id,) if chapter_id else ()).fetchall()
totals: Counter[str] = Counter()
flagged: list[dict[str, Any]] = []
for chapter in chapters:
rows = con.execute(
"SELECT utterance_index, type, speaker_name, text FROM utterances "
"WHERE chapter_id = ? ORDER BY utterance_index",
(chapter["id"],),
).fetchall()
utts = [dict(r) for r in rows]
scene = sorted(
{
(u.get("speaker_name") or "").strip()
for u in utts
if (u.get("type") or "").lower() == "dialogue"
and (u.get("speaker_name") or "").strip()
and (u.get("speaker_name") or "").strip().casefold() != "narrator"
}
)
for i, u in enumerate(utts):
if (u.get("type") or "").lower() != "dialogue":
continue
totals["dialogue"] += 1
speaker = (u.get("speaker_name") or "").strip()
ctx = nearest_tag(utts, i, cast)
verdict = support(
speaker, ctx.tag, cast, scene, text=u.get("text") or "", context=ctx
)
totals[verdict.verdict] += 1
if verdict.needs_review:
flagged.append(
{
"chapter_id": chapter["id"],
"chapter_index": chapter["chapter_index"],
"title": chapter["title"],
"utterance_index": u["utterance_index"],
"speaker": speaker,
"line": (u.get("text") or "").strip(),
"narration": ctx.narration or "",
"tag": ctx.tag.value if ctx.tag else None,
"tag_kind": ctx.tag.kind if ctx.tag else None,
"mid_conversation": ctx.mid_conversation,
"unparsed": ctx.unparsed,
"verdict": verdict.verdict,
"candidate_count": verdict.candidate_count,
"note": verdict.note,
}
)
checked = totals["dialogue"] or 1
print(f"project : {project_dir.parent.name}")
print(f"chapters : {len(chapters)}")
print(f"dialogue lines : {checked}")
print("verdicts :")
for key in ("supported", "unconstrained", "contradicted", "unsupported"):
n = totals[key]
share = 100 * n / checked
print(f" {key:14s} {n:5d} {share:5.2f}%")
print(f" review queue {len(flagged):5d} {100 * len(flagged) / checked:5.2f}%")
unparsed = [f for f in flagged if f.get("unparsed")]
print(f" of which unparsed narration (segmentation defects): {len(unparsed)}")
confident = [f for f in flagged if f["candidate_count"] == 1]
print(f" of which one-candidate (confident suggestion): {len(confident)}")
shown = [f for f in flagged if not wanted or f["verdict"] == wanted]
if not shown:
print("\nno lines match the filter")
con.close()
return 0
print(f"\n{'=' * 78}\n{len(shown)} line(s) to read\n{'=' * 78}")
for f in shown[:MAX_LISTED]:
print(f"\nchapter_id={f['chapter_id']} (index {f['chapter_index']}) '{f['title']}'")
print(f" utt{f['utterance_index']} {f['verdict'].upper()} speaker={f['speaker']!r}")
if f["narration"]:
print(f" narration : {f['narration'][:110]}")
print(f" line : {f['line'][:110]}")
print(f" why : {f['note']}")
if len(shown) > MAX_LISTED:
print(f"\n... and {len(shown) - MAX_LISTED} more (raise --verdict or use --chapter-id)")
con.close()
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", required=True)
parser.add_argument("--chapter-id", type=int, default=None)
parser.add_argument(
"--verdict", choices=["contradicted", "unsupported"], default=None
)
args = parser.parse_args()
root = Path.cwd()
if not (root / args.project).exists():
raise SystemExit(f"project directory not found: {root / args.project}")
return report(root / args.project / ".umbra", args.chapter_id, args.verdict)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -273,3 +273,106 @@ book nobody in the project has read.
Should a line that *is* a name always be attributed to the caller?
4. **The Officer / Officer** — 1.4 semitones apart, two different characters. Re-cast one, or
accept?
---
## 9. Addendum — item A is built and measured (2026-10-04)
`umbraaudio/analysis/speech_evidence.py` and `scripts/report_speech_evidence.py` implement
§4A. Read-only, model-free, 44 tests. Measured on `Last Dwarf`, all 49 chapters:
| | lines | share |
|---|---|---|
| `supported` | 1,844 | 47.1% |
| `unconstrained` (no tag applies — mid-conversation, action or thought narration) | 2,032 | 51.9% |
| `contradicted` | 34 | 0.87% |
| `unsupported` (a tag names someone with no profile — the missed-speaker signal) | 8 | 0.20% |
| **review queue** | **42** | **1.07%** |
### The design decision that made it usable
The first working version put **60% of the book** in the queue, because it reported every
line with no adjacent speech tag as a finding. Most dialogue in a novel has no tag: once a
conversation starts, only the first line is introduced. So "no tag" was split into two
verdicts that are not findings:
- `unconstrained` — the line sits inside a conversation, or the preceding narration is an
action or a thought ("He looks at the dried meat, thinking,").
- `unsupported` — a tag **exists** and names somebody the cast does not contain.
And `ambiguous` was **removed** as a verdict. It was hiding the important case: a line
assigned to `Hallr` under the tag "The leader on the right" is wrong no matter how many
leaders exist, so the verdict is `contradicted` whenever the assignment is outside a
non-empty candidate set. How many candidates exist now rides along as `candidate_count`,
where it decides whether the suggested fix is confident or a question — which is where it
belonged.
### What it caught
The chapter 6 defect, exactly:
```
chapter_id=6 (index 5) 'Tattered Dwarf'
utt29 CONTRADICTED speaker='Hallr'
narration : The leader on the right for Hallr says,
line : "We want to be the ones to claim we killed the last dwarf."
why : tag 'The leader on the right' does not support 'Hallr';
2 possible speaker(s): Scum leader, The Leader
```
Nothing else in the system flagged this line. The existing `attribution_qa` reported 11
findings book-wide; this reports 42, and its chapter 6 finding is not among the 11.
### Corrections to §3, made by measuring
- **§3 said 20 role-tag violations at a ~40% false-positive rate. The real number is 11**,
produced by the existing `attribution_qa.role_tag_guessed`. My ad-hoc probe was worse
than the checker I was about to replace.
- **Coverage of *identity* is already 100%.** Every one of the 3,918 dialogue lines
resolves to a profile or an alias. The failure is not a missing speaker; it is a line
attributed to the **wrong existing** speaker. That is why §4A is an evidence gate and not
a profile-completeness check.
- **The verifier was circular.** The attributor's verify pass asks the same class of model
whether its own guess is right. Confirmed by every defect surviving it.
### The six extractor bugs the measurement exposed
Each of these produced a wrong tag on real text, and each is now a regression test:
| narration | tag before the fix | tag now |
|---|---|---|
| `The leader on the right for Hallr says,` | *no tag at all* | `The leader on the right`, addressee `Hallr` |
| `The kid licks his lips, with a breath, says,` | `The kid licks his lips` | `The kid` |
| `Sheyla shakes her head, and Tanisha says,` | `Sheyla` | `Tanisha` |
| `, Hallr chuckles, and Milfred says,` | `Hallr` | `Milfred` |
| `The woman\u2019s voice says,` | `The woman\u2019s voice` → matched `Voice (Coven)` | `The woman` |
| `One of the six men says,` | `One of the six` | `One of the six men`, head `man` |
Two generalisable rules came out of them:
1. **A speech tag's subject is a noun phrase, and a prepositional qualifier is still part
of it.** "The leader **on the right**" is one reference. Enumerating verbs to find where
a reference stops never ends; the head noun is the boundary.
2. **A coordinated predicate speaks as its last subject**, and a subordinating conjunction
opens a clause of its own — "The dragon swallows **when Hallr says**" is Hallr.
### Still open
- **~8 of the 42 are residual false positives** from hard narration: "Sheyla's Past Hallr
says,", "He hurries to the horse's head, close to the dragon, and says,". Each is a real
line to read, so they are not harmful — the queue is simply not 100% precise.
- **The gate is not wired into `attribution_qa` or the review UI yet.** It is a report.
- **`unsupported` is the metric to watch**: 8 lines book-wide name a speaker with no
profile. That is the "missing speaker" count the operator asked about, and it is small
here because earlier work already created most of them.
- **§4B (constrained assignment) is still the biggest open win.** The gate now *measures*
the problem; it does not prevent it.
### A measurement note worth keeping
Chapter references in every report carry `chapter_id` **and** the title, because
`chapter_id` and `chapter_index` differ by one in every project in this repo. Reporting a
bare "ch8 utt264" sent the operator to the wrong chapter: chapter 8 ("After the Battle")
utt264 is `Hallr` and was never flagged; the flagged line is chapter 9 ("Peace With the
Enemy") utt264.

View File

@@ -0,0 +1,373 @@
"""Tests for the mechanical speech-evidence gate.
The fixtures are the real defect shapes found in the Last Dwarf chapter 6 and chapter 9
reviews, including the two that the previous regex matcher could not parse at all.
"""
from __future__ import annotations
from umbraaudio.analysis.speech_evidence import (
candidate_speakers,
extract_tags,
nearest_tag,
support,
)
CAST = {
"hallr": {"primary_name": "Hallr", "gender": "male", "aliases": []},
"scum leader": {"primary_name": "Scum leader", "gender": "male", "aliases": ["scum leader"]},
"the leader": {"primary_name": "The Leader", "gender": "male", "aliases": []},
"scum country boy": {
"primary_name": "Scum country boy",
"gender": "male",
"aliases": [],
},
"scum guy1": {"primary_name": "Scum guy1", "gender": "male", "aliases": []},
"sheyla": {"primary_name": "Sheyla", "gender": "female", "aliases": []},
"neriah": {"primary_name": "Neriah", "gender": "female", "aliases": []},
"the officer": {"primary_name": "The Officer", "gender": "male", "aliases": []},
"royal guard": {"primary_name": "Royal Guard", "gender": "male", "aliases": []},
"feliu": {"primary_name": "Feliu", "gender": "male", "aliases": []},
"officer": {"primary_name": "Officer", "gender": "male", "aliases": []},
"the woman": {"primary_name": "The Woman", "gender": "female", "aliases": []},
"the kid": {"primary_name": "The Kid", "gender": "male", "aliases": []},
}
SCENE = ["Hallr", "Scum leader", "Scum country boy", "Scum guy1", "Sheyla"]
class TestExtraction:
def test_role_with_spatial_qualifier_and_addressee(self):
scan = extract_tags("The leader on the right for Hallr says,", CAST)
assert len(scan.tags) == 1
tag = scan.tags[0]
assert tag.kind == "role"
assert tag.addressee == "Hallr"
assert tag.head_noun == "leader"
def test_role_with_addressee_only(self):
tag = extract_tags("The woman says to Tobias,", CAST).tags[0]
assert tag.kind == "role"
assert tag.value == "The woman"
def test_plain_role(self):
tag = extract_tags("The guard says,", CAST).tags[0]
assert tag.kind == "role"
assert tag.head_noun == "guard"
def test_name_tag_resolved(self):
tag = extract_tags("Sheyla says,", CAST).tags[0]
assert (tag.kind, tag.value, tag.resolved) == ("name", "Sheyla", True)
def test_name_tag_unknown_is_unresolved_not_role(self):
tag = extract_tags("Marbeth says,", CAST).tags[0]
assert tag.kind == "name"
assert tag.resolved is False
def test_pronoun_tag(self):
assert extract_tags("He says,", CAST).tags[0].kind == "pronoun"
def test_merged_row_returns_both_tags_and_the_passive_fragment(self):
scan = extract_tags("says the oldest The boy says,", CAST)
assert scan.has_unparseable
assert "oldest" in scan.passive_fragments
assert scan.tags[-1].kind == "role"
assert scan.tags[-1].head_noun == "boy"
assert [tag.head_noun for tag in scan.tags] == ["old", "boy"]
def test_verb_with_no_subject_is_unparseable(self):
scan = extract_tags("says", CAST)
assert scan.has_unparseable
assert scan.tags == []
def test_trailing_conjunction_is_rejected(self):
assert extract_tags("The robot and tells him,", CAST).tags == []
def test_role_with_unknown_addressee_is_not_reported_as_addressed(self):
""""for the king" names nobody in the cast, so it is not an addressee.
The tag value keeps only the person reference; the report prints the narration
verbatim beside it, so nothing the reviewer needs to read is lost.
"""
tag = extract_tags("The guard for the king says,", CAST).tags[0]
assert tag.addressee is None
assert tag.head_noun == "guard"
class TestCandidates:
def test_role_candidates_ignore_the_position_qualifier(self):
tag = extract_tags("The leader on the right for Hallr says,", CAST).tags[0]
found = candidate_speakers(tag, CAST, SCENE)
assert set(found) == {"Scum leader", "The Leader"}
def test_a_role_tag_reaches_a_proper_name_by_gender_not_by_name(self):
"""Hallr's name contains no "man", so "The man" reaches him only via the scene.
Without this fallback every "The woman says," tag would match nobody and the
correct speaker would look like an error - which is exactly what the first
book-wide run produced.
"""
tag = extract_tags("The man says,", CAST).tags[0]
assert "man" not in "hallr"
assert "Hallr" in candidate_speakers(tag, CAST, SCENE)
assert support("Hallr", tag, CAST, SCENE).verdict == "supported"
def test_name_candidate_is_the_canonical_name(self):
tag = extract_tags("Scum leader says,", CAST).tags[0]
assert candidate_speakers(tag, CAST, SCENE) == ("Scum leader",)
def test_pronoun_candidates_are_gender_filtered(self):
assert set(candidate_speakers(extract_tags("She says,", CAST).tags[0], CAST, SCENE)) == {
"Sheyla"
}
def test_unknown_pronoun_gender_is_not_narrowed(self):
found = candidate_speakers(extract_tags("They say,", CAST).tags[0], CAST, SCENE)
assert set(found) == set(SCENE)
class TestVerdicts:
def test_chapter6_utt29_is_contradicted(self):
"""The real defect: a leader's line given to Hallr.
Two profiles both carry "leader", so which leader is a question for the reviewer -
but the assignment is wrong either way, and that is the verdict.
"""
tag = extract_tags("The leader on the right for Hallr says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "contradicted"
assert verdict.is_error
assert verdict.candidate_count == 2
assert not verdict.suggestion_is_confident
def test_a_real_leader_assignment_is_supported(self):
tag = extract_tags("The leader on the right for Hallr says,", CAST).tags[0]
assert support("Scum leader", tag, CAST, SCENE).verdict == "supported"
def test_chapter9_utt264_kid_attributed_to_the_king_is_contradicted(self):
""""Feliu." spoken by Feliu, behind the tag "The boy says,".
The tag's candidates are the males present, Feliu among them, so the assignment
stands. Attributing the same line to The King would not.
"""
tag = extract_tags("The boy says,", CAST).tags[0]
assert support("Feliu", tag, CAST, SCENE, text="\u201cFeliu.\u201d").verdict == "supported"
assert support("The King", tag, CAST, SCENE).verdict == "contradicted"
def test_royal_guard_under_the_guard_tag_is_supported(self):
tag = extract_tags("The guard says to Tobias,", CAST).tags[0]
assert support("Royal Guard", tag, CAST, SCENE).verdict == "supported"
def test_two_officers_make_the_replacement_a_question_not_a_verdict(self):
"""The operator's real case: The Officer and Officer are two characters."""
tag = extract_tags("The officer says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "contradicted"
assert verdict.candidate_count == 2
assert not verdict.suggestion_is_confident
for name in ("The Officer", "Officer"):
assert support(name, tag, CAST, SCENE).verdict == "supported"
def test_a_missing_tag_is_unconstrained_and_never_an_error(self):
verdict = support("Hallr", None, CAST, SCENE)
assert verdict.verdict == "unconstrained"
assert not verdict.is_error
assert not verdict.needs_review
def test_a_role_matching_nobody_is_the_missed_speaker_signal(self):
""""The dragon says," with no dragon in the cast is a speaker never created."""
tag = extract_tags("The dragon says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "unsupported"
assert verdict.needs_review
assert "never created" in verdict.note
def test_a_tag_naming_an_unknown_person_is_the_missed_speaker_signal(self):
tag = extract_tags("Marbeth says,", CAST).tags[0]
verdict = support("Hallr", tag, CAST, SCENE)
assert verdict.verdict == "unsupported"
assert "Marbeth" in verdict.note
def test_self_naming_line_is_never_an_error(self):
""""My name is Neriah" behind "The woman says," must not be called an error."""
tag = extract_tags("The woman says,", CAST).tags[0]
verdict = support(
"Neriah",
tag,
CAST,
SCENE,
text="\u201cMy name is Neriah. You\u2019re the first dwarf anyone has seen.\u201d",
)
assert verdict.verdict == "supported"
def test_narrator_is_never_a_claim_about_the_cast(self):
tag = extract_tags("The woman says,", CAST).tags[0]
assert support("narrator", tag, CAST, SCENE).verdict == "supported"
class TestNearestTag:
def _utts(self):
return [
{"type": "narration", "text": "It began.", "speaker_name": "narrator"},
{
"type": "narration",
"text": "The leader on the right for Hallr says,",
"speaker_name": "narrator",
},
{"type": "dialogue", "text": "A real dwarf.", "speaker_name": "Hallr"},
]
def test_finds_the_tag_immediately_back(self):
ctx = nearest_tag(self._utts(), 2, CAST)
tag, text = ctx.tag, ctx.narration
assert tag is not None
assert tag.head_noun == "leader"
assert "for Hallr" in (text or "")
def test_a_tag_never_spans_a_dialogue_line(self):
rows = [
{"type": "narration", "text": "The woman says,", "speaker_name": "narrator"},
{"type": "dialogue", "text": "One.", "speaker_name": "Sheyla"},
{"type": "dialogue", "text": "Two.", "speaker_name": "Sheyla"},
]
ctx = nearest_tag(rows, 2, CAST)
assert ctx.tag is None
assert ctx.mid_conversation
def test_scene_break_terminates_the_search(self):
rows = [
{"type": "narration", "text": "The woman says,", "speaker_name": "narrator"},
{"type": "scene_break", "text": "<<<>>>", "speaker_name": "narrator"},
{"type": "dialogue", "text": "One.", "speaker_name": "Sheyla"},
]
ctx = nearest_tag(rows, 2, CAST)
assert ctx.tag is None
assert ctx.mid_conversation
def test_a_passive_tag_is_a_tag(self):
""""says the oldest" means the oldest one speaks, so it is evidence, not nothing."""
rows = [
{"type": "narration", "text": "says the oldest", "speaker_name": "narrator"},
{"type": "dialogue", "text": "Feliu.", "speaker_name": "Feliu"},
]
ctx = nearest_tag(rows, 1, CAST)
assert ctx.tag is not None
assert ctx.tag.head_noun == "old"
assert ctx.narration == "says the oldest"
def test_a_bare_speech_verb_returns_the_text_and_no_tag(self):
rows = [
{"type": "narration", "text": "Hallr says", "speaker_name": "narrator"},
{"type": "dialogue", "text": "What?", "speaker_name": "Hallr"},
]
# A name tag with nothing after it still identifies the speaker.
assert nearest_tag(rows, 1, CAST).tag is not None
rows[0]["text"] = "The witch mutters something"
ctx = nearest_tag(rows, 1, CAST)
assert ctx.tag is not None and ctx.tag.head_noun == "witch"
class TestUnconstrained:
def test_a_line_inside_a_conversation_is_unconstrained_not_unsupported(self):
"""The majority of dialogue in a novel has no tag. That is not a finding."""
rows = [
{
"type": "narration",
"text": "The leader on the right for Hallr says,",
"speaker_name": "narrator",
},
{"type": "dialogue", "text": "One.", "speaker_name": "Scum leader"},
{"type": "dialogue", "text": "Two.", "speaker_name": "Scum leader"},
{"type": "dialogue", "text": "Three.", "speaker_name": "Scum leader"},
]
ctx = nearest_tag(rows, 3, CAST)
assert ctx.mid_conversation
verdict = support("Scum leader", ctx.tag, CAST, ["Scum leader"], context=ctx)
assert verdict.verdict == "unconstrained"
assert not verdict.needs_review
def test_unparsed_narration_is_unconstrained_but_flagged_as_segmentation(self):
rows = [
{"type": "narration", "text": "says the", "speaker_name": "narrator"},
{"type": "dialogue", "text": "One.", "speaker_name": "Hallr"},
]
ctx = nearest_tag(rows, 1, CAST)
assert ctx.unparsed
assert not ctx.mid_conversation
verdict = support("Hallr", ctx.tag, CAST, ["Hallr"], context=ctx)
assert verdict.verdict == "unconstrained"
assert "segmentation" in verdict.note
class TestNormalisation:
def test_irregular_plurals_reach_the_singular_role_word(self):
""""one of the six men" has to reach "man" to match a male speaker."""
tag = extract_tags("One of the six men says,", CAST).tags[0]
assert tag.head_noun == "man"
def test_a_possessive_voice_names_the_person_not_the_voice(self):
""""The woman's voice says," is a woman, not one of the profiles called Voice."""
tag = extract_tags("The woman\u2019s voice says,", CAST).tags[0]
assert tag.kind == "role"
assert tag.head_noun == "woman"
assert support("Sheyla", tag, CAST, SCENE).verdict == "supported"
def test_a_named_possessive_voice_resolves_to_that_name(self):
tag = extract_tags("Sheyla\u2019s voice says,", CAST).tags[0]
assert (tag.kind, tag.value, tag.resolved) == ("name", "Sheyla", True)
def test_the_possessive_voice_tail_is_dropped_from_the_tag(self):
"""The tag names the woman, not the voice. The narration is shown beside it."""
tag = extract_tags("The woman\u2019s voice says,", CAST).tags[0]
assert tag.value == "The woman"
assert "voice" not in tag.value
class TestNarrativeConstructs:
"""Shapes measured on Last Dwarf that each produced a wrong tag before being fixed."""
def test_a_subordinating_conjunction_opens_a_new_clause(self):
""""The dragon swallows when Hallr says," is Hallr. Reading the dragon is wrong."""
row = "The dragon puts its head back and swallows when Hallr says,"
tag = extract_tags(row, CAST).tags[0]
assert (tag.kind, tag.value) == ("name", "Hallr")
assert support("Hallr", tag, CAST, SCENE).verdict == "supported"
assert support("The King", tag, CAST, SCENE).verdict == "contradicted"
def test_a_leading_participle_is_not_the_speaker(self):
""""Nodding Tobias says," once produced a speaker called "Nodding"."""
assert extract_tags("Nodding Tobias says,", CAST).tags == []
def test_a_leading_adverb_is_not_the_speaker(self):
assert extract_tags("Now Milfred says,", CAST).tags == []
def test_a_trailing_participial_clause_is_not_the_subject(self):
""", Feliu comes running from the back of the wagon, saying," is Feliu."""
row = ", Feliu comes running from the back of the wagon, saying,"
tag = extract_tags(row, CAST).tags[0]
assert (tag.kind, tag.value) == ("name", "Feliu")
def test_a_coordinated_predicate_speaks_as_its_last_subject(self):
""""Sheyla shakes her head, and Tanisha says," is Tanisha, not Sheyla."""
tag = extract_tags("Sheyla shakes her head, and Tanisha says,", CAST).tags[0]
assert (tag.kind, tag.value) == ("name", "Tanisha")
def test_an_action_after_the_head_noun_ends_the_reference(self):
""""The kid licks his lips" is The Kid, not a role phrase about lip-licking."""
row = "The kid licks his lips, with a breath, says,"
tag = extract_tags(row, CAST).tags[0]
assert tag.value == "The kid"
assert support("The Kid", tag, CAST, SCENE).verdict == "supported"
def test_a_composed_designation_keeps_both_head_nouns(self):
""""The oldest girl" must not truncate to "The oldest"."""
tag = extract_tags("The oldest girl says,", CAST).tags[0]
assert tag.value == "The oldest girl"
assert tag.head_noun == "girl"
def test_a_gendered_role_tag_never_matches_by_name(self):
"""Hallr has no "man" in his name, so this only works through the scene."""
tag = extract_tags("The man says,", CAST).tags[0]
assert "Hallr" not in tag.value.lower()
assert support("Hallr", tag, CAST, SCENE).verdict == "supported"

View File

@@ -0,0 +1,972 @@
"""Mechanical, model-free evidence for who is speaking a dialogue line.
Every defect found in the Last Dwarf review of chapter 6 and chapter 9 had the same
shape: a line was attributed to a speaker that the surrounding narration does not
support. The attributor guessed, and the existing verification pass asked a model
whether its own guess was right, so every guess looked confident.
This module replaces that circular check with arithmetic. It answers three questions
per dialogue line, none of which involve a language model:
1. What speaker does the nearest narration tag actually name? (:func:`extract_tags`)
2. Which speakers in the cast could that tag refer to? (:func:`candidate_speakers`)
3. Is the assigned speaker among them? (:func:`support`)
The answer to (3) is one of four verdicts, and only one of them is a hard error:
``supported``
The assignment is inside the candidate set. No action.
``contradicted``
The candidate set is non-empty and the assigned speaker is **not** in it. A hard
error: the narration names a role, and a different, named character was given the
line. This is the chapter 6 ``utt29`` class.
``unsupported``
A tag exists and is **unusable**: it names a speaker the cast does not contain, or a
role that matches nobody. This is the missed-speaker signal, and it is the only
verdict that usually means "the analysis invented somebody".
``unconstrained``
No tag applies, because the line sits inside an ongoing conversation. This is the
normal state of most dialogue in a novel and it is **not** a finding. Treating it as
one put 59% of Last Dwarf in the queue on the first run, which is the same as having
no gate at all.
The distinction between ``contradicted`` and ``unconstrained`` is the whole point. A gate
that cannot tell "the text says this is wrong" from "the text says nothing" will be
ignored, and an ignored gate is worse than no gate.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from umbraaudio.analysis.speaker_dedup import (
ACTIVITY_MODIFIERS,
DESIGNATION_TOKENS,
DISTINGUISHING_MODIFIERS,
ROLE_WORDS,
normalize_name,
)
__all__ = [
"SpeechTag",
"SupportVerdict",
"SPEECH_VERBS",
"candidate_speakers",
"extract_tags",
"nearest_tag",
"support",
]
SPEECH_VERBS = re.compile(
r"\b(says|said|say|asks|asked|ask|replies|replied|reply|whispers|whispered|"
r"shouts|shouted|shout|yells|yelled|yell|screams|screamed|scream|grunts|grunted|"
r"sighs|sighed|laughs|laughed|cries|cried|weeps|snaps|snapped|growls|mutters|"
r"muttered|whistles|declares|declared|announces|announced|orders|ordered|commands|"
r"instructs|instructed|warns|warned|begs|pleads|insists|concludes|recites|sings|"
r"hums|boasts|taunts|chuckles|snorts|offers|offers|states|stated|protests|"
r"calls|called|answers|answered|tells|told|explains|explained|exclaims|"
r"gasps|sighs|responds|replies|returns|continues|repeats|adds|notes|remarks|"
r"saying|telling|responding|responds|responded|adds|added|continues|continued|"
r"yelling|shouting|calling|asking|answering|whispering|offering)\b",
)
_PRONOUN_SUBJECTS = frozenset(
{"he", "she", "it", "they", "him", "her", "them", "we", "i", "you", "one", "this", "that"}
)
# Words that may sit between the role noun and the speech verb. "the leader on the
# right for Hallr says," and "the one on the left says," both need this.
_QUALIFIERS = frozenset(
{
"on", "in", "at", "to", "of", "with", "near", "beside", "behind", "under",
"above", "from", "by", "for", "next", "over", "across", "around", "inside",
"the", "a", "an", "his", "her", "their", "its", "left", "right", "front", "back",
"side", "hand", "corner", "end", "middle", "rear", "far", "old", "younger",
"older", "taller", "shorter", "bigger", "smaller", "first", "second", "third",
"1", "2", "3", "4", "5", "looking", "wearing", "holding", "standing", "sitting",
"lying", "kneeling", "crouching", "walking", "turned", "facing",
# Numerals and collectives qualify a designation without naming a person, so
# "one of the six men" keeps its head noun instead of truncating at "six".
"six", "seven", "eight", "nine", "ten", "eleven", "twelve", "couple",
"few", "several", "other", "others", "rest", "many", "all", "both",
"either", "neither", "group", "bunch", "row", "pair", "half",
}
)
# Trailing prepositional phrases that name the RECIPIENT, not the speaker. Stripped so
# "The leader on the right for Hallr says," yields the role "The leader on the right"
# rather than a role contaminated by a name.
_ADDRESSEE_PHRASES = ("for", "to", "at", "toward", "towards", "with")
_CONJUNCTIONS = frozenset({"and", "or", "but", "then", "so"})
# A sentence ending in a bare designation with no verb, e.g. "...says the oldest".
# Action verbs, needed to locate the MAIN clause of a narration row. Without them the
# subject is read off the trailing participial phrase: ", Feliu comes running from the back
# of the wagon, saying," yields the role "The back of the wagon" instead of Feliu.
_NARRATION_VERBS = re.compile(
r"\b("
r"says|said|say|asks|asked|ask|replies|replied|reply|whispers|whispered|shouts|shouted|"
r"shout|yells|yelled|yell|exclaims|gasps|sighs|sighed|laughs|laughed|cries|cried|weeps|"
r"snaps|growls|mutters|whistles|declares|announces|orders|commands|instructs|warns|"
r"begs|pleads|insists|concludes|recites|sings|hums|boasts|taunts|chuckles|snorts|"
r"offers|states|protests|calls|answers|tells|tells|explains|responding|responds|"
r"adds|notes|remarks|returns|repeats|continues|saying|telling|responding|adds|"
r"comes|goes|walks|runs|nods|sits|stands|turns|looks|smiles|frowns|shakes|holds|"
r"grabs|pushes|pulls|picks|drops|brings|gives|takes|reaches|opens|closes|steps|"
r"leans|rises|throws|hands|puts|enters|exits|approaches|moves|leans|keeps|helps|"
r"lifts|lower|lowers|sits|squats|kneels|crawls|reaches|draws|drags|points|"
r"turns|round|lift|place|places|set|sets|stands|stare|stares|watch|watches"
r")\b",
)
# A subordinating conjunction opens its own clause, so the subject is after it, not
# before: "The dragon swallows when Hallr says," is Hallr, not the dragon.
_SUBORDINATORS = frozenset(
{"when", "while", "after", "before", "as", "once", "unless", "if", "since",
"until", "though", "because", "where", "whenever", "wherever", "lest"}
)
# Openers that cannot be the subject of the speech verb. "Nodding Tobias says," is Tobias
# and "Now Milfred says," is Milfred; reading the opener as the reference invented a
# speaker called "Nodding".
_SUBORDINATOR_RE = re.compile(
r"\b(?:" + "|".join(sorted(_SUBORDINATORS)) + r")\b"
)
_NON_SUBJECT_OPENERS = frozenset(
{
"now", "then", "suddenly", "finally", "meanwhile", "also", "still", "just",
"soon", "later", "again", "immediately", "quickly", "slowly", "carefully",
"quietly", "softly", "loudly", "here", "there", "nowadays", "his", "her",
"their", "its", "my", "your", "our",
}
)
# A trailing participle marks an adverbial clause, never a subject.
_PARTICIPLE_START = frozenset(
{
"knowing", "saying", "telling", "calling", "asking", "laughing", "smiling",
"looking", "holding", "standing", "sitting", "wearing", "still", "then",
"so", "and", "but", "while", "after", "before", "when", "as", "because",
}
)
def _governing_clause(before: str) -> str:
"""The clause immediately governing the speech verb, without punctuation.
Prefers the LAST clause, because coordinated predicates put the speaking subject
last: "Sheyla shakes her head, and Tanisha says," is Tanisha. A leading conjunction is
stripped rather than treated as a reason to reject the clause. Clauses that cannot
introduce a speaker - bare verbs, prepositional phrases, participles - are skipped.
"""
segments = [seg.strip() for seg in re.split(r"[,;:.]", before) if seg.strip()]
for segment in reversed(segments):
words = segment.split()
while words and words[0].casefold() in _CONJUNCTIONS:
words.pop(0)
if not words:
continue
first = words[0].casefold()
if first in _ADDRESSEE_PHRASES or first in _PARTICIPLE_START:
continue
if first in _NON_SUBJECT_OPENERS or first.endswith("ing"):
continue
if _NARRATION_VERBS.fullmatch(first):
continue
return " ".join(words)
return ""
def _leading_reference(clause: str, alias_map: Mapping[str, str] | None = None) -> str:
"""Trim ``clause`` to the person reference it opens with.
A designation runs from its determiner to its last head noun, and a prepositional
qualifier after the head is still part of it - "The leader on the right" is one
reference. Anything after that describes an action instead: "The kid licks his lips"
names The Kid and then reports what he did, and reading it as a role phrase produced a
tag matching nobody. Proper names are capped at two consecutive capitalised tokens.
``one`` is treated as a pronoun noun, not a determiner, because "The big one" and
"One rider" are both references and neither is truncated by it.
"""
alias_map = alias_map or {}
tokens = clause.split()
roleish = (
ROLE_NOUNS
| ACTIVITY_MODIFIERS
| DISTINGUISHING_MODIFIERS
| (DESIGNATION_TOKENS - _DETERMINERS - _QUALIFIERS)
| {"one"}
)
# Determiners and qualifiers do not count toward the phrase length, so a long
# prepositional qualifier cannot push the head noun out of the window.
continuable = (_DETERMINERS | _QUALIFIERS) - {"one"}
limit = 6
out: list[str] = []
capitals = 0
content = 0
for index, token in enumerate(tokens):
bare = token.strip(",.;:\"\u201c\u201d'").casefold()
if not bare:
break
if bare in _CONJUNCTIONS:
break
if out and _has_possessive(bare) and _stem(bare) not in ROLE_NOUNS:
break
if out and token[:1].isupper() and (alias_map.get(bare) or capitals == 0):
break # a cast name after the reference is the addressee, not the speaker
if out and bare in _ADDRESSEE_PHRASES:
break
out.append(token)
if token[:1].isupper():
capitals += 1
if capitals >= 2:
break
# A lowercase word straight after a name is its verb, not more of the name:
# "Feliu comes running from the wagon" names Feliu and then reports an action.
nxt = (
tokens[index + 1].strip(",.;:\"\u201c\u201d'").casefold()
if index + 1 < len(tokens)
else ""
)
# "Sheyla's voice" is one reference; the possessive tail is removed later,
# when the owner is resolved against the cast.
if nxt == "voice":
continue
# Break on a verb, not merely on a lowercase word: "Scum leader" is one
# designation whose second word happens to be lowercase, while "Feliu comes
# running" is a name followed by an action.
if (
bare not in _DETERMINERS
and nxt
and nxt not in continuable
and _stem(nxt) not in roleish
and not tokens[index + 1][:1].isupper()
):
break
continue
capitals = 0
is_role = _stem(bare) in roleish
if not is_role and bare not in continuable:
break
if not is_role:
continue
content += 1
# Head noun reached. What follows decides whether the reference continues:
# another head noun ("the oldest girl"), a qualifier ("on the right") or a verb
# ("the kid licks his lips") end it respectively.
nxt = (
tokens[index + 1].strip(",.;:\"\u201c\u201d'").casefold()
if index + 1 < len(tokens)
else ""
)
if nxt and nxt not in alias_map and (
nxt in continuable or nxt in roleish or _stem(nxt) in roleish
):
continue
break
if content >= limit:
return " ".join(out[:limit]).strip()
return " ".join(out).strip()
_PASSIVE_FRAGMENT = re.compile(
r"\b(?:says|said|asks|asked|calls|called)\s+((?:the\s+)?[a-z][a-z-]*)"
)
TagKind = Literal["name", "role", "pronoun"]
Verdict = Literal["supported", "contradicted", "unsupported", "unconstrained"]
@dataclass(frozen=True)
class SpeechTag:
"""One speaker reference found in a narration line."""
kind: TagKind
value: str
resolved: bool = False
addressee: str | None = None
raw: str = ""
@property
def head_noun(self) -> str:
"""The matching key of a role tag: the first token that names a kind of person.
"the leader on the right" -> "leader"; "one of the six men" -> "man". Returned in
normalised form because this is the key matched against speaker names, so
"the oldest" yields "old" and "the six men" yields "man" rather than the surface
word. Normalisation is what lets a single speaker answer to however the narration
chose to describe him.
"""
tokens = [
t.strip(",.;:\"\u201c\u201d")
for t in self.value.split()
if _stem(t) not in _DETERMINERS
]
for token in tokens:
if _stem(token) in ROLE_NOUNS:
return _stem(token)
for token in tokens:
if _stem(token) in DESIGNATION_TOKENS:
return _stem(token)
# Superlatives reach this last: "the oldest" stems to "old", which is what the
# distinguishing-modifier set contains, so the stem has to be checked, not the word.
for token in tokens:
if _stem(token) in (
ACTIVITY_MODIFIERS | DISTINGUISHING_MODIFIERS | DESIGNATION_TOKENS
):
return _stem(token)
return ""
def as_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"value": self.value,
"resolved": self.resolved,
"addressee": self.addressee,
"raw": self.raw,
}
@dataclass(frozen=True)
class SupportVerdict:
"""The mechanical verdict on one assigned speaker."""
verdict: Verdict
tag: SpeechTag | None
candidate_count: int = 0
note: str = ""
@property
def is_error(self) -> bool:
"""True only when the text positively contradicts the assignment."""
return self.verdict == "contradicted"
@property
def needs_review(self) -> bool:
"""Only a contradiction or a dead tag is worth a reviewer's time.
``unconstrained`` never is, and ``supported`` never is.
"""
return self.verdict in ("contradicted", "unsupported")
@property
def suggestion_is_confident(self) -> bool:
"""True when exactly one candidate exists, so the fix is not a question."""
return self.candidate_count == 1
def as_dict(self) -> dict[str, Any]:
return {
"verdict": self.verdict,
"tag": self.tag.as_dict() if self.tag else None,
"candidate_count": self.candidate_count,
"note": self.note,
}
@dataclass
class TagScan:
"""All tags found in one narration line, plus anything unparseable."""
text: str = ""
tags: list[SpeechTag] = field(default_factory=list)
passive_fragments: list[str] = field(default_factory=list)
@property
def has_unparseable(self) -> bool:
"""A speech verb with no parseable subject, or a verb-first passive fragment.
Both indicate a segmentation defect rather than an attribution defect, and both
need a different fix, so they are reported separately.
"""
if self.passive_fragments:
return True
return bool(SPEECH_VERBS.search(self.text or "")) and not self.tags
def _clean(text: str) -> str:
return " ".join((text or "").split())
def _possessive(word: str) -> str:
"""Strip a trailing possessive from a token.
Manuscripts write \u2019 and the role sets hold bare words, so "woman\u2019s voice" is
compared as an unknown token and the whole reference is dropped.
"""
return word.rstrip("\u2019'").casefold()
def _has_possessive(word: str) -> bool:
bare = word.casefold()
return bare.endswith("\u2019s") or bare.endswith("'s") or bare.endswith("\u2019")
# Irregular plurals, which normalize_name cannot stem because it only handles
# superlatives and ordinals. "One of the six men" has to reach "man".
_IRREGULAR_PLURALS = {
"men": "man",
"women": "woman",
"children": "child",
"people": "person",
"mice": "mouse",
"geese": "goose",
}
def _stem(word: str) -> str:
"""The word a designation is matched on, after possessive and plural normalisation.
The possessive has to be removed in full, quote AND the "s": normalize_name turns
"woman's" into "s woman", because the apostrophe is not alphanumeric and becomes a
separator. Silently, the stem of "woman\u2019s" is therefore not "woman", and every
reference built on it is dropped.
"""
bare = word.casefold()
for suffix in ("\u2019s", "'s"):
if bare.endswith(suffix):
bare = bare[: -len(suffix)]
break
bare = bare.rstrip("\u2019'")
if bare in _IRREGULAR_PLURALS:
return _IRREGULAR_PLURALS[bare]
if bare.endswith("s") and bare[:-1] in _IRREGULAR_PLURALS:
return _IRREGULAR_PLURALS[bare[:-1]]
return normalize_name(bare)
def alias_index(cast: Mapping[str, Mapping[str, Any]] | None) -> dict[str, str]:
"""Lowercase name/alias -> canonical ``primary_name``.
One cast has exactly one shape across this module. Accepting both a name->str map and a
name->profile map at different call sites is a footgun that produced a real bug during
development, so there is a single constructor and every entry point takes the cast.
"""
out: dict[str, str] = {}
for lowered, profile in (cast or {}).items():
if isinstance(profile, str):
out.setdefault(lowered, profile)
continue
canonical = str(profile.get("primary_name") or lowered)
out.setdefault(lowered, canonical)
for alias in profile.get("aliases") or []:
out.setdefault(str(alias).casefold(), canonical)
return out
def _strip_addressee(phrase: str, alias_map: Mapping[str, str]) -> tuple[str, str | None]:
"""Split "the leader on the right for Hallr" into the role and the addressee.
Only strips a trailing prepositional phrase when it names somebody the cast knows, so
"the guard for the king" keeps its full phrase rather than becoming "the guard".
"""
words = phrase.split()
for i in range(len(words) - 1, 0, -1):
if words[i - 1].casefold() in _ADDRESSEE_PHRASES:
tail = " ".join(words[i:]).strip(" ,.;:")
if tail.casefold() in alias_map:
return " ".join(words[: i - 1]).strip(), tail
return phrase, None
return phrase, None
def extract_tags(text: str, cast: Mapping[str, Mapping[str, Any]] | None = None) -> TagScan:
"""Extract every speaker reference in one narration line.
``cast`` maps lowercase name/alias to a profile dict (see :func:`alias_index`).
Handles the shapes the chapter 6 and chapter 9 defects actually had, which the previous
single-regex matcher could not:
- ``"The leader on the right for Hallr says,"`` - a role phrase with a spatial
qualifier and a named addressee. The old pattern stopped after two words and
matched nothing, so the line had no tag at all.
- ``"The woman says to Tobias,"`` - role plus addressee.
- ``"Sheyla says,"`` / ``"He says,"`` - name and pronoun.
- ``"says the oldest The boy says,"`` - two tags in one merged narration row. Both
are returned; :func:`nearest_tag` picks the last, which is the one that introduces
the following dialogue.
"""
alias_map = alias_index(cast)
scan = TagScan(text=_clean(text))
body = scan.text
if not body:
return scan
for m in _PASSIVE_FRAGMENT.finditer(body):
fragment = m.group(1).strip().lower()
if fragment in _PRONOUN_SUBJECTS or fragment in _CONJUNCTIONS:
continue
if fragment in _ADDRESSEE_PHRASES or fragment in _DETERMINERS:
continue
scan.passive_fragments.append(re.sub(r"^the\s+", "", fragment))
# Each verb's subject is bounded by the PREVIOUS verb, so a merged narration row like
# "says the oldest The boy says," yields two tags instead of one contaminated tag.
verbs = list(SPEECH_VERBS.finditer(body))
pending_passive_words = 0
for i, m in enumerate(verbs):
start = verbs[i - 1].end() if i else 0
before = body[start : m.start()].rstrip(" ,.;:")
# A subordinating conjunction opens a clause of its own, so the reference lives
# after it rather than before it.
sub = _SUBORDINATOR_RE.search(before)
if sub is not None:
before = before[sub.end() :].strip()
after_end = verbs[i + 1].start() if i + 1 < len(verbs) else len(body)
after = body[m.end() : after_end].lstrip(" ,.;:").split(",")[0].split(".")[0]
raw = body[max(0, m.start() - 60) : m.end()]
if pending_passive_words and before:
# This verb's window still opens with the previous verb's passive subject, so
# that text is consumed first: "the oldest The boy" leaves "The boy".
words = before.split()
trimmed = " ".join(words[pending_passive_words:])
pending_passive_words = 0
tag = _subject_tag(trimmed, after, alias_map, raw=raw) if trimmed else None
elif before:
tag = _subject_tag(before, after, alias_map, raw=raw)
else:
# Verb-first: "says the oldest" - the subject follows the verb, and it is only
# the FIRST noun phrase there. Taking the whole remainder yields "oldest The boy",
# whose head noun is "boy", silently attributing both tags to the same person.
chunk, consumed_words = _leading_chunk(after)
tag = _subject_tag(chunk, "", alias_map, raw=raw, passive=True)
pending_passive_words = consumed_words
if tag is not None:
scan.tags.append(tag)
return scan
def _leading_chunk(text: str) -> tuple[str, int]:
"""The leading ``determiner + noun`` phrase of ``text``, and its word count."""
words = text.split()
if not words or words[0].strip(",.;:").casefold() not in _DETERMINERS:
return "", 0
i = 1
while i < len(words):
word = words[i].strip(",.;:").casefold()
if word in _DETERMINERS:
break
if i > 1 and words[i][:1].isupper():
break
i += 1
return " ".join(words[:i]), i
_DETERMINERS = frozenset({"the", "a", "an", "this", "that", "one", "some", "another"})
_PROPER_RUN = re.compile(r"[A-Z][a-zA-Z'’-]*(?:\s+[A-Z][a-zA-Z'’-]*)*")
# Role nouns used ONLY to recognise a role reference and to find its head noun. Kept
# separate from ``speaker_dedup.ROLE_WORDS`` on purpose: widening that set changes merge
# proposals ("The King" would start counting as a designation and could be offered as a
# duplicate of "King"), and a merge proposal must never move because a QA rule gained a
# word. This set only decides whether a narration phrase refers to a person at all.
EXTRA_ROLE_NOUNS = frozenset(
{
"king", "queen", "prince", "princess", "royal", "ruler", "sovereign",
"witch", "wizard", "sorceress", "dragon", "dwarf", "giant", "troll",
"wolf", "bear", "stranger", "guest", "merchant", "trader", "farmer",
"innkeeper", "child", "children", "elder", "priest", "monk", "hunter",
"fighter", "warrior", "thief", "murderer", "victim", "prisoner", "master",
"lady", "mother", "father", "sister", "brother", "uncle", "aunt",
"grandfather", "grandmother", "apprentice", "teacher", "boss", "partner",
"wife", "husband", "peasant", "serf", "slave", "student", "aide", "attendant",
}
)
ROLE_NOUNS = ROLE_WORDS | EXTRA_ROLE_NOUNS
# Role nouns that describe a person by gender. A tag like "The woman says," cannot be
# matched against a proper name, so its referent has to be found by who is present and of
# which gender. Without this fallback every "The woman" tag resolves to nobody and the
# correct speaker looks like a contradiction.
_GENDERED_NOUNS = {
"man": "male",
"guy": "male",
"boy": "male",
"woman": "female",
"lady": "female",
"girl": "female",
"mother": "female",
"sister": "female",
"wife": "female",
"father": "male",
"brother": "male",
"husband": "male",
}
def _addressee_after(after: str, alias_map: Mapping[str, str]) -> str | None:
"""Find a post-verb recipient: "The woman says to Tobias,"."""
match = re.match(
r"(?:to|toward|towards|for|with|at)\s+([A-Z][a-zA-Z'’-]*(?:\s+[A-Z][a-zA-Z'’-]*)*)",
after.strip(),
)
if not match:
return None
tail = match.group(1).strip(" ,.;:")
return tail if tail.casefold() in alias_map else None
def _subject_tag(
before: str, after: str, alias_map: Mapping[str, str], raw: str, passive: bool = False
) -> SpeechTag | None:
"""Classify the words around a speech verb as a name, role or pronoun reference.
The phrase is located by its determiner rather than by walking back over qualifiers.
Walking back is the obvious approach and it is wrong: in "The leader on the right for
Hallr says," the walk stops at the addressee and returns the name ``Hallr`` as the
speaker, which is precisely the chapter 6 error this module exists to catch.
"""
# Two independent reductions, each fixing a class of false positive that a real
# measurement produced:
#
# 1. The clause immediately before the speech verb governs it. Coordinated predicates
# put the real subject in the LAST clause: "Sheyla shakes her head, and Tanisha
# says," is Tanisha, and "Feliu nods, Tanisha nods, and Merial asks," is Merial.
# Taking the first clause reported both as Sheyla and Feliu.
# 2. The subject is a NOUN PHRASE, so it ends at its head noun. "The kid licks his
# lips, with a breath, says," is The Kid, not "The kid licks his lips"; enumerating
# action verbs to find the boundary never ends, so the boundary is the head noun.
before, pre_addressee = _strip_addressee(before, alias_map)
clause = _governing_clause(before)
if not clause:
return None
clause = _leading_reference(clause, alias_map)
if not clause:
return None
if clause.split()[0].casefold() in _PARTICIPLE_START:
return None
if passive and SPEECH_VERBS.search(clause):
return None
words = clause.split()
if words[-1].casefold() in _PRONOUN_SUBJECTS:
leading = [_possessive(w) for w in words[:-1]]
if not leading or all(w in _DETERMINERS or w in _QUALIFIERS for w in leading):
return SpeechTag(kind="pronoun", value=words[-1].casefold(), raw=raw)
# A leading pronoun chain: "He says" already handled; "the woman she replies" is not.
# The FIRST determiner introduces the subject noun phrase; later ones belong to
# relative clauses. Taking the last one in "The leader on the right" starts the phrase
# at "right", and "right" is a qualifier, so the tag is discarded and the line is left
# with no evidence at all.
determiner_at = -1
for i, word in enumerate(words):
if word.strip(",.;:\"“”").casefold() in _DETERMINERS:
determiner_at = i
break
determiner = ""
if determiner_at >= 0:
determiner = words[determiner_at].strip(",.;:\"“”")
words = words[determiner_at + 1 :]
phrase = " ".join(words).strip(" ,.;:\"“”")
if not phrase:
return None
phrase, addressee = _strip_addressee(phrase, alias_map)
addressee = addressee or pre_addressee or _addressee_after(after, alias_map)
if not phrase:
return None
# A possessive voice is still the person: "The woman's voice says," is Sheyla when the
# only woman present is Sheyla. Checked before the determiner split, because the
# determiner is what "The woman's voice" starts with.
# "The woman's voice says," names the woman whoever she is, so the possessive tail is
# dropped and the owner becomes the reference. When the owner IS a known name this
# resolves straight to that person.
voice_owner = re.sub(r"[\u2019']s?\s+voice$", "", phrase, flags=re.IGNORECASE).strip()
if voice_owner != phrase:
owner = alias_map.get(voice_owner.casefold())
if owner:
return SpeechTag(kind="name", value=owner, resolved=True, raw=raw)
owner_stem = _stem(voice_owner.split()[-1]) if voice_owner.split() else ""
if owner_stem and owner_stem in (
ROLE_NOUNS | ACTIVITY_MODIFIERS | DISTINGUISHING_MODIFIERS
):
label = voice_owner
for word in clause.split():
if _stem(word) in _DETERMINERS:
label = f"{word} {voice_owner}"
break
return SpeechTag(
kind="role",
value=label,
resolved=False,
addressee=_addressee_after(after, alias_map),
raw=raw,
)
if determiner_at < 0:
canonical = alias_map.get(phrase.casefold())
if canonical:
return SpeechTag(kind="name", value=canonical, resolved=True, raw=raw)
if _PROPER_RUN.fullmatch(phrase):
return SpeechTag(kind="name", value=phrase, resolved=False, raw=raw)
tokens = [t.casefold().strip(",.;:\"“”") for t in phrase.split()]
tokens = [t for t in tokens if t]
if not tokens or tokens[-1] in _CONJUNCTIONS:
return None
if all(t in _QUALIFIERS or t in _DETERMINERS for t in tokens):
return None
roleish = (
ROLE_NOUNS
| DESIGNATION_TOKENS
| ACTIVITY_MODIFIERS
| DISTINGUISHING_MODIFIERS
)
# Stem as well as match: the word sets hold "old", "big" and "tall", while the text
# says "oldest", "biggest" and "tallest". Matching the raw word alone silently
# discards every superlative tag, which is most of a crowd scene.
if not any(_stem(t) in roleish for t in tokens):
return None
label = f"{determiner} {phrase}".strip()
return SpeechTag(kind="role", value=label, resolved=False, addressee=addressee, raw=raw)
@dataclass(frozen=True)
class TagContext:
"""Why a tag does or does not apply to one dialogue line.
``mid_conversation`` is the distinction that makes the gate usable. A speech tag
constrains only the first line after it; every later line of the same exchange is
carried by conversational continuity, not by a tag. Those lines are unconstrained,
not unsupported, and they are the majority of any novel.
"""
tag: SpeechTag | None
narration: str | None = None
mid_conversation: bool = False
unparsed: bool = False
@property
def applies(self) -> bool:
return self.tag is not None
def nearest_tag(
utterances: Sequence[Mapping[str, Any]],
dialogue_index: int,
cast: Mapping[str, Mapping[str, Any]] | None = None,
) -> TagContext:
"""Find the speech tag governing ``dialogue_index``.
Looks back at most three narration lines and stops at the first dialogue row, because
a tag never spans a dialogue line. ``unparsed`` marks a narration line that has a
speech verb but no recoverable subject - a segmentation defect, reported separately
because it needs a different fix.
"""
for j in range(dialogue_index - 1, max(-1, dialogue_index - 4), -1):
row = utterances[j]
if (row.get("type") or "").lower() != "narration":
return TagContext(None, mid_conversation=True)
text = _clean(row.get("text") or "")
if not text or text == "<<<>>>":
return TagContext(None)
scan = extract_tags(text, cast)
if scan.tags:
return TagContext(scan.tags[-1], text)
has_verb = bool(SPEECH_VERBS.search(text))
return TagContext(None, text, unparsed=has_verb)
return TagContext(None, mid_conversation=dialogue_index > 0)
def candidate_speakers(
tag: SpeechTag | None,
cast: Mapping[str, Mapping[str, Any]] | None = None,
scene_speakers: Sequence[str] = (),
) -> tuple[str, ...]:
"""Speakers who could own ``tag``, derived from the cast rather than the assignment.
``cast`` maps lowercase name/alias to a profile dict. ``scene_speakers`` are the
canonical names with lines in the chapter, used for pronoun tags.
This is what makes a contradicted verdict possible at all: the candidate set is built
without reference to who was assigned, so "the narration names a role and Hallr is
not one of the leaders" is a fact rather than a comparison against the guess.
"""
if tag is None:
return ()
if tag.kind == "name":
if tag.resolved:
return (tag.value,)
lowered = tag.value.casefold()
if cast and lowered in cast:
profile = cast[lowered]
canonical = profile.get("primary_name") if isinstance(profile, dict) else None
return (canonical or tag.value,)
return ()
if tag.kind == "role":
head = tag.head_noun
if not head:
return ()
out: list[str] = []
wanted_gender = _GENDERED_NOUNS.get(head)
if wanted_gender:
genders = _gender_map(cast)
for name in sorted(set(scene_speakers)):
if genders.get(name.casefold(), wanted_gender) == wanted_gender:
out.append(name)
for lowered, profile in (cast or {}).items():
canonical = profile.get("primary_name") if isinstance(profile, dict) else lowered
names = (
[canonical, *(profile.get("aliases") or [])]
if isinstance(profile, dict)
else [lowered]
)
for candidate_name in names:
if head in normalize_name(candidate_name).split():
if canonical not in out:
out.append(canonical)
break
return tuple(sorted(out))
# Pronoun: everyone with a line in the scene, optionally narrowed by gender.
genders = _gender_map(cast)
wanted = _pronoun_gender(tag.value)
if wanted is None:
return tuple(sorted(set(scene_speakers)))
out = []
for name in sorted(set(scene_speakers)):
gender = genders.get(name.casefold())
if gender is None or gender == wanted:
out.append(name)
return tuple(out)
def _pronoun_gender(pronoun: str) -> str | None:
if pronoun in ("he", "him", "his"):
return "male"
if pronoun in ("she", "her", "hers"):
return "female"
return None
def _gender_map(cast: Mapping[str, Mapping[str, Any]] | None) -> dict[str, str]:
out: dict[str, str] = {}
for lowered, profile in (cast or {}).items():
if not isinstance(profile, dict):
continue
gender = (profile.get("gender") or "").strip().casefold()
if not gender:
continue
canonical = profile.get("primary_name") or lowered
if gender.startswith("m"):
out[str(canonical).casefold()] = "male"
elif gender.startswith("f"):
out[str(canonical).casefold()] = "female"
return out
def _self_named(speaker: str, text: str, cast: Mapping[str, Mapping[str, Any]] | None) -> bool:
"""True when the line itself contains the speaker's own name.
A speaker who says "My name is Neriah" is introduced by their own line, so the
surrounding "The woman says," cannot overrule it. Without this rule the single most
reliable kind of attribution - a character naming themselves - is reported as an error.
"""
if not text:
return False
lowered = text.casefold()
names = {str(speaker)}
for key, profile in (cast or {}).items():
if isinstance(profile, dict) and str(profile.get("primary_name") or "") == speaker:
names.update(str(a) for a in profile.get("aliases") or [])
names.add(str(key))
for name in names:
tokens = [t for t in re.findall(r"[a-z]+", name.casefold()) if len(t) > 2]
if tokens and all(re.search(rf"\b{re.escape(t)}\b", lowered) for t in tokens):
return True
return False
def support(
assigned: str,
tag: SpeechTag | None,
cast: Mapping[str, Mapping[str, Any]] | None = None,
scene_speakers: Sequence[str] = (),
text: str = "",
context: TagContext | None = None,
) -> SupportVerdict:
"""Classify one assignment against the tag's candidate set.
The verdict answers one question: *is this assignment supported by the narration?*
How many speakers the tag could refer to affects the suggested replacement, not the
verdict. Collapsing those two ideas is what makes a gate unusable: "ambiguous" was
originally a fourth verdict here, and it hid the fact that a line assigned to Hallr
under the tag "The leader on the right" is wrong no matter how many leaders exist.
So the verdict is ``contradicted`` whenever the assigned speaker is outside a non-empty
candidate set, and ``candidate_count`` rides along. A single-candidate tag yields a
confident suggestion; a many-candidate tag yields a question.
"""
assigned = (assigned or "").strip()
if assigned.casefold() in ("narrator", "the narrator"):
return SupportVerdict(
"supported", tag, candidate_count=0, note="narrator assignment is not a cast claim"
)
candidates = list(candidate_speakers(tag, cast, scene_speakers))
if tag is None:
# No tag means the narration did not name a speaker here, which covers three very
# different situations that all have one thing in common: there is nothing to
# check the assignment against. Reporting them as findings put 60% of the book in
# the queue on the first run and would have been ignored.
if context is None:
reason = "no speech tag was searched for"
elif context.mid_conversation:
reason = "inside an ongoing conversation, so no tag applies"
elif context.unparsed:
reason = "narration has a speech verb but no recoverable subject (segmentation)"
else:
reason = "preceding narration is action or thought, not a speech tag"
return SupportVerdict("unconstrained", tag, candidate_count=0, note=reason)
if tag.kind == "name" and not tag.resolved:
return SupportVerdict(
"unsupported",
tag,
candidate_count=0,
note=(
f"narration names '{tag.value}', who has no speaker profile - "
f"a speaker the analysis never created"
),
)
lowered = {c.casefold() for c in candidates}
if assigned.casefold() not in lowered and _self_named(assigned, text, cast):
candidates.append(assigned)
lowered.add(assigned.casefold())
if assigned.casefold() in lowered:
return SupportVerdict("supported", tag, candidate_count=len(candidates))
if not candidates:
return SupportVerdict(
"unsupported",
tag,
candidate_count=0,
note=(
f"tag '{tag.value}' matches no speaker by name and no one present in the "
f"scene fits it - a speaker the analysis never created"
),
)
note = (
f"tag '{tag.value}' does not support '{assigned}'; "
f"{len(candidates)} possible speaker(s): {', '.join(candidates[:5])}"
)
return SupportVerdict("contradicted", tag, candidate_count=len(candidates), note=note)