From 9d445b8f86bb94ed213dd37a32ac6be84d4fb54f Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 22 May 2026 16:51:11 -0600 Subject: [PATCH 1/2] feat: add show_progress option to suppress tqdm progress bars - Add show_progress field to AnalysisConfig (defaults to True) - Pass disable=not show_progress to all tqdm calls in embedding backends and scorer - Add --quiet / -q CLI flag that sets show_progress=False - Useful for CI pipelines and library usage where progress bars add noise to output Co-authored-by: Cursor --- benchmark/evaluate.py | 2 +- src/cordon/analysis/scorer.py | 1 + src/cordon/cli.py | 7 +++++++ src/cordon/core/config.py | 3 +++ src/cordon/embedding/llama_cpp.py | 1 + src/cordon/embedding/remote.py | 1 + src/cordon/embedding/transformer.py | 1 + 7 files changed, 15 insertions(+), 1 deletion(-) diff --git a/benchmark/evaluate.py b/benchmark/evaluate.py index fa9c8cf..e0b5dc0 100644 --- a/benchmark/evaluate.py +++ b/benchmark/evaluate.py @@ -223,7 +223,7 @@ def run_cordon_analysis( scored = scorer.score_windows(embedded, config) # extract embeddings and scores for later use - embeddings = np.array([sw.embedding for sw in scored]) + embeddings = np.array([emb for _, emb in embedded]) scores = np.array([sw.score for sw in scored]) window_ranges = [(w.window.start_line, w.window.end_line) for w in scored] diff --git a/src/cordon/analysis/scorer.py b/src/cordon/analysis/scorer.py index 8e9d6e4..e2c154a 100644 --- a/src/cordon/analysis/scorer.py +++ b/src/cordon/analysis/scorer.py @@ -126,6 +126,7 @@ def _score_windows( desc=_SCORING_PROGRESS_DESC, unit="batch", total=(n_samples + query_batch_size - 1) // query_batch_size, + disable=not config.show_progress, ): batch_end = min(batch_start + query_batch_size, n_samples) batch_embeddings = embeddings_tensor[batch_start:batch_end] diff --git a/src/cordon/cli.py b/src/cordon/cli.py index 02ed715..ae8e3f1 100644 --- a/src/cordon/cli.py +++ b/src/cordon/cli.py @@ -148,6 +148,12 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Overwrite output file if it exists", ) + output_group.add_argument( + "--quiet", + "-q", + action="store_true", + help="Suppress progress bars (useful for CI or library usage)", + ) return parser.parse_args() @@ -322,6 +328,7 @@ def _main_impl() -> None: n_ctx=args.n_ctx, api_key=args.api_key, endpoint=args.endpoint, + show_progress=not args.quiet, ) except ValueError as error: print(f"Configuration error: {error}", file=sys.stderr) diff --git a/src/cordon/core/config.py b/src/cordon/core/config.py index d5e61d4..60fb0d5 100644 --- a/src/cordon/core/config.py +++ b/src/cordon/core/config.py @@ -67,6 +67,8 @@ class AnalysisConfig: api_key: API key for remote embedding providers. endpoint: Custom API endpoint URL (remote backend). request_timeout: HTTP request timeout in seconds (remote backend). + show_progress: Whether to display tqdm progress bars during + embedding and scoring. Set to False for CI or library use. """ window_size: int = 4 @@ -87,6 +89,7 @@ class AnalysisConfig: api_key: str | None = None endpoint: str | None = None request_timeout: float = 60.0 + show_progress: bool = True def __post_init__(self) -> None: """Validate configuration parameters.""" diff --git a/src/cordon/embedding/llama_cpp.py b/src/cordon/embedding/llama_cpp.py index 753a43e..e963c6b 100644 --- a/src/cordon/embedding/llama_cpp.py +++ b/src/cordon/embedding/llama_cpp.py @@ -69,6 +69,7 @@ def embed_windows( desc="Generating embeddings", total=total_batches, unit="batch", + disable=not self.config.show_progress, ): batch = window_list[batch_start : batch_start + batch_size] texts = [w.content for w in batch] diff --git a/src/cordon/embedding/remote.py b/src/cordon/embedding/remote.py index 19571f3..4d6eebe 100644 --- a/src/cordon/embedding/remote.py +++ b/src/cordon/embedding/remote.py @@ -52,6 +52,7 @@ def embed_windows( desc="Generating embeddings", total=total_batches, unit="batch", + disable=not self.config.show_progress, ): batch = window_list[batch_start_idx : batch_start_idx + batch_size] texts = [window.content for window in batch] diff --git a/src/cordon/embedding/transformer.py b/src/cordon/embedding/transformer.py index a4ec872..a6e865e 100644 --- a/src/cordon/embedding/transformer.py +++ b/src/cordon/embedding/transformer.py @@ -77,6 +77,7 @@ def embed_windows( desc="Generating embeddings", total=total_batches, unit="batch", + disable=not self.config.show_progress, ): batch = window_list[batch_start_idx : batch_start_idx + batch_size] texts = [window.content for window in batch] From 958f2db616aaa41fa8fed2945e26f1650a21931e Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 22 May 2026 17:09:10 -0600 Subject: [PATCH 2/2] feat: add JSON output format and structured blocks in AnalysisResult - Add blocks field to AnalysisResult for direct structured access to anomaly data without parsing formatted output - Create JsonFormatter producing JSON with anomalies array - Add output_format config field and --format CLI flag - Pipeline selects formatter based on config when none injected - Add JSON formatter tests and update existing tests Co-authored-by: Cursor --- src/cordon/cli.py | 9 ++++ src/cordon/core/config.py | 7 +++ src/cordon/core/types.py | 2 + src/cordon/pipeline.py | 12 ++++- src/cordon/postprocess/__init__.py | 3 +- src/cordon/postprocess/json_formatter.py | 48 ++++++++++++++++++ tests/test_cli.py | 12 +++++ tests/test_core.py | 51 ++++++++++++++++++- tests/test_integration.py | 1 + tests/test_json_formatter.py | 62 ++++++++++++++++++++++++ tests/test_pipeline_unit.py | 47 ++++++++++++++++++ 11 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 src/cordon/postprocess/json_formatter.py create mode 100644 tests/test_json_formatter.py diff --git a/src/cordon/cli.py b/src/cordon/cli.py index ae8e3f1..96db9c6 100644 --- a/src/cordon/cli.py +++ b/src/cordon/cli.py @@ -148,6 +148,14 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Overwrite output file if it exists", ) + output_group.add_argument( + "--format", + type=str, + choices=["xml", "json"], + default="xml", + dest="output_format", + help="Output format for anomaly blocks (default: xml)", + ) output_group.add_argument( "--quiet", "-q", @@ -329,6 +337,7 @@ def _main_impl() -> None: api_key=args.api_key, endpoint=args.endpoint, show_progress=not args.quiet, + output_format=args.output_format, ) except ValueError as error: print(f"Configuration error: {error}", file=sys.stderr) diff --git a/src/cordon/core/config.py b/src/cordon/core/config.py index 60fb0d5..ffaf00e 100644 --- a/src/cordon/core/config.py +++ b/src/cordon/core/config.py @@ -69,6 +69,7 @@ class AnalysisConfig: request_timeout: HTTP request timeout in seconds (remote backend). show_progress: Whether to display tqdm progress bars during embedding and scoring. Set to False for CI or library use. + output_format: Output format for anomaly blocks. """ window_size: int = 4 @@ -90,6 +91,7 @@ class AnalysisConfig: endpoint: str | None = None request_timeout: float = 60.0 show_progress: bool = True + output_format: Literal["xml", "json"] = "xml" def __post_init__(self) -> None: """Validate configuration parameters.""" @@ -115,6 +117,11 @@ def _validate_core_params(self) -> None: raise ValueError("scoring_batch_size must be >= 1 or None for auto-detect") if self.device is not None and self.device not in ("cuda", "mps", "cpu"): raise ValueError("device must be 'cuda', 'mps', 'cpu', or None") + _ALLOWED_FORMATS = ("xml", "json") + if self.output_format not in _ALLOWED_FORMATS: + raise ValueError( + f"output_format must be one of {_ALLOWED_FORMATS}, got {self.output_format!r}" + ) def _validate_anomaly_range(self) -> None: """Validate anomaly range parameters.""" diff --git a/src/cordon/core/types.py b/src/cordon/core/types.py index 7c1497c..31388f8 100644 --- a/src/cordon/core/types.py +++ b/src/cordon/core/types.py @@ -90,6 +90,7 @@ class AnalysisResult: Attributes: output: Formatted output string with XML tags. + blocks: Structured list of merged anomaly blocks. total_lines: Total number of lines in the input file. total_windows: Total number of windows created. significant_windows: Number of windows above threshold. @@ -99,6 +100,7 @@ class AnalysisResult: """ output: str + blocks: list[MergedBlock] total_lines: int total_windows: int significant_windows: int diff --git a/src/cordon/pipeline.py b/src/cordon/pipeline.py index de7f365..813f29a 100644 --- a/src/cordon/pipeline.py +++ b/src/cordon/pipeline.py @@ -60,7 +60,14 @@ def __init__( self._scorer = scorer if scorer is not None else DensityAnomalyScorer() self._thresholder = thresholder if thresholder is not None else ThresholderImpl() self._merger = merger if merger is not None else IntervalMerger() - self._formatter = formatter if formatter is not None else XmlFormatter() + if formatter is not None: + self._formatter = formatter + elif self.config.output_format == "json": + from cordon.postprocess.json_formatter import JsonFormatter + + self._formatter = JsonFormatter() + else: + self._formatter = XmlFormatter() def analyze_file(self, file_path: Path) -> str: """Analyze a log file and return formatted output. @@ -69,7 +76,7 @@ def analyze_file(self, file_path: Path) -> str: file_path: Path to the log file to analyze. Returns: - Formatted string with XML-tagged significant blocks. + Formatted string with significant blocks (XML or JSON, per output_format). """ result = self.analyze_file_detailed(file_path) return result.output @@ -119,6 +126,7 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult: return AnalysisResult( output=output, + blocks=list(merged), total_lines=total_lines, total_windows=total_windows, significant_windows=significant_windows, diff --git a/src/cordon/postprocess/__init__.py b/src/cordon/postprocess/__init__.py index d3ddc9b..52137d7 100644 --- a/src/cordon/postprocess/__init__.py +++ b/src/cordon/postprocess/__init__.py @@ -1,4 +1,5 @@ from cordon.postprocess.formatter import XmlFormatter +from cordon.postprocess.json_formatter import JsonFormatter from cordon.postprocess.merger import IntervalMerger -__all__ = ["IntervalMerger", "XmlFormatter"] +__all__ = ["IntervalMerger", "JsonFormatter", "XmlFormatter"] diff --git a/src/cordon/postprocess/json_formatter.py b/src/cordon/postprocess/json_formatter.py new file mode 100644 index 0000000..ee4c86b --- /dev/null +++ b/src/cordon/postprocess/json_formatter.py @@ -0,0 +1,48 @@ +"""JSON output formatter for anomaly blocks.""" + +import json +from collections.abc import Sequence + +from cordon.core.types import MergedBlock + + +class JsonFormatter: + """Format merged blocks as a JSON document. + + Produces a JSON object with an 'anomalies' array where each entry + contains start_line, end_line, score, and the original line content. + """ + + def format_blocks( + self, + merged_blocks: Sequence[MergedBlock], + lines: Sequence[tuple[int, str]], + ) -> str: + """Format merged blocks into a JSON string. + + Args: + merged_blocks: Sequence of merged blocks to format. + lines: Sequence of (line_number, line_content) tuples. + + Returns: + JSON string with anomaly blocks. + """ + sorted_blocks = sorted(merged_blocks, key=lambda b: b.start_line) + line_map = dict(lines) + + anomalies = [] + for block in sorted_blocks: + content_lines = [] + for line_num in range(block.start_line, block.end_line + 1): + content_lines.append(line_map.get(line_num, "")) + + anomalies.append( + { + "start_line": block.start_line, + "end_line": block.end_line, + "score": round(block.max_score, 4), + "content": "\n".join(content_lines), + } + ) + + return json.dumps({"anomalies": anomalies}, indent=2) diff --git a/tests/test_cli.py b/tests/test_cli.py index a0db220..219bcf3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,6 +72,18 @@ def test_detailed_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: args = parse_args() assert args.detailed is True + def test_format_json(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test --format json flag.""" + monkeypatch.setattr(sys, "argv", ["cordon", "--format", "json", "test.log"]) + args = parse_args() + assert args.output_format == "json" + + def test_format_default_xml(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --format defaults to xml.""" + monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) + args = parse_args() + assert args.output_format == "xml" + class TestAnalyzeFile: """Tests for analyze_file function.""" diff --git a/tests/test_core.py b/tests/test_core.py index 5332b73..8e07112 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,7 +3,7 @@ import pytest from cordon.core.config import AnalysisConfig -from cordon.core.types import MergedBlock, ScoredWindow, TextWindow +from cordon.core.types import AnalysisResult, MergedBlock, ScoredWindow, TextWindow class TestTextWindow: @@ -167,3 +167,52 @@ def test_max_line_length_validation(self) -> None: assert config.max_line_length is None config = AnalysisConfig(max_line_length=500) assert config.max_line_length == 500 + + def test_output_format_default(self) -> None: + """Test that output_format defaults to xml.""" + config = AnalysisConfig() + assert config.output_format == "xml" + + def test_output_format_json(self) -> None: + """Test that output_format can be set to json.""" + config = AnalysisConfig(output_format="json") + assert config.output_format == "json" + + +class TestAnalysisResult: + """Tests for AnalysisResult dataclass.""" + + def test_blocks_field_populated(self) -> None: + """Test that blocks field stores MergedBlock instances.""" + blocks = [ + MergedBlock(start_line=1, end_line=4, original_windows=(0,), max_score=0.5), + MergedBlock(start_line=10, end_line=16, original_windows=(2, 3), max_score=0.9), + ] + result = AnalysisResult( + output="", + blocks=blocks, + total_lines=100, + total_windows=25, + significant_windows=3, + merged_blocks=2, + score_distribution={"min": 0.0, "max": 1.0, "mean": 0.5, "median": 0.5, "p90": 0.9}, + processing_time=1.0, + ) + assert result.blocks == blocks + assert len(result.blocks) == 2 + assert result.blocks[0].start_line == 1 + assert result.blocks[1].max_score == 0.9 + + def test_empty_blocks(self) -> None: + """Test that blocks field can be empty.""" + result = AnalysisResult( + output="", + blocks=[], + total_lines=0, + total_windows=0, + significant_windows=0, + merged_blocks=0, + score_distribution={"min": 0.0, "max": 0.0, "mean": 0.0, "median": 0.0, "p90": 0.0}, + processing_time=0.0, + ) + assert result.blocks == [] diff --git a/tests/test_integration.py b/tests/test_integration.py index eb563d3..3c580ac 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -41,6 +41,7 @@ def test_analyze_simple_log(self) -> None: assert "min" in result.score_distribution assert "max" in result.score_distribution assert result.output is not None + assert isinstance(result.blocks, list) finally: temp_path.unlink() diff --git a/tests/test_json_formatter.py b/tests/test_json_formatter.py new file mode 100644 index 0000000..080d7c9 --- /dev/null +++ b/tests/test_json_formatter.py @@ -0,0 +1,62 @@ +"""Tests for JsonFormatter.""" + +import json + +from cordon.core.types import MergedBlock +from cordon.postprocess.json_formatter import JsonFormatter + + +class TestJsonFormatter: + """Tests for JsonFormatter output.""" + + def test_format_single_block(self) -> None: + """Test formatting a single anomaly block.""" + lines = [(1, "line 1"), (2, "line 2"), (3, "line 3")] + blocks = [MergedBlock(start_line=1, end_line=2, original_windows=(0,), max_score=0.8)] + formatter = JsonFormatter() + output = formatter.format_blocks(blocks, lines) + data = json.loads(output) + assert len(data["anomalies"]) == 1 + assert data["anomalies"][0]["start_line"] == 1 + assert data["anomalies"][0]["end_line"] == 2 + assert data["anomalies"][0]["score"] == 0.8 + assert "line 1" in data["anomalies"][0]["content"] + + def test_format_multiple_blocks(self) -> None: + """Test formatting multiple anomaly blocks.""" + lines = [(i, f"line {i}") for i in range(1, 11)] + blocks = [ + MergedBlock(start_line=1, end_line=2, original_windows=(0,), max_score=0.8), + MergedBlock(start_line=7, end_line=9, original_windows=(1,), max_score=0.9), + ] + formatter = JsonFormatter() + output = formatter.format_blocks(blocks, lines) + data = json.loads(output) + assert len(data["anomalies"]) == 2 + assert data["anomalies"][0]["score"] == 0.8 + assert data["anomalies"][1]["score"] == 0.9 + + def test_format_empty_blocks(self) -> None: + """Test formatting with no blocks produces empty anomalies array.""" + lines = [(1, "line 1")] + formatter = JsonFormatter() + output = formatter.format_blocks([], lines) + data = json.loads(output) + assert data["anomalies"] == [] + + def test_no_xml_escaping_in_json(self) -> None: + """Test that special characters are preserved without XML escaping.""" + lines = [(1, "x < y && z > 10")] + blocks = [MergedBlock(start_line=1, end_line=1, original_windows=(0,), max_score=0.5)] + formatter = JsonFormatter() + output = formatter.format_blocks(blocks, lines) + data = json.loads(output) + assert "x < y && z > 10" in data["anomalies"][0]["content"] + + def test_output_is_valid_json(self) -> None: + """Test that output is always valid JSON.""" + lines = [(1, "test")] + blocks = [MergedBlock(start_line=1, end_line=1, original_windows=(0,), max_score=0.1234)] + formatter = JsonFormatter() + output = formatter.format_blocks(blocks, lines) + json.loads(output) diff --git a/tests/test_pipeline_unit.py b/tests/test_pipeline_unit.py index 548c711..2a16c56 100644 --- a/tests/test_pipeline_unit.py +++ b/tests/test_pipeline_unit.py @@ -43,6 +43,7 @@ def test_custom_formatter(self, mock_create: MagicMock, default_config: Analysis mock_formatter.format_blocks.assert_called_once() assert result.output == "" + assert isinstance(result.blocks, list) @patch("cordon.pipeline.create_embedder") def test_default_components_used_when_none_provided( @@ -84,3 +85,49 @@ def test_custom_scorer(self, mock_create: MagicMock, default_config: AnalysisCon analyzer.analyze_file_detailed(Path("dummy.log")) mock_scorer.score_windows.assert_called_once() + + @patch("cordon.pipeline.create_embedder") + def test_result_blocks_populated( + self, mock_create: MagicMock, default_config: AnalysisConfig + ) -> None: + """Test that result.blocks contains the merged blocks.""" + from cordon.core.types import MergedBlock, ScoredWindow, TextWindow + + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + mock_reader = MagicMock() + mock_reader.read_lines.return_value = iter([(1, "line 1"), (2, "line 2")]) + + expected_blocks = [ + MergedBlock(start_line=1, end_line=2, original_windows=(0,), max_score=0.8), + ] + mock_merger = MagicMock() + mock_merger.merge_windows.return_value = expected_blocks + + mock_thresholder = MagicMock() + window = TextWindow(content="line 1\nline 2", start_line=1, end_line=2, window_id=0) + mock_thresholder.select_significant.return_value = [ScoredWindow(window=window, score=0.8)] + + analyzer = SemanticLogAnalyzer( + default_config, + reader=mock_reader, + merger=mock_merger, + thresholder=mock_thresholder, + ) + result = analyzer.analyze_file_detailed(Path("dummy.log")) + + assert result.blocks == expected_blocks + assert len(result.blocks) == 1 + assert result.blocks[0].start_line == 1 + + @patch("cordon.pipeline.create_embedder") + def test_json_formatter_selected(self, mock_create: MagicMock) -> None: + """Test that JSON formatter is selected when output_format is json.""" + from cordon.postprocess.json_formatter import JsonFormatter + + mock_create.return_value = MagicMock() + config = AnalysisConfig(device="cpu", output_format="json") + analyzer = SemanticLogAnalyzer(config) + assert isinstance(analyzer._formatter, JsonFormatter)