Also rename services/tz.user_briefing_date → user_day_date with a backwards compat alias (briefing modules using the old name will be deleted in the upcoming briefing tear-down stage). Update services/chat.py to_dict to use day_date. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
131 lines
5.4 KiB
Python
131 lines
5.4 KiB
Python
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),
|
|
)
|