fix: address three gaps in Task 8 spec compliance

- get_rag_operational_stats() now returns at_risk_count,
  pinned_count, avg_retrieval_count via scroll-based computation
- GET /api/rag/stats requires admin role (was guest-accessible)
- test_rag_stats_endpoint asserts full shape per spec
- Add test_rag_stats_requires_admin (guest → 403)
This commit is contained in:
gramps
2026-07-06 08:05:14 -07:00
parent 36e310e646
commit cb7a6c5cb5
3 changed files with 87 additions and 7 deletions
+60
View File
@@ -199,10 +199,70 @@ async def get_rag_operational_stats() -> dict:
if datetime.fromisoformat(e["timestamp"]) > cutoff_30m if datetime.fromisoformat(e["timestamp"]) > cutoff_30m
) )
pinned_count = 0
avg_retrieval_count = 0.0
at_risk_count = 0
try:
async with httpx.AsyncClient() as client:
pinned_filter = {
"should": [
{"match": {"key": "source", "value": src}}
for src in RAG_PINNED_SOURCES
]
}
pinned_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={"filter": pinned_filter, "limit": 10000, "with_payload": True, "with_vector": False},
timeout=10.0,
)
if pinned_resp.status_code == 200:
pinned_count = len(pinned_resp.json().get("result", {}).get("points", []))
nonpinned_filter = {
"must_not": [
{"match": {"key": "source", "value": src}}
for src in RAG_PINNED_SOURCES
]
}
np_resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json={"filter": nonpinned_filter, "limit": 10000, "with_payload": True, "with_vector": False},
timeout=10.0,
)
if np_resp.status_code == 200:
points = np_resp.json().get("result", {}).get("points", [])
if points:
retrievals = []
scored = []
for p in points:
payload = p.get("payload", {})
rc = payload.get("retrieval_count", 0) or 0
retrievals.append(rc)
date_str = payload.get("ingest_date") or payload.get("upload_date", "")
if date_str:
age_hours = (now - datetime.fromisoformat(date_str)).total_seconds() / 3600
else:
age_hours = 999999
score = rc * RAG_ACCESS_WEIGHT + age_hours * RAG_AGE_WEIGHT
last_accessed = payload.get("last_accessed", date_str)
scored.append((score, last_accessed))
avg_retrieval_count = round(sum(retrievals) / len(retrievals), 2)
scored.sort(key=lambda x: (x[0], x[1]))
at_risk_threshold = max(1, len(scored) // 10)
at_risk_count = at_risk_threshold
except Exception as e:
log.warning(f"RAG operational stats scroll error: {e}")
stats.update({ stats.update({
"grace_hours": RAG_GRACE_HOURS, "grace_hours": RAG_GRACE_HOURS,
"eviction_counts_last_1m": eviction_1m, "eviction_counts_last_1m": eviction_1m,
"eviction_counts_last_5m": eviction_5m, "eviction_counts_last_5m": eviction_5m,
"eviction_counts_last_30m": eviction_30m, "eviction_counts_last_30m": eviction_30m,
"pinned_count": pinned_count,
"avg_retrieval_count": avg_retrieval_count,
"at_risk_count": at_risk_count,
}) })
return stats return stats
+4 -2
View File
@@ -2,7 +2,7 @@
import logging import logging
import httpx import httpx
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from eviction import get_rag_operational_stats, EVICTION_LOG from eviction import get_rag_operational_stats, EVICTION_LOG
@@ -13,7 +13,9 @@ router = APIRouter()
@router.get("/api/rag/stats") @router.get("/api/rag/stats")
async def rag_stats(): async def rag_stats(request: Request):
if getattr(request.state, "session_role", "none") != "admin":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
stats = await get_rag_operational_stats() stats = await get_rag_operational_stats()
stats["eviction_log_size"] = len(EVICTION_LOG) stats["eviction_log_size"] = len(EVICTION_LOG)
return stats return stats
+23 -5
View File
@@ -291,14 +291,32 @@ def test_rag_operational_stats_shape(monkeypatch):
def test_rag_stats_endpoint(tmp_path, monkeypatch): def test_rag_stats_endpoint(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient()) monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client: with make_client(tmp_path) as client:
resp = client.get("/api/rag/stats", headers=_guest_headers(client)) resp = client.get("/api/rag/stats", headers=_admin_headers(client))
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "vector_count" in data
assert "max_vectors" in data
assert "grace_hours" in data
assert "eviction_log_size" in data
assert data["vector_count"] == 123 assert data["vector_count"] == 123
assert data["max_vectors"] == 50000
assert "high_water_mark" in data
assert "low_water_mark" in data
assert data["high_water_pct"] == 80
assert data["low_water_pct"] == 20
assert "percent_full" in data
assert data["pinned_sources"] == ["upload", "profile"]
assert data["grace_hours"] == 1
assert "eviction_counts_last_1m" in data
assert "eviction_counts_last_5m" in data
assert "eviction_counts_last_30m" in data
assert "pinned_count" in data
assert "avg_retrieval_count" in data
assert "at_risk_count" in data
assert "eviction_log_size" in data
def test_rag_stats_requires_admin(tmp_path, monkeypatch):
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: FakeAsyncClient())
with make_client(tmp_path) as client:
resp = client.get("/api/rag/stats", headers=_guest_headers(client))
assert resp.status_code == 403
# ---------- POST /api/rag/flush ---------- # ---------- POST /api/rag/flush ----------