srbhr/Resume-Matcher · error · ValueError

Resume wizard LLM response must be a JSON object.

Error message

Resume wizard LLM response must be a JSON object.

What it means

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.

Solutions

  1. Retry the LLM call once with the same prompt (transient malformed output is common)
  2. Strengthen the prompt to insist the reply be a single JSON object with resume_data and inferred_skills keys
  3. Reduce prompt size / input resume length so output isn't truncated by max_tokens=8192
  4. Wrap the call in try/except ValueError and return a 502/friendly error so the wizard can recover

Example fix

// before
result = await complete_json(prompt, max_tokens=8192, schema_type="resume")
if not isinstance(result, dict):
    raise ValueError("Resume wizard LLM response must be a JSON object.")
// after
result = await complete_json(prompt, max_tokens=8192, schema_type="resume")
if not isinstance(result, dict):
    logger.warning("wizard LLM returned %s, retrying", type(result).__name__)
    result = await complete_json(prompt, max_tokens=8192, schema_type="resume")
if not isinstance(result, dict):
    raise ValueError("Resume wizard LLM response must be a JSON object.")
Defensive patterns

Strategy: type-guard

Validate before calling

import json
def llm_reply_is_object(raw: str) -> bool:
    try:
        return isinstance(json.loads(raw), dict)
    except Exception:
        return False

Type guard

def is_llm_result_dict(v: object) -> TypeGuard[dict]:
    return isinstance(v, dict)

Try / catch

try:
    result = await run_ai_turn(state, action, answer)
except ValueError as e:
    if "must be a JSON object" in str(e):
        result = await run_ai_turn(state, action, answer)  # one retry
    else:
        raise

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of srbhr/Resume-Matcher@116f9cc3b0 (2026-08-28). Data as JSON: /api/errors/29f6717d428f47ab. Report an issue: GitHub.

Appendix: source

Thrown at apps/backend/app/services/resume_wizard.py:362

    section = state.current_question.section
    resume_json = json.dumps(state.resume_data.model_dump(mode="json"), ensure_ascii=False)
    prompt_answer = (
        "(The user skipped this question. Do NOT modify resume_data. "
        "Ask the next most useful question for a different section.)"
        if skip
        # Strip prompt-injection patterns AND redact credential-like tokens
        # (sk-…/AIza…/Bearer …) before the answer reaches the LLM.
        else _scrub_secrets(_sanitize_user_input(answer_text))
    )
    prompt = RESUME_WIZARD_TURN_PROMPT.format(
        output_language=get_language_name(get_content_language()),
        current_section=section,
        resume_json=resume_json,
        answer_text=prompt_answer,
    )
    result = await complete_json(prompt, max_tokens=8192, schema_type="resume")
    if not isinstance(result, dict):
        raise ValueError("Resume wizard LLM response must be a JSON object.")

    raw_resume = result.get("resume_data")
    inferred = _string_list(result.get("inferred_skills"))

    if skip or not isinstance(raw_resume, dict):
        data = state.resume_data.model_copy(deep=True)
    else:
        updated = ResumeData.model_validate(normalize_wizard_resume_data(raw_resume))
        data = _merge_section(
            existing=state.resume_data,
            updated=updated,
            raw_updated=raw_resume,
            section=section,
            inferred_skills=inferred,
        )

    if section == "intro" and not data.personalInfo.name.strip():
        fallback = extract_intro_name(answer_text)

View on GitHub (pinned to 116f9cc3b0)