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:
+64
-7
@@ -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}
|
||||
|
||||
|
||||
def _apply_speaker_labels(dialogue: list, label_map: dict) -> list:
|
||||
"""map a set-of-mark face label ("P1") in the speaker field back to its local_id. gemma may also
|
||||
answer with the local_id directly (legend shows both) — that already matches, so it's left as-is."""
|
||||
if not label_map:
|
||||
return dialogue
|
||||
_GENDER_SUFFIX = re.compile(r"\s*\((?:m|f|male|female|man|woman|unknown)\)\s*$", re.I)
|
||||
_ID_SHAPED = re.compile(r"^(?:person|char|face|p)[\s_-]*\d+$", re.I)
|
||||
|
||||
|
||||
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:
|
||||
s = (d.get("speaker") or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
if s in label_map:
|
||||
d["speaker"] = label_map[s]
|
||||
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
|
||||
|
||||
|
||||
@@ -465,7 +508,7 @@ async def dialogue(data: DialogueInput):
|
||||
result.setdefault("entities", [])
|
||||
result.setdefault("named", []) # [{local_id, name}] explicit namings -> bound to character_id by orchestrator
|
||||
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)
|
||||
result["panel_id"] = data.panel_id
|
||||
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)
|
||||
if d is None: # missing is unresolved, never manufactured as a silent success
|
||||
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({
|
||||
"panel_id": 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"}],
|
||||
{"P1": "person_3"})
|
||||
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
|
||||
pf = _pair_faces_to_present(
|
||||
[{"bbox": [10, 10, 50, 50]}, {"bbox": [200, 10, 240, 50]}, {"bbox": [400, 10, 440, 50]}],
|
||||
|
||||
Reference in New Issue
Block a user