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:
co-authored by
Claude Opus 4.8
parent
16848daf54
commit
0df8fee947
@@ -14,3 +14,6 @@ build/
|
||||
*.swp
|
||||
my-project
|
||||
|
||||
# Compass decision store — private decision data, never commit (DPLAN-0212)
|
||||
devpulse_json/compass/
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user