DEV Community

Shan Liu
Shan Liu

Posted on

The LLM in my app is not allowed to decide anything

I build software in the single worst domain for LLM truthfulness: fortune-telling. A BaZi (Chinese Four-Pillars astrology) reading app, where the model's job is to sound like a wise master — and where user reviews of competing AI products converge on one complaint: "pure nonsense." An LLM asked to "read a birth chart" will hallucinate chart elements that aren't there, invent rules that don't exist in the tradition, and deliver it all in a voice of total confidence. In a domain with zero external ground truth to check against, users can't tell — until two readings of the same chart contradict each other.

Whatever you think of the domain (I wrote about its genuinely hard timezone math earlier), the engineering answer is portable to any LLM product that must not make things up. It's one rule:

The deterministic engine decides what is said. The LLM decides only how to say it.

The chart, the element strengths, the favorable-element analysis, every derived fact — computed by a rules engine in TypeScript, unit-tested, published constants and all. The model receives those facts as a compact block and a directive: cite only what's given. It's a translator with a persona, not an oracle.

That's the easy 80%. The interesting engineering is in three places where the rule almost broke.

1. The hard case: a hole in the input

Many users don't know their birth hour — and the hour is one of the four pillars. The naive options are both bad: refuse the user, or let the model improvise around the gap. Guess which one a model does if you just omit the hour: it fills the hole. Silently. With a specific, plausible, invented pillar.

The fix is to make the engine handle the uncertainty, deterministically. Unknown hour → there are exactly 12 possible charts. Compute all twelve, then take the intersection: only facts that hold in every candidate chart survive into the prompt. Element strength agrees across all 12? State it. It splits 7/5? Then the prompt says, verbatim: "strength undetermined (7 of 12 candidates lean strong) — you may not build on this."

And crucially, the hole itself is made explicit rather than omitted:

Chart: 己未 丙寅 庚午 ▢   (hour pillar unknown — 12 candidate
charts computed, only facts true in all of them are listed)
Enter fullscreen mode Exit fullscreen mode

That ▢ earned its place. A stated hole beats a silent one: leave the slot empty and the model backfills it; mark it and instruct ("do not mention the hour pillar; do not discuss the life areas it governs; inventing one is lying") and the model routes around it. The prompt even tells the model how to end gracefully — one sentence noting what more could be seen if the user learns their birth time. Uncertainty became a product feature instead of a hallucination site.

2. The second gate: validate the output like you don't trust the first gate

Prompts are policy, not enforcement. So generated text passes through a validator before it's stored:

  • Invented-pillar detection. For unknown-hour charts, scan the output for any of the 12 candidate hour pillars. The trick is matching the two-character stem-branch pair, never single characters — 子 alone appears inside the ordinary word 孩子 ("child"), 金 inside 资金 ("funds"). Pair matching has essentially no false positives; single-char matching would flag every other sentence.
  • Forbidden-pattern scan. Regex list of fatalistic/fear-mongering constructions ("will surely divorce", "short-lived", "incurable") — the domain's dark patterns, encoded. This isn't just taste: every platform policy that governs this vertical (search quality guidelines, ad policies, payment processors) draws its allow/ban line at concrete doom claims vs. interpretive reflection. The regex list is the compliance boundary as code.
  • Closed-vocabulary check. A list of star/deity terms the engine never computes; if one appears in the output, the model imported folklore from its training data. Flag for review.

3. The confession: my guardrail was dead code and I didn't notice

The validator originally had a third, stronger check: a whitelist assertion that every stem, branch, and "ten god" term in the output came from the chart JSON. Code-reviewing it months later, I found both of its loops were asking whether a set contained items taken from that same set — a condition that is always true, wired to a check that could therefore never fire. Two supporting arrays were never read at all. It had caught zero violations, ever, and couldn't.

I deleted it and wrote a comment explaining why, including what a real version would need to solve (the same single-character collision problem as above). Two lessons I now apply everywhere:

  • A guardrail that cannot fire is worse than no guardrail — it shows up in every architecture diagram and code review as "we validate that," and everyone stops thinking about it.
  • Test your validators the way you test code: with inputs that must fail. A validation function with no failing test case is a hypothesis, not a gate.

Bonus fight: prompt-language gravity

The persona and all instructions are written in Chinese; the app also serves English readings. A one-line "respond in English" does not survive contact with a 2,000-character Chinese prompt: the model would ship hybrid sentences into production — actual example: "Your盘的里,其实事业和财这两条线比性格更有讲头" — lifted straight from a Chinese example sentence inside the persona. The fix that held: an explicit paragraph stating that every quoted sentence above is a tone demonstration only, must not be copied or translated, and that the output may contain no Chinese characters except glossed pinyin. When your prompt is bilingual, the language directive has to out-shout the entire rest of the prompt.

Why bother, beyond truthfulness

  • Cost. The model writes 300 words of styled prose per section instead of "reasoning" about the chart. No chain-of-thought needed — thinking mode is off, calls are ~1.5s and fractions of a cent.
  • Consistency. Two users with the same chart get stylistic variation on the same facts, not two different fates. Re-reads don't contradict.
  • The line is auditable. When a user asks "why does it say that?", there's an engine fact to point to — the same one published on the site's method page.

The pattern is old, honestly — it's a compiler emitting facts and a pretty-printer rendering them. The only new part is that the pretty-printer went to art school and will invent facts if you let it. Don't let it: compute the truth, mark the holes, validate the output, and test that your validators can actually fail.

The app: auspiceoracle.com — the engine's scoring constants are public on the method page, which is the same "show your work" rule applied to marketing.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The compiler/pretty-printer analogy is exactly right. I would push the interface one step further and make every allowed claim a typed object with a stable claim ID, confidence/coverage and provenance back to the engine inputs.

Then require the model to emit structured sections that reference those claim IDs before rendering prose. A deterministic post-processor can enforce:

  • every factual sentence cites one or more allowed claims
  • no claim outside the intersection set appears
  • uncertainty qualifiers survive into the rendered text
  • language constraints hold per span
  • the same claim is not contradicted elsewhere in the response

This avoids relying only on scanning prose for a growing blacklist. The validator can reason over a closed set, while regex remains a useful final defense.

The dead-code lesson also suggests mutation testing: deliberately invert or delete each guard and prove at least one test fails. Property-based cases could vary missing pillars, languages and candidate-chart splits; metamorphic tests could assert that removing input information never creates a more specific output claim.

A guardrail suite should demonstrate both rejection and sensitivity to its own removal. Otherwise, as you found, green tests may only prove the gate exists syntactically.