From 2e735508bc8787c05ed3d2efcfc32cfa34bbcf05 Mon Sep 17 00:00:00 2001 From: kigland Date: Tue, 7 Jul 2026 10:42:29 +0800 Subject: [PATCH] isolate malformed LLM batch responses Signed-off-by: kigland --- src/skillspector/llm_analyzer_base.py | 32 ++++++++++++++++--- tests/nodes/test_llm_analyzer_base.py | 44 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/skillspector/llm_analyzer_base.py b/src/skillspector/llm_analyzer_base.py index c5ab9dce..b2c97d16 100644 --- a/src/skillspector/llm_analyzer_base.py +++ b/src/skillspector/llm_analyzer_base.py @@ -33,7 +33,7 @@ from typing import Literal from langchain_core.messages import BaseMessage -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, ValidationError, field_validator from skillspector.llm_utils import get_chat_model from skillspector.logging_config import get_logger @@ -386,11 +386,35 @@ def run_batches( len(batch.findings), ) if self._structured_llm: - response = self._structured_llm.invoke(prompt) + try: + response = self._structured_llm.invoke(prompt) + except ValidationError as exc: + logger.warning("LLM batch failed for %s: %s", batch.file_label, exc) + continue + except (ValueError, NotImplementedError): + raise + except Exception as exc: + logger.warning("LLM batch failed for %s: %s", batch.file_label, exc) + continue else: - response = _message_text(self._llm.invoke(prompt)) + try: + response = _message_text(self._llm.invoke(prompt)) + except (ValueError, NotImplementedError): + raise + except Exception as exc: + logger.warning("LLM batch failed for %s: %s", batch.file_label, exc) + continue logger.debug("LLM response for %s", batch.file_label) - parsed = self.parse_response(response, batch) + try: + parsed = self.parse_response(response, batch) + except ValidationError as exc: + logger.warning("LLM batch parse failed for %s: %s", batch.file_label, exc) + continue + except (ValueError, NotImplementedError): + raise + except Exception as exc: + logger.warning("LLM batch parse failed for %s: %s", batch.file_label, exc) + continue results.append((batch, parsed)) return results diff --git a/tests/nodes/test_llm_analyzer_base.py b/tests/nodes/test_llm_analyzer_base.py index e344e654..70a0f471 100644 --- a/tests/nodes/test_llm_analyzer_base.py +++ b/tests/nodes/test_llm_analyzer_base.py @@ -388,6 +388,50 @@ async def test_arun_batches_uses_message_text_for_content_blocks(self) -> None: assert results[0][1] == ["async chunk"] +# --------------------------------------------------------------------------- +# LLMAnalyzerBase.run_batches (sync execution) +# --------------------------------------------------------------------------- + + +class TestRunBatches: + MODEL = "nvidia/openai/gpt-oss-120b" + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + def test_malformed_structured_batch_does_not_abort_the_others(self) -> None: + """A malformed structured response costs only its own batch.""" + + def _invoke(prompt: str) -> LLMAnalysisResult: + if "b.py" in prompt: + return LLMAnalysisResult.model_validate({"findings": 'We{"findings":[]}'}) + return LLMAnalysisResult( + findings=[ + LLMFinding(rule_id="T-1", message="hit", severity="LOW", start_line=1), + ] + ) + + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.invoke.side_effect = _invoke + + batches = [ + Batch(file_path="a.py", content="code a"), + Batch(file_path="b.py", content="code b"), + Batch(file_path="c.py", content="code c"), + ] + results = analyzer.run_batches(batches) + + assert {batch.file_path for batch, _ in results} == {"a.py", "c.py"} + assert [items[0].rule_id for _, items in results] == ["T-1", "T-1"] + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + def test_value_error_still_propagates(self) -> None: + """ValueError signals misconfiguration, not a malformed model response.""" + analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL) + analyzer._structured_llm.invoke.side_effect = ValueError("no API key") + + with pytest.raises(ValueError, match="no API key"): + analyzer.run_batches([Batch(file_path="a.py", content="code")]) + + # --------------------------------------------------------------------------- # LLMAnalyzerBase.arun_batches (async parallel execution) # ---------------------------------------------------------------------------