Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions src/skillspector/llm_analyzer_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -374,7 +374,9 @@ def run_batches(

The element type of the inner list depends on the subclass: the default
:meth:`parse_response` returns :class:`Finding` objects; subclasses may
return dicts or other types.
return dicts or other types. Malformed structured responses are isolated
per batch and omitted from the result so callers can detect partial
analysis by comparing submitted and returned batches.
"""
results: list[tuple[Batch, list]] = []
for batch in batches:
Expand All @@ -386,11 +388,38 @@ def run_batches(
len(batch.findings),
)
if self._structured_llm:
response = self._structured_llm.invoke(prompt)
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainability: this is the first of three near-identical try/except blocks in the loop. Extracting the policy into a small helper (skip on ValidationError/other Exceptions, propagate ValueError/NotImplementedError) would keep it in one place and let arun_batches share it, preventing the sync/async drift that left the async path unfixed. Also, the run_batches docstring should document the new failure-isolation semantics the way arun_batches's docstring does.

response = self._structured_llm.invoke(prompt)
except ValidationError as exc:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This carve-out is correct here, but the same fix is missing in arun_batches: pydantic ValidationError subclasses ValueError, so isinstance(result, (ValueError, NotImplementedError)) at line 441 re-raises it, and all three async callers (semantic_quality_policy, semantic_developer_intent, meta_analyzer) re-raise ValueError at node level — the whole-scan crash from #250 is still reproducible through those stages (verified against this branch with the same We{"findings":[]} payload). Please add an isinstance(result, ValidationError) check before the ValueError re-raise in arun_batches, plus an async regression test.

logger.warning("LLM batch failed for %s: %s", batch.file_label, exc)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the batch with only a log warning gives callers no signal. Previously a ValidationError reached semantic_security_discovery's node-level handler, which recorded llm_call_record(ok=False, error="malformed LLM response: ...") and fed the report's degradation notice; that handler is now dead code, so a scan where every batch fails to parse still reports the LLM stage as fully OK. Consider having the node compare submitted vs returned batches (as meta_analyzer does) and record a degraded call, so partial results stay annotated in the report.

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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistency: this raw-mode branch has no ValidationError carve-out, so a ValidationError raised here (or in a subclass's raw-mode pipeline) propagates via the ValueError re-raise, while the structured branch above skips it. If intentional, add a comment; otherwise align the two branches.

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
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

Expand All @@ -408,12 +437,12 @@ async def arun_batches(
cross-chunk batches are parallelized in a single gather call.

Failures are isolated per batch: a transient error (timeout, 429,
oversized-chunk 400, ...) costs only its own batch, which is logged
and omitted from the result, so one bad call cannot cancel the rest
of the fan-out. Callers can detect partial results by comparing the
returned batches against the submitted ones. ``ValueError`` and
``NotImplementedError`` signal misconfiguration rather than infra
trouble and keep propagating.
oversized-chunk 400, malformed structured response, ...) costs only
its own batch, which is logged and omitted from the result, so one bad
call cannot cancel the rest of the fan-out. Callers can detect partial
results by comparing the returned batches against the submitted ones.
``ValueError`` and ``NotImplementedError`` signal misconfiguration
rather than infra trouble and keep propagating.

The return type mirrors :meth:`run_batches`.
"""
Expand All @@ -438,6 +467,9 @@ async def _process(batch: Batch) -> tuple[Batch, list]:
results = await asyncio.gather(*[_process(b) for b in batches], return_exceptions=True)
successful: list[tuple[Batch, list]] = []
for batch, result in zip(batches, results, strict=True):
if isinstance(result, ValidationError):
logger.warning("LLM batch failed for %s: %s", batch.file_label, result)
continue
if isinstance(result, (ValueError, NotImplementedError)):
raise result
if isinstance(result, BaseException):
Expand Down
6 changes: 6 additions & 0 deletions src/skillspector/nodes/analyzers/semantic_developer_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
results = asyncio.run(analyzer.arun_batches(batches))
findings = analyzer.collect_findings(results)
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
if len(results) < len(batches):
error = f"{len(batches) - len(results)}/{len(batches)} LLM batches failed"
return {
"findings": findings,
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=False, error=error)],
}
return {"findings": findings, "llm_call_log": [llm_call_record(ANALYZER_ID, ok=True)]}
except ValueError:
raise
Expand Down
6 changes: 6 additions & 0 deletions src/skillspector/nodes/analyzers/semantic_quality_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
results = asyncio.run(analyzer.arun_batches(batches))
findings = analyzer.collect_findings(results)
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
if len(results) < len(batches):
error = f"{len(batches) - len(results)}/{len(batches)} LLM batches failed"
return {
"findings": findings,
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=False, error=error)],
}
return {"findings": findings, "llm_call_log": [llm_call_record(ANALYZER_ID, ok=True)]}
except ValueError:
raise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
results = analyzer.run_batches(batches)
findings = analyzer.collect_findings(results)
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
if len(results) < len(batches):
error = f"{len(batches) - len(results)}/{len(batches)} LLM batches failed"
return {
"findings": findings,
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=False, error=error)],
}
return {"findings": findings, "llm_call_log": [llm_call_record(ANALYZER_ID, ok=True)]}
except ValidationError as exc:
# Malformed LLM response — degrade gracefully rather than crashing the graph
Expand Down
6 changes: 6 additions & 0 deletions src/skillspector/nodes/meta_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,12 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse:
len(findings),
len(filtered),
)
if unanalysed:
error = f"{len(batches) - len(batch_results)}/{len(batches)} LLM batches failed"
return {
"filtered_findings": filtered,
"llm_call_log": [llm_call_record("meta_analyzer", ok=False, error=error)],
}
return {
"filtered_findings": filtered,
"llm_call_log": [llm_call_record("meta_analyzer", ok=True)],
Expand Down
9 changes: 7 additions & 2 deletions tests/nodes/analyzers/test_semantic_developer_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import pytest

from skillspector.llm_analyzer_base import LLMAnalysisResult, LLMFinding
from skillspector.llm_analyzer_base import Batch, LLMAnalysisResult, LLMFinding
from skillspector.models import Finding
from skillspector.nodes.analyzers.semantic_developer_intent import (
ANALYZER_ID,
Expand Down Expand Up @@ -240,7 +240,12 @@ class TestLLMCallTelemetry:
def test_success_records_ok_true(self) -> None:
from skillspector.llm_analyzer_base import LLMAnalyzerBase

with patch.object(LLMAnalyzerBase, "arun_batches", new_callable=AsyncMock, return_value=[]):
with patch.object(
LLMAnalyzerBase,
"arun_batches",
new_callable=AsyncMock,
return_value=[(Batch(file_path="main.py", content="import os"), [])],
):
result = node({"file_cache": {"main.py": "import os"}})
assert result["llm_call_log"] == [{"node": ANALYZER_ID, "ok": True, "error": None}]

Expand Down
9 changes: 6 additions & 3 deletions tests/nodes/analyzers/test_semantic_security_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import pytest
from pydantic import ValidationError

from skillspector.llm_analyzer_base import LLMAnalysisResult, LLMFinding
from skillspector.llm_analyzer_base import Batch, LLMAnalysisResult, LLMFinding
from skillspector.models import Finding
from skillspector.nodes.analyzers.semantic_security_discovery import (
ANALYZER_ID,
Expand Down Expand Up @@ -316,7 +316,11 @@ class TestLLMCallTelemetry:
def test_success_records_ok_true(self, base_state) -> None:
from skillspector.llm_analyzer_base import LLMAnalyzerBase

with patch.object(LLMAnalyzerBase, "run_batches", return_value=[]):
with patch.object(
LLMAnalyzerBase,
"run_batches",
return_value=[(Batch(file_path="SKILL.md", content="# Skill"), [])],
):
result = node(base_state)
assert result["llm_call_log"] == [{"node": ANALYZER_ID, "ok": True, "error": None}]

Expand Down Expand Up @@ -374,7 +378,6 @@ def _build_file_cache(skill_dir: Path) -> dict[str, str]:

def _make_file_aware_run_batches(responses: dict[str, LLMAnalysisResult]):
"""Return a mock run_batches that dispatches based on file_path in each batch."""
from skillspector.llm_analyzer_base import Batch

def _run_batches(self_inner, batches: list[Batch], **_kwargs):
results = []
Expand Down
70 changes: 70 additions & 0 deletions tests/nodes/test_llm_analyzer_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good regression test for the sync path. Please add the async counterpart: an arun_batches test where ainvoke raises this same ValidationError and the other batches survive — it currently fails on this branch because arun_batches re-raises ValidationError via the (ValueError, NotImplementedError) isinstance check. Coverage for the parse_response leg and raw-string mode of the new sync handling would also be worthwhile.

"""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)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -577,6 +621,32 @@ async def _flaky_ainvoke(prompt: str) -> LLMAnalysisResult:
results = await analyzer.arun_batches(batches)
assert {batch.file_path for batch, _ in results} == {"a.py", "c.py"}

@patch(MOCK_PATCH_TARGET, _mock_get_chat_model)
async def test_malformed_structured_batch_does_not_abort_the_others(self) -> None:
"""A malformed structured response is isolated even though it is a ValueError."""

async def _ainvoke(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.ainvoke = _ainvoke

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 = await analyzer.arun_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)
async def test_all_batches_failed_returns_empty(self) -> None:
analyzer = LLMAnalyzerBase(base_prompt="test", model=self.MODEL)
Expand Down
7 changes: 5 additions & 2 deletions tests/nodes/test_meta_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,15 +261,18 @@ def _degr_state(**overrides: object) -> SkillspectorState:


def test_records_ok_true_on_success() -> None:
state = _degr_state()
with (
patch("skillspector.llm_analyzer_base.get_chat_model", return_value=MagicMock()),
patch(
"skillspector.nodes.meta_analyzer.LLMMetaAnalyzer.arun_batches",
new_callable=AsyncMock,
return_value=[],
return_value=[
(Batch(file_path="SKILL.md", content="# Skill", findings=state["findings"]), [])
],
),
):
result = meta_analyzer(_degr_state())
result = meta_analyzer(state)
assert result["llm_call_log"] == [{"node": "meta_analyzer", "ok": True, "error": None}]


Expand Down
9 changes: 7 additions & 2 deletions tests/nodes/test_semantic_quality_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import pytest

from skillspector.llm_analyzer_base import LLMAnalysisResult, LLMFinding
from skillspector.llm_analyzer_base import Batch, LLMAnalysisResult, LLMFinding
from skillspector.models import Finding
from skillspector.nodes.analyzers.semantic_quality_policy import (
ANALYZER_ID,
Expand Down Expand Up @@ -269,7 +269,12 @@ class TestLLMCallTelemetry:
def test_success_records_ok_true(self) -> None:
from skillspector.llm_analyzer_base import LLMAnalyzerBase

with patch.object(LLMAnalyzerBase, "arun_batches", new_callable=AsyncMock, return_value=[]):
with patch.object(
LLMAnalyzerBase,
"arun_batches",
new_callable=AsyncMock,
return_value=[(Batch(file_path="SKILL.md", content="# Skill"), [])],
):
result = node({"file_cache": {"SKILL.md": "# Skill"}})
assert result["llm_call_log"] == [{"node": ANALYZER_ID, "ok": True, "error": None}]

Expand Down