Remove the dead narrator profiles; add --force for decisions a scan cannot make

The operator confirmed Narratorold and OrigNarrator are dead profiles. --force
exists for exactly this: a decision the scan is not entitled to make. It prints
the overridden conflict before acting, and never deletes a reference file.

Applied to Last Dwarf:
  profiles                        93 -> 91
  approved voices                 13 -> 13   (unchanged)
  voice ref files                 72 -> 72   (unchanged, both WAVs left on disk)
  dialogue rows labelled 'narrator'  3 -> 0   (now the approved Narrator)
  narration rows labelled 'narrator' 5978 -> 5978 (untouched)

The dead names survive as aliases of Narrator so no stray utterance can
resurrect a profile, and the 3 re-pointed rows are a genuine attribution fix:
ch5 utt26 and ch27 utt125 are narration mis-segmented as dialogue; ch42 utt290
is a first-person line that may be intended inner thought.

Rescan: 10 auto, 2 review, 3 blocked, unresolved spoken names 3 -> 2.
Full suite 575 passed / 8 failed, the same 8 pre-existing failures. ruff clean.
This commit is contained in:
B.D. Murphy
2026-10-04 19:05:47 -07:00
parent 714b11367a
commit 216bc78a3a
2 changed files with 90 additions and 10 deletions

View File

@@ -82,24 +82,62 @@ def _print_report(facts, groups, unresolved) -> None: # noqa: ANN001
"-> exists only as a differently-cased profile")
def _apply(data_dir: Path, spec: str) -> int:
"""``"Survivor <- A, B"``"""
def _apply(data_dir: Path, spec: str, force: bool) -> int:
"""``"Survivor <- A, B"``. ``force`` overrides the conflicting-voice refusal."""
if "<-" not in spec:
print("--apply needs: 'Survivor <- A, B'", file=sys.stderr)
return 1
survivor, rest = spec.split("<-", 1)
survivors = survivor.strip()
survivor = survivor.strip()
duplicates = [d.strip() for d in rest.split(",") if d.strip()]
if force:
# A human has decided these are dead profiles or the same person, so the
# conflicting-voice guard is deliberately bypassed. Print exactly what is being
# overridden so the decision is on the record.
from umbraaudio.analysis.orphan_resolution import merge_speakers
facts = load_speaker_facts(data_dir)
clashes = [
f"{name}={f.ref_sha256}"
for name in duplicates
if (f := facts.get(name)) and f.has_ref and f.ref_sha256
]
if clashes:
print(f"FORCED: overriding conflicting voice references: {', '.join(clashes)}")
print(" those reference FILES are left untouched on disk.")
db = sqlite3.connect(str(data_dir / "project.db"))
db.row_factory = sqlite3.Row
try:
for name in duplicates:
if name in profile_names(data_dir):
r = merge_speakers(db, data_dir, name, survivor)
print(f" merged profile {name!r} into {survivor!r} "
f"({r.get('utterances_changed', 0)} utt re-pointed)")
else:
cur = db.execute(
"UPDATE utterances SET speaker_name=? "
"WHERE speaker_name=? AND lower(type)='dialogue'",
(survivor, name),
)
print(f" re-pointed spoken-only {name!r} -> {survivor!r} "
f"({cur.rowcount or 0} dialogue utt)")
db.commit()
finally:
db.close()
return 0
db = sqlite3.connect(str(data_dir / "project.db"))
db.row_factory = sqlite3.Row
try:
result = merge_group(db, data_dir, survivors, duplicates)
result = merge_group(db, data_dir, survivor, duplicates)
finally:
db.close()
if not result.get("ok"):
print(f"REFUSED: {result.get('reason')}", file=sys.stderr)
for detail in result.get("detail", []) or []:
print(f" {detail}", file=sys.stderr)
print(" re-run with --force if you have decided this by hand", file=sys.stderr)
return 2
print(f"merged into {result['survivor']}: "
f"{', '.join(result['merged'] + result['repointed_only'])}")
@@ -118,6 +156,9 @@ def main() -> int:
parser.add_argument("--json", action="store_true")
parser.add_argument("--verdict", choices=("auto", "review", "blocked"), default=None)
parser.add_argument("--apply", default=None, metavar="'Survivor <- A, B'")
parser.add_argument("--force", action="store_true",
help="override the conflicting-voice refusal; use when a human "
"has already decided (e.g. dead profiles)")
parser.add_argument("--apply-unresolved", action="store_true",
help="re-point case-variant spoken names at their profile")
args = parser.parse_args()
@@ -126,7 +167,7 @@ def main() -> int:
data_dir = cfg.data_dir
if args.apply:
return _apply(data_dir, args.apply)
return _apply(data_dir, args.apply, args.force)
if args.apply_unresolved:
facts = load_speaker_facts(data_dir)

