From 1f404a20ca52a196eb1a6e31b8dfb7e066053ede Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Feb 2026 10:38:22 -0500 Subject: [PATCH] fix: handle truncated AI responses and relax progressive tree validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip unclosed [TREE_UPDATE] and [METADATA] blocks from display when response is truncated by max_tokens - Increase send_message max_tokens from 2000 to 8000 to prevent truncation of large tree JSON - Use lightweight validation for progressive tree updates (valid root node only) instead of strict 5-node minimum — strict validation still applies at final /generate step Co-Authored-By: Claude Opus 4.6 --- backend/app/core/ai_chat_service.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/app/core/ai_chat_service.py b/backend/app/core/ai_chat_service.py index b87a3698..2571ee5b 100644 --- a/backend/app/core/ai_chat_service.py +++ b/backend/app/core/ai_chat_service.py @@ -187,6 +187,13 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]: except (json.JSONDecodeError, ValueError) as e: logger.warning("Failed to parse tree update JSON: %s", e) result["content"] = raw_response[: tree_match.start()] + raw_response[tree_match.end() :] + else: + # Handle truncated response — opening tag exists but no closing tag + # (happens when max_tokens cuts off the JSON block) + truncated_match = re.search(r"\[TREE_UPDATE\][\s\S]*$", raw_response) + if truncated_match: + logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display") + result["content"] = raw_response[: truncated_match.start()] # Extract [PHASE:name] phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"]) @@ -205,6 +212,11 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]: except (json.JSONDecodeError, ValueError) as e: logger.warning("Failed to parse metadata JSON: %s", e) result["content"] = result["content"][: meta_match.start()] + result["content"][meta_match.end() :] + else: + truncated_meta = re.search(r"\[METADATA\][\s\S]*$", result["content"]) + if truncated_meta: + logger.warning("Truncated [METADATA] block detected — stripping from display") + result["content"] = result["content"][: truncated_meta.start()] # Clean up extra whitespace from marker removal result["content"] = re.sub(r"\n{3,}", "\n\n", result["content"]).strip() @@ -294,18 +306,21 @@ async def send_message( response_text, input_tokens, output_tokens = await provider.generate_text( system_prompt=system_prompt, messages=provider_messages, - max_tokens=2000, + max_tokens=8000, ) parsed = _parse_ai_response(response_text) - # Validate tree update if present + # Validate tree update if present (lightweight check for progressive builds — + # only require valid root structure, not min node counts) tree_update = parsed["tree_update"] if tree_update: - errors = validate_generated_tree(tree_update) - if errors: - logger.warning("AI tree update failed validation: %s", errors) - tree_update = None # Silently discard invalid updates + if not isinstance(tree_update, dict) or tree_update.get("type") != "decision": + logger.warning("AI tree update rejected: root must be a decision node") + tree_update = None + elif not tree_update.get("id"): + logger.warning("AI tree update rejected: root node missing id") + tree_update = None # Update session state history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso})