0. Series Loop (Follow Along Without Public Source Code)
End-to-End Pipeline: Vue Frontend → api/routes/chat.py → Guide Multi-turn SSE → run_analysis_pipeline (Parse → Analyze → Match → Report) → tools/pdf_exporter PDF.
This Article: 12/17 · Structure Loop · JSON
| Stage | User Visible | Code Entry | Corresponding Article |
|---|---|---|---|
| Create Session | Welcome Message | POST /api/sessions | 09 |
| Multi-turn Dialogue | SSE Streaming | chat/stream → run_guide_single_turn | 06, 14 |
| Information Sufficient | Start Analysis | _run_analysis_background | 05, 07 |
| Resume Parsing | Progress 30% | run_resume_parser | 12 |
| Profile/RIASEC | Progress 50% | run_profile_analyzer | 03, 13 |
| Career Matching | Progress 70% | run_career_matcher | 02 |
| Report | Progress 90% | run_reporter | 11 |
| Download PDF | File | GET …/report/pdf | 11, 15 |
| Description | |
|---|---|
| Before reading this | Article 08: invoke_llm_with_json |
| After reading this | Manually work through parse_json_from_text four-layer strategy |
| Next loop | Article 03/13: Business JSON schema (Article 13) |
Full Series Loop Index: SERIES-LOOP.md
1. What Problem to Solve
In the iCan main flow, resume_parser_node needs to convert the natural language resume collected during the Guide phase into a structured_profile for profile_analyzer_node to consume. The input is unstructured text, and the output must be a dict with a fixed schema.
Common failure modes during actual integration:
- The model wraps JSON inside a `
jsoncode block, or mixes it directly with explanatory text; - Ollama local models do not support
response_format={"type": "json_object"}, causingbindto throw an error; - Minor JSON syntax issues (trailing commas, single quotes), causing
json.loadsto fail directly; - LLM returns an empty dict on both calls, breaking the entire parsing pipeline.
iCan’s strategy is Prompt constraint schema + JSON mode at the call layer + four-layer text extraction + regex fallback, rather than expecting the model to “get it perfect in one shot.”
2. Implementation Location
| Module | Responsibility |
|---|---|
llm/prompts.py |
RESUME_PARSER_SYSTEM_PROMPT: Complete JSON example + field rules |
llm/providers.py |
invoke_llm_with_json: response_format first, fallback on failure |
llm/parsers.py |
parse_json_from_text four-layer extraction; validate_structured_profile validation |
agents/resume_parser.py |
Assemble messages, select get_light_model(), retry and _regex_extract_profile fallback |
Subgraph order (create_resume_parser_graph): load_input → extract_information → build_profile → validate_profile.
3. Prompt Design: ResumeParser’s Schema Contract
The Prompt is defined in RESUME_PARSER_SYSTEM_PROMPT in llm/prompts.py. The core is not a single sentence “please output JSON,” but four things clearly stated at once:
- Complete Example: Shows all fields:
basic_info,work_experience,skill_set,certifications,career_progression,parsing_confidence; - Missing Strategy: “If not mentioned, use null, do not fabricate”;
- Inference Annotation:
parsing_confidence.inferred_fieldslists inferred fields; - Chinese and Format: Differentiate technical/soft skills, integrate and deduplicate across multi-turn dialogue.
The Prompt embeds a complete example wrapped in ```json—this aligns with the regex r"```json\s*([\s\S]*?)\s*```" in llm/parsers.py strategy 1: if the model follows the Prompt and outputs a code block, the parser hits it on the first layer.
extract_information in agents/resume_parser.py combines the system prompt and user’s original text into messages:
1 | |
Model Selection: Resume parsing uses get_light_model() (code defaults to LLM_MODEL_LIGHT=gpt-4o-mini), not the chat model. In .env, it’s common to change to DeepSeek or Ollama qwen3.5:9b inside Docker—switching models does not affect Prompt/schema, but affects JSON mode compatibility (see pitfalls).
4. Call Layer: invoke_llm_with_json Dual Channel
In llm/providers.py, JSON invocation is not a simple ainvoke, but a three-level fallback:
1 | |
The flow can be summarized as:
1 | |
Additionally, when LLM_BASE_URL contains 11434 and the model name contains qwen3, _inject_no_think prepends /no_think before the system message to avoid Qwen3 thinking blocks contaminating the JSON—an extra layer of JSON stability on local Ollama.
5. Four-Layer Fallback Parser: parse_json_from_text
parse_json_from_text in llm/parsers.py is the last net, trying in order:
| Strategy | Regex/Logic | Typical Scenario |
|---|---|---|
| 1 | r"```json\s*([\s\S]*?)\s*```" |
ChatGPT style output |
| 2 | Normal ``` ... ```, content starts with { or [ |
Code block without json label |
| 3 | r"\{[\s\S]*\}" greedy match outermost braces |
“Okay, the result is: {…}” |
| 4 | json.loads(text.strip()) |
Pure JSON response |
| Fallback | Return {} |
Completely unparseable |
Unlike general tutorials, each layer failure does not throw up in the implementation—if a strategy’s json.loads fails, it moves to the next layer; the outermost JSONDecodeError is caught and returns {}. This means the caller must check for empty dict—invoke_llm_with_json will then raise ValueError, and extract_information will enter retry or regex fallback.
In the source code, each layer has logger.info annotating the strategy number (Strategy 1–4). During debugging, you can check the logs to see which layer was reached.
6. Agent Retry and Regex Fallback
extract_information in agents/resume_parser.py has business retry on top of the LLM layer:
1 | |
_regex_extract_profile uses regex to extract name, education, work experience, etc.—field names are not identical to the Prompt schema (e.g., it produces skills instead of skill_set.technical_skills). build_profile fills missing keys with default empty structures—intentionally “something is better than nothing,” but validate_profile will likely still report missing required fields.
7. Quality Closure After Parsing
The LLM’s self-evaluated parsing_confidence is extracted into confidence_scores in build_profile; validate_profile calls validate_structured_profile from llm/parsers.py for code-side validation. Required fields include:
basic_info.education,basic_info.major- Non-empty
work_experiencelist skill_set.technical_skills,skill_set.soft_skillscareer_progression.total_years
Missing items are written into parse_errors, and validation_passed is written into confidence_scores. The confidence from the Prompt and the Python validation are complementary: the former reflects the model’s self-assessment, the latter ensures downstream Agents do not receive a “skeleton profile.”
8. Position in the Pipeline
In the top-level workflow.py: when guide_node has enough information, it enters resume_parser_node, which outputs structured_profile written to iCanWorkflowState, then passes it to profile_analyzer_node.
Data flow:
1 | |
The same invoke_llm_with_json + parse_json_from_text is also reused by other nodes needing JSON, such as ProfileAnalyzer, CareerMatcher, etc. (see Article 8 LLM layer); ResumeParser is the call point with the most complex schema and longest fallback chain.
9. Pitfalls and Boundaries
Pitfall 1: response_format is not a universal capability. Some Ollama models fail on bind and fall back to text mode, relying more on the JSON example in the Prompt and parse_json_from_text. When integrating with Docker default qwen3.5:9b, check logs for the “falling back to text mode” warning.
Pitfall 2: Strategy 3 greedy match may cut incorrectly. \{[\s\S]*\} goes from the first { to the last }. If the model embeds other curly braces before or after the JSON, the whole parse may fail and fall into {}. The Prompt requirement “output only JSON” is still necessary; the parser cannot replace Prompt constraints.
Pitfall 3: Regex fallback and schema misalignment. _regex_extract_profile produces fields like skills, which are not automatically mapped to skill_set.technical_skills. Downstream validation failure is expected behavior—guide the user to supplement information or retry the LLM, rather than treating the fallback as a successful parse.
Pitfall 4: Empty dict and retry. extract_information attempts at most 2 times; if invoke_llm_with_json returns an empty dict (without throwing an exception), it logs a warning and retries. TimeoutError is caught separately and does not block indefinitely.
10. Summary
- The Prompt locks the schema with a complete JSON example + null/inference rules, defined in
llm/prompts.py. invoke_llm_with_jsoninllm/providers.pyfirst triesjson_objectmode, then normal call, thenjson.loads→parse_json_from_text.parse_json_from_textinllm/parsers.pyis four-layer fallback; returns{}on failure; callers must handle empty results.extract_informationinagents/resume_parser.pyusesget_light_model()with 2 retries +_regex_extract_profileas the final fallback.validate_structured_profilevalidates required fields with code rules, parallel toparsing_confidenceself-evaluation.
Next article: RIASEC assessment Prompt engineering (Article 13).
Appendix: Key Source Code (Line-by-Line Annotations)
The following code is from the iCan implementation. Chinese annotations are above each line, allowing you to follow along without the public repository.
Generation command: python3 bin/build-ican-annotated-snippets.py
parse_json_from_text Four-Layer Strategy
1 | |
invoke_llm_with_json
1 | |
extract_information
1 | |
Series Navigation
| Article | Topic |
|---|---|
| 1 | System Overview |
| 2 | Five Agent Collaboration |
| 3 | Holland RIASEC |
| 4–7 | State · Routing · Nesting · Fault Tolerance |
| 8–11 | LLM Layer · SSE/WS · DB Migration · PDF |
| 12–14 | JSON Prompt · RIASEC Prompt · Guide Prompt |
| 15–17 | Docker · Middleware · Configuration |