Skip to content

Use structured JSON output for local model classification#23

Open
jdcodes1 wants to merge 4 commits into
imbue-ai:mainfrom
jdcodes1:feat/structured-json-output
Open

Use structured JSON output for local model classification#23
jdcodes1 wants to merge 4 commits into
imbue-ai:mainfrom
jdcodes1:feat/structured-json-output

Conversation

@jdcodes1

@jdcodes1 jdcodes1 commented Apr 12, 2026

Copy link
Copy Markdown

Summary

  • Use WebLLM's response_format: { type: 'json_object' } to force local models to output valid JSON instead of freeform text
  • Update parseLocalModelResponse() to try JSON parsing first, falling back to the existing regex-based parsing for backward compatibility
  • Simplify LOCAL_SYSTEM_PROMPT from 21 lines to 4 — format constraints are now enforced by the engine, not by prompt instructions
  • Pass responseFormat as an optional parameter to generate() so non-classification callers (e.g. suggestAnnoyingReasons) still get freeform text

Problem: Local model responses are parsed with regex looking for "Matches X" or "No match" in freeform text. When the model phrases things differently, the regex misses and the post silently gets classified as "no match" — a false negative.

Fix: Constrained decoding guarantees valid JSON output. The model can only produce {"reasoning": "...", "match": "..."}. Zero parsing ambiguity. The vendored WebLLM (0.2.82-custom) already supports json_object response format — it just wasn't being used.

Changes

File What
src/background/local-model.ts Add CLASSIFICATION_RESPONSE_FORMAT constant, make responseFormat optional on generate(), pass it from callLocalInference, guard against "null" string match
src/shared/prompts.ts Simplify LOCAL_SYSTEM_PROMPT (21 lines → 4 lines)
tests/background/local-model.test.ts 6 new tests: JSON match, no-match, empty string, "null" string, regex fallback, malformed JSON fallback

Test plan

  • 6 new tests for JSON parsing + edge cases
  • All 205 tests pass (existing regex-based tests still pass via fallback path)
  • response_format only applied to classification calls, not suggestAnnoyingReasons
  • Backward compatible — non-JSON model output still parsed via regex fallback

jdcodes1 and others added 4 commits April 11, 2026 21:43
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…regex fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ation output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…" string match

- response_format is now passed by callers (callLocalInference) rather
  than hardcoded in generate(), so non-classification callers like
  suggestAnnoyingReasons still get freeform text output
- Treat the literal string "null" as no-match in JSON parsing
- Add test for "null" string edge case

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
// Not JSON — fall through to regex parsing
}

// Fallback: freeform text parsing (backward compatibility)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this backward compatibility you speak of?

You will be provided with a post (<post>) and a list of filter categories (<filter_categories>).
Assess whether the topic of the post relates to any of the topics in the filter categories list.
Your reasoning must be AT MOST 15 words, and MUST end with a statement of "Matches <topic>" or "No match".
Respond with JSON: {"reasoning": "<10-15 words about what the post is about>", "match": "<matched category or null>"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what evals did you perform to validate this achieves similar metrics? What's the F1 score, accuracy, precision, etc...?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the key point - we experimented previously with structured output like this and found that this simple of a prompt actually leads to far worse classification performance, which is why local models now receive approximately the same prompt as API ones. I'd love to be able to use a much shorter prompt if it would actually work as well since it would certainly process much faster.

rishabgit added a commit to rishabgit/bouncer that referenced this pull request May 25, 2026
The 'Check upstream' example implied local models always use a reasoning
prompt (per PR imbue-ai#23). That was Qwen-era: after migrating local inference to
LiteRT/Gemma, upstream itself adopted the terse table_yesno prompt. Note the
lesson is model-specific + eval-gated, and that PR imbue-ai#23 was a third-party,
unmerged JSON-output PR on the old codebase.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants