{"record":{"id":"55806171c2e004f1","repo":"mvanhorn/last30days-skill","slug":"gemini-response-did-not-contain-text","errorCode":null,"errorMessage":"Gemini response did not contain text.","messagePattern":"Gemini response did not contain text\\.","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/last30days/scripts/evaluate_search_quality.py","lineNumber":212,"sourceCode":"\ndef resolve_google_judge_api_key(config: dict[str, Any]) -> str | None:\n    return (\n        os.environ.get(\"GOOGLE_API_KEY\")\n        or config.get(\"GOOGLE_API_KEY\")\n        or os.environ.get(\"GEMINI_API_KEY\")\n        or config.get(\"GEMINI_API_KEY\")\n        or os.environ.get(\"GOOGLE_GENAI_API_KEY\")\n        or config.get(\"GOOGLE_GENAI_API_KEY\")\n    )\n\n\ndef extract_gemini_text(payload: dict[str, Any]) -> str:\n    for candidate in payload.get(\"candidates\") or []:\n        content = candidate.get(\"content\") or {}\n        for part in content.get(\"parts\") or []:\n            if part.get(\"text\"):\n                return part[\"text\"]\n    raise ValueError(\"Gemini response did not contain text.\")\n\n\ndef call_gemini_judge(api_key: str, model: str, prompt: str) -> dict[str, Any]:\n    body = {\n        \"contents\": [{\"parts\": [{\"text\": prompt}]}],\n        \"generationConfig\": {\"temperature\": 0, \"responseMimeType\": \"application/json\"},\n    }\n    request = Request(\n        GEMINI_API_URL.format(model=model, api_key=api_key),\n        data=json.dumps(body).encode(\"utf-8\"),\n        headers={\"Content-Type\": \"application/json\"},\n        method=\"POST\",\n    )\n    try:\n        with urlopen(request, timeout=120) as response:\n            payload = json.loads(response.read().decode(\"utf-8\"))\n    except HTTPError as exc:\n        detail = exc.read().decode(\"utf-8\", errors=\"replace\")","sourceCodeStart":194,"sourceCodeEnd":230,"githubUrl":"https://github.com/mvanhorn/last30days-skill/blob/c7460f6114449ddfe6ea3fc2f23c3d910c0e740c/skills/last30days/scripts/evaluate_search_quality.py#L194-L230","documentation":"Raised by extract_gemini_text() in the search-quality evaluator after a Gemini generateContent call succeeded at the HTTP level but no candidate contained a non-empty text part. This happens when the model returns only empty parts, a finishReason like SAFETY/MAX_TOKENS with no content, or an empty candidates array. The evaluator requires JSON text output (responseMimeType=application/json), so a contentless response cannot be scored.","triggerScenarios":"POST to GEMINI_API_URL generateContent with generationConfig {temperature: 0, responseMimeType: 'application/json'}; response payload where candidates is [], or every candidate's content.parts contains no part with a truthy 'text' field (e.g. finishReason=SAFETY, RECITATION, or empty completion).","commonSituations":"Safety filters blocking the prompt (judge prompt includes scraped titles/urls that trip filters); API key quota/billing issues that return a 200 with empty candidates; model returning thoughts only; prompt too long causing MAX_TOKENS with no text.","solutions":["Inspect payload['candidates'][0].get('finishReason') and promptFeedback.blockReason before treating the response as scorable; log the full payload once.","If blockReason/SAFETY: shorten or sanitize the judge prompt items (titles/urls at line 235-240) and retry.","If empty candidates persists, retry with a different model or lower maxOutputTokens pressure; add a retry loop around call_gemini_judge.","Catch ValueError at the call site in evaluate_search_quality.py and surface 'judge returned no text (finishReason=X)' instead of a bare ValueError."],"exampleFix":"// before\nreturn json.loads(extract_gemini_text(payload))\n\n// after\nfinish = (payload.get('candidates') or [{}])[0].get('finishReason')\nblock = (payload.get('promptFeedback') or {}).get('blockReason')\nif block or not finish or finish not in ('STOP', 'MAX_TOKENS'):\n    raise RuntimeError(f'Gemini judge produced no text (finishReason={finish}, blockReason={block})')\nreturn json.loads(extract_gemini_text(payload))","handlingStrategy":"try-catch","validationCode":"def gemini_payload_has_text(payload: dict) -> bool:\n    return any(\n        part.get('text')\n        for cand in payload.get('candidates') or []\n        for part in (cand.get('content') or {}).get('parts') or []\n    )","typeGuard":"def is_text_gemini_payload(payload: Any) -> TypeGuard[dict]:\n    return (\n        isinstance(payload, dict)\n        and isinstance(payload.get('candidates'), list)\n        and gemini_payload_has_text(payload)\n    )","tryCatchPattern":"try:\n    verdict = call_gemini_judge(api_key, model, prompt)\nexcept ValueError as exc:\n    finish = (payload.get('candidates') or [{}])[0].get('finishReason', 'UNKNOWN')\n    raise RuntimeError(f'judge produced no text (finishReason={finish}); retry with sanitized prompt') from exc","preventionTips":["Check promptFeedback.blockReason and candidates[0].finishReason before extracting text.","Keep judge prompts free of raw scraped URLs/titles that trip safety filters.","Cap items per judge prompt so MAX_TOKENS cannot consume the entire response."],"tags":["gemini","llm","api-response","evaluation"],"backgroundTag":null,"analyzedSha":"c7460f6114449ddfe6ea3fc2f23c3d910c0e740c","analyzedAt":"2026-08-15T03:34:49.540Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}