feat: complete RAG runbook workflow and release docs
Some checks failed
CI / test (push) Failing after 15s
Some checks failed
CI / test (push) Failing after 15s
This commit is contained in:
@@ -174,6 +174,7 @@ def test_build_system_prompt_contains_key_instructions() -> None:
|
||||
assert "Evidence" in prompt
|
||||
assert "Recommended Actions" in prompt
|
||||
assert "read-only" in prompt.lower()
|
||||
assert "absent or not installed" in prompt
|
||||
|
||||
|
||||
def test_build_user_message_contains_issue_and_host() -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from typer.testing import CliRunner
|
||||
@@ -31,7 +32,7 @@ def test_run_command_prints_scaffold_summary() -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"run", "apache failed",
|
||||
"--host",
|
||||
"web01",
|
||||
"--port",
|
||||
@@ -62,7 +63,7 @@ def test_probe_success_prints_remote_output_by_default(monkeypatch) -> None: #
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["apache failed", "--host", "ssh.archflux.net", "--port", "5566", "--probe"],
|
||||
["run", "apache failed", "--host", "ssh.archflux.net", "--port", "5566", "--probe"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -84,7 +85,7 @@ def test_probe_failure_returns_non_zero(monkeypatch) -> None: # type: ignore[no
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["apache failed", "--host", "ssh.archflux.net", "--port", "5566", "--probe"],
|
||||
["run", "apache failed", "--host", "ssh.archflux.net", "--port", "5566", "--probe"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -126,7 +127,7 @@ def test_collect_success_prints_summary(monkeypatch) -> None: # type: ignore[no
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"run", "apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
@@ -172,7 +173,7 @@ def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"run", "apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
@@ -210,8 +211,8 @@ def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type:
|
||||
commands = iter(["what should I check next?", "/quit"])
|
||||
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
|
||||
monkeypatch.setattr(
|
||||
"tai.cli.AIClient.stream",
|
||||
lambda *_args, **_kwargs: iter(["Check logs."]),
|
||||
"tai.cli.AIClient.complete",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(content="Check logs."),
|
||||
)
|
||||
monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands))
|
||||
monkeypatch.setattr("tai.cli._stdin_is_tty", lambda: True)
|
||||
@@ -220,7 +221,7 @@ def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"run", "apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
@@ -257,7 +258,10 @@ def test_interactive_prints_rag_fallback_notice_on_index_failure(monkeypatch) ->
|
||||
commands = iter(["what should I check next?", "/quit"])
|
||||
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
|
||||
monkeypatch.setattr("tai.cli._try_embed_report", lambda *_args: (None, "embed failed", 1.0))
|
||||
monkeypatch.setattr("tai.cli.AIClient.stream", lambda *_args, **_kwargs: iter(["Check logs."]))
|
||||
monkeypatch.setattr(
|
||||
"tai.cli.AIClient.complete",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(content="Check logs."),
|
||||
)
|
||||
monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands))
|
||||
monkeypatch.setattr("tai.cli._stdin_is_tty", lambda: True)
|
||||
|
||||
@@ -265,7 +269,7 @@ def test_interactive_prints_rag_fallback_notice_on_index_failure(monkeypatch) ->
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"run", "apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
@@ -310,7 +314,10 @@ def test_interactive_rag_debug_prints_retrieval_scores(monkeypatch) -> None: #
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("tai.cli.AIClient.embed", lambda *_args, **_kwargs: [1.0, 0.0])
|
||||
monkeypatch.setattr("tai.cli.AIClient.stream", lambda *_args, **_kwargs: iter(["Check logs."]))
|
||||
monkeypatch.setattr(
|
||||
"tai.cli.AIClient.complete",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(content="Check logs."),
|
||||
)
|
||||
monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands))
|
||||
monkeypatch.setattr("tai.cli._stdin_is_tty", lambda: True)
|
||||
|
||||
@@ -318,7 +325,7 @@ def test_interactive_rag_debug_prints_retrieval_scores(monkeypatch) -> None: #
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"apache failed",
|
||||
"run", "apache failed",
|
||||
"--host",
|
||||
"ssh.archflux.net",
|
||||
"--port",
|
||||
|
||||
@@ -80,6 +80,7 @@ def test_nginx_in_issue_adds_nginx_service_commands() -> None:
|
||||
plan = plan_from_request(_req("nginx is failing to start"))
|
||||
names = _names(plan)
|
||||
cmds = _commands(plan)
|
||||
assert "unit-file-nginx" in names
|
||||
assert "service-nginx" in names
|
||||
assert "journal-nginx" in names
|
||||
assert any("systemctl status nginx" in c for c in cmds)
|
||||
@@ -98,6 +99,30 @@ def test_sshd_adds_config_cat() -> None:
|
||||
assert any("cat /etc/ssh/sshd_config" in c for c in cmds)
|
||||
|
||||
|
||||
def test_sssd_in_issue_adds_presence_service_and_config_commands() -> None:
|
||||
plan = plan_from_request(_req("troubleshoot sssd login failures"))
|
||||
names = _names(plan)
|
||||
cmds = _commands(plan)
|
||||
assert "unit-file-sssd" in names
|
||||
assert "binary-sssd-1" in names
|
||||
assert "service-sssd" in names
|
||||
assert "journal-sssd" in names
|
||||
assert any("cat /etc/sssd/sssd.conf" in c for c in cmds)
|
||||
assert any("ls -l /usr/sbin/sssd" in c for c in cmds)
|
||||
assert any("list-unit-files sssd.service" in c for c in cmds)
|
||||
|
||||
|
||||
def test_docker_presence_probe_checks_package_and_binary() -> None:
|
||||
plan = plan_from_request(_req("docker daemon not running"))
|
||||
names = _names(plan)
|
||||
cmds = _commands(plan)
|
||||
assert "unit-file-docker" in names
|
||||
assert "binary-docker-1" in names
|
||||
assert "binary-docker-2" in names
|
||||
assert any("ls -l /usr/bin/docker" in c for c in cmds)
|
||||
assert any("ls -l /usr/bin/dockerd" in c for c in cmds)
|
||||
|
||||
|
||||
def test_unknown_service_name_no_config_cat() -> None:
|
||||
plan = plan_from_request(_req("myweirdapp service crashed"))
|
||||
cmds = _commands(plan)
|
||||
|
||||
253
tests/test_runbook_store.py
Normal file
253
tests/test_runbook_store.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Tests for runbook_store — no network calls, ChromaDB mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tai.runbook_store import (
|
||||
RunbookChunk,
|
||||
RunbookMeta,
|
||||
RunbookStore,
|
||||
_build_embed_text,
|
||||
_parse_frontmatter,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_frontmatter_extracts_service() -> None:
|
||||
text = "---\nservice: nginx\n---\n## Body\nsome content"
|
||||
meta, body = _parse_frontmatter(text)
|
||||
assert meta.service == "nginx"
|
||||
assert "## Body" in body
|
||||
|
||||
|
||||
def test_parse_frontmatter_extracts_tags_as_list() -> None:
|
||||
text = "---\ntags: nginx, web, http\n---\nbody"
|
||||
meta, body = _parse_frontmatter(text)
|
||||
assert meta.tags == ["nginx", "web", "http"]
|
||||
|
||||
|
||||
def test_parse_frontmatter_extracts_symptoms_as_list() -> None:
|
||||
text = "---\nsymptoms: 502 Bad Gateway, upstream refused\n---\nbody"
|
||||
meta, body = _parse_frontmatter(text)
|
||||
assert meta.symptoms == ["502 Bad Gateway", "upstream refused"]
|
||||
|
||||
|
||||
def test_parse_frontmatter_returns_empty_meta_when_missing() -> None:
|
||||
text = "# Just a heading\nno frontmatter here"
|
||||
meta, body = _parse_frontmatter(text)
|
||||
assert meta.service == ""
|
||||
assert meta.tags == []
|
||||
assert meta.symptoms == []
|
||||
assert "Just a heading" in body
|
||||
|
||||
|
||||
def test_parse_frontmatter_body_strips_delimiter() -> None:
|
||||
text = "---\nservice: ssh\n---\nBody starts here."
|
||||
_, body = _parse_frontmatter(text)
|
||||
assert body.strip() == "Body starts here."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_embed_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_embed_text_includes_title_and_service() -> None:
|
||||
meta = RunbookMeta(service="nginx", symptoms=["502"], tags=["web"])
|
||||
result = _build_embed_text("nginx", meta, "body content")
|
||||
assert "title: nginx" in result
|
||||
assert "service: nginx" in result
|
||||
|
||||
|
||||
def test_build_embed_text_includes_symptoms_and_tags() -> None:
|
||||
meta = RunbookMeta(service="nginx", symptoms=["502 Bad Gateway"], tags=["web", "http"])
|
||||
result = _build_embed_text("nginx", meta, "body")
|
||||
assert "502 Bad Gateway" in result
|
||||
assert "web" in result
|
||||
|
||||
|
||||
def test_build_embed_text_includes_body_excerpt() -> None:
|
||||
meta = RunbookMeta()
|
||||
result = _build_embed_text("disk", meta, "check df -h output")
|
||||
assert "check df -h output" in result
|
||||
|
||||
|
||||
def test_build_embed_text_truncates_long_body() -> None:
|
||||
meta = RunbookMeta()
|
||||
long_body = "x" * 2000
|
||||
result = _build_embed_text("disk", meta, long_body)
|
||||
# Body excerpt is capped at 800 chars
|
||||
assert len(result) < 1500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RunbookStore — unit tests using tmp_path and mocked chromadb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_chromadb_mock() -> MagicMock:
|
||||
"""Return a chromadb mock that satisfies RunbookStore internals."""
|
||||
collection = MagicMock()
|
||||
collection.count.return_value = 0
|
||||
client = MagicMock()
|
||||
client.get_or_create_collection.return_value = collection
|
||||
chroma_mod = MagicMock()
|
||||
chroma_mod.PersistentClient.return_value = client
|
||||
return chroma_mod
|
||||
|
||||
|
||||
def _make_ai_mock(embedding: list[float] | None = None) -> MagicMock:
|
||||
ai = MagicMock()
|
||||
ai.embed.return_value = embedding or [0.1, 0.2, 0.3]
|
||||
return ai
|
||||
|
||||
|
||||
def test_runbook_store_sync_returns_count(tmp_path: Path) -> None:
|
||||
(tmp_path / "nginx.md").write_text(
|
||||
"---\nservice: nginx\ntags: web\nsymptoms: 502\n---\n## Body\ncontent"
|
||||
)
|
||||
(tmp_path / "ssh.md").write_text(
|
||||
"---\nservice: ssh\ntags: ssh\nsymptoms: refused\n---\n## Body\ncontent"
|
||||
)
|
||||
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
count = store.sync(tmp_path, ai)
|
||||
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_runbook_store_sync_calls_upsert(tmp_path: Path) -> None:
|
||||
(tmp_path / "nginx.md").write_text("---\nservice: nginx\n---\nbody")
|
||||
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
collection = chroma_mock.PersistentClient.return_value.get_or_create_collection.return_value
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
store.sync(tmp_path, ai)
|
||||
|
||||
collection.upsert.assert_called_once()
|
||||
call_kwargs = collection.upsert.call_args.kwargs
|
||||
assert "nginx" in call_kwargs["ids"]
|
||||
|
||||
|
||||
def test_runbook_store_sync_empty_dir_returns_zero(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
count = store.sync(tmp_path, ai)
|
||||
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_runbook_store_sync_missing_dir_raises(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
store.sync(tmp_path / "nonexistent", ai)
|
||||
|
||||
|
||||
def test_runbook_store_query_returns_empty_when_no_docs(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
# collection.count() returns 0 by default in our mock
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
results = store.query("disk full", ai)
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_runbook_store_query_returns_runbook_chunks(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
collection = chroma_mock.PersistentClient.return_value.get_or_create_collection.return_value
|
||||
collection.count.return_value = 2
|
||||
collection.query.return_value = {
|
||||
"documents": [["## Body\ncheck df -h"]],
|
||||
"metadatas": [
|
||||
[{"title": "disk", "service": "disk", "tags": "disk, storage", "symptoms": "full"}]
|
||||
],
|
||||
}
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
results = store.query("disk is full", ai)
|
||||
|
||||
assert len(results) == 1
|
||||
assert isinstance(results[0], RunbookChunk)
|
||||
assert results[0].title == "disk"
|
||||
assert results[0].service == "disk"
|
||||
assert "disk" in results[0].tags
|
||||
assert "df -h" in results[0].content
|
||||
|
||||
|
||||
def test_runbook_store_list_indexed_returns_metadata(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
collection = chroma_mock.PersistentClient.return_value.get_or_create_collection.return_value
|
||||
collection.count.return_value = 1
|
||||
collection.get.return_value = {
|
||||
"metadatas": [{"title": "nginx", "service": "nginx", "tags": "web", "symptoms": "502"}]
|
||||
}
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
entries = store.list_indexed()
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["title"] == "nginx"
|
||||
|
||||
|
||||
def test_runbook_store_count_delegates_to_collection(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
collection = chroma_mock.PersistentClient.return_value.get_or_create_collection.return_value
|
||||
collection.count.return_value = 5
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
assert store.count() == 5
|
||||
|
||||
|
||||
def test_runbook_store_sync_single_upserts_one(tmp_path: Path) -> None:
|
||||
runbook = tmp_path / "nginx.md"
|
||||
runbook.write_text("---\nservice: nginx\ntags: web\n---\nbody text")
|
||||
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
collection = chroma_mock.PersistentClient.return_value.get_or_create_collection.return_value
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
store.sync_single(runbook, ai)
|
||||
|
||||
collection.upsert.assert_called_once()
|
||||
call_kwargs = collection.upsert.call_args.kwargs
|
||||
assert call_kwargs["ids"] == ["nginx"]
|
||||
|
||||
|
||||
def test_runbook_store_sync_single_missing_file_raises(tmp_path: Path) -> None:
|
||||
chroma_mock = _make_chromadb_mock()
|
||||
ai = _make_ai_mock()
|
||||
|
||||
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
|
||||
store = RunbookStore(tmp_path / "store")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
store.sync_single(tmp_path / "missing.md", ai)
|
||||
Reference in New Issue
Block a user