Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ a decision with visible consequences.
call refused), evidence processing stored an empty `local_ai_proposal` analysis, so the
evidence counted as analysed and was never analysed again. Now nothing is stored.

- **The audit list is bounded.** `GET /audit` returned every row ever written; it now returns the
newest `limit` rows (default 500, at most 2000). `/audit/metrics` counts with GROUP BY instead of
loading every row of the window.

- **The conflict check reads whole words and keeps the owner's words in recall.** It counted
"not" inside "knot" or "cannot " and matched long words as substrings over the first eight
only. Now it compares whole words ("not", "never", "no longer", "stopped", "cannot", "-n't"
Expand Down
29 changes: 16 additions & 13 deletions services/api/app/routes/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@
from app.core.db import SessionLocal
from app.models.audit import MemoryAudit
from fastapi import APIRouter
from sqlalchemy import select
from sqlalchemy import func, select

router = APIRouter(prefix="/audit", tags=["audit"])

READ_ACTIONS = {"runtime_context", "runtime_ask", "read"}
WRITE_ACTIONS = {"write", "upgrade", "edit", "delete", "transcript_write", "transcript_delete"}

DEFAULT_LIMIT = 500
MAX_LIMIT = 2000


@router.get("")
def list_audit():
def list_audit(limit: int = DEFAULT_LIMIT):
"""The newest audit rows, at most `limit` (1..2000): the table grows with every read."""
db = SessionLocal()
try:
rows = db.execute(
select(MemoryAudit).order_by(MemoryAudit.created_at.desc())
select(MemoryAudit).order_by(MemoryAudit.created_at.desc(), MemoryAudit.id.desc())
.limit(max(1, min(limit, MAX_LIMIT)))
).scalars().all()

return [
Expand All @@ -38,19 +44,16 @@ def audit_metrics(hours: int = 24):
cutoff = datetime.now(UTC) - timedelta(hours=bounded_hours)
db = SessionLocal()
try:
rows = db.execute(
select(MemoryAudit.action, MemoryAudit.created_at).where(MemoryAudit.created_at >= cutoff)
counts = db.execute(
select(MemoryAudit.action, func.count()).where(MemoryAudit.created_at >= cutoff)
.group_by(MemoryAudit.action)
).all()
by_action: dict[str, int] = {}
reads = 0
writes = 0
for action, _created_at in rows:
for action, count in counts:
key = str(action or "unknown")
by_action[key] = by_action.get(key, 0) + 1
if key in READ_ACTIONS:
reads += 1
if key in WRITE_ACTIONS:
writes += 1
by_action[key] = by_action.get(key, 0) + count
reads = sum(count for key, count in by_action.items() if key in READ_ACTIONS)
writes = sum(count for key, count in by_action.items() if key in WRITE_ACTIONS)
return {
"window_hours": bounded_hours,
"reads": reads,
Expand Down
16 changes: 16 additions & 0 deletions services/api/tests/test_audit_list_bounded.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""The audit list is bounded: it returns the newest rows, at most `limit`."""

from app.models.audit import MemoryAudit
from memory_stack import stack as stack


def test_the_audit_list_returns_at_most_the_limit_newest_first(stack):
with stack.sessions() as db:
db.add_all([MemoryAudit(id=f"a{n:03d}", action="read", payload_json="{}") for n in range(30)])
db.commit()

assert len(stack.get("/audit?limit=10").json()) == 10
assert len(stack.get("/audit?limit=0").json()) == 1
assert len(stack.get("/audit").json()) == 30
metrics = stack.get("/audit/metrics").json()
assert metrics["by_action"]["read"] == 30 and metrics["reads"] == 30
Loading