diff --git a/__pycache__/ai_service.cpython-312.pyc b/__pycache__/ai_service.cpython-312.pyc index b93d229..bf7ea1f 100644 Binary files a/__pycache__/ai_service.cpython-312.pyc and b/__pycache__/ai_service.cpython-312.pyc differ diff --git a/ai_service.py b/ai_service.py index ec1b03e..ce4b057 100644 --- a/ai_service.py +++ b/ai_service.py @@ -37,12 +37,30 @@ def llm_chat(messages, temperature=0.3, max_tokens=4000, response_format=None): def extract_json(text): - """Extract JSON from LLM response, handling markdown code blocks.""" + """Extract JSON from LLM response, handling markdown code blocks and truncation.""" json_str = text.strip() if json_str.startswith("```"): json_str = re.sub(r'^```(?:json)?\s*', '', json_str) json_str = re.sub(r'\s*```$', '', json_str) - return json.loads(json_str) + + try: + return json.loads(json_str) + except json.JSONDecodeError: + # Try to fix truncated JSON by closing open braces/brackets + open_b = json_str.count('{') + close_b = json_str.count('}') + open_arr = json_str.count('[') + close_arr = json_str.count(']') + 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() + # Remove trailing incomplete key-value (e.g. cut off mid-string) + json_str = re.sub(r'[\s,]*"[^"]*":\s*"?[^",}\]]*$', '', json_str) + json_str += '}' * max(0, open_b - json_str.count('}')) + json_str += ']' * max(0, open_arr - json_str.count(']')) + return json.loads(json_str) + raise # ============================================================ @@ -333,8 +351,34 @@ def generate_template(description: str) -> dict: {"role": "system", "content": "You are a CV template designer that outputs only valid JSON."}, {"role": "user", "content": prompt} ] - resp = llm_chat(messages, temperature=0.5, max_tokens=2000) - return extract_json(resp) + + # Retry with increasing max_tokens if JSON parsing fails + for attempt, max_tok in enumerate([2000, 3000, 4000]): + try: + resp = llm_chat(messages, temperature=0.5, max_tokens=max_tok) + return extract_json(resp) + except json.JSONDecodeError as e: + if attempt < 2: + # Retry with more tokens + continue + # Last attempt failed — try to salvage partial JSON + try: + # Attempt to fix truncated JSON by closing braces + json_str = resp.strip() + if json_str.startswith("```"): + json_str = re.sub(r'^```(?:json)?\s*', '', json_str) + json_str = re.sub(r'\s*```$', '', json_str) + # Count open vs close braces + open_b = json_str.count('{') + close_b = json_str.count('}') + open_arr = json_str.count('[') + close_arr = json_str.count(']') + # Append missing closers + json_str += '}' * (open_b - close_b) + json_str += ']' * (open_arr - close_arr) + return json.loads(json_str) + except: + raise ValueError(f"AI returned invalid JSON (truncated at {max_tok} tokens). Please try again with a shorter description.") # ============================================================ diff --git a/static/app.js b/static/app.js index 074989a..ba3f3b5 100644 --- a/static/app.js +++ b/static/app.js @@ -286,18 +286,37 @@ async function createTemplate() { async function generateTemplate() { const desc = document.getElementById('tmpl-gen-desc').value; if (!desc) { toast('Description required', 'error'); return; } - document.querySelector('.modal button').textContent = 'Generating...'; - document.querySelector('.modal button').disabled = true; + const btn = document.querySelector('.modal button'); + btn.textContent = 'Generating...'; + btn.disabled = true; try { - await api('/api/templates/generate', 'POST', { - name: document.getElementById('tmpl-gen-name').value || 'AI Generated Template', - description: desc + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 120000); // 2 min timeout + const resp = await fetch(API + '/api/templates/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: document.getElementById('tmpl-gen-name').value || 'AI Generated Template', + description: desc + }), + signal: controller.signal }); + clearTimeout(timeout); + if (!resp.ok) { + const err = await resp.text(); + throw new Error(err); + } closeModal(); toast('Template generated with AI'); loadTemplates(); } catch(e) { - toast('Generation failed: ' + e.message, 'error'); + btn.textContent = 'Generate with AI'; + btn.disabled = false; + if (e.name === 'AbortError') { + toast('Generation timed out after 2 minutes. Try a shorter description.', 'error'); + } else { + toast('Generation failed: ' + e.message, 'error'); + } } }