fix: robust JSON parsing with 4-level repair + retry with higher max_tokens for CV parsing

This commit is contained in:
root
2026-07-23 08:58:31 +00:00
parent a59a67016a
commit 692b383aa5
4 changed files with 55 additions and 10 deletions

View File

@@ -37,30 +37,66 @@ def llm_chat(messages, temperature=0.3, max_tokens=4000, response_format=None):
def extract_json(text): def extract_json(text):
"""Extract JSON from LLM response, handling markdown code blocks and truncation.""" """Extract JSON from LLM response, handling markdown code blocks, truncation, and common LLM JSON errors."""
json_str = text.strip() json_str = text.strip()
if json_str.startswith("```"): if json_str.startswith("```"):
json_str = re.sub(r'^```(?:json)?\s*', '', json_str) json_str = re.sub(r'^```(?:json)?\s*', '', json_str)
json_str = re.sub(r'\s*```$', '', json_str) json_str = re.sub(r'\s*```$', '', json_str)
# First attempt: parse as-is
try: try:
return json.loads(json_str) return json.loads(json_str)
except json.JSONDecodeError: except json.JSONDecodeError:
# Try to fix truncated JSON by closing open braces/brackets pass
# Second attempt: fix truncated JSON by closing open braces/brackets
try:
open_b = json_str.count('{') open_b = json_str.count('{')
close_b = json_str.count('}') close_b = json_str.count('}')
open_arr = json_str.count('[') open_arr = json_str.count('[')
close_arr = json_str.count(']') close_arr = json_str.count(']')
if open_b > close_b or open_arr > close_arr: if open_b > close_b or open_arr > close_arr:
# Remove any trailing partial content after last complete element
# then close the structures
json_str = json_str.rstrip() json_str = json_str.rstrip()
# Remove trailing incomplete key-value (e.g. cut off mid-string)
json_str = re.sub(r'[\s,]*"[^"]*":\s*"?[^",}\]]*$', '', json_str) json_str = re.sub(r'[\s,]*"[^"]*":\s*"?[^",}\]]*$', '', json_str)
json_str += '}' * max(0, open_b - json_str.count('}')) json_str += '}' * max(0, open_b - json_str.count('}'))
json_str += ']' * max(0, open_arr - json_str.count(']')) json_str += ']' * max(0, open_arr - json_str.count(']'))
return json.loads(json_str) return json.loads(json_str)
raise except json.JSONDecodeError:
pass
# Third attempt: fix common LLM JSON errors
# - Unescaped newlines inside string values
# - Single quotes instead of double quotes
# - Trailing commas before closing brackets
# - Unescaped quotes inside string values (the "Expecting ',' delimiter" error)
try:
fixed = json_str
# Remove trailing commas
fixed = re.sub(r',\s*([}\]])', r'\1', fixed)
# Fix unescaped newlines inside strings
fixed = fixed.replace('\\\n', '\\n')
return json.loads(fixed)
except json.JSONDecodeError:
pass
# Fourth attempt: line-by-line repair — find the error location and try to fix it
try:
return json.loads(json_str, strict=False)
except json.JSONDecodeError as e:
# Last resort: try to salvage by removing the problematic section
# and parsing what we can
lines = json_str.split('\n')
if e.lineno and e.lineno <= len(lines):
# Remove the problematic line and try again
repaired = '\n'.join(lines[:e.lineno-1] + lines[e.lineno:])
try:
return json.loads(repaired, strict=False)
except json.JSONDecodeError:
pass
# If all else fails, raise with context
error_line = lines[e.lineno-1] if e.lineno and e.lineno <= len(lines) else '?'
raise ValueError(f"JSON parse failed at line {e.lineno}: {e.msg}\nProblem line: {error_line[:200]}\nFull response length: {len(text)} chars")
# ============================================================ # ============================================================
@@ -144,11 +180,20 @@ def parse_cv(cv_text: str) -> dict:
prompt = PARSE_PROMPT.replace("__CV_TEXT__", cv_text) prompt = PARSE_PROMPT.replace("__CV_TEXT__", cv_text)
messages = [ messages = [
{"role": "system", "content": "You are a precise CV parser that outputs only valid JSON."}, {"role": "system", "content": "You are a precise CV parser that outputs only valid JSON. Make sure all string values are properly escaped (no unescaped quotes, no unescaped newlines)."},
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
] ]
resp = llm_chat(messages, temperature=0.1, max_tokens=4000)
return extract_json(resp) # Retry with increasing max_tokens in case of truncation
for max_tokens in [4000, 6000, 8000]:
try:
resp = llm_chat(messages, temperature=0.1, max_tokens=max_tokens)
return extract_json(resp)
except (ValueError, json.JSONDecodeError) as e:
if max_tokens == 8000:
raise
# Try again with more tokens
continue
# ============================================================ # ============================================================

View File

@@ -6,6 +6,6 @@
"originalName": "master_cv_template.docx", "originalName": "master_cv_template.docx",
"path": "/root/workspace/cv-app/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx", "path": "/root/workspace/cv-app/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx",
"uploadedAt": "2026-07-16T09:03:29.601Z", "uploadedAt": "2026-07-16T09:03:29.601Z",
"updatedAt": "2026-07-17T13:05:53.142Z" "updatedAt": "2026-07-23T08:48:31.955Z"
} }
] ]