Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,11 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
"inspection_ledger": events,
"analyzer_status_events": [status],
"llm_call_log": [
llm_call_record(ANALYZER_ID, ok=bool(outcome.successful) or not outcome.failures)
# A record is ok only when every submitted batch succeeded. A
# partial batch failure (e.g. one file's batch 429'd while
# another's succeeded) is still lost coverage, so it must not
# read as ok=True just because some batches came back.
llm_call_record(ANALYZER_ID, ok=not outcome.failures)
],
"inference_usage": analyzer.inference_usage,
}
Expand Down
6 changes: 5 additions & 1 deletion src/skillspector/nodes/analyzers/semantic_quality_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,11 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
"inspection_ledger": events,
"analyzer_status_events": [status],
"llm_call_log": [
llm_call_record(ANALYZER_ID, ok=bool(outcome.successful) or not outcome.failures)
# A record is ok only when every submitted batch succeeded. A
# partial batch failure (e.g. one file's batch 429'd while
# another's succeeded) is still lost coverage, so it must not
# read as ok=True just because some batches came back.
llm_call_record(ANALYZER_ID, ok=not outcome.failures)
],
"inference_usage": analyzer.inference_usage,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,11 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
"inspection_ledger": all_events,
"analyzer_status_events": [status],
"llm_call_log": [
llm_call_record(ANALYZER_ID, ok=bool(outcome.successful) or not outcome.failures)
# A record is ok only when every submitted batch succeeded. A
# partial batch failure (e.g. one file's batch 429'd while
# another's succeeded) is still lost coverage, so it must not
# read as ok=True just because some batches came back.
llm_call_record(ANALYZER_ID, ok=not outcome.failures)
],
"inference_usage": analyzer.inference_usage,
}
Expand Down
9 changes: 5 additions & 4 deletions src/skillspector/nodes/meta_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,10 +846,11 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse:
"inspection_ledger": ledger_events,
"analyzer_status_events": [status],
"llm_call_log": [
llm_call_record(
"meta_analyzer",
ok=bool(detailed.successful) or not detailed.failures,
)
# A record is ok only when every submitted batch succeeded. A
# partial batch failure (e.g. one file's batch 429'd while
# another's succeeded) is still lost coverage, so it must not
# read as ok=True just because some batches came back.
llm_call_record("meta_analyzer", ok=not detailed.failures)
],
"inference_usage": analyzer.inference_usage,
}
Expand Down
59 changes: 44 additions & 15 deletions src/skillspector/nodes/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -1027,25 +1027,27 @@ def _llm_runtime_status(
"""Return ``(attempted, succeeded, degraded)`` from the LLM call log.

``degraded`` is True when the LLM stage was requested and at least one call
was attempted, but every call failed at runtime — meaning the report
reflects static analysis only despite a deep scan being requested.
was attempted, but not every call succeeded: a dropped or throttled batch
(e.g. a 429) leaves the same coverage gap as a full failure, so a partial
pass is degraded too, not just a total one.
"""
attempted = len(llm_call_log)
succeeded = sum(1 for r in llm_call_log if r.get("ok"))
degraded = bool(use_llm and attempted > 0 and succeeded == 0)
degraded = bool(use_llm and attempted > 0 and succeeded < attempted)
Comment thread
rng1995 marked this conversation as resolved.
return attempted, succeeded, degraded


def _llm_degradation_notice(
use_llm: bool, llm_call_log: Sequence[Mapping[str, object]]
) -> str | None:
"""Return a human-readable degraded-scan warning, or None if not degraded."""
attempted, _succeeded, degraded = _llm_runtime_status(use_llm, llm_call_log)
attempted, succeeded, degraded = _llm_runtime_status(use_llm, llm_call_log)
if not degraded:
return None
failed = attempted - succeeded
return (
f"LLM analysis was requested but all {attempted} LLM call(s) failed - "
"results reflect STATIC analysis only."
f"LLM analysis was requested but {failed} of {attempted} LLM call(s) failed - "
"results reflect STATIC analysis only for the affected batch(es)."
)


Expand All @@ -1060,19 +1062,45 @@ def _build_metadata(
) -> dict[str, object]:
"""Build the metadata section shared by all output formats."""
llm_call_log = llm_call_log or []
llm_available, llm_error = is_llm_available()
provider_available, llm_error = is_llm_available()
attempted, succeeded, degraded = _llm_runtime_status(use_llm, llm_call_log)
# meta_analysis_applied reflects whether the LLM meta-analysis effectively
# ran: requested, available, and not fully degraded (every call failing).
meta_analysis_applied = use_llm and llm_available and not degraded

# meta_analyzer's own record, independent of whether a DIFFERENT
# LLM-backed node (a semantic_* analyzer) lost coverage to a dropped
# batch. A missing record means meta_analyzer never ran (e.g. there were
# no findings to filter), which is not itself a failure, so it reads as
# vacuously ok for llm_available (provider/runtime truth). When it does
# run it always emits exactly one record.
meta_analyzer_records = [r for r in llm_call_log if r.get("node") == "meta_analyzer"]
meta_analyzer_ok = all(bool(r.get("ok")) for r in meta_analyzer_records)
Comment thread
rng1995 marked this conversation as resolved.
# meta_analysis_applied is stricter: "did meta-analysis actually run"
# cannot be satisfied vacuously. all([]) is True on an empty list, so
# meta_analyzer_ok alone is also True when meta_analyzer made no call at
# all (the no-findings path) - require at least one record, and that
# record must have succeeded.
meta_analyzer_succeeded = bool(meta_analyzer_records) and meta_analyzer_ok

# meta_analysis_applied / llm_available answer different questions.
# llm_available is provider availability: the binary/credentials were
# available AND meta_analyzer's own call (if it ran) succeeded - it stays
# vacuously true when meta_analyzer never ran, because the provider being
# reachable does not depend on there being findings to filter.
# meta_analysis_applied is "did meta-analysis itself run", which needs an
# actual successful meta_analyzer record, not just the absence of a
# failure. A different analyzer's partial batch loss is a coverage gap,
# reported separately below via llm_degraded / llm_calls_attempted /
# llm_calls_succeeded, and must not flip these two fields on its own -
# that would conflate two independent contracts (meta-analysis ran vs.
# some coverage was lost) into one boolean.
meta_analysis_applied = use_llm and provider_available and meta_analyzer_succeeded

meta: dict[str, object] = {
"has_executable_scripts": has_executable_scripts,
"skillspector_version": skillspector_version,
"llm_requested": use_llm,
# llm_available reflects runtime truth: the binary/credentials were
# available AND the stage was not fully degraded (every call failing).
"llm_available": llm_available and not degraded,
# available AND meta_analyzer's own call (if it ran) succeeded.
"llm_available": provider_available and meta_analyzer_ok,
"meta_analysis_applied": meta_analysis_applied,
# A list (including an empty list) makes observability explicit. Empty
# means the provider/transport supplied no counters; it is never an
Expand All @@ -1090,11 +1118,12 @@ def _build_metadata(
{str(r.get("error")) for r in llm_call_log if not r.get("ok") and r.get("error")}
)
detail = f" Reasons: {'; '.join(reasons)}" if reasons else ""
failed = attempted - succeeded
meta["llm_error"] = (
f"LLM analysis was requested but all {attempted} LLM call(s) failed; "
f"results reflect static analysis only.{detail}"
f"LLM analysis was requested but {failed} of {attempted} LLM call(s) failed; "
f"results reflect static analysis only for the affected batch(es).{detail}"
)
elif use_llm and not llm_available:
elif use_llm and not provider_available:
meta["llm_error"] = llm_error
if transitive_targets_scanned is not None:
meta["transitive_targets_scanned"] = transitive_targets_scanned
Expand Down
11 changes: 9 additions & 2 deletions tests/nodes/analyzers/test_semantic_developer_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,14 @@ def test_success_records_ok_true(self) -> None:
assert result["llm_call_log"] == [{"node": ANALYZER_ID, "ok": True, "error": None}]

@patch(MOCK_PATCH_TARGET, _mock_get_chat_model)
def test_partial_batch_failure_records_llm_success(self) -> None:
def test_partial_batch_failure_records_llm_failure(self) -> None:
"""One batch succeeding does not hide another batch's dropped coverage.

Regression for the case where a two-file run has one file batch
succeed and the other 429 / time out: the record must be ok=False so
the report can detect the coverage gap, not ok=True just because
`outcome.successful` was non-empty.
"""
from skillspector.llm_analyzer_base import LLMAnalyzerBase

async def partially_succeeds(self, batches, **_kwargs):
Expand All @@ -267,7 +274,7 @@ async def partially_succeeds(self, batches, **_kwargs):
with patch.object(LLMAnalyzerBase, "arun_batches", partially_succeeds):
result = node({"file_cache": {"first.py": "print(1)", "second.py": "print(2)"}})

assert result["llm_call_log"] == [{"node": ANALYZER_ID, "ok": True, "error": None}]
assert result["llm_call_log"] == [{"node": ANALYZER_ID, "ok": False, "error": None}]

@patch(MOCK_PATCH_TARGET)
def test_exception_records_ok_false(self, mock_get_model: MagicMock) -> None:
Expand Down
Loading
Loading