Files
microfish/backend/app/models/memory.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

142 lines
6.7 KiB
Python

"""Durable local graph-memory schema replacing the storage side of Zep."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import DateTime, Float, ForeignKey, Index, JSON, String, Text, UniqueConstraint, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db import Base
def _id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
class MemoryGraph(Base):
__tablename__ = "memory_graphs"
id: Mapped[str] = mapped_column(String(128), primary_key=True, default=lambda: _id("graph"))
organization_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
project_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
ontology: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, server_default=text("'{}'"))
status: Mapped[str] = mapped_column(String(32), nullable=False, default="ready", server_default=text("'ready'"))
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default=text("1"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now, server_default=func.now(), onupdate=_utc_now
)
episodes: Mapped[list["MemoryEpisode"]] = relationship(
back_populates="graph", cascade="all, delete-orphan"
)
nodes: Mapped[list["MemoryNode"]] = relationship(
back_populates="graph", cascade="all, delete-orphan"
)
edges: Mapped[list["MemoryEdge"]] = relationship(
back_populates="graph", cascade="all, delete-orphan"
)
class MemoryEpisode(Base):
__tablename__ = "memory_episodes"
__table_args__ = (
UniqueConstraint("graph_id", "source_type", "source_ref", name="uq_memory_episode_source"),
Index("ix_memory_episodes_graph_status", "graph_id", "status"),
)
id: Mapped[str] = mapped_column(String(128), primary_key=True, default=lambda: _id("episode"))
graph_id: Mapped[str] = mapped_column(
ForeignKey("memory_graphs.id", ondelete="CASCADE"), nullable=False, index=True
)
source_type: Mapped[str] = mapped_column(String(32), nullable=False)
source_ref: Mapped[str] = mapped_column(String(256), nullable=False)
normalized_text: Mapped[str] = mapped_column(Text, nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False, default="", server_default=text("''"))
status: Mapped[str] = mapped_column(String(32), nullable=False, default="processed", server_default=text("'processed'"))
extractor_version: Mapped[str] = mapped_column(String(64), nullable=False, default="v1", server_default=text("'v1'"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now, server_default=func.now()
)
graph: Mapped[MemoryGraph] = relationship(back_populates="episodes")
class MemoryNode(Base):
__tablename__ = "memory_nodes"
__table_args__ = (
UniqueConstraint("graph_id", "normalized_name", name="uq_memory_node_graph_name"),
Index("ix_memory_nodes_graph_name", "graph_id", "normalized_name"),
)
id: Mapped[str] = mapped_column(String(128), primary_key=True, default=lambda: _id("node"))
graph_id: Mapped[str] = mapped_column(
ForeignKey("memory_graphs.id", ondelete="CASCADE"), nullable=False, index=True
)
canonical_name: Mapped[str] = mapped_column(String(512), nullable=False)
normalized_name: Mapped[str] = mapped_column(String(512), nullable=False)
labels: Mapped[list] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'"))
aliases: Mapped[list] = mapped_column(JSON, nullable=False, default=list, server_default=text("'[]'"))
attributes: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, server_default=text("'{}'"))
summary: Mapped[str] = mapped_column(Text, nullable=False, default="", server_default=text("''"))
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0, server_default=text("0"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now, server_default=func.now(), onupdate=_utc_now
)
graph: Mapped[MemoryGraph] = relationship(back_populates="nodes")
outgoing_edges: Mapped[list["MemoryEdge"]] = relationship(
foreign_keys="MemoryEdge.source_node_id", back_populates="source_node"
)
incoming_edges: Mapped[list["MemoryEdge"]] = relationship(
foreign_keys="MemoryEdge.target_node_id", back_populates="target_node"
)
class MemoryEdge(Base):
__tablename__ = "memory_edges"
__table_args__ = (
Index("ix_memory_edges_graph_relation", "graph_id", "relation"),
Index("ix_memory_edges_graph_temporal", "graph_id", "valid_at", "invalid_at"),
)
id: Mapped[str] = mapped_column(String(128), primary_key=True, default=lambda: _id("edge"))
graph_id: Mapped[str] = mapped_column(
ForeignKey("memory_graphs.id", ondelete="CASCADE"), nullable=False, index=True
)
source_node_id: Mapped[str] = mapped_column(
ForeignKey("memory_nodes.id", ondelete="CASCADE"), nullable=False, index=True
)
target_node_id: Mapped[str] = mapped_column(
ForeignKey("memory_nodes.id", ondelete="CASCADE"), nullable=False, index=True
)
relation: Mapped[str] = mapped_column(String(128), nullable=False)
fact: Mapped[str] = mapped_column(Text, nullable=False)
attributes: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, server_default=text("'{}'"))
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0, server_default=text("0"))
valid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utc_now, server_default=func.now()
)
graph: Mapped[MemoryGraph] = relationship(back_populates="edges")
source_node: Mapped[MemoryNode] = relationship(
foreign_keys=[source_node_id], back_populates="outgoing_edges"
)
target_node: Mapped[MemoryNode] = relationship(
foreign_keys=[target_node_id], back_populates="incoming_edges"
)