Skip to content

Structured outputs: replace hand-rolled JSON extraction with native grammar-constrained JSON #103

Description

@NickCrew

Goal

Replace the hand-rolled JSON extraction scaffolding — <JSON_TAG> sentinels,
unwrapFencedJson, brace-matching, jsonrepair, the JsonExtractionError
taxonomy in src/utils/llmProxy.ts, and the TASK-167 prompt-contract text baked
into system prompts — with Anthropic native structured outputs
(output_config.format = { type: 'json_schema', schema }). This deletes the
entire "model returned unparseable JSON" failure class.

Verified plumbing (no proxy change required)

  • Client LlmProxyOptions.outputConfig already exists and is sent in the request
    body (src/utils/llmProxy.ts).
  • Proxy normalizeOutputConfig is a pure passthrough; output_config is
    forwarded to messages.create; the effort-strip only touches .effort. So
    output_config.format flows through unmodified — confirmed against
    proxy/facetServer.js.
  • GA on every model Facet uses (Sonnet 4.6, Opus 4.x, Haiku 4.5). No beta header.

Plan (incremental, measured)

  • Pilot: bulletReframing (first PR under this issue). Trivial
    { reframed, reasoning } contract — proves the mechanism end-to-end and
    deletes its extractJsonBlock / JsonExtractionError path on the flag. Gated
    by VITE_FACET_STRUCTURED_OUTPUTS (with a per-call override for tests).
    Measure: JsonExtractionError rate → 0 on the flagged path.
  • Roll out call-by-call to the other generators, keeping the sentinel path as
    a per-call fallback until each is migrated and verified.
  • Migrate identity.extractrequires schema-shape decisions (blocker
    below).
  • When the last caller is off it, delete the <JSON_TAG> sentinel machinery,
    unwrapFencedJson, JsonExtractionError, the jsonrepair dependency, and the
    TASK-167 prompt-contract text in the affected system prompts.

Known constraints / decisions

  • identity.extract is blocked on schema shape. Its output contract
    (EXTRACTION_SYSTEM_PROMPT) contains open-keyed maps — metrics: {} (arbitrary
    keys → string | number | boolean) and preferences.constraints: {}. Structured
    outputs requires additionalProperties: false on every object and does not
    support additionalProperties set to a schema, so these are unrepresentable
    as-is. Decide a representation (typed map, [{ key, value }] array, or
    stringify — each lossy or migration-touching) before migrating extraction. This
    is why the pilot is bulletReframing, not identity.extract.
  • thinking + format is untested. Neither pilot call passes a thinking
    budget, so the structured-outputs × adaptive-thinking interaction is not
    exercised here. Test it explicitly on the first thinking-enabled call that
    migrates before relying on it.
  • Schema tooling for the rollout. The pilot hand-writes a tiny JSON Schema
    (zero new deps, guaranteed constrained-decoding-compatible). For the broader
    rollout, adopt Zod (z.toJSONSchema()) for ergonomics plus a runtime
    validation backstop — a deliberate dependency/bundle decision, not a pilot
    side-effect.
  • Structured outputs guarantees shape, not content — keep the existing
    semantic validation on every migrated call.
  • Incompatible with citations and assistant prefill (neither is used on the
    migrated calls).

Rollback

Per-call flag flip back to the sentinel path. The old extraction code stays until
the migration completes, so rollback is a config change, not a revert.

Acceptance criteria

  • A pilot PR migrates bulletReframing to native structured outputs; JsonExtractionError rate on the flagged path drops to zero.
  • All other AI generators are migrated call-by-call; each PR removes its extractJsonBlock / JsonExtractionError path.
  • identity.extract schema-shape decision is documented and implemented (or explicitly deferred with rationale).
  • The <JSON_TAG> sentinel, unwrapFencedJson, JsonExtractionError taxonomy, jsonrepair dependency, and TASK-167 prompt-contract text are deleted when the last caller is migrated.
  • Tests cover structured output paths for each migrated generator.
  • Thinking + structured-outputs interaction is explicitly tested on the first thinking-enabled migrated call.

Context: derived from the AI-proxy caching + structured-outputs handoff.
Prompt caching (the handoff's item 1) was assessed and deliberately not
pursued
— at current prompt sizes only 2 of ~20 system prompts clear the model
cache minimums (Sonnet 4.6 = 2048, Opus/Haiku = 4096 tokens), and the
large stable context (identity) is re-serialized per call in the user message and
mutates across the bulk-deepen loop, so it would not cache. Revisit if prompts
grow or a hot loop with a frozen snapshot emerges.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    • Status
      In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions