B4 RAG Corpus Management UI + AGENTS.md → ai.md rename

- Backend: GET/DELETE/PATCH /api/rag/point/{id}, GET /api/rag/points
  (paginated, semantic search, source filter, sort)
- Frontend: admin-only RAG modal with stats bar, search, paginated table,
  per-row edit (re-embed) and delete, bulk flush with double confirm
- 14 new tests, 214 total passing
- AGENTS.md → ai.md (tool-agnostic), CLAUDE.md now references ai.md
- Bump v0.21.0 → v0.22.0
This commit is contained in:
gramps
2026-07-14 14:57:57 -07:00
parent 990b7c860d
commit 61f5a88673
8 changed files with 728 additions and 86 deletions
+193 -2
View File
@@ -1,17 +1,38 @@
"""JarvisChat routers — RAG corpus management admin endpoints."""
import logging
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from crypto import decrypt_text, encrypt_text
from eviction import get_rag_operational_stats, EVICTION_LOG
from rag import QDRANT_URL, RAG_COLLECTION
from rag import QDRANT_URL, RAG_COLLECTION, EMBED_URL, EMBED_MODEL
log = logging.getLogger("caic")
router = APIRouter()
def _normalise_date(payload: dict) -> str:
return payload.get("ingest_date") or payload.get("upload_date") or ""
def _decrypt_payload(payload: dict) -> dict:
text = payload.get("text", "")
return {
"text": decrypt_text(text),
"source": payload.get("source"),
"type": payload.get("type"),
"date": _normalise_date(payload),
"retrieval_count": payload.get("retrieval_count", 0),
"topic": payload.get("topic"),
"fact": payload.get("fact"),
"filename": payload.get("filename"),
"last_accessed": payload.get("last_accessed"),
}
@router.get("/api/rag/stats")
async def rag_stats(request: Request):
if getattr(request.state, "session_role", "none") != "admin":
@@ -21,8 +42,178 @@ async def rag_stats(request: Request):
return stats
@router.get("/api/rag/points")
async def rag_list_points(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: str = Query(None),
search: str = Query(None),
source: str = Query(None),
sort: str = Query("date"),
order: str = Query("desc"),
):
if getattr(request.state, "session_role", "none") != "admin":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
async with httpx.AsyncClient() as client:
if search:
embed_resp = await client.post(
f"{EMBED_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": search},
timeout=10.0,
)
if embed_resp.status_code != 200:
return JSONResponse(status_code=502, content={"detail": "Embedding failed"})
vector = embed_resp.json()["embedding"]
search_body = {"vector": vector, "limit": limit, "with_payload": True}
if source:
search_body["filter"] = {"must": [{"match": {"key": "source", "value": source}}]}
sr = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/search",
json=search_body,
timeout=10.0,
)
if sr.status_code != 200:
return JSONResponse(status_code=502, content={"detail": "Qdrant search failed"})
results = sr.json().get("result", [])
points = []
for r in results:
p = _decrypt_payload(r.get("payload", {}))
p["id"] = r["id"]
p["score"] = r.get("score")
points.append(p)
return {"points": points, "total": len(points), "next_offset": None}
scroll_body = {
"limit": limit,
"with_payload": True,
"with_vector": False,
}
if offset:
scroll_body["offset"] = offset
if source:
scroll_body["filter"] = {"must": [{"match": {"key": "source", "value": source}}]}
if sort:
direction = "asc" if order == "asc" else "desc"
scroll_body["order_by"] = {"key": sort, "direction": direction}
sr = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/scroll",
json=scroll_body,
timeout=10.0,
)
if sr.status_code != 200:
return JSONResponse(status_code=502, content={"detail": "Qdrant scroll failed"})
result = sr.json().get("result", {})
raw_points = result.get("points", [])
next_offset = result.get("next_page_offset")
info_resp = await client.get(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}",
timeout=5.0,
)
total = 0
if info_resp.status_code == 200:
total = info_resp.json().get("result", {}).get("vectors_count", 0)
points = []
for r in raw_points:
p = _decrypt_payload(r.get("payload", {}))
p["id"] = r["id"]
points.append(p)
return {"points": points, "total": total, "next_offset": next_offset}
@router.get("/api/rag/point/{point_id}")
async def rag_get_point(point_id: str, request: Request):
if getattr(request.state, "session_role", "none") != "admin":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/{point_id}",
timeout=10.0,
)
if resp.status_code != 200:
raise HTTPException(status_code=404, detail="Point not found")
result = resp.json().get("result", {})
if not result:
raise HTTPException(status_code=404, detail="Point not found")
payload = result.get("payload", {})
point = _decrypt_payload(payload)
point["id"] = result["id"]
return point
@router.delete("/api/rag/point/{point_id}")
async def rag_delete_point(point_id: str, request: Request):
if getattr(request.state, "session_role", "none") != "admin":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/delete",
json={"points": [point_id]},
timeout=10.0,
)
if resp.status_code not in (200, 201) and resp.status_code != 404:
return JSONResponse(status_code=502, content={"detail": "Qdrant delete failed"})
log.info(f"RAG point {point_id} deleted by admin")
return {"status": "deleted", "id": point_id}
@router.patch("/api/rag/point/{point_id}")
async def rag_update_point(point_id: str, request: Request):
if getattr(request.state, "session_role", "none") != "admin":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
body = await request.json()
new_text = (body.get("text") or "").strip()
if not new_text:
raise HTTPException(status_code=400, detail="text required")
async with httpx.AsyncClient() as client:
get_resp = await client.get(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points/{point_id}",
timeout=10.0,
)
if get_resp.status_code != 200:
raise HTTPException(status_code=404, detail="Point not found")
existing = get_resp.json().get("result", {})
if not existing:
raise HTTPException(status_code=404, detail="Point not found")
old_payload = existing.get("payload", {})
embed_resp = await client.post(
f"{EMBED_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": new_text},
timeout=10.0,
)
if embed_resp.status_code != 200:
return JSONResponse(status_code=502, content={"detail": "Embedding failed"})
vector = embed_resp.json()["embedding"]
new_payload = dict(old_payload)
new_payload["text"] = encrypt_text(new_text)
date_field = "ingest_date" if "ingest_date" in old_payload else "upload_date"
new_payload[date_field] = datetime.now(timezone.utc).isoformat()
upsert_resp = await client.put(
f"{QDRANT_URL}/collections/{RAG_COLLECTION}/points?wait=true",
json={"points": [{"id": point_id, "vector": vector, "payload": new_payload}]},
timeout=10.0,
)
if upsert_resp.status_code not in (200, 201):
return JSONResponse(status_code=502, content={"detail": "Qdrant upsert failed"})
log.info(f"RAG point {point_id} updated by admin")
return {"status": "updated", "id": point_id}
@router.post("/api/rag/flush")
async def rag_flush(request: Request):
if getattr(request.state, "session_role", "none") != "admin":
raise HTTPException(status_code=403, detail="Admin PIN required for this action")
try:
async with httpx.AsyncClient() as client:
scroll_resp = await client.post(