diff --git a/review_roadmap/agent/nodes.py b/review_roadmap/agent/nodes.py index 71b56c9..d775a98 100644 --- a/review_roadmap/agent/nodes.py +++ b/review_roadmap/agent/nodes.py @@ -472,8 +472,26 @@ def reflect_on_roadmap(state: ReviewState) -> Dict[str, Any]: # Parse response (with fallback for non-JSON responses) import json + import re + + # Strip markdown code fences if present (LLM often wraps JSON in ```json ... ```) + content = response.content.strip() + # Try complete code fence first + code_fence_pattern = r'^```(?:json)?\s*\n?(.*?)\n?```$' + match = re.match(code_fence_pattern, content, re.DOTALL) + if match: + content = match.group(1).strip() + else: + # Handle truncated response or unclosed code fence + if content.startswith('```'): + # Remove opening fence (```json or ```) + content = re.sub(r'^```(?:json)?\s*\n?', '', content) + # Remove closing fence if present + content = re.sub(r'\n?```$', '', content) + content = content.strip() + try: - result = json.loads(response.content) + result = json.loads(content) passed = result.get("passed", False) feedback = result.get("feedback", "") notes = result.get("notes", "") diff --git a/tests/test_agent_nodes.py b/tests/test_agent_nodes.py index 63361d1..1ade887 100644 --- a/tests/test_agent_nodes.py +++ b/tests/test_agent_nodes.py @@ -453,6 +453,62 @@ def test_reflection_handles_non_json_response( assert result["reflection_passed"] is True assert result["reflection_iterations"] == 1 + def test_reflection_handles_json_in_code_fence( + self, sample_review_state_with_roadmap: ReviewState + ): + """Test that reflection correctly parses JSON wrapped in markdown code fences.""" + mock_response = MagicMock() + # This is the format the LLM often returns - JSON wrapped in code fences + mock_response.content = '```json\n{"passed": true, "notes": "Self-review: looks good"}\n```' + + mock_chain = MagicMock() + mock_chain.invoke.return_value = mock_response + + mock_llm = MagicMock() + mock_llm.__or__ = MagicMock(return_value=mock_chain) + + with patch("review_roadmap.agent.nodes._get_llm_instance", return_value=mock_llm): + with patch("review_roadmap.agent.nodes.ChatPromptTemplate") as mock_template: + mock_prompt = MagicMock() + mock_prompt.__or__ = MagicMock(return_value=mock_chain) + mock_template.from_messages.return_value = mock_prompt + + from review_roadmap.agent.nodes import reflect_on_roadmap + + result = reflect_on_roadmap(sample_review_state_with_roadmap) + + # Should correctly parse the JSON from within code fences + assert result["reflection_passed"] is True + assert result["reflection_iterations"] == 1 + + def test_reflection_handles_truncated_code_fence( + self, sample_review_state_with_roadmap: ReviewState + ): + """Test that reflection handles truncated code fences (missing closing ```).""" + mock_response = MagicMock() + # Truncated response - missing closing ``` + mock_response.content = '```json\n{"passed": true, "notes": "Self-review: good"}' + + mock_chain = MagicMock() + mock_chain.invoke.return_value = mock_response + + mock_llm = MagicMock() + mock_llm.__or__ = MagicMock(return_value=mock_chain) + + with patch("review_roadmap.agent.nodes._get_llm_instance", return_value=mock_llm): + with patch("review_roadmap.agent.nodes.ChatPromptTemplate") as mock_template: + mock_prompt = MagicMock() + mock_prompt.__or__ = MagicMock(return_value=mock_chain) + mock_template.from_messages.return_value = mock_prompt + + from review_roadmap.agent.nodes import reflect_on_roadmap + + result = reflect_on_roadmap(sample_review_state_with_roadmap) + + # Should correctly parse the JSON even with truncated code fence + assert result["reflection_passed"] is True + assert result["reflection_iterations"] == 1 + def test_reflection_increments_iteration_count( self, sample_review_state_with_roadmap: ReviewState ):