CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
5.4 KiB
Python
103 lines
5.4 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import Boolean, DateTime, Float, Index, Integer, Text
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from grimoire.models import Base
|
|
from grimoire.models.base import iso
|
|
|
|
|
|
class RetrievalLog(Base):
|
|
"""One row per semantic-retrieval call, for KB-injection tuning.
|
|
|
|
Captures what a query asked for, what came back, and the score
|
|
distribution of the results — the empirical basis for tuning the
|
|
similarity threshold and top-k per surface. `result_ids` holds the ranked
|
|
hits (id + score + rank) so a later pass can correlate "what we surfaced"
|
|
against "what the agent then fetched/referenced".
|
|
|
|
Deliberately FK-free on user_id (mirrors AppLog): telemetry should outlive
|
|
the row it describes, and a deleted user shouldn't cascade away history.
|
|
"""
|
|
|
|
__tablename__ = "retrieval_logs"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
# Declared here rather than via CreatedAtMixin on purpose: the composite
|
|
# index below orders on `created_at.desc()`, which needs the column object
|
|
# in this class body — a mixin's column is not in scope there.
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
# Retrieval surface: 'mcp_search' | 'rest_search' | 'auto_inject' | ...
|
|
source: Mapped[str] = mapped_column(Text, nullable=False)
|
|
query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# Effective parameters actually used for this call.
|
|
threshold: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
limit_n: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
project_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
# The content-type filter as passed to semantic_search_notes: True=tasks,
|
|
# False=notes, NULL=any.
|
|
is_task: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# How many scored hits this call DROPPED because the session had already
|
|
# been shown them. NULLABLE, and the null is load-bearing: it means "this
|
|
# surface does not report suppression", which must not read as "nothing was
|
|
# suppressed". `result_count == 0` alone conflates two different events —
|
|
# the ranker found nothing above threshold, and the ranker found something
|
|
# the reader already had — and only the first says a threshold is too high.
|
|
# Reading a zero as a ranker decline sends a reader to move a floor that
|
|
# was never involved; an unmeasured value that renders as 0 is the same mistake with a nicer
|
|
# face, so surfaces that filter INSIDE the search leave this null.
|
|
suppressed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
top_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
min_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
# The best score the ranker COULD have offered, before the threshold — as
|
|
# against `top_score`, which is the best it DID offer. They are equal on
|
|
# any call that returned something, and only this one exists on a call
|
|
# that returned nothing, which is the only place a bar can be judged from
|
|
# Null means the caller did not measure it, never "nothing was
|
|
# close": a 0.0 there would read as a corpus with no relevant records at
|
|
# all, which is an artifact standing in for a measurement.
|
|
best_available_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
# WHICH record scored that, so a reader can judge what the bar refused
|
|
# rather than only how close it came. Written from the same ranked
|
|
# candidate as the score above — the two describing different records would
|
|
# be worse than no id at all, because it invites judging the wrong one.
|
|
#
|
|
# Not a foreign key on purpose: this table spans record types (the rule arms
|
|
# store rule ids, the note arms store note ids) and `source` is what says
|
|
# which, exactly as `result_ids` has always worked.
|
|
best_available_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
# [{"id": int, "score": float, "rank": int}, ...], highest-first.
|
|
result_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
|
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
__table_args__ = (
|
|
Index("ix_retrieval_logs_created_at", "created_at"),
|
|
Index("ix_retrieval_logs_user_id", "user_id"),
|
|
Index("ix_retrieval_logs_source", "source"),
|
|
Index("ix_retrieval_logs_source_created_at", "source", created_at.desc()),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"created_at": iso(self.created_at),
|
|
"user_id": self.user_id,
|
|
"source": self.source,
|
|
"query": self.query,
|
|
"threshold": self.threshold,
|
|
"limit_n": self.limit_n,
|
|
"project_id": self.project_id,
|
|
"is_task": self.is_task,
|
|
"result_count": self.result_count,
|
|
"suppressed_count": self.suppressed_count,
|
|
"top_score": self.top_score,
|
|
"min_score": self.min_score,
|
|
"result_ids": self.result_ids,
|
|
"duration_ms": self.duration_ms,
|
|
}
|