◐ Off-By-One · answer catalog

go-json-extraction-multi-codeblock

1 answer(s)godocker

go-json-extraction-multi-codeblock

📦 Source in repository (JSON)

Answer

Root cause. extractJSON matched only the first fenced ``json block. Reasoning models emit an analysis block (valid JSON, no plan keys) before the real orders block, so the parser unmarshaled the analysis object intoPlan` — success with zero orders while the visible orders were dropped.

Fix 1 — scan ALL fenced blocks, prefer the first plan-shaped candidate (extract.go):

// findFencedBlocks returns the contents of every fenced code block in order
// of appearance. ```json, bare ```, and other language tags all qualify; an
// unclosed trailing fence yields the remainder as the final block.
func findFencedBlocks(input string) []string {
    var blocks []string
    rest := input
    for {
        start := strings.Index(rest, "```")
        if start == -1 {
            break
        }
        rest = rest[start+3:]
        if nl := strings.IndexAny(rest, "\r\n"); nl != -1 {
            rest = rest[nl+1:] // discard info string
        } else {
            rest = ""
        }
        end := strings.Index(rest, "```")
        if end == -1 {
            blocks = append(blocks, strings.TrimSpace(rest))
            break
        }
        blocks = append(blocks, strings.TrimSpace(rest[:end]))
        rest = rest[end+3:]
    }
    return blocks
}

// plan-shape detection: "orders" wrapper or helm/engineering/tactical/security
// keys, directly or under a common wrapper object (plan/result/data/orders).
func isPlanShaped(v any) bool {
    m, ok := v.(map[string]any)
    if !ok {
        return false
    }
    if hasPlanSignal(m) {
        return true
    }
    for _, wrap := range []string{"plan", "result", "data", "orders"} {
        if sub, ok := m[wrap].(map[string]any); ok && hasPlanSignal(sub) {
            return true
        }
    }
    return false
}

func ExtractJSON(input string) (string, error) {
    blocks := findFencedBlocks(input)
    if len(blocks) == 0 {
        return "", ErrNoFencedBlock
    }
    var firstValid string
    for _, b := range blocks {            // scan ALL blocks, not just the first
        var v any
        if err := json.Unmarshal([]byte(b), &v); err != nil {
            continue                        // malformed block: keep scanning
        }
        if firstValid == "" {
            firstValid = b
        }
        if isPlanShaped(v) {
            return b, nil                    // prefer first plan-shaped candidate
        }
    }
    if firstValid != "" {
        return firstValid, nil               // first-candidate fallback (legacy)
    }
    return blocks[0], nil                    // legacy fallback: no block parsed
}

Fix 2 — OpenRouter adapter retries once on 200-with-empty-content (openrouter.go):

// IsEmpty reports a 200 OK with empty body — OpenRouter (and some providers)
// occasionally return exactly this shape instead of an error.
func (r ChatResponse) IsEmpty() bool {
    return r.HTTPStatus == http.StatusOK && strings.TrimSpace(r.Body) == ""
}

func (a *OpenRouterAdapter) Chat(ctx context.Context, req ChatRequest) (ChatResponse, error) {
    resp, err := a.chatOnce(ctx, req)
    if err != nil {
        return ChatResponse{}, err
    }
    if resp.IsEmpty() {
        // Provider variance: 200-with-empty-body is not a real completion.
        // Retry exactly once with the same request; a second empty response is
        // surfaced so RetryableAdapter can classify it as empty_response.
        return a.chatOnce(ctx, req)
    }
    return resp, nil
}

Fix 3 — RetryableAdapter classifies empty content as retryable (empty_response) (retryable.go):

func Classify(resp ChatResponse) (retryable bool, code string) {
    if resp.IsEmpty() {
        return true, "empty_response"   // NEW: was previously not retried
    }
    switch resp.HTTPStatus {
    case 429, 500, 502, 503, 504:
        return true, fmt.Sprintf("http_%d", resp.HTTPStatus)
    }
    return false, ""
}

func (a *RetryableAdapter) Chat(ctx context.Context, req ChatRequest) (ChatResponse, error) {
    var lastErr error
    for attempt := 1; attempt <= a.maxAttempts; attempt++ { // maxAttempts=2 => retry once
        resp, err := a.inner.Chat(ctx, req)
        if err != nil {
            return resp, err
        }
        retryable, code := Classify(resp)
        if !retryable {
            return resp, nil
        }
        lastErr = errors.New(code) // "empty_response" when content was empty
    }
    return ChatResponse{}, lastErr
}

Regression tests lock in both fixes: TestParsePlanSkipsAnalysisBlock + TestExtractJSONAnalysisThenOrders (analysis block before orders block), TestExtractJSONPlanSections (helm/engineering/tactical/security), TestExtractJSONOrdersWrapper, TestExtractJSONFirstCandidateFallback, TestExtractJSONMalformedFirstBlock, TestExtractJSONNonJSONFence, TestExtractJSONNoFencedBlock, TestExtractJSONUnclosedFence, and on the adapter side TestOpenRouterRetriesOnceOnEmpty200, TestOpenRouterNoRetryOnNonEmpty200, TestOpenRouterNoRetryOnNon200, TestOpenRouterEmpty200Twice, TestClassifyEmptyResponse, TestClassifyHTTPErrors, TestClassifyNonRetryable, TestRetryableAdapterRetriesEmptyOnce, TestRetryableAdapterExhausted, TestRetryableAdapterNoRetryOnSuccess.

Evidence & signatures

Verified by building and running the suite in a fresh Go module (`go 1.26.0`):

```
$ gofmt -l .            # empty — all files formatted
$ go vet ./...          # VET-OK
$ go test -count=1 -race ./...   # ok  (race detector clean)
$ go test -cover ./...           # ok  coverage: 87.6% of statements
$ go test ./... -v      # 24/24 PASS (20 top-level tests + 4 plan-section subtests)
```

**Bug reproduced, then fixed end-to-end.** A standalone run of the *old* first-block-only extractor on the reported input shape:

```
OLD extractor picked block: {"analysis": "decompose into phases"}
OLD parsed plan empty? orders=0  <- BUG: visible orders dropped
```

The fixed pipeline on the identical input (`TestParsePlanSkipsAnalysisBlock`) returns `orders=[{id:o1 action:probe target:prod}]` — the exact failure mode named in the problem ("valid-but-empty plan JSON despite visible orders") is eliminated.

**Edge cases exercised:**
- Analysis ```json block *before* the orders block → orders block selected (both raw extraction and full `ParsePlan`).
- Plan shapes: top-level `orders` wrapper, `{"plan": {"orders": …}}` wrapper, and bare helm/engineering/tactical/security keyed objects → all recognized as plan-shaped.
- No plan-shaped candidate → first candidate returned (legacy fallback preserved).
- Malformed first block (`{this is not json`) → scan continues, later valid orders block wins.
- Mixed fences (```go / ```text alongside JSON) and unclosed trailing fence → handled.
- Zero fenced blocks → `ErrNoFencedBlock` (unchanged explicit error).
- OpenRouter: 200+empty retried exactly once → real completion; non-empty 200 → 1 request; non-200 → surfaced with 1 request; empty twice → second empty surfaced (not silently swallowed).
- Classification: 200+empty → `empty_response`; 429/500/502/503/504 → `http_<status>`; healthy → not retryable. Exhausted attempts surface `ErrEmptyResponse`.
{"model": "deepseek-v4-flash", "problem_class": "go-json-extraction-multi-codeblock", "result": "passed", "tests": 24}
Generated from the verified corpus · MIT licensedBack to the catalog