From b9b2cfdf6a7ce5a9be72c2950bcf0e6eefa26e15 Mon Sep 17 00:00:00 2001 From: robotrocketscience Date: Sun, 26 Apr 2026 19:21:33 -0700 Subject: [PATCH] feat: add feedback_history table and Store helpers Adds the audit table that records every successful Bayesian update. Closes the v2.0 gap surfaced in the round-9 instrumentation review, where the audit log only fired on ignored/superseded events. Without this table, the project's feedback regime is not recoverable after the fact and the regime classifier downstream has no real telemetry. Schema: id (autoinc), belief_id, valence (signed float), source (text), created_at (ISO timestamp). Indexed on belief_id for per-belief lookups. New FeedbackEvent dataclass; Store gains insert_feedback_event(), list_feedback_events(belief_id=None, limit=100), and count_feedback_events(belief_id=None). The insert path is callable but no production code writes to it yet; apply_feedback in the next commit becomes the single writer. Nine atomic short tests cover: empty state, single insert returns rowid, distinct rowids per insert, total/per-belief counts, typed-object round-trip, ordering by id DESC, limit clamp, per-belief filter. --- src/aelfrice/models.py | 16 +++++ src/aelfrice/store.py | 83 +++++++++++++++++++++++ tests/test_feedback_history_schema.py | 95 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 tests/test_feedback_history_schema.py diff --git a/src/aelfrice/models.py b/src/aelfrice/models.py index 7d24088fc..e7cc5df0d 100644 --- a/src/aelfrice/models.py +++ b/src/aelfrice/models.py @@ -77,3 +77,19 @@ class Edge: dst: str type: str weight: float + + +@dataclass +class FeedbackEvent: + """One row in the feedback_history audit log. + + Recorded for every successful apply_feedback call so the project's + feedback regime can be characterized after the fact. Closes the v2.0 + gap where the audit table only logged ignored/superseded events. + """ + + id: int + belief_id: str + valence: float + source: str + created_at: str diff --git a/src/aelfrice/store.py b/src/aelfrice/store.py index 8ab96774a..0c1a56227 100644 --- a/src/aelfrice/store.py +++ b/src/aelfrice/store.py @@ -21,6 +21,7 @@ EDGE_VALENCE, Belief, Edge, + FeedbackEvent, ) # --- Schema --------------------------------------------------------------- @@ -54,8 +55,18 @@ CREATE VIRTUAL TABLE IF NOT EXISTS beliefs_fts USING fts5(id UNINDEXED, content, tokenize='porter unicode61') """, + """ + CREATE TABLE IF NOT EXISTS feedback_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + belief_id TEXT NOT NULL, + valence REAL NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """, "CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src)", "CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst)", + "CREATE INDEX IF NOT EXISTS idx_feedback_belief ON feedback_history(belief_id)", ) @@ -84,6 +95,16 @@ def _row_to_edge(row: sqlite3.Row) -> Edge: ) +def _row_to_feedback(row: sqlite3.Row) -> FeedbackEvent: + return FeedbackEvent( + id=row["id"], + belief_id=row["belief_id"], + valence=row["valence"], + source=row["source"], + created_at=row["created_at"], + ) + + class Store: """SQLite store. Pass `:memory:` for tests, a path otherwise.""" @@ -186,6 +207,68 @@ def search_beliefs(self, query: str, limit: int = 20) -> list[Belief]: ) return [_row_to_belief(r) for r in cur.fetchall()] + # --- Feedback history ------------------------------------------------ + + def insert_feedback_event( + self, + belief_id: str, + valence: float, + source: str, + created_at: str, + ) -> int: + """Append one row to feedback_history; return its rowid. + + Called by apply_feedback() for every successful Bayesian update. + """ + cur = self._conn.execute( + """ + INSERT INTO feedback_history (belief_id, valence, source, created_at) + VALUES (?, ?, ?, ?) + """, + (belief_id, valence, source, created_at), + ) + self._conn.commit() + rowid = cur.lastrowid + if rowid is None: + raise RuntimeError("feedback_history insert returned no rowid") + return rowid + + def list_feedback_events( + self, + belief_id: str | None = None, + limit: int = 100, + ) -> list[FeedbackEvent]: + """Recent feedback events, ordered by id DESC. Filter by belief if given.""" + if belief_id is None: + cur = self._conn.execute( + "SELECT * FROM feedback_history ORDER BY id DESC LIMIT ?", + (limit,), + ) + else: + cur = self._conn.execute( + """ + SELECT * FROM feedback_history + WHERE belief_id = ? + ORDER BY id DESC LIMIT ? + """, + (belief_id, limit), + ) + return [_row_to_feedback(r) for r in cur.fetchall()] + + def count_feedback_events(self, belief_id: str | None = None) -> int: + """Count rows; total or per-belief.""" + if belief_id is None: + cur = self._conn.execute("SELECT COUNT(*) AS n FROM feedback_history") + else: + cur = self._conn.execute( + "SELECT COUNT(*) AS n FROM feedback_history WHERE belief_id = ?", + (belief_id,), + ) + row = cur.fetchone() + if row is None: + return 0 + return int(row["n"]) + def list_locked_beliefs(self) -> list[Belief]: """All beliefs with lock_level != 'none', ordered by locked_at DESC. diff --git a/tests/test_feedback_history_schema.py b/tests/test_feedback_history_schema.py new file mode 100644 index 000000000..903b8a0db --- /dev/null +++ b/tests/test_feedback_history_schema.py @@ -0,0 +1,95 @@ +"""Smoke tests for the feedback_history schema and Store helpers. + +The audit table records every apply_feedback call so a project's +feedback regime is recoverable after the fact. Pre-commit #5 in scope. + +Tests are split per the deterministic-atomic-short policy: one +property per test, each :memory: store, all run in milliseconds. +""" +from __future__ import annotations + +from aelfrice.models import FeedbackEvent +from aelfrice.store import Store + + +def test_feedback_history_starts_empty() -> None: + s = Store(":memory:") + assert s.list_feedback_events() == [] + assert s.count_feedback_events() == 0 + + +def test_insert_feedback_event_returns_positive_rowid() -> None: + s = Store(":memory:") + rowid = s.insert_feedback_event( + belief_id="b1", + valence=1.0, + source="user", + created_at="2026-04-26T18:00:00Z", + ) + assert rowid > 0 + + +def test_insert_two_events_yields_two_distinct_rowids() -> None: + s = Store(":memory:") + r1 = s.insert_feedback_event("b1", 1.0, "user", "2026-04-26T18:00:00Z") + r2 = s.insert_feedback_event("b1", -1.0, "system", "2026-04-26T18:01:00Z") + assert r1 != r2 + + +def test_count_feedback_events_total_after_three_inserts() -> None: + s = Store(":memory:") + s.insert_feedback_event("b1", 1.0, "user", "2026-04-26T18:00:00Z") + s.insert_feedback_event("b1", -1.0, "system", "2026-04-26T18:01:00Z") + s.insert_feedback_event("b2", 1.0, "user", "2026-04-26T18:02:00Z") + assert s.count_feedback_events() == 3 + + +def test_count_feedback_events_per_belief_filter() -> None: + s = Store(":memory:") + s.insert_feedback_event("b1", 1.0, "user", "2026-04-26T18:00:00Z") + s.insert_feedback_event("b1", -1.0, "system", "2026-04-26T18:01:00Z") + s.insert_feedback_event("b2", 1.0, "user", "2026-04-26T18:02:00Z") + assert s.count_feedback_events("b1") == 2 + assert s.count_feedback_events("b2") == 1 + assert s.count_feedback_events("nonexistent") == 0 + + +def test_list_feedback_events_round_trip_returns_typed_object() -> None: + s = Store(":memory:") + s.insert_feedback_event("b1", 0.7, "user", "2026-04-26T18:00:00Z") + events = s.list_feedback_events() + assert len(events) == 1 + e = events[0] + assert isinstance(e, FeedbackEvent) + assert e.belief_id == "b1" + assert e.valence == 0.7 + assert e.source == "user" + assert e.created_at == "2026-04-26T18:00:00Z" + + +def test_list_feedback_events_orders_by_id_desc() -> None: + s = Store(":memory:") + r1 = s.insert_feedback_event("b1", 1.0, "user", "2026-04-26T18:00:00Z") + r2 = s.insert_feedback_event("b1", -1.0, "user", "2026-04-26T18:01:00Z") + r3 = s.insert_feedback_event("b2", 1.0, "user", "2026-04-26T18:02:00Z") + events = s.list_feedback_events() + ids = [e.id for e in events] + assert ids == [r3, r2, r1] + + +def test_list_feedback_events_limit_caps_result_size() -> None: + s = Store(":memory:") + for i in range(7): + s.insert_feedback_event("b1", 1.0, "user", f"2026-04-26T18:0{i}:00Z") + events = s.list_feedback_events(limit=3) + assert len(events) == 3 + + +def test_list_feedback_events_belief_filter_excludes_others() -> None: + s = Store(":memory:") + s.insert_feedback_event("b1", 1.0, "user", "2026-04-26T18:00:00Z") + s.insert_feedback_event("b2", 1.0, "user", "2026-04-26T18:01:00Z") + s.insert_feedback_event("b1", -1.0, "user", "2026-04-26T18:02:00Z") + events = s.list_feedback_events(belief_id="b1") + belief_ids = {e.belief_id for e in events} + assert belief_ids == {"b1"}