{"record":{"id":"29f6717d428f47ab","repo":"srbhr/Resume-Matcher","slug":"resume-wizard-llm-response-must-be-a-json-object","errorCode":null,"errorMessage":"Resume wizard LLM response must be a JSON object.","messagePattern":"Resume wizard LLM response must be a JSON object\\.","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"apps/backend/app/services/resume_wizard.py","lineNumber":362,"sourceCode":"    section = state.current_question.section\n    resume_json = json.dumps(state.resume_data.model_dump(mode=\"json\"), ensure_ascii=False)\n    prompt_answer = (\n        \"(The user skipped this question. Do NOT modify resume_data. \"\n        \"Ask the next most useful question for a different section.)\"\n        if skip\n        # Strip prompt-injection patterns AND redact credential-like tokens\n        # (sk-…/AIza…/Bearer …) before the answer reaches the LLM.\n        else _scrub_secrets(_sanitize_user_input(answer_text))\n    )\n    prompt = RESUME_WIZARD_TURN_PROMPT.format(\n        output_language=get_language_name(get_content_language()),\n        current_section=section,\n        resume_json=resume_json,\n        answer_text=prompt_answer,\n    )\n    result = await complete_json(prompt, max_tokens=8192, schema_type=\"resume\")\n    if not isinstance(result, dict):\n        raise ValueError(\"Resume wizard LLM response must be a JSON object.\")\n\n    raw_resume = result.get(\"resume_data\")\n    inferred = _string_list(result.get(\"inferred_skills\"))\n\n    if skip or not isinstance(raw_resume, dict):\n        data = state.resume_data.model_copy(deep=True)\n    else:\n        updated = ResumeData.model_validate(normalize_wizard_resume_data(raw_resume))\n        data = _merge_section(\n            existing=state.resume_data,\n            updated=updated,\n            raw_updated=raw_resume,\n            section=section,\n            inferred_skills=inferred,\n        )\n\n    if section == \"intro\" and not data.personalInfo.name.strip():\n        fallback = extract_intro_name(answer_text)","sourceCodeStart":344,"sourceCodeEnd":380,"githubUrl":"https://github.com/srbhr/Resume-Matcher/blob/116f9cc3b00e1ac91734a6c2679bf41ea64a0edc/apps/backend/app/services/resume_wizard.py#L344-L380","documentation":"run_ai_turn calls complete_json expecting the LLM to return a parsed JSON object. If the model's response parses to a non-dict (string, list, number, or None), it raises this ValueError because the subsequent result.get(\"resume_data\") contract requires a mapping.","triggerScenarios":"LLM returns a bare JSON array/string, returns prose instead of JSON that still parses oddly, or complete_json returns a non-dict fallback; occurs in resume_wizard_turn and its test paths.","commonSituations":"Model degradation or prompt too long causing truncated/garbled output; temperature/sampling changes; schema_type=\"resume\" contract drift in complete_json; max_tokens=8192 truncation cutting the object.","solutions":["Retry the LLM call once with the same prompt (transient malformed output is common)","Strengthen the prompt to insist the reply be a single JSON object with resume_data and inferred_skills keys","Reduce prompt size / input resume length so output isn't truncated by max_tokens=8192","Wrap the call in try/except ValueError and return a 502/friendly error so the wizard can recover"],"exampleFix":"// before\nresult = await complete_json(prompt, max_tokens=8192, schema_type=\"resume\")\nif not isinstance(result, dict):\n    raise ValueError(\"Resume wizard LLM response must be a JSON object.\")\n// after\nresult = await complete_json(prompt, max_tokens=8192, schema_type=\"resume\")\nif not isinstance(result, dict):\n    logger.warning(\"wizard LLM returned %s, retrying\", type(result).__name__)\n    result = await complete_json(prompt, max_tokens=8192, schema_type=\"resume\")\nif not isinstance(result, dict):\n    raise ValueError(\"Resume wizard LLM response must be a JSON object.\")","handlingStrategy":"type-guard","validationCode":"import json\ndef llm_reply_is_object(raw: str) -> bool:\n    try:\n        return isinstance(json.loads(raw), dict)\n    except Exception:\n        return False","typeGuard":"def is_llm_result_dict(v: object) -> TypeGuard[dict]:\n    return isinstance(v, dict)","tryCatchPattern":"try:\n    result = await run_ai_turn(state, action, answer)\nexcept ValueError as e:\n    if \"must be a JSON object\" in str(e):\n        result = await run_ai_turn(state, action, answer)  # one retry\n    else:\n        raise","preventionTips":["Retry malformed LLM responses once before surfacing an error","Keep prompts within token budgets to avoid truncated JSON","Pin/monitor the model and validate complete_json output shape in CI tests","Use structured output / JSON mode when the provider supports it"],"tags":["llm","json","unexpected-response"],"backgroundTag":"llm-invalid-json-response","analyzedSha":"116f9cc3b00e1ac91734a6c2679bf41ea64a0edc","analyzedAt":"2026-08-28T22:51:40.999Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}