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:
165
scripts/report_speech_evidence.py
Normal file
165
scripts/report_speech_evidence.py
Normal 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())
|
||||
@@ -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.
|
||||
|
||||
373
tests/test_speech_evidence.py
Normal file
373
tests/test_speech_evidence.py
Normal 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"
|
||||
972
umbraaudio/analysis/speech_evidence.py
Normal file
972
umbraaudio/analysis/speech_evidence.py
Normal 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)
|
||||
Reference in New Issue
Block a user