Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/aelfrice/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
83 changes: 83 additions & 0 deletions src/aelfrice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
EDGE_VALENCE,
Belief,
Edge,
FeedbackEvent,
)

# --- Schema ---------------------------------------------------------------
Expand Down Expand Up @@ -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)",
)


Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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.

Expand Down
95 changes: 95 additions & 0 deletions tests/test_feedback_history_schema.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading