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:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -0,0 +1,44 @@
|
||||
"""Audit action names introduced by the org-platform workstream.
|
||||
|
||||
Kept in the identity domain instead of the shared ``app.domain.audit`` module so
|
||||
the parallel workstreams never edit the same file (``app.domain.audit`` is
|
||||
foundation-owned). Values are free-form strings — ``AuditAction`` accepts any.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# --- platform invites (owner creates a new manager account) ----------------
|
||||
PLATFORM_INVITE_CREATE = "platform_invite.create"
|
||||
PLATFORM_INVITE_REVOKE = "platform_invite.revoke"
|
||||
PLATFORM_INVITE_ACCEPT = "platform_invite.accept"
|
||||
|
||||
# --- org sharing -----------------------------------------------------------
|
||||
ORG_TRANSFER_OWNERSHIP = "organization.transfer_ownership"
|
||||
ORG_LEAVE = "organization.leave"
|
||||
ORG_INVITE_REVOKE = "member.invite_revoke"
|
||||
|
||||
# --- wizard ----------------------------------------------------------------
|
||||
ORG_CREATED_VIA_WIZARD = "organization.created_via_wizard"
|
||||
APP_PROFILE_UPDATE = "app_profile.update"
|
||||
APP_GIT_ROTATE = "app_git.rotate"
|
||||
APP_DEPLOY_TARGET_SET = "app_deploy_target.set"
|
||||
|
||||
# --- owner model grants and org policy -------------------------------------
|
||||
MODEL_GRANT_SET = "model_grant.set"
|
||||
MODEL_GRANT_REVOKE = "model_grant.revoke"
|
||||
DEFAULT_MODEL_GRANT_SET = "default_model_grant.set"
|
||||
DEFAULT_MODEL_GRANT_REMOVE = "default_model_grant.remove"
|
||||
ORG_POLICY_UPDATE = "org_policy.update"
|
||||
|
||||
# --- org memory ------------------------------------------------------------
|
||||
MEMORY_CREATE = "memory.create"
|
||||
MEMORY_UPDATE = "memory.update"
|
||||
MEMORY_PIN = "memory.pin"
|
||||
MEMORY_CONFIRM = "memory.confirm"
|
||||
MEMORY_DELETE = "memory.delete"
|
||||
|
||||
# --- MCP gateway -----------------------------------------------------------
|
||||
MCP_TOKEN_CREATE = "mcp_token.create"
|
||||
MCP_TOKEN_REVOKE = "mcp_token.revoke"
|
||||
MCP_GLOBAL_SWITCH = "mcp.global_switch"
|
||||
MCP_ORG_SWITCH = "mcp.org_switch"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Org memory domain package (owned by Agent 1)."""
|
||||
|
||||
from app.domain.memory.models import (
|
||||
CONFIRMED_CONFIDENCE,
|
||||
CONTENT_MAX_CHARS,
|
||||
KEY_MAX_CHARS,
|
||||
KIND_LABELS,
|
||||
LESSON_TTL_DAYS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
PREFERENCE_ROLES,
|
||||
REFLECTION_CONFIDENCE,
|
||||
SOURCE_LABELS,
|
||||
STRUCTURAL_KINDS,
|
||||
STRUCTURAL_ROLES,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
coerce_kind,
|
||||
coerce_source,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
relevance,
|
||||
tokenise,
|
||||
validate_confidence,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
|
||||
__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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user