From 15f5074c7617bcc4900429cb35e7b744e50e472e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 12:28:12 +0000 Subject: [PATCH 1/3] feat(cli): scope raglogs ask to latest ingestion (#29) Match explain/timeline/compare: default to the latest completed ingestion, with --ingestion-job and --all-ingestions to override. Closes #29. Co-authored-by: Leonardo --- README.md | 12 ++++ src/cli/commands/ask.py | 22 ++++++++ tests/unit/test_cli_ask.py | 109 +++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 tests/unit/test_cli_ask.py diff --git a/README.md b/README.md index 4ed4a6c..3374c13 100644 --- a/README.md +++ b/README.md @@ -564,8 +564,20 @@ raglogs ask "why did login fail?" raglogs ask "what changed before latency increased?" --since 2h raglogs ask "what happened in billing?" --since 1h raglogs ask "why are checkouts failing?" --format json +raglogs ask "why did login fail?" --ingestion-job +raglogs ask "why did login fail?" --all-ingestions ``` +Like `explain` / `timeline` / `compare`, `ask` defaults to the latest completed ingestion. Pass `--ingestion-job` to target a specific job, or `--all-ingestions` to search every ingested log. + +| Flag | Description | +|---|---| +| `--since` | Relative window: `30m`, `1h`, `24h` | +| `--service` | Filter to one service | +| `--format` | `text` or `json` | +| `--ingestion-job` | Scope to a specific ingestion job UUID | +| `--all-ingestions` | Search all historical ingestions, not just the latest | + **How retrieval works** 1. **Semantic** — if `EMBEDDINGS_PROVIDER` is `openai` or `local` *and* log lines were ingested with `--with-embeddings`, the question is embedded and nearest neighbors are fetched from pgvector (`ASK_SEMANTIC_TOP_K`, `ASK_SEMANTIC_MIN_SIMILARITY`). Paraphrases that keyword search would miss (e.g. "why are payments being declined?" vs "Stripe signature verification failed") can still match. diff --git a/src/cli/commands/ask.py b/src/cli/commands/ask.py index 3941496..e97be5a 100644 --- a/src/cli/commands/ask.py +++ b/src/cli/commands/ask.py @@ -15,6 +15,16 @@ def ask_cmd( since: Optional[str] = typer.Option(None, "--since", help="Time window e.g. 2h"), service: Optional[str] = typer.Option(None, "--service", help="Filter by service"), fmt: str = typer.Option("text", "--format", help="Output format: text|json"), + all_ingestions: bool = typer.Option( + False, + "--all-ingestions", + help="Search all historical ingestion data (not just latest)", + ), + ingestion_job: Optional[str] = typer.Option( + None, + "--ingestion-job", + help="Scope search to a specific ingestion job UUID", + ), scope: str = typer.Option( "default", "--scope", @@ -30,6 +40,9 @@ def ask_cmd( console.print("[dim]Example: raglogs ask 'why did login fail?'[/dim]") raise typer.Exit(1) + import uuid + + from src.core.explain.summarizer import get_latest_ingestion_job_id from src.core.retrieval.question_router import answer_question from src.db.session import get_db from src.utils.time import resolve_window @@ -45,12 +58,21 @@ def ask_cmd( with console.status(f"[cyan]Searching logs for: {question}[/cyan]"): try: with get_db() as db: + job_id = None + if ingestion_job: + job_id = uuid.UUID(ingestion_job) + elif not all_ingestions: + # Default: scope to latest ingestion job to avoid mixing incidents + job_id = get_latest_ingestion_job_id(db, scope=scope) + if job_id: + console.print(f"[dim]Scoped to latest ingestion: {job_id}[/dim]") result = answer_question( db=db, question=question, window_start=window_start, window_end=window_end, service=service, + ingestion_job_id=job_id, scope=scope, ) except Exception as e: diff --git a/tests/unit/test_cli_ask.py b/tests/unit/test_cli_ask.py new file mode 100644 index 0000000..d342e7a --- /dev/null +++ b/tests/unit/test_cli_ask.py @@ -0,0 +1,109 @@ +"""CLI wiring for `raglogs ask` ingestion scoping. + +The core retrieval path already accepts `ingestion_job_id`; these tests +cover Typer flag wiring only (no database). +""" +import uuid +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from src.cli.main import app +from src.core.retrieval.question_router import AskResult + +runner = CliRunner() + + +def _mock_result() -> AskResult: + return AskResult( + question="why did login fail?", + answer_text="Auth token invalid in api.", + evidence_items=["12 events: Auth token invalid"], + clusters_used=[], + total_matches=12, + retrieval_mode="keyword", + ) + + +def _ctx_db() -> MagicMock: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + return mock_db + + +class TestAskCliIngestionFlags: + def test_help_lists_ingestion_flags(self): + result = runner.invoke(app, ["ask", "--help"]) + assert result.exit_code == 0 + assert "--ingestion-job" in result.output + assert "--all-ingestions" in result.output + + def test_defaults_to_latest_completed_ingestion(self): + job_id = uuid.uuid4() + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch( + "src.core.explain.summarizer.get_latest_ingestion_job_id", + return_value=job_id, + ) as mock_latest, \ + patch( + "src.core.retrieval.question_router.answer_question", + return_value=_mock_result(), + ) as mock_answer: + result = runner.invoke(app, ["ask", "why did login fail?"]) + + assert result.exit_code == 0 + mock_latest.assert_called_once_with(mock_db, scope="default") + assert mock_answer.call_args.kwargs["ingestion_job_id"] == job_id + + def test_ingestion_job_flag_passed_through(self): + job_id = str(uuid.uuid4()) + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch( + "src.core.explain.summarizer.get_latest_ingestion_job_id", + ) as mock_latest, \ + patch( + "src.core.retrieval.question_router.answer_question", + return_value=_mock_result(), + ) as mock_answer: + result = runner.invoke(app, [ + "ask", "why did login fail?", "--ingestion-job", job_id, + ]) + + assert result.exit_code == 0 + mock_latest.assert_not_called() + assert str(mock_answer.call_args.kwargs["ingestion_job_id"]) == job_id + + def test_all_ingestions_skips_scoping(self): + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch( + "src.core.explain.summarizer.get_latest_ingestion_job_id", + ) as mock_latest, \ + patch( + "src.core.retrieval.question_router.answer_question", + return_value=_mock_result(), + ) as mock_answer: + result = runner.invoke(app, [ + "ask", "why did login fail?", "--all-ingestions", + ]) + + assert result.exit_code == 0 + mock_latest.assert_not_called() + assert mock_answer.call_args.kwargs["ingestion_job_id"] is None + + def test_invalid_ingestion_job_exits_1(self): + mock_db = _ctx_db() + with patch("src.db.session.get_db", side_effect=lambda: mock_db), \ + patch( + "src.core.retrieval.question_router.answer_question", + ) as mock_answer: + result = runner.invoke(app, [ + "ask", "why did login fail?", "--ingestion-job", "not-a-uuid", + ]) + + assert result.exit_code == 1 + mock_answer.assert_not_called() + assert "Error" in result.output From 83cf74a0a4a6473745c7c4cb0553b2b45dc97923 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 12:29:48 +0000 Subject: [PATCH 2/3] fix(cli): register ask as a command so flags follow the question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested Typer groups treat trailing options as subcommands, so `raglogs ask "…" --ingestion-job` failed. Register ask like explain. Co-authored-by: Leonardo --- src/cli/commands/ask.py | 6 ------ src/cli/main.py | 4 ++-- tests/unit/test_cli_ask.py | 8 ++++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/ask.py b/src/cli/commands/ask.py index e97be5a..c2a28bf 100644 --- a/src/cli/commands/ask.py +++ b/src/cli/commands/ask.py @@ -4,13 +4,10 @@ from rich.console import Console from rich.panel import Panel -app = typer.Typer() console = Console() -@app.callback(invoke_without_command=True) def ask_cmd( - ctx: typer.Context, question: Optional[str] = typer.Argument(None, help="Your question about the logs"), since: Optional[str] = typer.Option(None, "--since", help="Time window e.g. 2h"), service: Optional[str] = typer.Option(None, "--service", help="Filter by service"), @@ -32,9 +29,6 @@ def ask_cmd( ), ): """Ask a natural language question about your logs.""" - if ctx.invoked_subcommand is not None: - return - if not question: console.print("[red]Error:[/red] Please provide a question.") console.print("[dim]Example: raglogs ask 'why did login fail?'[/dim]") diff --git a/src/cli/main.py b/src/cli/main.py index 9c97db9..f3e6051 100644 --- a/src/cli/main.py +++ b/src/cli/main.py @@ -11,7 +11,7 @@ def _build_app() -> typer.Typer: from src.cli.commands.clusters import clusters_cmd from src.cli.commands.status import status_cmd from src.cli.commands.config_cmd import config_cmd - from src.cli.commands.ask import app as ask_app + from src.cli.commands.ask import ask_cmd from src.cli.commands.demo import demo_cmd from src.cli.commands.worker import worker_cmd from src.cli.commands.timeline import timeline_cmd @@ -37,7 +37,7 @@ def _build_app() -> typer.Typer: _app.command("timeline")(timeline_cmd) _app.command("compare")(compare_cmd) _app.command("purge")(purge_cmd) - _app.add_typer(ask_app, name="ask") + _app.command("ask")(ask_cmd) _app.add_typer(keys_app, name="keys") return _app diff --git a/tests/unit/test_cli_ask.py b/tests/unit/test_cli_ask.py index d342e7a..ecde149 100644 --- a/tests/unit/test_cli_ask.py +++ b/tests/unit/test_cli_ask.py @@ -53,7 +53,7 @@ def test_defaults_to_latest_completed_ingestion(self): ) as mock_answer: result = runner.invoke(app, ["ask", "why did login fail?"]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output mock_latest.assert_called_once_with(mock_db, scope="default") assert mock_answer.call_args.kwargs["ingestion_job_id"] == job_id @@ -72,7 +72,7 @@ def test_ingestion_job_flag_passed_through(self): "ask", "why did login fail?", "--ingestion-job", job_id, ]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output mock_latest.assert_not_called() assert str(mock_answer.call_args.kwargs["ingestion_job_id"]) == job_id @@ -90,7 +90,7 @@ def test_all_ingestions_skips_scoping(self): "ask", "why did login fail?", "--all-ingestions", ]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output mock_latest.assert_not_called() assert mock_answer.call_args.kwargs["ingestion_job_id"] is None @@ -104,6 +104,6 @@ def test_invalid_ingestion_job_exits_1(self): "ask", "why did login fail?", "--ingestion-job", "not-a-uuid", ]) - assert result.exit_code == 1 + assert result.exit_code == 1, result.output mock_answer.assert_not_called() assert "Error" in result.output From 21c4149322b8f59a0a771fac9844a180be08202b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 12:33:59 +0000 Subject: [PATCH 3/3] test(cli): make ask --help flag assertions CI-stable GitHub Actions wraps Rich help, so `--ingestion-job` can split across lines or pick up ANSI. Strip color, collapse whitespace, and pin a wide dumb terminal before asserting the flags. Co-authored-by: Leonardo --- tests/unit/test_cli_ask.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_cli_ask.py b/tests/unit/test_cli_ask.py index ecde149..d6a1278 100644 --- a/tests/unit/test_cli_ask.py +++ b/tests/unit/test_cli_ask.py @@ -3,6 +3,7 @@ The core retrieval path already accepts `ingestion_job_id`; these tests cover Typer flag wiring only (no database). """ +import re import uuid from unittest.mock import MagicMock, patch @@ -13,6 +14,13 @@ runner = CliRunner() +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _plain_help(text: str) -> str: + """Strip ANSI and whitespace so wrapped flags like `--ingestion-\\njob` match.""" + return re.sub(r"\s+", "", _ANSI_RE.sub("", text)) + def _mock_result() -> AskResult: return AskResult( @@ -34,10 +42,15 @@ def _ctx_db() -> MagicMock: class TestAskCliIngestionFlags: def test_help_lists_ingestion_flags(self): - result = runner.invoke(app, ["ask", "--help"]) + result = runner.invoke( + app, + ["ask", "--help"], + env={"COLUMNS": "120", "NO_COLOR": "1", "TERM": "dumb"}, + ) assert result.exit_code == 0 - assert "--ingestion-job" in result.output - assert "--all-ingestions" in result.output + help_text = _plain_help(result.output) + assert "--ingestion-job" in help_text + assert "--all-ingestions" in help_text def test_defaults_to_latest_completed_ingestion(self): job_id = uuid.uuid4()