feat(provider): inbound memory-call observe log (#17 observe brick)

Structured [memory-provider] request/response logging on the memory-call
path: search REQUEST (scope_filter, top_k) + RESPONSE (chunk_ids, scores,
scope), plus concise entry lines on upsert_many/delete_many. Self-contained
stdout handler so the lines reach the provider stdout under uvicorn.

Additive observability only — no search-semantics change (AND-parity with
bifrost's reference store holds). This is the first concrete brick of #17's
observe half, and the lens that root-caused #295's cold-recall miss (the
persist/recall scope-axis asymmetry) from the provider side.
This commit is contained in:
vh
2026-06-16 01:15:16 -07:00
parent 2b47dcff5a
commit 2fef6e39f9
3 changed files with 44 additions and 5 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.3" version = "0.17.4"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+42 -3
View File
@@ -14,7 +14,9 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
import sqlite3 import sqlite3
import sys
import time import time
from typing import Any from typing import Any
@@ -31,6 +33,17 @@ from bifrost.reference_server import JwtVerifier
_SHORT_RETRY_TTL_SECONDS = 300 _SHORT_RETRY_TTL_SECONDS = 300
_DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60 _DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60
# Inbound memory-call observability (#17 observe brick). A self-contained
# stdout handler so the lines reliably reach the provider's stdout regardless
# of uvicorn's logging config. INFO-level, no propagation to root.
_log = logging.getLogger("ratatoskr.provider.memory")
if not _log.handlers:
_h = logging.StreamHandler(sys.stdout)
_h.setFormatter(logging.Formatter("%(asctime)s [memory-provider] %(message)s"))
_log.addHandler(_h)
_log.setLevel(logging.INFO)
_log.propagate = False
def _ctx_actor(ctx: Any) -> str: def _ctx_actor(ctx: Any) -> str:
"""Reference `_ctx_actor`: actor = job_id | jwt_sub | session_id (never the record).""" """Reference `_ctx_actor`: actor = job_id | jwt_sub | session_id (never the record)."""
@@ -113,6 +126,11 @@ class RatatoskrMemoryStore:
) -> dict: ) -> dict:
if not (isinstance(idempotency_key, str) and idempotency_key): # PRE-001 if not (isinstance(idempotency_key, str) and idempotency_key): # PRE-001
raise InvalidArguments("idempotency_key required") raise InvalidArguments("idempotency_key required")
_log.info(
"memory-call upsert_many REQUEST: %d record(s) idempotency_key=%s actor=%s scopes=%s",
len(records), idempotency_key, _ctx_actor(ctx),
[r.get("scope") for r in records],
)
# INV-002: idempotency_id = ("default", verb, actor-from-ctx, key); digest over payload. # INV-002: idempotency_id = ("default", verb, actor-from-ctx, key); digest over payload.
digest = _payload_digest({"records": records, "expected_revisions": expected_revisions}) digest = _payload_digest({"records": records, "expected_revisions": expected_revisions})
idempotency_id = "|".join(("default", "upsert_many", _ctx_actor(ctx), idempotency_key)) idempotency_id = "|".join(("default", "upsert_many", _ctx_actor(ctx), idempotency_key))
@@ -187,11 +205,31 @@ class RatatoskrMemoryStore:
raise InvalidArguments("metadata_filter is unsupported in v1") raise InvalidArguments("metadata_filter is unsupported in v1")
if scope_filter is not None and not isinstance(scope_filter, dict): # STEP 1 if scope_filter is not None and not isinstance(scope_filter, dict): # STEP 1
raise InvalidArguments("scope_filter must be a flat {axis: value} dict") raise InvalidArguments("scope_filter must be a flat {axis: value} dict")
_log.info(
"memory-call search REQUEST: scope_filter=%r top_k=%s metadata_filter=%r vec_dim=%d",
scope_filter, top_k, metadata_filter, len(vector),
)
def _emit(rs: list[dict]) -> list[dict]:
_log.info(
"memory-call search RESPONSE: %d hit(s) %s",
len(rs),
[
{
"chunk_id": r["chunk_id"],
"score": round(r["score"], 4),
"scope": r["chunk"].get("scope"),
}
for r in rs
],
)
return rs
if top_k <= 0: # POST-001: at most top_k if top_k <= 0: # POST-001: at most top_k
return [] return _emit([])
total = self._conn.execute("SELECT COUNT(*) FROM memory_vec").fetchone()[0] total = self._conn.execute("SELECT COUNT(*) FROM memory_vec").fetchone()[0]
if total == 0: if total == 0:
return [] return _emit([])
# Over-fetch every candidate ranked by cosine distance, then scope-filter and # Over-fetch every candidate ranked by cosine distance, then scope-filter and
# take top_k — so top_k counts IN-SCOPE hits (INV-005), not pre-filter hits. # take top_k — so top_k counts IN-SCOPE hits (INV-005), not pre-filter hits.
rows = self._conn.execute( rows = self._conn.execute(
@@ -216,7 +254,7 @@ class RatatoskrMemoryStore:
) )
if len(results) >= top_k: if len(results) >= top_k:
break break
return results return _emit(results)
async def get(self, chunk_id: str) -> dict | None: async def get(self, chunk_id: str) -> dict | None:
# INV-001: verbatim round-trip + an attached revision key, or None. # INV-001: verbatim round-trip + an attached revision key, or None.
@@ -241,6 +279,7 @@ class RatatoskrMemoryStore:
async def delete_many(self, ids: list[str]) -> dict: async def delete_many(self, ids: list[str]) -> dict:
# One transaction: chunk row + its vec row leave together (no orphan vec rows). # One transaction: chunk row + its vec row leave together (no orphan vec rows).
_log.info("memory-call delete_many REQUEST: ids=%s", ids)
deleted = 0 deleted = 0
with self._conn: with self._conn:
for chunk_id in ids: for chunk_id in ids:
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.3" version = "0.17.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },