"""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", ]