The model echoed my prompt back
· comic-narrator · llm, nemotron, json-parsing, ai-integration, debugging
I’d been running the comic narration pipeline on synthetic test pages for a week. Every pass found the dialogue, extracted characters, scripted the voices. Then I fed in the first real manga page and zero dialogue made it into the narration script. Not wrong dialogue. Zero.
The error
My PASS2_SYSTEM prompt described the response schema like this:
"dialogues[]": ["the spoken lines, in reading order"]
Reasonable documentation notation for an array field. The problem: Nemotron read that bracket suffix and returned it as the literal key name.
The actual response:
{
"dialogues[]": ["HEY, LUFFY!", "HMPH"],
"captions[]": ["The sea stretched endlessly..."]
}
The consumer did data.get("dialogues"), which returned None. All the dialogue was right there in the response, indexed under a key nobody asked for.
The synthetic fixtures happened to return plain keys. Every test passed; the real-world behavior was completely unexercised.
Two more shapes of the same problem
Bug 6 introduced a pattern that came back twice more in the same project:
Normalized geometry (Bug 3, Session 1). Same prompt, same page, temperature 0.1: one panel returned pixel bboxes like [145, 203, 88, 61], another returned normalized floats like [0.22, 0.3, 0.14, 0.18]. The Pydantic BBox schema rejected floats (strict ints) and dropped the whole panel’s analysis silently. The log read:
[WARN] Pass 2 failed for panel 3: validation errors for BBox
No exception raised. The panel disappeared.
json_repair returning a bare string (Bug 9, Session 5). When Nemotron’s output couldn’t be parsed as JSON at all, json_repair returned a bare string, not a dict and not None. The caller did .get() on a string and got None. One bad sample voided an entire dialogue panel with no crash and no logged error.
Three bugs, one shape: model output varied from what the code expected, and the failure mode was silent.
The fix
All three fixes land at the same place: the parse boundary, before any consumer touches the data.
Normalize bracket-suffixed keys:
def _normalize_keys(d: dict) -> dict:
return {k.rstrip("[]"): v for k, v in d.items()}
Called once per parsed response. Any key[] the model echoed back becomes key.
Coerce geometry unconditionally:
def _coerce_bbox(coords, img_w, img_h):
if all(c <= 1.0 for c in coords):
coords = [c * dim for c, dim in zip(coords, [img_w, img_h, img_w, img_h])]
return [max(0, int(c)) for c in coords]
The <= 1.0 signature reliably detects normalized coordinates. Scale, clamp, move on.
Reject non-dict output and retry once:
result = json_repair.loads(raw)
if not isinstance(result, dict):
result = json_repair.loads(call_model_again(prompt))
if not isinstance(result, dict):
return stub_panel()
json_repair returning a bare string is a valid outcome, not a library bug. Check isinstance(result, dict) before trusting it as one. A single retry at temperature 0.1 lands a parseable result most of the time.
The lesson
Any literal notation in your prompt is a candidate output key. Document a field as "dialogues[] (array):" and some samples return "dialogues[]" as the key. The model is interpolating from your format, not following a grammar.
Model output at temperature 0.1 is not deterministic. It’s a distribution. The same prompt produces pixel bboxes or normalized floats; "dialogues" or "dialogues[]"; valid JSON or a repaired string. The prompt controls the center of that distribution. The parse layer handles the tails.
The discipline: normalize at the parse boundary, validate types, retry once per item before stubbing. Not “write a better prompt.” Boundary-level coercion, every time.
What I’d tell you to check today
- Run your LLM integration against real inputs, not just synthetic fixtures. Fixtures look cleaner than what the model sends on content it hasn’t encountered before.
- Trace every
.get()call on parsed model output. What happens when it returnsNone? Is it a recoverable stub or a silent data loss propagating three calls downstream? - If your prompt uses any notation to describe a field structure (brackets, type annotations, colons), add key normalization at parse time. Any literal in your prompt is a candidate key.
- Check
isinstance(result, dict)after callingjson_repair. On a badly malformed response, it gives you a string. Calling.get()on a string in Python 3 doesn’t raise: it returnsNone.
The comic-manga-narrator pipeline, from manga page to narrated MP4 with voice acting and sound design, is at github.com/MushiSenpai/comic-manga-narrator.