◐ Off-By-One · answer catalog

go-json-extraction-llm-output

1 answer(s)godocker

go-json-extraction-llm-output

📦 Source in repository (JSON)

Answer

The Kobayashi-Maru parser is split into three layers, each fixing one failure mode:

1. extract.go — scan all fences, prefer plan-shaped JSON (fixes parsed-empty-plans): the old extractJSON returned after the first fenced block, so a reasoning model's analysis ```json preamble was parsed as the plan. The fix enumerates every fence in document order, parses each as JSON, and returns the first plan-shaped object (top-level orders/helm/engineering/tactical key). Analysis and generic blocks are skipped. If nothing is plan-shaped it falls back to the first valid JSON block (no regression for legacy generic outputs), else ErrNoPlan.

// PlanKeys are the object keys that distinguish a real plan from an analysis
// preamble. Reasoning models front-load a ```json block describing their
// chain of thought; the orders block appears later and carries these keys.
var PlanKeys = []string{"orders", "helm", "engineering", "tactical"}

func ExtractPlan(output string) (json.RawMessage, error) {
    var firstValid json.RawMessage
    for _, body := range fencedBlocks(output) {
        raw, err := parseBlock(body)
        if err != nil {
            continue // garbage fence: skip, don't abort
        }
        if firstValid == nil {
            firstValid = raw
        }
        if isPlanShaped(raw) {
            return raw, nil
        }
    }
    if firstValid != nil {
        return firstValid, nil // backward-compat fallback
    }
    return nil, ErrNoPlan
}

// fencedBlocks returns every ``` fence body in document order. The language
// tag is deliberately ignored: models tag analysis blocks "json" too.
func fencedBlocks(output string) []string {
    var blocks []string
    rest := output
    for {
        open := strings.Index(rest, "```")
        if open < 0 {
            return blocks
        }
        after := rest[open+3:]
        nl := strings.IndexByte(after, '\n')
        if nl < 0 {
            return blocks // unterminated fence opener
        }
        body := after[nl+1:]
        close := strings.Index(body, "```")
        if close < 0 {
            return blocks // unterminated fence
        }
        blocks = append(blocks, body[:close])
        rest = body[close+3:]
    }
}

func isPlanShaped(raw json.RawMessage) bool {
    var obj map[string]json.RawMessage
    if err := json.Unmarshal(raw, &obj); err != nil {
        return false // arrays/scalars are not plans
    }
    for _, key := range PlanKeys {
        if _, ok := obj[key]; ok {
            return true
        }
    }
    return false
}

2. openrouter.go — one HTTP request, no internal retries, empty-content surfaced cleanly: the adapter performs exactly one POST /chat/completions. A 200 with empty content returns &Message{Content: "", FinishReason: fr} (content is a *string so null and "" are equivalent). Non-200 → *APIError; network failures → returned raw. It never retries; that decision belongs to the retry policy, so API/network errors can never be double-billed.

if resp.StatusCode != http.StatusOK {
    return nil, &APIError{Status: resp.StatusCode, Body: openRouterErrorMessage(body, string(body))}
}
var parsed openRouterResponse
if err := json.Unmarshal(body, &parsed); err != nil { ... }
if len(parsed.Choices) == 0 {
    return &Message{Role: "assistant"}, nil // same failure mode as empty content
}
choice := parsed.Choices[0]
content := ""
if choice.Message.Content != nil {
    content = *choice.Message.Content
}
return &Message{Role: choice.Message.Role, Content: content, FinishReason: choice.FinishReason}, nil

3. retry.go — RetryableAdapter: retry only empty_content, exactly once (fixes the ~50% empty-plan rate): MaxAttempts defaults to 2 (send + one retry). The loop retries only on empty content or a *ParseError from an inner adapter. API errors, network errors, and context cancellation hit the default branch and surface immediately — never retried. On exhausted retries it returns a *ParseError that carries finish_reason, and Plan wraps any post-parse failure the same way, so callers can distinguish stop from length cutoffs without re-parsing.

func (r *RetryableAdapter) Complete(ctx context.Context, prompt string) (*Message, error) {
    max := r.MaxAttempts
    if max <= 0 {
        max = 2
    }
    var lastErr error
    for attempt := 1; attempt <= max; attempt++ {
        msg, err := r.Inner.Complete(ctx, prompt)
        switch {
        case err == nil && isEmpty(msg):
            lastErr = &ParseError{FinishReason: finishReason(msg), Err: ErrEmptyContent}
        case isRetryable(err): // ErrEmptyContent or *ParseError
            lastErr = err
        default:
            return msg, err // API, network, cancel: NEVER retried
        }
    }
    return nil, lastErr
}

func (r *RetryableAdapter) Plan(ctx context.Context, prompt string) (json.RawMessage, error) {
    msg, err := r.Complete(ctx, prompt)
    if err != nil {
        return nil, err
    }
    plan, perr := ExtractPlan(msg.Content)
    if perr != nil {
        return nil, &ParseError{FinishReason: msg.FinishReason, Err: perr} // finish_reason preserved
    }
    return plan, nil
}

Error taxonomy: *APIError (never retried), ErrEmptyContent + *ParseError{FinishReason, Err} (retryable / diagnostic), ErrNoPlan (extraction failure).

Evidence & signatures

Verified with `go test -race -count=3` (13 tests, all green), `go vet` clean, `gofmt` clean. The three required regression fixtures plus retry proofs:

| Test | What it proves |
|---|---|
| `TestExtractPlanAnalysisBeforeOrders` | analysis ` ```json ` block first, orders second → orders block returned (exact match) |
| `TestExtractPlanOrdersFirstStaysFirst` | orders already first → first plan-shaped candidate still wins, document order preserved |
| `TestExtractPlanGenericNonPlanFirst` | generic `{"status":"ok"}` first, plan second → plan wins |
| `TestExtractPlanBareFence` / `SkipsInvalidBlocks` | untagged fences are candidates; corrupt fences are skipped without aborting |
| `TestExtractPlanFallsBackToFirstValidJSON` / `NoJSONReturnsErrNoPlan` | fallback for legacy generic output; hard error (never a silently-empty plan) on no JSON |
| `TestRetryOnceOnEmptyContent` | httptest returns 200+empty then orders → plan returned, **exactly 2 requests** |
| `TestEmptyContentRetryExhaustedCarriesFinishReason` | both attempts empty → `*ParseError` with `finish_reason="length"`, 2 requests |
| `TestAPIErrorNeverRetried` | 429 rate limit → **exactly 1 request**, `*APIError` with upstream message |
| `TestNetworkErrorNeverRetried` | hijacked/closed connection → **exactly 1 request**, raw error |
| `TestParseErrorCarriesFinishReason` | 200 with prose → `*ParseError` with `finish_reason="stop"`, wrapped `ErrNoPlan` |
| `TestNoRetryWhenFirstAttemptSucceeds` | clean first response → exactly 1 request |

Edge cases handled: `null` vs `""` content (`*string`), 200-with-no-choices treated as empty, CRLF fence bodies (`TrimSpace`), unterminated fences, arrays/scalars rejected as non-plans, context cancellation never retried. Live failure signature — reasoning model: `send → 200 empty → retry → orders block`; on `length` cutoff the final `ParseError.FinishReason` tells ops the retry died from truncation, not a clean stop.
{"model": "deepseek-v4-flash", "problem_class": "go-json-extraction-llm-output", "result": "passed", "tests": 13}
Generated from the verified corpus · MIT licensedBack to the catalog