View File

@@ -145,12 +145,51 @@ python scripts/scan_speaker_duplicates.py --project "Last Dwarf" --apply-unresol
- A merged speaker's `chapters_appeared` widens, and the loser's name becomes an alias, so
the merge is reversible by editing one JSON file.
## 9. Open, for the operator
## 9. Applied 2026-10-03
The operator confirmed `Narratorold` and `OrigNarrator` are dead profiles. Applied with the
new `--force` flag, which exists precisely for a decision a scan cannot make — it prints the
overridden conflict before acting and never deletes a reference file:
```
FORCED: overriding conflicting voice references: Narratorold=7e8a91ac2ff2936e,
OrigNarrator=27572e3f16e1cfc7
those reference FILES are left untouched on disk.
merged profile 'Narratorold' into 'Narrator' (0 utt re-pointed)
merged profile 'OrigNarrator' into 'Narrator' (0 utt re-pointed)
re-pointed spoken-only 'narrator' -> 'Narrator' (3 dialogue utt)
```
| | before | after |
|---|---|---|
| profiles | 93 | **91** |
| approved voices | 13 | **13** |
| voice ref files | 72 | **72** |
| dialogue rows labelled lowercase `narrator` | 3 | **0** |
| narration rows labelled lowercase `narrator` | 5978 | 5978 (untouched) |
The two dead names survive as aliases of `Narrator` (`narrator`, `the narrator`,
`Narratorold`, `OrigNarrator`), so no stray utterance can resurrect a profile. Their two
voice reference WAVs are **left on disk** — deleting a generated binary was not asked for.
The 3 re-pointed rows are a real attribution fix, not cosmetic: ch5 utt26, ch27 utt125 and
ch42 utt290 were dialogue attributed to `narrator`. ch5 utt26 and ch27 utt125 read as
narration mis-segmented as dialogue; ch42 utt290 is a first-person line that may be intended
inner thought. All three now resolve to the approved `Narrator`.
Rescan after the change: **10 auto, 2 review, 3 blocked**, 2 unresolved names (down from 3).
## 10. Open, for the operator
1. **The Girl.** `The Girl` and `The Girl helping` are the same person with two generated
voices. Which ref survives — or regenerate one voice for both?
2. **Officer.** Same question, 24/4 against 5/2.
3. **Narrator.** `Narrator` (approved) versus `Narratorold` and `OrigNarrator` — three refs.
Are the latter two dead profiles from an earlier book state?
4. **Guard 1.** Merge `The first guard` in, or are they two different guards?
5. **The Big One / The Biggest One.** Merge cosmetically, or leave both?
3. **Guard 1.** Merge `The first guard` in, or are they two different guards?
4. **The Big One / The Biggest One.** Merge cosmetically, or leave both?
### Not duplicates, but worth knowing
Two voice reference files have no matching profile: `narrator2_ref.wav` and
`rieland_ref.wav`. `Rieland` looks like a misspelling of the profile `Rielan` — a voice
generated under a typo'd name and never wired to a speaker. Not a merge case; a prune-or-adopt
decision. `manifest.md` already records orphan ref files as a known condition.