import datetime from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from fabledassistant.models import Base # Junction tables. People, Places, Tasks, and (regular) Notes all live in # the `notes` table — the four junctions are kept separate (rather than one # merged table with a discriminator) so per-kind queries don't require a # filter, and so the schema is explicit about which links are which. moment_people = Table( "moment_people", Base.metadata, Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), ) moment_places = Table( "moment_places", Base.metadata, Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), ) moment_tasks = Table( "moment_tasks", Base.metadata, Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), ) moment_notes = Table( "moment_notes", Base.metadata, Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), ) class Moment(Base): """A small structured extraction from a journal conversation. Many per day. Emitted by the LLM via the `record_moment` tool when it notices a meaningful beat. Stored separately from Notes — they are different in kind: Notes are curated artifacts; Moments are ambient trace data with their own embedding index, so notes-RAG and journal-RAG cannot cross-contaminate. """ __tablename__ = "moments" id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) conversation_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True ) source_message_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True ) day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False) occurred_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), nullable=False ) recorded_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=lambda: datetime.datetime.now(datetime.timezone.utc), ) content: Mapped[str] = mapped_column(Text, nullable=False) raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True) places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True) tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True) notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True) __table_args__ = ( Index("ix_moments_user_day", "user_id", "day_date"), Index("ix_moments_user_occurred", "user_id", "occurred_at"), ) def to_dict(self, *, include_links: bool = True) -> dict: result: dict = { "id": self.id, "user_id": self.user_id, "conversation_id": self.conversation_id, "source_message_id": self.source_message_id, "day_date": self.day_date.isoformat(), "occurred_at": self.occurred_at.isoformat(), "recorded_at": self.recorded_at.isoformat(), "content": self.content, "raw_excerpt": self.raw_excerpt, "tags": list(self.tags or []), "pinned": self.pinned, } if include_links: result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])] result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])] result["task_ids"] = [t.id for t in (self.tasks or [])] result["note_ids"] = [n.id for n in (self.notes or [])] return result class MomentEmbedding(Base): """Embedding vector for a Moment — used by `search_journal` semantic mode. Stored separately from `note_embeddings` so notes-RAG and journal-RAG cannot cross-contaminate. This is a hard invariant of the journal design. """ __tablename__ = "moment_embeddings" moment_id: Mapped[int] = mapped_column( Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True, ) user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) embedding: Mapped[list] = mapped_column(JSONB, nullable=False) updated_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.datetime.now(datetime.timezone.utc), )