feat(search): retrieval telemetry — log every semantic retrieval
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 58s
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 58s
Add retrieval_logs (migration 0068) + services/retrieval_telemetry with a fire-and-forget record_retrieval(), wired into the MCP search tool (source=mcp_search) and the REST search route (source=rest_search). Captures query, effective params, and the per-result score distribution so KB-injection thresholds can be tuned from data rather than guessed. Scribe: project 2, milestone 93, task 1032. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""Retrieval telemetry — one RetrievalLog row per semantic-retrieval call.
|
||||
|
||||
This is the empirical basis for KB-injection tuning: it records what each query
|
||||
asked for, the score distribution of what came back, and the effective params,
|
||||
so the similarity threshold and top-k can be tuned from data rather than guessed.
|
||||
|
||||
Design notes:
|
||||
- Fire-and-forget, mirroring upsert_note_embedding: `record_retrieval` extracts
|
||||
the primitives it needs SYNCHRONOUSLY (while the caller's Note objects are
|
||||
still valid) and schedules the DB insert as a background task, so logging
|
||||
never adds latency to — or can break — the search response.
|
||||
- Result objects are reduced to {id, score, rank} before scheduling; the
|
||||
background writer touches only plain data, never a possibly-detached ORM row.
|
||||
- Every failure path is swallowed: telemetry must never take down retrieval.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.retrieval_log import RetrievalLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
user_id: int | None,
|
||||
source: str,
|
||||
query: str | None,
|
||||
threshold: float | None,
|
||||
limit: int | None,
|
||||
project_id: int | None,
|
||||
is_task: bool | None,
|
||||
results: list[tuple[float, Note]],
|
||||
duration_ms: float | None,
|
||||
) -> dict:
|
||||
"""Reduce a retrieval call to a flat, JSON-safe RetrievalLog payload.
|
||||
|
||||
Pure and synchronous (no DB, no event loop) so it is unit-testable and safe
|
||||
to run inline before scheduling the write. `results` is the
|
||||
`(score, Note)` list from semantic_search_notes, already highest-first.
|
||||
"""
|
||||
items = [
|
||||
{"id": int(note.id), "score": round(float(score), 5), "rank": rank}
|
||||
for rank, (score, note) in enumerate(results)
|
||||
]
|
||||
scores = [it["score"] for it in items]
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"source": source,
|
||||
"query": query,
|
||||
"threshold": threshold,
|
||||
"limit_n": limit,
|
||||
"project_id": project_id,
|
||||
"is_task": is_task,
|
||||
"result_count": len(items),
|
||||
"top_score": (scores[0] if scores else None),
|
||||
"min_score": (scores[-1] if scores else None),
|
||||
"result_ids": items,
|
||||
"duration_ms": (round(duration_ms, 2) if duration_ms is not None else None),
|
||||
}
|
||||
|
||||
|
||||
async def _insert_retrieval_log(payload: dict) -> None:
|
||||
"""Persist one RetrievalLog row. Best-effort: all errors are swallowed."""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
session.add(RetrievalLog(**payload))
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.debug("retrieval telemetry write skipped", exc_info=True)
|
||||
|
||||
|
||||
def record_retrieval(
|
||||
*,
|
||||
user_id: int | None,
|
||||
source: str,
|
||||
query: str | None,
|
||||
threshold: float | None,
|
||||
limit: int | None,
|
||||
project_id: int | None,
|
||||
is_task: bool | None,
|
||||
results: list[tuple[float, Note]],
|
||||
duration_ms: float | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget: record one retrieval call.
|
||||
|
||||
Builds the payload inline (synchronously) then schedules the insert so the
|
||||
caller returns immediately. Never raises — telemetry must not affect search.
|
||||
"""
|
||||
try:
|
||||
payload = _build_payload(
|
||||
user_id=user_id,
|
||||
source=source,
|
||||
query=query,
|
||||
threshold=threshold,
|
||||
limit=limit,
|
||||
project_id=project_id,
|
||||
is_task=is_task,
|
||||
results=results,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("retrieval telemetry payload build failed", exc_info=True)
|
||||
return
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(_insert_retrieval_log(payload))
|
||||
except RuntimeError:
|
||||
# No running loop (e.g. called from sync context outside the app) —
|
||||
# skip rather than block. The app paths always run on the loop.
|
||||
logger.debug("retrieval telemetry skipped — no running event loop")
|
||||
Reference in New Issue
Block a user