A memory system that only answers from its own store is a filing cabinet, not a partner. The transactive rule the project is named for — not knowing is fine when you know who does — needed a holder registry, a directory query, and a referral that never leaks the text it may not read. - schema v5 adds `holders` (person/agent/service, declared topics, contact). A holder with memories exists whether or not anybody registered them; the registry adds reachability, it does not grant existence. - `who_knows` ranks holders by their own memories and is deliberately NOT filtered by visibility: "she knows" is the answer. The answer is filtered. - `ask` searches the asker's visible scope first (hop 0), then walks the directory with a visited set and a hop budget. Three terminal states: answered, referred, no_holder_knows. Nobody knowing is a result, not an error. - Confidence is ordinal, not calibrated: relative strength, coverage, and declared topics. Every hit carries the raw numbers and the basis lines that produced the score, so a ranking can be argued with. - Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders, holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders). Four bugs found by running the thing, not by reading it: - FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column. - A CJK question is longer than the sentence that answers it, so the whole-run phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency is kept, equal length is not. Latin path untouched, baseline unaffected. - The hop budget counted the asker as a hop, so max_hops=1 asked nobody. - A second, stricter readability test in _answered() disagreed with the SQL visibility clause and hid a memory the asker was allowed to read. The SQL clause is the only authority now. A method named `list` shadowed the builtin inside its own class and made mypy reject the return annotation; renamed rather than cast around it. Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals on one SQLite file — the husband asks through MCP, gets `referred` with his wife's contact channel, and her private sentence appears nowhere in the reply.
116 lines
3.6 KiB
Python
116 lines
3.6 KiB
Python
"""Models for the directory side of memory: who exists, who knows what, and who answered.
|
|
|
|
Confidence here is ordinal, not calibrated: it orders candidates by the evidence in front of
|
|
us. Every model that carries a score also carries the raw evidence behind it, so a caller can
|
|
disagree with the number without re-running the query.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
HolderKind = Literal["person", "agent", "service"]
|
|
"""The registry's own vocabulary; migrations.py enforces the same set in the database."""
|
|
|
|
|
|
class Holder(BaseModel):
|
|
"""A registered person, agent, or service that can hold or answer memories."""
|
|
|
|
holder_id: str
|
|
name: str = ""
|
|
kind: HolderKind = "person"
|
|
knows_about: list[str] = Field(default_factory=list)
|
|
contact: dict[str, str] = Field(default_factory=dict)
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
@field_validator("holder_id")
|
|
@classmethod
|
|
def _holder_id_is_usable(cls, value: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError("holder_id cannot be blank; a directory entry has to name somebody")
|
|
return value.strip()
|
|
|
|
|
|
class HolderUpsertRequest(BaseModel):
|
|
"""What a caller may set about a holder. The id comes from the path, never the body."""
|
|
|
|
name: str = ""
|
|
kind: HolderKind = "person"
|
|
knows_about: list[str] = Field(default_factory=list)
|
|
contact: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class WhoKnowsHit(BaseModel):
|
|
"""One candidate holder, the evidence for them, and how to reach them."""
|
|
|
|
holder_id: str
|
|
name: str = ""
|
|
confidence: float
|
|
matched: int
|
|
top_score: float
|
|
latest_time: datetime | None = None
|
|
basis: list[str] = Field(default_factory=list)
|
|
contact: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class WhoKnowsReport(BaseModel):
|
|
"""The directory's answer to a question. `no_holder_knows` is a result, not a failure."""
|
|
|
|
query: str
|
|
as_holder: str | None = None
|
|
status: Literal["found", "no_holder_knows"]
|
|
hits: list[WhoKnowsHit] = Field(default_factory=list)
|
|
|
|
|
|
class WhoKnowsRequest(BaseModel):
|
|
"""Ask the directory who holds an answer."""
|
|
|
|
query: str
|
|
as_holder: str | None = None
|
|
limit: int = Field(default=8, gt=0)
|
|
|
|
@field_validator("query")
|
|
@classmethod
|
|
def _query_is_usable(cls, value: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError("who_knows needs a query")
|
|
return value.strip()
|
|
|
|
|
|
class AskRequest(BaseModel):
|
|
"""Ask a question on behalf of one holder, allowing a bounded chain of referrals."""
|
|
|
|
question: str
|
|
as_holder: str | None = None
|
|
max_hops: int = 3
|
|
include_text: bool = False
|
|
|
|
@field_validator("question")
|
|
@classmethod
|
|
def _question_is_usable(cls, value: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError("ask needs a question")
|
|
return value.strip()
|
|
|
|
|
|
class ReferralAnswer(BaseModel):
|
|
"""Who answered, who was asked along the way, and what may be shown.
|
|
|
|
`referred` means the directory found someone who knows but the asker may not read their
|
|
memory: the answer is the pointer and the contact, never the text.
|
|
"""
|
|
|
|
question: str
|
|
as_holder: str | None = None
|
|
status: Literal["answered", "referred", "no_holder_knows"]
|
|
answered_by: str | None = None
|
|
asked: list[str] = Field(default_factory=list)
|
|
hops: int = 0
|
|
atom_ids: list[str] = Field(default_factory=list)
|
|
answer: str | None = None
|
|
basis: list[str] = Field(default_factory=list)
|