From 0df8fee94768796b1b0e2012febe74faf7aebb07 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 16 Jun 2026 02:49:57 -0700 Subject: [PATCH] =?UTF-8?q?feat(devpulse):=20compass=20v2=20P1=20=E2=80=94?= =?UTF-8?q?=20SQLite/FTS5=20rated=20decision=20store=20(DPLAN-0212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage core for devpulse-owned Compass: decisions table + FTS5 BM25 search, ratings (good/bad/impressive/interesting), add/query/stats/rate/archive/review. Stdlib sqlite3 only, branch-root-relative DB path, fail-loud validation. DB path gitignored (private decision data). 26 tests, seedgo 29/29. Fresh start, no migration. Navigator burial + public-ize deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aipass/devpulse/.gitignore | 3 + src/aipass/devpulse/.seedgo/bypass.json | 5 + .../apps/handlers/compass/__init__.py | 43 ++ .../devpulse/apps/handlers/compass/store.py | 416 ++++++++++++++++++ .../devpulse/tests/test_compass_store.py | 285 ++++++++++++ 5 files changed, 752 insertions(+) create mode 100644 src/aipass/devpulse/apps/handlers/compass/__init__.py create mode 100644 src/aipass/devpulse/apps/handlers/compass/store.py create mode 100644 src/aipass/devpulse/tests/test_compass_store.py diff --git a/src/aipass/devpulse/.gitignore b/src/aipass/devpulse/.gitignore index f4eb447c..d6754f44 100644 --- a/src/aipass/devpulse/.gitignore +++ b/src/aipass/devpulse/.gitignore @@ -14,3 +14,6 @@ build/ *.swp my-project +# Compass decision store — private decision data, never commit (DPLAN-0212) +devpulse_json/compass/ + diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index 0e1f5d00..63a3dd4c 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -109,6 +109,11 @@ "standard": "documentation", "file": "tools/rm_shim/redteam_suite.py", "reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant." + }, + { + "standard": "encapsulation", + "file": "tests/test_compass_store.py", + "reason": "Storage-layer unit test imports the compass handler's store submodule directly to exercise the _verify_fts5 FTS5 probe — there is no apps/modules/ command entry point for compass until P2 (DPLAN-0212). Same pattern as test_feedback_storage.py." } ], "notes": { diff --git a/src/aipass/devpulse/apps/handlers/compass/__init__.py b/src/aipass/devpulse/apps/handlers/compass/__init__.py new file mode 100644 index 00000000..7ec0cbc3 --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/compass/__init__.py @@ -0,0 +1,43 @@ +# =================== AIPass ==================== +# Name: __init__.py +# Description: Compass handler package — devpulse-owned rated decision store +# Version: 1.0.0 +# Created: 2026-06-16 +# Modified: 2026-06-16 +# ============================================= + +"""Compass — devpulse-owned, SQLite/FTS5-backed rated decision store. + +P1 ships the storage core only (``store.py``). The drone command, slash +command, and maintenance wiring arrive in later phases (see DPLAN-0212). + +The package is the public entry point: the storage API is re-exported here so +callers (later-phase commands, tests) depend on ``compass`` rather than +reaching into the ``store`` submodule directly. +""" + +from aipass.devpulse.apps.handlers.compass.store import ( + DEFAULT_DB_PATH, + VALID_RATINGS, + VALID_SOURCES, + VALID_STATUSES, + add_decision, + archive, + query_decisions, + rate, + review, + stats, +) + +__all__ = [ + "DEFAULT_DB_PATH", + "VALID_RATINGS", + "VALID_SOURCES", + "VALID_STATUSES", + "add_decision", + "archive", + "query_decisions", + "rate", + "review", + "stats", +] diff --git a/src/aipass/devpulse/apps/handlers/compass/store.py b/src/aipass/devpulse/apps/handlers/compass/store.py new file mode 100644 index 00000000..048432fe --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/compass/store.py @@ -0,0 +1,416 @@ +# =================== AIPass ==================== +# Name: store.py +# Description: Compass storage core — SQLite/FTS5 rated decision store +# Version: 1.0.0 +# Created: 2026-06-16 +# Modified: 2026-06-16 +# ============================================= + +"""Compass storage core — the SQLite/FTS5 layer for rated decisions. + +Compass holds short, *rated* decisions (``good | bad | impressive | +interesting``). The rating is the signal: repeat the good, avoid the bad. +This is deliberately separate from the @memory vector store — compass is +truth (curated), @memory is story (ingest-all). See DPLAN-0212. + +Storage is one SQLite file with a ``decisions`` table and an FTS5 virtual +table (``decisions_fts``) mirroring ``context, decision, note, tags``. Search +ranks by FTS5 BM25 relevance. Stdlib ``sqlite3`` only — no embeddings, no +ChromaDB, no extra dependencies. FTS5 is compiled into standard CPython's +sqlite3; availability is verified at runtime and a clear error is raised if +it is missing. + +Every public function accepts an optional ``db_path`` so tests can point at a +temp file. The default path is resolved relative to the branch root via +``Path(__file__)`` parents — never a hardcoded absolute path. + +This module is the storage core ONLY (FPLAN P1). The drone command, slash +command, and maintenance UX are later phases. +""" + +import logging +import sqlite3 +from datetime import date +from pathlib import Path +from typing import Optional + +# Prefer the prax system logger so compass logs land with the rest of the +# ecosystem; fall back to stdlib logging if prax is unavailable in this +# context (e.g. an isolated test run). Verified working at build time — +# prax import is clean here, so this is the active path. +try: + from aipass.prax.apps.modules.logger import system_logger as logger +except Exception as _prax_exc: # pragma: no cover - defensive fallback only + logger = logging.getLogger("aipass.devpulse.compass") + logger.info("[compass] prax logger unavailable, using stdlib logging: %s", _prax_exc) + +from aipass.devpulse.apps.handlers.json import json_handler + +# Branch-root-relative default DB path. store.py lives at +# /apps/handlers/compass/store.py, so parents[3] is the branch +# root. NEVER hardcode an absolute /home/... path here. +_BRANCH_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_DB_PATH = _BRANCH_ROOT / "devpulse_json" / "compass" / "compass.db" + +VALID_RATINGS = ("good", "bad", "impressive", "interesting") +VALID_SOURCES = ("devpulse", "patrick") +VALID_STATUSES = ("active", "archived") + +# Columns we return / surface from the decisions table (everything useful). +_DECISION_COLUMNS = ( + "id", + "created", + "context", + "decision", + "rating", + "note", + "tags", + "source", + "score", + "status", + "last_reviewed", + "times_surfaced", +) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY, + created TEXT, + context TEXT NOT NULL, + decision TEXT NOT NULL, + rating TEXT NOT NULL CHECK(rating IN ('good','bad','impressive','interesting')), + note TEXT, + tags TEXT, + source TEXT NOT NULL DEFAULT 'devpulse', + score INTEGER, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), + last_reviewed TEXT, + times_surfaced INTEGER NOT NULL DEFAULT 0 +); + +CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5( + context, + decision, + note, + tags, + content='decisions', + content_rowid='id' +); + +-- Keep the FTS5 mirror in sync with the decisions table via triggers. +CREATE TRIGGER IF NOT EXISTS decisions_ai AFTER INSERT ON decisions BEGIN + INSERT INTO decisions_fts(rowid, context, decision, note, tags) + VALUES (new.id, new.context, new.decision, new.note, new.tags); +END; + +CREATE TRIGGER IF NOT EXISTS decisions_ad AFTER DELETE ON decisions BEGIN + INSERT INTO decisions_fts(decisions_fts, rowid, context, decision, note, tags) + VALUES ('delete', old.id, old.context, old.decision, old.note, old.tags); +END; + +CREATE TRIGGER IF NOT EXISTS decisions_au AFTER UPDATE ON decisions BEGIN + INSERT INTO decisions_fts(decisions_fts, rowid, context, decision, note, tags) + VALUES ('delete', old.id, old.context, old.decision, old.note, old.tags); + INSERT INTO decisions_fts(rowid, context, decision, note, tags) + VALUES (new.id, new.context, new.decision, new.note, new.tags); +END; +""" + + +def _verify_fts5(conn: sqlite3.Connection) -> None: + """Raise a clear error if FTS5 is not compiled into this sqlite3. + + FTS5 ships with standard CPython, but we never assume — a missing module + must fail loudly, not silently degrade search. + """ + try: + conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS _fts5_probe USING fts5(x)") + conn.execute("DROP TABLE IF EXISTS _fts5_probe") + except sqlite3.OperationalError as exc: + raise RuntimeError( + "Compass requires SQLite FTS5, which is not available in this " + "Python's sqlite3 build. Compass cannot operate without it." + ) from exc + + +def _resolve_db_path(db_path: Optional[Path | str]) -> Path: + """Resolve the effective DB path, defaulting to the branch-root location.""" + return Path(db_path) if db_path is not None else DEFAULT_DB_PATH + + +def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection: + """Open (and lazily initialise) the compass DB. + + Creates parent directories on first use, verifies FTS5, ensures schema. + Rows come back as ``sqlite3.Row`` so we can build clean dicts. + """ + path = _resolve_db_path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + _verify_fts5(conn) + conn.executescript(_SCHEMA) + return conn + + +def _row_to_dict(row: sqlite3.Row) -> dict: + """Convert a decisions ``sqlite3.Row`` into a plain dict of useful fields.""" + return {col: row[col] for col in _DECISION_COLUMNS} + + +def add_decision( + context: str, + decision: str, + rating: str, + note: Optional[str] = None, + tags: Optional[str] = None, + source: str = "devpulse", + db_path: Optional[Path | str] = None, + created: Optional[str] = None, +) -> int: + """Add a rated decision and return its new id. + + Args: + context: Short description of the situation / the fork. + decision: Short description of what was chosen. + rating: One of ``good | bad | impressive | interesting``. + note: Optional human observation. + tags: Optional comma-separated tags. + source: ``devpulse`` or ``patrick`` (default ``devpulse``). + db_path: Optional DB path override (tests pass a temp path). + created: Optional ISO date override; defaults to today. This is the + ONLY place a "today" date is stamped. + + Returns: + The new row's integer id. + + Raises: + ValueError: On empty context/decision or invalid rating/source. + """ + if not context or not context.strip(): + raise ValueError("context must be a non-empty string") + if not decision or not decision.strip(): + raise ValueError("decision must be a non-empty string") + if rating not in VALID_RATINGS: + raise ValueError(f"rating must be one of {VALID_RATINGS}, got {rating!r}") + if source not in VALID_SOURCES: + raise ValueError(f"source must be one of {VALID_SOURCES}, got {source!r}") + + stamp = created if created is not None else date.today().isoformat() + + conn = _connect(db_path) + try: + cur = conn.execute( + """ + INSERT INTO decisions (created, context, decision, rating, note, tags, source) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (stamp, context.strip(), decision.strip(), rating, note, tags, source), + ) + conn.commit() + if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT + raise RuntimeError("compass: INSERT did not return a rowid") + new_id = int(cur.lastrowid) + finally: + conn.close() + + logger.info("[compass] added decision id=%s rating=%s source=%s", new_id, rating, source) + json_handler.log_operation("compass_add", {"id": new_id, "rating": rating, "source": source}) + return new_id + + +def query_decisions( + query: str, + rating: Optional[str] = None, + limit: int = 5, + db_path: Optional[Path | str] = None, +) -> list[dict]: + """Search active decisions, ranked by FTS5 BM25 relevance. + + Increments ``times_surfaced`` for every returned row. + + Args: + query: FTS5 match query (keywords). + rating: Optional exact rating filter (one of VALID_RATINGS). + limit: Max rows to return (default 5). + db_path: Optional DB path override. + + Returns: + A list of decision dicts (most relevant first). Each dict includes the + rating and all useful fields. + + Raises: + ValueError: On empty query, bad rating filter, or non-positive limit. + """ + if not query or not query.strip(): + raise ValueError("query must be a non-empty string") + if rating is not None and rating not in VALID_RATINGS: + raise ValueError(f"rating filter must be one of {VALID_RATINGS}, got {rating!r}") + if limit <= 0: + raise ValueError(f"limit must be a positive integer, got {limit!r}") + + select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS) + sql = f""" + SELECT {select_cols} + FROM decisions_fts f + JOIN decisions d ON d.id = f.rowid + WHERE decisions_fts MATCH ? + AND d.status = 'active' + """ + params: list = [query.strip()] + if rating is not None: + sql += " AND d.rating = ?" + params.append(rating) + sql += " ORDER BY bm25(decisions_fts) ASC LIMIT ?" + params.append(limit) + + conn = _connect(db_path) + try: + rows = conn.execute(sql, params).fetchall() + results = [_row_to_dict(r) for r in rows] + ids = [r["id"] for r in results] + if ids: + placeholders = ",".join("?" for _ in ids) + conn.execute( + f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})", + ids, + ) + conn.commit() + # Reflect the increment in the returned dicts without a re-query. + for r in results: + r["times_surfaced"] = (r["times_surfaced"] or 0) + 1 + finally: + conn.close() + + logger.info("[compass] query %r rating=%s -> %d hit(s)", query, rating, len(results)) + return results + + +def stats(db_path: Optional[Path | str] = None) -> dict: + """Return decision counts by rating, by status, and the total. + + Returns: + ``{"total": int, "by_rating": {...}, "by_status": {...}}`` where each + valid rating/status key is always present (zero when none). + """ + conn = _connect(db_path) + try: + total = int(conn.execute("SELECT COUNT(*) FROM decisions").fetchone()[0]) + + by_rating = {r: 0 for r in VALID_RATINGS} + for row in conn.execute("SELECT rating, COUNT(*) AS n FROM decisions GROUP BY rating"): + by_rating[row["rating"]] = int(row["n"]) + + by_status = {s: 0 for s in VALID_STATUSES} + for row in conn.execute("SELECT status, COUNT(*) AS n FROM decisions GROUP BY status"): + by_status[row["status"]] = int(row["n"]) + finally: + conn.close() + + return {"total": total, "by_rating": by_rating, "by_status": by_status} + + +def rate(decision_id: int, rating: str, db_path: Optional[Path | str] = None) -> bool: + """Change the rating of an existing decision. + + Args: + decision_id: Target decision id. + rating: New rating (one of VALID_RATINGS). + db_path: Optional DB path override. + + Returns: + True if a row was updated, False if no such id. + + Raises: + ValueError: On invalid rating. + """ + if rating not in VALID_RATINGS: + raise ValueError(f"rating must be one of {VALID_RATINGS}, got {rating!r}") + + conn = _connect(db_path) + try: + cur = conn.execute("UPDATE decisions SET rating = ? WHERE id = ?", (rating, decision_id)) + conn.commit() + changed = cur.rowcount > 0 + finally: + conn.close() + + logger.info("[compass] rate id=%s -> %s (changed=%s)", decision_id, rating, changed) + json_handler.log_operation("compass_rate", {"id": decision_id, "rating": rating, "changed": changed}) + return changed + + +def archive(decision_id: int, db_path: Optional[Path | str] = None) -> bool: + """Archive a decision (sets ``status='archived'``). + + Archived rows drop out of ``query_decisions`` and ``review`` but are never + deleted — bad decisions are kept on purpose as an avoid-list. + + Args: + decision_id: Target decision id. + db_path: Optional DB path override. + + Returns: + True if a row was updated, False if no such id. + """ + conn = _connect(db_path) + try: + cur = conn.execute("UPDATE decisions SET status = 'archived' WHERE id = ?", (decision_id,)) + conn.commit() + changed = cur.rowcount > 0 + finally: + conn.close() + + logger.info("[compass] archive id=%s (changed=%s)", decision_id, changed) + json_handler.log_operation("compass_archive", {"id": decision_id, "changed": changed}) + return changed + + +def review( + db_path: Optional[Path | str] = None, + reviewed_on: Optional[str] = None, +) -> Optional[dict]: + """Surface ONE active decision to review and stamp ``last_reviewed``. + + Selection prefers the oldest ``last_reviewed`` (NULL/never-reviewed first); + among ties it picks randomly. The chosen row's ``last_reviewed`` is stamped + before returning. + + Args: + db_path: Optional DB path override. + reviewed_on: Optional ISO date override for the stamp; defaults to today. + + Returns: + The reviewed decision dict (with the fresh ``last_reviewed`` stamp), or + None if there are no active decisions. + """ + stamp = reviewed_on if reviewed_on is not None else date.today().isoformat() + + conn = _connect(db_path) + try: + # NULLs first, then oldest reviewed; random() breaks ties (and orders + # within the all-NULL group, satisfying "else random"). + row = conn.execute( + """ + SELECT * FROM decisions + WHERE status = 'active' + ORDER BY (last_reviewed IS NOT NULL), last_reviewed ASC, random() + LIMIT 1 + """ + ).fetchone() + if row is None: + return None + + decision_id = row["id"] + conn.execute("UPDATE decisions SET last_reviewed = ? WHERE id = ?", (stamp, decision_id)) + conn.commit() + + refreshed = conn.execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone() + result = _row_to_dict(refreshed) + finally: + conn.close() + + logger.info("[compass] review surfaced id=%s stamped=%s", result["id"], stamp) + json_handler.log_operation("compass_review", {"id": result["id"], "last_reviewed": stamp}) + return result diff --git a/src/aipass/devpulse/tests/test_compass_store.py b/src/aipass/devpulse/tests/test_compass_store.py new file mode 100644 index 00000000..4abfd957 --- /dev/null +++ b/src/aipass/devpulse/tests/test_compass_store.py @@ -0,0 +1,285 @@ +# =================== AIPass ==================== +# Name: test_compass_store.py +# Description: Tests for the compass SQLite/FTS5 storage core +# Version: 1.0.0 +# Created: 2026-06-16 +# Modified: 2026-06-16 +# ============================================= + +"""Tests for compass storage core — add/query round-trip, rating filter, +stats, rate, archive, review, and FTS5 BM25 ranking. + +Every test uses a tmp_path DB via the ``db_path=`` kwarg — never the real +branch-root compass.db. +""" + +import sqlite3 + +import pytest + +from aipass.devpulse.apps.handlers import compass +from aipass.devpulse.apps.handlers.compass import store # internal probe access only + + +@pytest.fixture +def db(tmp_path): + """Return a temp compass DB path (file created lazily on first use).""" + return tmp_path / "compass.db" + + +class TestFts5Available: + """FTS5 must be present for compass to function at all.""" + + def test_fts5_compiled_in(self): + """The FTS5 probe used by _connect succeeds on this interpreter.""" + conn = sqlite3.connect(":memory:") + try: + store._verify_fts5(conn) # should not raise + finally: + conn.close() + + +class TestAddQueryRoundTrip: + """add → query round-trip: the decision is found and its rating returned.""" + + def test_add_returns_id(self, db): + """add_decision returns a positive integer id.""" + new_id = compass.add_decision( + "Choosing a storage backend", + "Use SQLite with FTS5", + "good", + db_path=db, + ) + assert isinstance(new_id, int) + assert new_id > 0 + + def test_query_finds_added_decision_with_rating(self, db): + """A keyword query finds the added decision and returns its rating + fields.""" + compass.add_decision( + "Choosing a storage backend for compass", + "Use SQLite with FTS5 instead of ChromaDB", + "good", + note="Lighter, no heavy deps", + tags="storage,sqlite", + db_path=db, + ) + results = compass.query_decisions("sqlite", db_path=db) + assert len(results) == 1 + hit = results[0] + assert hit["rating"] == "good" + assert "SQLite" in hit["decision"] + assert hit["context"].startswith("Choosing a storage backend") + # Useful fields are present. + for field in ("id", "created", "note", "tags", "source", "status", "times_surfaced"): + assert field in hit + + def test_query_matches_on_tags_and_note(self, db): + """FTS search matches terms that only appear in the note or tags columns.""" + compass.add_decision( + "Picking a search ranking", + "BM25 ranking", + "interesting", + note="relevance ordering matters", + tags="ranking,fts5", + db_path=db, + ) + # 'relevance' only appears in the note; 'ranking' in tags + context. + assert len(compass.query_decisions("relevance", db_path=db)) == 1 + assert len(compass.query_decisions("ranking", db_path=db)) == 1 + + def test_created_date_is_stamped(self, db): + """add_decision stamps the supplied created date.""" + compass.add_decision("ctx", "dec", "good", created="2026-06-16", db_path=db) + hit = compass.query_decisions("ctx", db_path=db)[0] + assert hit["created"] == "2026-06-16" + + def test_query_increments_times_surfaced(self, db): + """Each query increments times_surfaced for the returned rows.""" + compass.add_decision("surfacing test context", "a decision", "good", db_path=db) + first = compass.query_decisions("surfacing", db_path=db)[0] + assert first["times_surfaced"] == 1 + second = compass.query_decisions("surfacing", db_path=db)[0] + assert second["times_surfaced"] == 2 + + +class TestRatingFilter: + """query rating filter returns only matching-rated rows.""" + + def test_rating_filter_returns_only_bad(self, db): + """rating='bad' returns only the bad-rated decision.""" + compass.add_decision("auth approach alpha", "store passwords in plaintext", "bad", db_path=db) + compass.add_decision("auth approach beta", "hash passwords with bcrypt", "good", db_path=db) + bad = compass.query_decisions("passwords", rating="bad", db_path=db) + assert len(bad) == 1 + assert bad[0]["rating"] == "bad" + assert "plaintext" in bad[0]["decision"] + + def test_invalid_rating_filter_raises(self, db): + """An unknown rating filter raises ValueError.""" + compass.add_decision("ctx", "dec", "good", db_path=db) + with pytest.raises(ValueError): + compass.query_decisions("ctx", rating="terrible", db_path=db) + + +class TestStats: + """stats counts by rating, by status, and total.""" + + def test_stats_counts(self, db): + """stats reports correct totals broken down by rating and status.""" + compass.add_decision("c1", "d1", "good", db_path=db) + compass.add_decision("c2", "d2", "good", db_path=db) + compass.add_decision("c3", "d3", "bad", db_path=db) + archived_id = compass.add_decision("c4", "d4", "interesting", db_path=db) + compass.archive(archived_id, db_path=db) + + s = compass.stats(db_path=db) + assert s["total"] == 4 + assert s["by_rating"]["good"] == 2 + assert s["by_rating"]["bad"] == 1 + assert s["by_rating"]["interesting"] == 1 + assert s["by_rating"]["impressive"] == 0 + assert s["by_status"]["active"] == 3 + assert s["by_status"]["archived"] == 1 + + def test_stats_empty_db(self, db): + """stats on an empty DB returns zeroed counts for all keys.""" + s = compass.stats(db_path=db) + assert s["total"] == 0 + assert s["by_rating"] == {"good": 0, "bad": 0, "impressive": 0, "interesting": 0} + assert s["by_status"] == {"active": 0, "archived": 0} + + +class TestRate: + """rate() changes the rating of an existing decision.""" + + def test_rate_changes_rating(self, db): + """rate updates an existing decision's rating and returns True.""" + did = compass.add_decision("revisited choice", "the chosen path", "good", db_path=db) + assert compass.rate(did, "bad", db_path=db) is True + hit = compass.query_decisions("revisited", db_path=db)[0] + assert hit["rating"] == "bad" + + def test_rate_missing_id_returns_false(self, db): + """rate on a non-existent id returns False (no silent create).""" + compass.add_decision("ctx", "dec", "good", db_path=db) + assert compass.rate(9999, "bad", db_path=db) is False + + def test_rate_invalid_rating_raises(self, db): + """rate with an invalid rating raises ValueError.""" + did = compass.add_decision("ctx", "dec", "good", db_path=db) + with pytest.raises(ValueError): + compass.rate(did, "nope", db_path=db) + + +class TestArchive: + """archive() removes a decision from active query results.""" + + def test_archive_removes_from_active_query(self, db): + """An archived decision no longer appears in active query results.""" + did = compass.add_decision("archivable context", "some decision", "good", db_path=db) + assert len(compass.query_decisions("archivable", db_path=db)) == 1 + assert compass.archive(did, db_path=db) is True + assert compass.query_decisions("archivable", db_path=db) == [] + + def test_archive_missing_id_returns_false(self, db): + """archive on a non-existent id returns False.""" + assert compass.archive(9999, db_path=db) is False + + +class TestReview: + """review() surfaces one active entry and stamps last_reviewed.""" + + def test_review_returns_entry_and_stamps(self, db): + """review returns an entry and stamps its last_reviewed date.""" + compass.add_decision("review me context", "a reviewable decision", "good", db_path=db) + result = compass.review(db_path=db, reviewed_on="2026-06-16") + assert result is not None + assert result["last_reviewed"] == "2026-06-16" + + def test_review_prefers_never_reviewed_first(self, db): + """review surfaces a never-reviewed (NULL last_reviewed) entry before reviewed ones.""" + first = compass.add_decision("alpha context", "alpha decision", "good", db_path=db) + second = compass.add_decision("beta context", "beta decision", "good", db_path=db) + # First review picks one of the two NULL-last_reviewed rows and stamps it. + picked = compass.review(db_path=db, reviewed_on="2026-06-10") + assert picked is not None + assert picked["id"] in (first, second) + # The other row is still NULL → it must be surfaced next (NULL-first). + other = second if picked["id"] == first else first + next_picked = compass.review(db_path=db, reviewed_on="2026-06-11") + assert next_picked is not None + assert next_picked["id"] == other + assert next_picked["last_reviewed"] == "2026-06-11" + + def test_review_none_when_empty(self, db): + """review returns None when there are no active decisions.""" + assert compass.review(db_path=db) is None + + def test_review_skips_archived(self, db): + """review ignores archived decisions and returns None when only archived exist.""" + did = compass.add_decision("only entry", "decision", "good", db_path=db) + compass.archive(did, db_path=db) + assert compass.review(db_path=db) is None + + +class TestFts5Ranking: + """FTS5 BM25 ranking returns the more relevant entry first.""" + + def test_more_relevant_first(self, db): + """The entry with denser keyword matches ranks ahead of the sparse one.""" + # Entry A mentions 'caching' once; entry B mentions it repeatedly and + # in multiple fields → B should rank above A for the term 'caching'. + compass.add_decision( + "A general note about performance", + "We briefly touched on caching", + "interesting", + db_path=db, + ) + compass.add_decision( + "Caching strategy for the API", + "Add a caching layer with caching invalidation", + "good", + note="caching is the dominant theme here", + tags="caching,performance", + db_path=db, + ) + results = compass.query_decisions("caching", limit=5, db_path=db) + assert len(results) == 2 + # The caching-heavy entry ranks first. + assert results[0]["context"].startswith("Caching strategy") + assert results[0]["rating"] == "good" + + +class TestInputValidation: + """Bad input raises, never silently no-ops.""" + + def test_empty_context_raises(self, db): + """add_decision rejects an empty/whitespace context.""" + with pytest.raises(ValueError): + compass.add_decision(" ", "dec", "good", db_path=db) + + def test_empty_decision_raises(self, db): + """add_decision rejects an empty decision.""" + with pytest.raises(ValueError): + compass.add_decision("ctx", "", "good", db_path=db) + + def test_invalid_rating_raises(self, db): + """add_decision rejects an out-of-range rating.""" + with pytest.raises(ValueError): + compass.add_decision("ctx", "dec", "meh", db_path=db) + + def test_invalid_source_raises(self, db): + """add_decision rejects an unknown source.""" + with pytest.raises(ValueError): + compass.add_decision("ctx", "dec", "good", source="stranger", db_path=db) + + def test_empty_query_raises(self, db): + """query_decisions rejects an empty query string.""" + with pytest.raises(ValueError): + compass.query_decisions(" ", db_path=db) + + def test_nonpositive_limit_raises(self, db): + """query_decisions rejects a non-positive limit.""" + compass.add_decision("ctx", "dec", "good", db_path=db) + with pytest.raises(ValueError): + compass.query_decisions("ctx", limit=0, db_path=db)