feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+315
View File
@@ -0,0 +1,315 @@
"""Org memory domain: the rules of what an organization may remember.
Pure Python — no FastAPI, no SQLAlchemy. Three concerns live here and nowhere
else:
* **The vocabulary.** ``MemoryKind`` and ``MemorySource`` are the only two
taxonomies the platform has. An org's memory is *small* on purpose: a handful
of standing rules, a few preferences, the lessons a finished task leaves
behind, and a code map of the one app the org owns.
* **The limits.** A key is 80 characters, an entry 1,500. Anything longer is a
document, and documents belong in the Knowledge Base (``knowledge.py``), which
is why the memory page shows that second tab rather than re-implementing it.
* **The shape of an entry.** ``normalise`` + ``content_hash`` turn "the same
thought typed twice" into one stored memory, which is what keeps an org's
memory from becoming a duplicate pile.
Nothing here touches the database: the repository (``repositories/memory.py``)
moves rows, and the service (``services/memory_service.py``) decides what may be
written.
"""
from __future__ import annotations
import hashlib
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
class MemoryKind(StrEnum):
"""What a memory *is*, which decides who may read it and how long it lives."""
#: A standing instruction from the org: "always write tests first".
RULE = "rule"
#: A taste the org expressed: "prefer Persian comments".
PREFERENCE = "preference"
#: Something a finished task taught the org. Expires, because a lesson that
#: was true once is not a law.
LESSON = "lesson"
#: A durable fact about the app or the organisation ("the API runs on 8000").
FACT = "fact"
#: A short map of the codebase, rewritten as the code changes.
CODE_MAP = "code_map"
class MemorySource(StrEnum):
"""Who wrote it. ``reflection`` means the org's own self-review did."""
USER = "user"
MANAGER = "manager"
SYSTEM = "system"
REFLECTION = "reflection"
#: Hard limits, enforced here so a router, an MCP tool and the reflection writer
#: all share one definition of "too big".
KEY_MAX_CHARS = 80
CONTENT_MAX_CHARS = 1500
#: How long a lesson stays usable. ``30`` days is long enough to outlive the
#: task that produced it and short enough that stale advice dies quietly.
LESSON_TTL_DAYS = 30
#: One task may teach at most this many memories. Without a ceiling a single
#: runaway reflection could fill the org's whole budget with one task's opinion.
MAX_WRITES_PER_TASK = 3
#: A reflection's own confidence: it observed, it did not verify.
REFLECTION_CONFIDENCE = 0.5
#: A confirmed lesson is treated as settled knowledge and stops expiring.
CONFIRMED_CONFIDENCE = 1.0
#: Kinds that describe the *code*: only the roles that read code get them.
STRUCTURAL_KINDS = frozenset({MemoryKind.CODE_MAP, MemoryKind.FACT})
#: ``programmer`` is the plan's spelling of the engine's ``developer`` role key;
#: both are accepted so a caller cannot silently lose the code map.
STRUCTURAL_ROLES = frozenset(
{"planner", "developer", "programmer", "reviewer", "tester"}
)
#: Preferences are the manager's business — the employee who interprets them.
PREFERENCE_ROLES = frozenset({"manager"})
#: One-line Persian labels for the context block and the UI.
KIND_LABELS: dict[str, str] = {
MemoryKind.RULE: "قاعده",
MemoryKind.PREFERENCE: "ترجیح",
MemoryKind.LESSON: "درس",
MemoryKind.FACT: "واقعیت",
MemoryKind.CODE_MAP: "نقشهٔ کد",
}
SOURCE_LABELS: dict[str, str] = {
MemorySource.USER: "کاربر",
MemorySource.MANAGER: "مدیر",
MemorySource.SYSTEM: "سامانه",
MemorySource.REFLECTION: "بازتاب",
}
_WHITESPACE = re.compile(r"\s+")
#: Words that carry no meaning in either language; matching against them would
#: make every memory "relevant" to every question.
_STOP_WORDS = frozenset(
{
"the", "a", "an", "and", "or", "but", "if", "then", "else", "for", "with",
"without", "from", "into", "that", "this", "these", "those", "is", "are",
"was", "were", "be", "been", "to", "of", "in", "on", "at", "by", "as",
"it", "its", "do", "does", "did", "not", "no", "so", "we", "you", "use",
"using", "should", "must", "را", "که", "از", "به", "با", "این", "آن",
"و", "در", "برای", "تا", "بر", "شود", "است", "هست", "می",
}
)
@dataclass(slots=True, kw_only=True)
class Memory:
"""One thing the organization knows. ``id`` and timestamps are domain-owned."""
organization_id: str
kind: MemoryKind
content: str
source: MemorySource = MemorySource.USER
id: str = field(default_factory=new_id)
#: Optional stable handle: the same key always addresses the same memory,
#: which is how "our port is 8000" is corrected instead of duplicated.
key: str | None = None
#: The task that produced a lesson, when it came from a reflection.
evidence_task_id: str | None = None
pinned: bool = False
confidence: float = CONFIRMED_CONFIDENCE
expires_at: datetime | None = None
hits: int = 0
last_used_at: datetime | None = None
created_by_user_id: str | None = None
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
deleted_at: datetime | None = None
@property
def label(self) -> str:
return KIND_LABELS.get(str(self.kind), "یادداشت")
@property
def content_hash(self) -> str:
"""Always the digest of the *current* content.
A property, not a column the writer has to remember to refresh: a memory
whose text changed must dedupe as the new text, never as the old one.
"""
return content_hash(self.content)
def is_live(self, *, now: datetime | None = None) -> bool:
"""Not deleted, not expired. Pinned entries never expire."""
if self.deleted_at is not None:
return False
if self.pinned or self.expires_at is None:
return True
return self.expires_at > (now or utcnow())
def is_confirmed(self) -> bool:
return self.confidence >= CONFIRMED_CONFIDENCE
def normalise(content: str) -> str:
"""Canonical form for comparison: NFC, whitespace collapsed, trimmed.
Two spellings of the same sentence («the api runs on 8000» / «The API runs
on 8000») must dedupe, so the comparison is case-folded.
"""
text = unicodedata.normalize("NFC", content or "")
return _WHITESPACE.sub(" ", text).strip()
def content_hash(content: str) -> str:
"""A stable short digest of the normalised content, for dedupe."""
digest = hashlib.sha256(normalise(content).casefold().encode("utf-8"))
return digest.hexdigest()[:32]
def validate_key(key: str | None) -> str | None:
"""``None`` (or empty) means "no key"; otherwise a short trimmed handle."""
if key is None:
return None
trimmed = normalise(key)
if not trimmed:
return None
if len(trimmed) > KEY_MAX_CHARS:
raise ValidationError("کلید یادداشت نمی‌تواند بیش از ۸۰ نویسه باشد.")
return trimmed
def validate_content(content: str) -> str:
cleaned = normalise(content)
if not cleaned:
raise ValidationError("محتوای یادداشت نمی‌تواند خالی باشد.")
if len(cleaned) > CONTENT_MAX_CHARS:
raise ValidationError("محتوای هر یادداشت نمی‌تواند بیش از ۱۵۰۰ نویسه باشد.")
return cleaned
def validate_confidence(value: float) -> float:
try:
confidence = float(value)
except (TypeError, ValueError) as exc: # pragma: no cover - guarded by callers
raise ValidationError("میزان اطمینان نامعتبر است.") from exc
if confidence < 0 or confidence > 1:
raise ValidationError("میزان اطمینان باید بین ۰ و ۱ باشد.")
return round(confidence, 2)
def coerce_kind(kind: str) -> MemoryKind:
try:
return MemoryKind(str(kind).strip().lower())
except ValueError as exc:
raise ValidationError("نوع یادداشت نامعتبر است.") from exc
def coerce_source(source: str) -> MemorySource:
try:
return MemorySource(str(source).strip().lower())
except ValueError as exc:
raise ValidationError("منبع یادداشت نامعتبر است.") from exc
def visible_kinds(role: str) -> frozenset[MemoryKind]:
"""The kinds one employee role may be shown.
Rules and lessons are everyone's business. ``code_map`` and ``fact``
describe the code, so only the roles that read code get them; preferences
belong to the manager alone, because a preference is an instruction to
interpret work, not a fact about it.
"""
role_key = str(role or "").strip().lower()
kinds = {MemoryKind.RULE, MemoryKind.LESSON}
if role_key in STRUCTURAL_ROLES:
kinds.update(STRUCTURAL_KINDS)
if role_key in PREFERENCE_ROLES:
kinds.add(MemoryKind.PREFERENCE)
return frozenset(kinds)
def lesson_expiry(*, pinned: bool, confirmed: bool) -> datetime | None:
"""Lessons expire; a pinned or confirmed lesson is treated as settled."""
if pinned or confirmed:
return None
return utcnow() + timedelta(days=LESSON_TTL_DAYS)
def tokenise(text: str) -> frozenset[str]:
"""Lower-cased words of both scripts, stop words removed."""
words = re.findall(r"[\w\u0600-\u06FF]+", (text or "").casefold())
return frozenset(w for w in words if len(w) > 1 and w not in _STOP_WORDS)
def relevance(memory: Memory, query_tokens: frozenset[str]) -> float:
"""How well a memory answers a query: keyword overlap, then recency.
Deliberately simple and explainable. An embedding search is a Phase-X
upgrade (the Knowledge Base already has a vector port to borrow); until an
org's memory is large enough for that to matter, word overlap is the honest
answer and it cannot surprise a reader.
"""
own = tokenise(memory.content)
overlap = len(query_tokens & own) / len(query_tokens) if query_tokens and own else 0.0
age_days = _age_days(memory.last_used_at or memory.created_at)
recency = 1.0 / (1.0 + age_days / LESSON_TTL_DAYS)
pinned_bonus = 0.1 if memory.pinned else 0.0
return round(overlap * 3.0 + recency + pinned_bonus, 4)
def _age_days(when: datetime | None) -> float:
"""Days since ``when``, treating a naive timestamp as UTC.
SQLite (the development and test store) hands back naive datetimes; a
recency score is not the place to discover that. A missing timestamp means
"very old", not "brand new".
"""
if when is None:
return LESSON_TTL_DAYS * 30
if when.tzinfo is None:
when = when.replace(tzinfo=UTC)
return max(0.0, (utcnow() - when).days)
__all__ = [
"CONFIRMED_CONFIDENCE",
"CONTENT_MAX_CHARS",
"KEY_MAX_CHARS",
"KIND_LABELS",
"LESSON_TTL_DAYS",
"MAX_WRITES_PER_TASK",
"Memory",
"MemoryKind",
"MemorySource",
"PREFERENCE_ROLES",
"REFLECTION_CONFIDENCE",
"SOURCE_LABELS",
"STRUCTURAL_KINDS",
"STRUCTURAL_ROLES",
"coerce_kind",
"coerce_source",
"content_hash",
"lesson_expiry",
"normalise",
"relevance",
"tokenise",
"validate_confidence",
"validate_content",
"validate_key",
"visible_kinds",
]