From 129507357e5a4caa7efc7fecda675c5baa898864 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 13 Mar 2026 11:02:50 +0900 Subject: [PATCH] Harden Gemini JSON parsing --- backend/services/gemini.go | 71 +++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/backend/services/gemini.go b/backend/services/gemini.go index 1cd4b27..79878b4 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -94,9 +94,14 @@ User query: ` + query, return []string{query}, fmt.Errorf("gemini query expansion returned no candidates") } + jsonText, err := extractJSONObject(payload.Candidates[0].Content.Parts[0].Text) + if err != nil { + return []string{query}, fmt.Errorf("gemini query expansion JSON extraction failed: %w", err) + } + var parsed QueryExpansion - if err := json.Unmarshal([]byte(payload.Candidates[0].Content.Parts[0].Text), &parsed); err != nil { - return []string{query}, fmt.Errorf("gemini query expansion JSON parse failed: %w", err) + if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { + return []string{query}, fmt.Errorf("gemini query expansion JSON parse failed: %w; raw=%q", err, truncateForError(payload.Candidates[0].Content.Parts[0].Text, 200)) } queries := []string{query} @@ -187,6 +192,11 @@ User query: ` + query, return nil, fmt.Errorf("gemini vision returned no candidates") } + jsonText, err := extractJSONObject(payload.Candidates[0].Content.Parts[0].Text) + if err != nil { + return nil, fmt.Errorf("gemini vision JSON extraction failed: %w", err) + } + var parsed struct { Recommendations []struct { Index int `json:"index"` @@ -194,8 +204,8 @@ User query: ` + query, Recommended bool `json:"recommended"` } `json:"recommendations"` } - if err := json.Unmarshal([]byte(payload.Candidates[0].Content.Parts[0].Text), &parsed); err != nil { - return nil, fmt.Errorf("gemini vision JSON parse failed: %w", err) + if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { + return nil, fmt.Errorf("gemini vision JSON parse failed: %w; raw=%q", err, truncateForError(payload.Candidates[0].Content.Parts[0].Text, 200)) } recommendations := make([]AIRecommendation, 0, len(parsed.Recommendations)) @@ -260,3 +270,56 @@ func min(a, b int) int { } return b } + +func extractJSONObject(text string) (string, error) { + cleaned := strings.TrimSpace(text) + cleaned = strings.TrimPrefix(cleaned, "```json") + cleaned = strings.TrimPrefix(cleaned, "```") + cleaned = strings.TrimSuffix(cleaned, "```") + cleaned = strings.TrimSpace(cleaned) + + start := strings.Index(cleaned, "{") + if start == -1 { + return "", fmt.Errorf("no JSON object start found in %q", truncateForError(cleaned, 200)) + } + + depth := 0 + inString := false + escaped := false + for i := start; i < len(cleaned); i++ { + ch := cleaned[i] + if escaped { + escaped = false + continue + } + if ch == '\\' && inString { + escaped = true + continue + } + if ch == '"' { + inString = !inString + continue + } + if inString { + continue + } + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return cleaned[start : i+1], nil + } + } + } + return "", fmt.Errorf("no complete JSON object found in %q", truncateForError(cleaned, 200)) +} + +func truncateForError(text string, limit int) string { + trimmed := strings.TrimSpace(text) + if len(trimmed) <= limit { + return trimmed + } + return trimmed[:limit] + "..." +}