Map gemma's speaker answer back to a present character

The dialogue prompt renders each present character as
`- person_1: brown ponytail, green dress (f)`, and gemma answers with what it was
shown: the description (15 lines), a bare local_id (9), a stale mark label (2),
or a name with the gender marker attached (2). All of them fell through
normalize_speaker as free-form names and never matched the registry, so 28 of 51
speech lines on job 778297bc lost a speaker the pipeline had already identified.
_apply_speaker_labels now resolves every string the prompt showed, drops an
id-shaped answer that names nobody present, and strips a trailing gender marker
so an off-panel name can still match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:39:30 +04:00
parent e8941d8ceb
commit 80711372ba
+64 -7
View File
@@ -123,16 +123,59 @@ def _set_of_mark(local_path: str, present: list):
return marked, "\n" + "\n".join(parts) + "\n", {f["label"]: f["local_id"] or "unknown" for f in faces} return marked, "\n" + "\n".join(parts) + "\n", {f["label"]: f["local_id"] or "unknown" for f in faces}
def _apply_speaker_labels(dialogue: list, label_map: dict) -> list: _GENDER_SUFFIX = re.compile(r"\s*\((?:m|f|male|female|man|woman|unknown)\)\s*$", re.I)
"""map a set-of-mark face label ("P1") in the speaker field back to its local_id. gemma may also _ID_SHAPED = re.compile(r"^(?:person|char|face|p)[\s_-]*\d+$", re.I)
answer with the local_id directly (legend shows both) — that already matches, so it's left as-is."""
if not label_map:
return dialogue def _present_keys(present: list) -> dict:
"""Every string the dialogue prompt shows for a present character, mapped to its local_id.
`build_dialogue_prompt` renders each one as `- person_1: brown ponytail, green dress (f)`, and gemma
answers with any part of that line, most often the description. Those answers used to fall through
`normalize_speaker` as free-form NAMES and never matched the registry, losing 28 of 51 speech lines
on job 778297bc. A key shared by two present characters is dropped: it cannot identify either."""
keys: dict = {}
for c in present:
lid = (c.get("local_id") or "").strip()
if not lid:
continue
name, desc, gender = c.get("name") or "", c.get("desc") or "", c.get("gender") or ""
shown = name or desc or "unknown"
forms = {lid, name, desc, shown}
if gender and gender != "unknown":
forms |= {f"{f} ({gender})" for f in (name, desc, shown) if f}
for f in forms:
k = f.strip().casefold()
if not k or k == "unknown": # the prompt prints "unknown" for a nameless character
continue
keys[k] = lid if keys.get(k, lid) == lid else None # ambiguous key -> unusable
return {k: v for k, v in keys.items() if v}
def _apply_speaker_labels(dialogue: list, label_map: dict, present: list | None = None) -> list:
"""Map gemma's speaker answer back to a panel-local id.
Three answer shapes reach here: a set-of-mark face label ("P1"), a local_id, and any string the
prompt showed for a present character. An id-shaped answer that names nobody present is junk and
becomes "unknown" rather than a name claim (invariant 6). A trailing gender marker is stripped, so
"Seonho (m)" can still match the registry name "Seonho" off-panel."""
keys = _present_keys(present or [])
for d in dialogue: for d in dialogue:
s = (d.get("speaker") or "").strip() s = (d.get("speaker") or "").strip()
if not s:
continue
if s in label_map: if s in label_map:
d["speaker"] = label_map[s] d["speaker"] = label_map[s]
d["speaker_method"] = "som_face" d["speaker_method"] = "som_face"
continue
bare = _GENDER_SUFFIX.sub("", s).strip()
lid = keys.get(s.casefold()) or keys.get(bare.casefold())
if lid:
d["speaker"] = lid
elif _ID_SHAPED.match(bare):
d["speaker"] = "unknown" # an id for nobody present: never mint a name from it
elif bare != s:
d["speaker"] = bare # off-panel name, gender marker stripped
return dialogue return dialogue
@@ -465,7 +508,7 @@ async def dialogue(data: DialogueInput):
result.setdefault("entities", []) result.setdefault("entities", [])
result.setdefault("named", []) # [{local_id, name}] explicit namings -> bound to character_id by orchestrator result.setdefault("named", []) # [{local_id, name}] explicit namings -> bound to character_id by orchestrator
result["named"] = _normalize_claims(result["named"], data.panel_id) result["named"] = _normalize_claims(result["named"], data.panel_id)
_apply_speaker_labels(result["dialogue"], label_map) _apply_speaker_labels(result["dialogue"], label_map, data.present_characters)
resolve_speakers(result["dialogue"], data.present_characters) resolve_speakers(result["dialogue"], data.present_characters)
result["panel_id"] = data.panel_id result["panel_id"] = data.panel_id
result.update(_dialogue_envelope([data.panel_id], [] if parse_failed else [result], parse_failed)) result.update(_dialogue_envelope([data.panel_id], [] if parse_failed else [result], parse_failed))
@@ -580,7 +623,7 @@ async def dialogue_window(data: DialogueWindowInput):
d = by_id.get(pid) d = by_id.get(pid)
if d is None: # missing is unresolved, never manufactured as a silent success if d is None: # missing is unresolved, never manufactured as a silent success
continue continue
_apply_speaker_labels(d.get("dialogue", []), label_maps.get(pid, {})) _apply_speaker_labels(d.get("dialogue", []), label_maps.get(pid, {}), present_by_id.get(pid, []))
out.append({ out.append({
"panel_id": pid, "panel_id": pid,
"dialogue": resolve_speakers(d.get("dialogue", []), present_by_id.get(pid, [])), "dialogue": resolve_speakers(d.get("dialogue", []), present_by_id.get(pid, [])),
@@ -1001,6 +1044,20 @@ if __name__ == "__main__":
lbl = _apply_speaker_labels([{"speaker": "P1"}, {"speaker": "Aria"}, {"speaker": "unknown"}], lbl = _apply_speaker_labels([{"speaker": "P1"}, {"speaker": "Aria"}, {"speaker": "unknown"}],
{"P1": "person_3"}) {"P1": "person_3"})
assert lbl[0]["speaker"] == "person_3" and lbl[1]["speaker"] == "Aria" and lbl[2]["speaker"] == "unknown" assert lbl[0]["speaker"] == "person_3" and lbl[1]["speaker"] == "Aria" and lbl[2]["speaker"] == "unknown"
# gemma answers with what the prompt SHOWED, not the id: description, name+gender, bare id, junk id.
# Each shape cost real lines on job 778297bc by falling through as a free-form name.
shown = [{"local_id": "person_1", "desc": "brown ponytail, green dress", "gender": "f"},
{"local_id": "person_2", "name": "Seonho", "gender": "m"}]
got = _apply_speaker_labels([{"speaker": "brown ponytail, green dress (f)"}, {"speaker": "Seonho"},
{"speaker": "person_2"}, {"speaker": "person_9"},
{"speaker": "Haeseon (f)"}, {"speaker": "unknown"}], {}, shown)
assert [d["speaker"] for d in got] == ["person_1", "person_2", "person_2", "unknown",
"Haeseon", "unknown"], got
# a description shared by two present characters identifies neither
twins = [{"local_id": "person_1", "desc": "schoolgirl"}, {"local_id": "person_2", "desc": "schoolgirl"}]
assert _apply_speaker_labels([{"speaker": "schoolgirl"}], {}, twins)[0]["speaker"] == "schoolgirl"
# a nameless present character is shown as "unknown"; that must not become an id
assert _apply_speaker_labels([{"speaker": "unknown"}], {}, [{"local_id": "person_1"}])[0]["speaker"] == "unknown"
# real detector face -> nearest present char's identity; each present char claimed once; leftover unknown # real detector face -> nearest present char's identity; each present char claimed once; leftover unknown
pf = _pair_faces_to_present( pf = _pair_faces_to_present(
[{"bbox": [10, 10, 50, 50]}, {"bbox": [200, 10, 240, 50]}, {"bbox": [400, 10, 440, 50]}], [{"bbox": [10, 10, 50, 50]}, {"bbox": [200, 10, 240, 50]}, {"bbox": [400, 10, 440, 50]}],