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:
2026-08-11 10:16:24 +04:00
parent 6d9df5bf2f
commit 0cc6302245
14 changed files with 787 additions and 219 deletions
+48 -6
View File
@@ -21,6 +21,10 @@ class ScriptInput(BaseModel):
recent: list = [] # #1: last few panels' narration text (local flow, no repeats)
introduced: list = [] # #2: names already narrated -> refer by name/pronoun, don't re-describe
panel_count: int = 1 # scene-level narration: #panels in this beat -> scales the length budget
# the orchestrator has been sending both of these on a failed-script retry since the verifier
# landed; pydantic dropped them silently, so the retry was another blind roll of the dice.
beat: dict = {} # {beat_id, panels:[{panel_id, dialogue:[{text}], actions}]} — the evidence
verifier_feedback: list = [] # [{kind, values|quote}] from verify_script on the previous attempt
class SummaryInput(BaseModel):
@@ -80,9 +84,33 @@ def _chars_line(name_by_id, genders_by_id):
return ", ".join(out) or "none"
def _feedback_block(feedback, beat) -> str:
"""Render the previous attempt's verifier failures as concrete corrections. Without this the
retry is just another sample at the same temperature — the model never learns what was wrong."""
if not feedback:
return ""
lines = []
for f in feedback:
kind = f.get("kind")
if kind == "unsupported-proper-noun":
lines.append("- You used name(s) that are not in this beat: "
+ ", ".join(str(v) for v in f.get("values", []))
+ ". Use only the names listed under 'Characters present', or a pronoun.")
elif kind == "misquote":
lines.append(f'- Your quote "{f.get("quote","")}" is not what the panel says. '
"Quote the exact words below, or drop the quotation marks.")
else:
lines.append(f"- {kind}: {json.dumps(f, ensure_ascii=False)[:200]}")
quotes = [d.get("text", "") for p in (beat or {}).get("panels", [])
for d in p.get("dialogue", []) if d.get("text")]
exact = ("Exact lines you may quote from:\n" + "\n".join(f'- "{q}"' for q in quotes) + "\n") if quotes else ""
return ("Your previous attempt was REJECTED. Fix exactly these problems and rewrite it:\n"
+ "\n".join(lines) + "\n" + exact + "\n")
def build_prompt(sg: dict, chapter_context: str, names_by_id=None,
brief: str = "", recent=None, introduced=None, genders_by_id=None,
panel_count: int = 1) -> str:
panel_count: int = 1, beat=None, verifier_feedback=None) -> str:
name_by_id = _name_map(sg.get("characters", []), names_by_id)
chars = _chars_line(name_by_id, genders_by_id)
dialogue = "\n".join(_render_line(d, name_by_id) for d in sg.get("dialogue", [])) or "none"
@@ -120,7 +148,7 @@ def build_prompt(sg: dict, chapter_context: str, names_by_id=None,
else:
unit, budget, thin = "this manga panel", "1-2 short sentences, max ~25 words", "panel"
return (
ov + rec + ctx + intro +
_feedback_block(verifier_feedback, beat) + ov + rec + ctx + intro +
f"Write TIGHT recap narration for {unit}: {budget}, "
"present tense. Tell it like you're recapping to a friend — natural, with momentum. Output "
"only the narration — no preamble, no notes, no alternatives, never ask for more info. If the "
@@ -182,7 +210,7 @@ async def summary(data: SummaryInput):
async def script(data: ScriptInput):
text = call_gemma4(build_prompt(data.scene_graph, data.chapter_context, data.names_by_id,
data.brief, data.recent, data.introduced, data.genders_by_id,
data.panel_count))
data.panel_count, data.beat, data.verifier_feedback))
return {"panel_id": data.panel_id, "text": text}
@@ -199,10 +227,16 @@ class NormalizeInput(BaseModel):
def _extract_json(raw: str) -> dict:
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: {text[:200]}")
return json.loads(m.group(0))
# raw_decode stops at the end of the FIRST object. The old greedy `\{.*\}` swallowed any trailing
# brace in prose after it and turned a parseable reply into a wasted repair call.
try:
obj, _ = json.JSONDecoder().raw_decode(text[i:])
except json.JSONDecodeError as e:
raise ValueError(f"bad json: {text[:200]}") from e
return obj
def build_normalize_prompt(names, entities) -> str:
@@ -297,4 +331,12 @@ if __name__ == "__main__":
nr = _extract_json('{"entities":[{"name":"Everyday","kind":"shop"}],'
'"name_fixes":{"CHOI HAESEON":"Choi Haeseon"}}')
assert nr["entities"][0]["name"] == "Everyday" and nr["name_fixes"]["CHOI HAESEON"] == "Choi Haeseon"
# trailing braced prose after the object must not break the parse (was a wasted repair call)
assert _extract_json('{"a":1}\nnote: {not json}')["a"] == 1
# verifier feedback reaches the retry prompt with the exact allowed quotes; absent by default
assert "REJECTED" not in p
beat = {"beat_id": "b1", "panels": [{"panel_id": "p1", "dialogue": [{"text": "stand proud"}]}]}
pf = build_prompt(sg, "s", beat=beat, verifier_feedback=[
{"kind": "unsupported-proper-noun", "values": ["Kyoto"]}, {"kind": "misquote", "quote": "be proud"}])
assert "REJECTED" in pf and "Kyoto" in pf and "be proud" in pf and '- "stand proud"' in pf
print("worker_script self-check ok")