diff --git a/README.md b/README.md index a4ac487..aa9b567 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Supported formats include ISO-8601 application logs, syslog, nginx access logs, ## Evidence-backed reasoning -The optional `--explain` flag turns existing detector output into concise, user-facing evidence statements. Each statement identifies its direct or statistical basis: parsed error level, message-length z-score with its configured threshold, or observed error count/rate in a complete analysis window. LogSight does not infer an incident root cause, use an LLM, send logs externally, or report a model-confidence score. +The optional `--explain` flag turns existing detector output into concise, user-facing evidence statements. Each statement identifies its direct or statistical basis: parsed error level, message-length z-score with its configured threshold, or observed error count/rate in a complete analysis window. Each statement also reports a deterministic support level: `single-signal` for one detector signal and `corroborated` when the same entry meets both error-level and statistical criteria. LogSight does not infer an incident root cause, use an LLM, send logs externally, or report a model-confidence score. ```bash logsight analyze application.log --window 200 --spike-threshold 0.20 --explain diff --git a/logsight/cli.py b/logsight/cli.py index 2f4d62b..3db38c8 100644 --- a/logsight/cli.py +++ b/logsight/cli.py @@ -56,8 +56,8 @@ def _print_explanations(explanations: list[EvidenceExplanation]) -> None: for explanation in explanations: evidence = ", ".join(explanation.evidence) console.print( - f" [{explanation.evidence_strength}] {escape(explanation.summary)} " - f"([dim]{escape(evidence)}[/dim])" + f" support={explanation.evidence_strength};{explanation.support_level} — " + f"{escape(explanation.summary)} ({escape(evidence)})" ) diff --git a/logsight/reasoning.py b/logsight/reasoning.py index d6bfdd9..aa19c07 100644 --- a/logsight/reasoning.py +++ b/logsight/reasoning.py @@ -12,6 +12,7 @@ from logsight.analyzer import AnomalyReport, ErrorRateSpike EvidenceStrength = Literal["direct", "statistical"] +SupportLevel = Literal["single-signal", "corroborated"] @dataclass(frozen=True) @@ -21,6 +22,7 @@ class EvidenceExplanation: category: str summary: str evidence_strength: EvidenceStrength + support_level: SupportLevel evidence: tuple[str, ...] @@ -42,6 +44,11 @@ def explain_report( "no root cause is inferred." ), evidence_strength="direct", + support_level=( + "corroborated" + if "message_length_zscore" in finding.reasons + else "single-signal" + ), evidence=( f"level={entry.level.value}", f"message_length={len(entry.message)}", @@ -57,6 +64,9 @@ def explain_report( "statistical threshold; no root cause is inferred." ), evidence_strength="statistical", + support_level=( + "corroborated" if "error_level" in finding.reasons else "single-signal" + ), evidence=( f"zscore={finding.message_length_zscore:.3f}", f"threshold={report.zscore_threshold:.3f}", @@ -74,6 +84,7 @@ def explain_report( "threshold; no root cause is inferred." ), evidence_strength="statistical", + support_level="single-signal", evidence=( f"errors={spike.error_count}", f"entries={spike.total}", diff --git a/tests/test_cli.py b/tests/test_cli.py index f37225e..16c742b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,4 +68,5 @@ def test_explain_uses_detector_evidence(self, tmp_path): assert result.exit_code == 0 assert "Evidence-backed findings" in result.output assert "level=ERROR" in result.output + assert "single-signal" in result.output assert "no root cause is inferred" in " ".join(result.output.split()) diff --git a/tests/test_reasoning.py b/tests/test_reasoning.py index d1e6990..52f72ab 100644 --- a/tests/test_reasoning.py +++ b/tests/test_reasoning.py @@ -19,6 +19,7 @@ def test_explains_direct_error_evidence_without_causal_claim(): assert len(explanations) == 1 assert explanations[0].category == "error-level" assert explanations[0].evidence_strength == "direct" + assert explanations[0].support_level == "single-signal" assert "level=ERROR" in explanations[0].evidence assert "no root cause is inferred" in explanations[0].summary @@ -48,3 +49,12 @@ def test_returns_no_explanations_when_detector_has_no_findings(): report = detect_anomalies([_entry("INFO", "healthy")]) assert explain_report(report) == [] + + +def test_marks_two_detector_signals_as_corroborated(): + entries = [_entry("INFO", "normal")] * 20 + [_entry("ERROR", "x" * 500)] + report = detect_anomalies(entries, zscore_threshold=2.0) + + explanations = explain_report(report) + + assert any(item.support_level == "corroborated" for item in explanations)