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:
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user