· T Today / invest-t-advisor
When Bilingual JSON Looks Fine in the Network Tab but Empty in the UI
UI는 한국어입니다. 글 본문은 아직 영어 또는 중국어만 있습니다.
Symptom
T Today’s holdings coach uses OpenAI with response_format: { type: "json_object" }. The model returns a big JSON blob with zh and en blocks. Users switched language — still empty cards. DevTools showed JSON with content. The app acted like analysis failed.
Classic “parser lied” bug.
The contract we want
One top-level object:
{
"v": 1,
"zh": { "summary": "…", "tDecision": { … }, "positions": [ … ] },
"en": { "summary": "…", "tDecision": { … }, "positions": [ … ] }
}
Human-readable strings differ per locale. extractedPortfolio must match in both leaves (symbols, share counts, cash).
The system prompt now states this loudly: numeric v: 1, no leaf fields at the top level.
What went wrong in code
parseBilingualAdvisory() had two paths:
- If
v === 1andzh/enexist → parse children. ✅ - Else → treat the whole object as a single leaf.
When the model returned { "v": 1, "zh": {…}, "en": {…} } but v was missing or not exactly 1, we fell into path 2. The wrapper has no summary at the top — only nested under zh/en. parseRiskAdvisoryStructured “succeeded” with empty strings and we duplicated that emptiness to both locales.
Users saw blank UI. No thrown error. Worse than a 500.
The fix
Parser rules now:
- If
zhandenare present → always parse those objects (ignorev). - Never run legacy leaf parsing on a wrapper that still has
zh/enkeys. - Legacy path only when the object looks like a leaf (
summary,positions, etc.) with no bilingual keys. - Salvage: if one locale parses, copy to the other.
Regression checks live in scripts/advisory-bilingual-smoke.ts.
Why bilingual at all
T Today targets EN and CN (plus Korean UI copy with English AI body for now). Re-running vision on every language toggle would be slow and expensive. We pay once, store { zh, en }, and pickLocalizedStructured() selects the right leaf.
Korean (ko) UI reads the English leaf until we add a third leaf — same pattern Invest AI uses for structured cards.
Takeaway
With JSON-mode LLMs, the schema in the prompt and the parser must agree. If you nest locales under zh/en, never parse the parent as if it were a leaf. One bad fall-through looks like “AI is broken” when the model did its job.
Further reading: ADR-0002, T-TODAY-USER-FLOW.md.