diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e427d..3a9ca4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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" diff --git a/services/api/app/routes/audit.py b/services/api/app/routes/audit.py index 76560a5..057c179 100644 --- a/services/api/app/routes/audit.py +++ b/services/api/app/routes/audit.py @@ -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 [ @@ -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, diff --git a/services/api/tests/test_audit_list_bounded.py b/services/api/tests/test_audit_list_bounded.py new file mode 100644 index 0000000..e29d3fc --- /dev/null +++ b/services/api/tests/test_audit_list_bounded.py @@ -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