Audit Phase 1: correctness and scheduling safety
Implements every P0 from AUDIT.md plus four P1s, across both halves of the pipeline. Verified by CPU-only self-checks and the orchestrator test suite. No GPU work ran and no pipeline ran. workpc: - worker_scene: read speaker_ref, not the rewritten speaker field. Every line narrated as "Someone" before this. Emit `actions` for the verifier. - worker_script: declare beat + verifier_feedback (pydantic dropped both, so the retry was blind) and render them as a repair prompt. - worker_vision: gate face->identity pairing on containment, assign globally shortest-first, map an out-of-range resolver index to `unresolved` instead of minting a character, parse JSON with raw_decode. - session_manager: tear down a server whose lease vanished mid-load, and spawn the supervisor respawn unlocked. orchestrator (edited in place, NOT committed there): - tracklets: canonicalize gender, add co-presence cannot-links, block transitive bridges across a hard constraint. - correctness: stop failing valid narration on sentence-initial capitals and short quotes; read action evidence from the singular key. - db: stop orphan flags leaking into every chapter; resolve by flag id. - service: TTS returns instead of raising under GATES, auto-resolves under autonomous mode; job admission control; registry names on dialogue resume. - session_proxy: queue on 409 instead of stealing the lease; run heartbeats. Docs restructured per the repo-structure layout: CLAUDE.md is a pointer table, NEXT.md replaces HANDOFF.md, plus ROADMAP.md, JOURNAL.md, decisions/ and caveats/. AUDIT.md now points at those instead of restating them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XD7cAy81MZrc7gCr6aZGWr
This commit is contained in:
+72
-23
@@ -19,7 +19,9 @@ GEMMA4_URL = os.environ.get("GEMMA4_URL", "http://127.0.0.1:8090")
|
||||
|
||||
# set-of-mark speaker attribution: detect text regions with comic-text-detector (ONNX, CPU) and draw
|
||||
# numbered boxes on the panel so gemma transcribes/attributes grounded regions instead of eyeballing
|
||||
# the whole image. Off by default — flip SOM_ATTRIBUTION=1 once tuned per title (see bubble_detect.py).
|
||||
# the whole image. ON by default: face->identity pairing is now gated on containment, so an unmatched
|
||||
# face is labelled "unknown" instead of borrowing the nearest name (see _pair_faces_to_present).
|
||||
# SOM_ATTRIBUTION=0 falls back to the holistic path (see bubble_detect.py).
|
||||
try:
|
||||
import bubble_detect
|
||||
except Exception: # onnxruntime/model absent -> feature simply stays unavailable
|
||||
@@ -31,29 +33,45 @@ except Exception:
|
||||
SOM = os.environ.get("SOM_ATTRIBUTION", "1") == "1"
|
||||
|
||||
|
||||
def _pair_faces_to_present(det_faces: list, present: list) -> list:
|
||||
def _pair_faces_to_present(det_faces: list, present: list, margin: float = 0.25) -> list:
|
||||
"""det_faces = real detector boxes (grounded but identity-less); present = characters gemma placed
|
||||
in the panel (identity + a coarse, imprecise bbox). Assign each detected face the identity of the
|
||||
present character whose gemma-bbox centre falls closest to it, so a green box carries a real name.
|
||||
A face with no nearby present char stays unknown; a present char is used at most once."""
|
||||
in the panel (identity + a coarse, imprecise bbox). Pair a face with a present character ONLY when
|
||||
the face centre falls inside that character's bbox grown by `margin` of its size — gemma's boxes are
|
||||
imprecise but not arbitrary. An unpaired face stays unknown instead of borrowing whoever happens to
|
||||
be nearest; the label it carries becomes `speaker_method="som_face"`, the highest-trust provenance
|
||||
the pipeline has, so an ungated guess used to launder itself into evidence.
|
||||
Pairs are taken globally shortest-first, so the first face processed cannot claim a character that
|
||||
fits a later face far better. Each face and each character is used at most once."""
|
||||
def cx_cy(b): return ((b[0] + b[2]) / 2, (b[1] + b[3]) / 2)
|
||||
used = set()
|
||||
out = []
|
||||
for i, f in enumerate(det_faces, 1):
|
||||
|
||||
def contains(face_box, char_box) -> bool:
|
||||
w, h = char_box[2] - char_box[0], char_box[3] - char_box[1]
|
||||
if w <= 0 or h <= 0:
|
||||
return False
|
||||
fx, fy = cx_cy(face_box)
|
||||
return (char_box[0] - margin * w <= fx <= char_box[2] + margin * w
|
||||
and char_box[1] - margin * h <= fy <= char_box[3] + margin * h)
|
||||
|
||||
pairs = []
|
||||
for i, f in enumerate(det_faces):
|
||||
fx, fy = cx_cy(f["bbox"])
|
||||
best, bestd = None, 1e18
|
||||
for j, c in enumerate(present):
|
||||
b = c.get("bbox")
|
||||
if not b or j in used:
|
||||
if not b or len(b) != 4 or not contains(f["bbox"], b):
|
||||
continue
|
||||
px, py = cx_cy(b)
|
||||
d = (px - fx) ** 2 + (py - fy) ** 2
|
||||
if d < bestd:
|
||||
best, bestd = j, d
|
||||
c = present[best] if best is not None else {}
|
||||
if best is not None:
|
||||
used.add(best)
|
||||
out.append({"label": f"P{i}", "bbox": f["bbox"], "local_id": c.get("local_id"),
|
||||
pairs.append(((px - fx) ** 2 + (py - fy) ** 2, i, j))
|
||||
taken_f, taken_c, match = set(), set(), {}
|
||||
for _, i, j in sorted(pairs):
|
||||
if i in taken_f or j in taken_c:
|
||||
continue
|
||||
taken_f.add(i)
|
||||
taken_c.add(j)
|
||||
match[i] = j
|
||||
out = []
|
||||
for i, f in enumerate(det_faces):
|
||||
c = present[match[i]] if i in match else {}
|
||||
out.append({"label": f"P{i + 1}", "bbox": f["bbox"], "local_id": c.get("local_id"),
|
||||
"who": c.get("name") or c.get("desc") or "unknown", "gender": c.get("gender")})
|
||||
return out
|
||||
|
||||
@@ -127,10 +145,17 @@ def _strip_thought(text: str) -> str:
|
||||
def _extract_json(raw: str) -> dict:
|
||||
"""strip gemma4 thought, pull the first JSON object, parse it."""
|
||||
text = _strip_thought(raw)
|
||||
m = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not m:
|
||||
i = text.find("{")
|
||||
if i < 0:
|
||||
raise ValueError(f"no json in response: {text[:200]}")
|
||||
return json.loads(m.group(0))
|
||||
# raw_decode stops at the end of the FIRST object. The old greedy `\{.*\}` ran to the LAST brace in
|
||||
# the reply, so a second object or any trailing braced prose produced an unparseable span and burned
|
||||
# a repair call on a response that was already fine.
|
||||
try:
|
||||
obj, _ = json.JSONDecoder().raw_decode(text[i:])
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"bad json in response: {text[:200]}") from e
|
||||
return obj
|
||||
|
||||
|
||||
def _img_part(image_path: str) -> dict:
|
||||
@@ -893,10 +918,18 @@ async def vision_resolve(data: ResolveInput):
|
||||
os.remove(crop)
|
||||
for _, path in refs:
|
||||
os.remove(path)
|
||||
# map gemma's 1-based choice back to a character_id; 0 / out-of-range -> NONE (new character).
|
||||
# map gemma's 1-based choice back to a character_id. An explicit 0 means NONE -> a new character.
|
||||
# An OUT-OF-RANGE index is a hallucination, not an answer: it must be `unresolved` like a parse
|
||||
# failure, or a bad index silently mints a brand new character in the permanent registry.
|
||||
choice = result.get("choice", 0)
|
||||
cid = data.candidates[choice - 1]["character_id"] if isinstance(choice, int) and 1 <= choice <= len(data.candidates) else None
|
||||
state = "known" if cid else ("unresolved" if result.get("reason") == "parse_failed" else "new")
|
||||
in_range = isinstance(choice, int) and 1 <= choice <= len(data.candidates)
|
||||
cid = data.candidates[choice - 1]["character_id"] if in_range else None
|
||||
if in_range:
|
||||
state = "known"
|
||||
elif choice == 0 and result.get("reason") != "parse_failed":
|
||||
state = "new"
|
||||
else:
|
||||
state = "unresolved"
|
||||
return {"character_id": cid, "state": state, "confidence": float(result.get("confidence", 0.0)),
|
||||
"reason": result.get("reason", "")}
|
||||
|
||||
@@ -1005,4 +1038,20 @@ if __name__ == "__main__":
|
||||
assert False
|
||||
except ValueError:
|
||||
pass
|
||||
# trailing braced prose after a complete object parses (used to burn a repair call)
|
||||
assert _extract_json('{"skip":false}\nnote {see above}')["skip"] is False
|
||||
|
||||
# face->identity pairing is GATED on containment: a face outside every gemma bbox stays unknown.
|
||||
faces = [{"bbox": [10, 10, 30, 30]}, {"bbox": [900, 900, 920, 920]}]
|
||||
present = [{"local_id": "person_1", "name": "Teto", "bbox": [0, 0, 100, 200]}]
|
||||
paired = _pair_faces_to_present(faces, present)
|
||||
assert paired[0]["local_id"] == "person_1" and paired[0]["who"] == "Teto"
|
||||
assert paired[1]["local_id"] is None and paired[1]["who"] == "unknown", paired[1]
|
||||
# globally shortest-first: the first face must not claim a character that fits the second better.
|
||||
faces2 = [{"bbox": [95, 95, 105, 105]}, {"bbox": [8, 8, 12, 12]}]
|
||||
present2 = [{"local_id": "a", "bbox": [0, 0, 20, 20]}, {"local_id": "b", "bbox": [80, 80, 120, 120]}]
|
||||
p2 = _pair_faces_to_present(faces2, present2)
|
||||
assert [f["local_id"] for f in p2] == ["b", "a"], p2
|
||||
# no present characters at all -> every face unknown, never a phantom identity
|
||||
assert _pair_faces_to_present(faces, [])[0]["local_id"] is None
|
||||
print("worker_vision self-check ok")
|
||||
|
||||
Reference in New Issue
Block a user