diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index f380e0d..2eda686 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -43,3 +43,11 @@ from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F40 from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401 from fabledassistant.models.rss_item_embedding import RssItemEmbedding # noqa: E402, F401 +from fabledassistant.models.moment import ( # noqa: E402, F401 + Moment, + MomentEmbedding, + moment_people, + moment_places, + moment_tasks, + moment_notes, +) diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py index a09a3e1..8c4bd0f 100644 --- a/src/fabledassistant/models/conversation.py +++ b/src/fabledassistant/models/conversation.py @@ -18,11 +18,11 @@ class Conversation(Base, TimestampMixin): ) title: Mapped[str] = mapped_column(Text, default="") model: Mapped[str] = mapped_column(Text, default="") - # 'chat' (default) or 'briefing'. Briefing conversations are hidden from - # the main chat view and managed by the briefing pipeline. + # 'chat' (default) or 'journal'. Journal conversations are day-anchored + # and rendered by the /journal view; they are hidden from the main chat list. conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat") - # For briefing conversations only: the calendar date this briefing covers. - briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True) + # For journal conversations only: the calendar date this conversation covers. + day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True) # NULL = orphan notes only; -1 = all notes; positive int = specific project rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) @@ -48,7 +48,7 @@ class Conversation(Base, TimestampMixin): "title": self.title, "model": self.model, "conversation_type": self.conversation_type, - "briefing_date": self.briefing_date.isoformat() if self.briefing_date else None, + "day_date": self.day_date.isoformat() if self.day_date else None, "rag_project_id": self.rag_project_id, "message_count": msg_count, "created_at": self.created_at.isoformat(), diff --git a/src/fabledassistant/models/moment.py b/src/fabledassistant/models/moment.py new file mode 100644 index 0000000..e2d8cb9 --- /dev/null +++ b/src/fabledassistant/models/moment.py @@ -0,0 +1,130 @@ +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), + ) diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index e3052b3..2adabad 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -80,7 +80,7 @@ async def list_conversations( "title": conv.title, "model": conv.model, "conversation_type": conv.conversation_type, - "briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None, + "day_date": conv.day_date.isoformat() if conv.day_date else None, "rag_project_id": conv.rag_project_id, "message_count": row[1], "created_at": conv.created_at.isoformat(), diff --git a/src/fabledassistant/services/tz.py b/src/fabledassistant/services/tz.py index b7b23b1..fb73bf3 100644 --- a/src/fabledassistant/services/tz.py +++ b/src/fabledassistant/services/tz.py @@ -13,11 +13,13 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fabledassistant.services.settings import get_setting -# Briefing day boundary — kept in sync with the compilation slot in -# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour -# (not midnight) so the 00:00–04:00 local window still shows yesterday's -# briefing until the 4am compilation generates the new one. -BRIEFING_DAY_START_HOUR = 4 +# Day-rollover boundary for journal/day-anchored views. The "day" flips at +# this local hour (not midnight) so the 00:00–04:00 local window still +# shows yesterday's content until the user "starts" the new day. +DAY_ROLLOVER_HOUR = 4 + +# Backwards-compat alias for code paths still referencing the old name. +BRIEFING_DAY_START_HOUR = DAY_ROLLOVER_HOUR async def get_user_tz(user_id: int) -> ZoneInfo: @@ -35,13 +37,16 @@ async def user_today(user_id: int) -> date: return datetime.now(tz).date() -async def user_briefing_date(user_id: int) -> date: - """Return the current "briefing day" in the user's local timezone. +async def user_day_date(user_id: int) -> date: + """Return the current "day" in the user's local timezone. - The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local), - aligned with the compilation slot that generates the day's briefing. - Between 00:00 and 04:00 local this still returns *yesterday*, so the - UI keeps showing the in-progress briefing until the new one is built. + The day flips at ``DAY_ROLLOVER_HOUR`` (4am local) rather than midnight, + so the 00:00–04:00 local window still returns *yesterday* — the user's + journal stays anchored to yesterday until they cross the rollover. """ tz = await get_user_tz(user_id) - return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date() + return (datetime.now(tz) - timedelta(hours=DAY_ROLLOVER_HOUR)).date() + + +# Backwards-compat alias used by briefing-only modules being torn down in Stage B. +user_briefing_date = user_day_date