Give the system a directory: ask who knows, then ask them
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.
This commit is contained in:
1 parent
9fe13fffb3
commit
dfc6cd4c28
19 files changed
+1403
-19
No files matched your search
@@ -0,0 +1,115 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user