feat(devpulse): compass v2 P1 — SQLite/FTS5 rated decision store (DPLAN-0212)

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) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-16 02:49:57 -07:00
co-authored by Claude Opus 4.8
parent 16848daf54
commit 0df8fee947
5 changed files with 752 additions and 0 deletions
+3
View File
@@ -14,3 +14,6 @@ build/
*.swp
my-project
# Compass decision store — private decision data, never commit (DPLAN-0212)
devpulse_json/compass/
+5
View File
@@ -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": {
@@ -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",
]
@@ -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
# <branch_root>/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
@@ -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)