feat(models): Moment + MomentEmbedding + junctions; rename Conversation.briefing_date → day_date
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>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user