Files
FabledScribe/src/fabledassistant/services/moments.py
T
bvandeusen d9ab538ef4 feat(journal): backend services (moments, search, prep, scheduler, pipeline)
- services/moments.py — CRUD with embedding sync + entity-link helpers
- services/journal_search.py — three-mode search (temporal / entity / semantic)
  with notes-RAG isolation; cosine-similarity helper unit-tested
- services/journal_prep.py — gathers tasks/events/weather/news/projects/
  recent-moments/open-threads into a structured prep block
- services/journal_scheduler.py — per-user APScheduler cron for daily prep,
  follows the BackgroundScheduler + threadsafe-async pattern from event_scheduler
- services/journal_pipeline.py — system prompt (persona + calibration rules)
  with last-48h ambient moments injection
- app.py: wire journal scheduler start/stop hooks
- routes/settings.py: re-add live-reschedule on timezone change (now via
  journal_scheduler.update_user_schedule)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:37:39 -04:00

186 lines
5.5 KiB
Python

"""Moment CRUD, entity linking, and embedding sync.
Moments are small structured extractions from journal conversations.
Stored separately from Notes — they have their own embedding index and
the isolation between notes-RAG and journal-RAG is enforced at the API
boundary, not just the schema.
"""
from __future__ import annotations
import datetime
from typing import Sequence
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from fabledassistant.models import (
Moment,
MomentEmbedding,
async_session,
moment_notes,
moment_people,
moment_places,
moment_tasks,
)
from fabledassistant.services.embeddings import get_embedding
async def create_moment(
*,
user_id: int,
content: str,
occurred_at: datetime.datetime,
day_date: datetime.date,
conversation_id: int | None = None,
source_message_id: int | None = None,
raw_excerpt: str | None = None,
tags: Sequence[str] = (),
person_ids: Sequence[int] = (),
place_ids: Sequence[int] = (),
task_ids: Sequence[int] = (),
note_ids: Sequence[int] = (),
) -> Moment:
"""Insert a Moment, write its embedding, create junction rows."""
async with async_session() as session:
moment = Moment(
user_id=user_id,
conversation_id=conversation_id,
source_message_id=source_message_id,
day_date=day_date,
occurred_at=occurred_at,
content=content,
raw_excerpt=raw_excerpt,
tags=list(tags),
)
session.add(moment)
await session.flush()
await _set_links(
session,
moment_id=moment.id,
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
embedding_vector = await get_embedding(content)
session.add(
MomentEmbedding(
moment_id=moment.id,
user_id=user_id,
embedding=embedding_vector,
)
)
await session.commit()
await session.refresh(moment, ["people", "places", "tasks", "notes"])
return moment
async def get_moment(*, user_id: int, moment_id: int) -> Moment | None:
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
return result.scalar_one_or_none()
async def update_moment(
*,
user_id: int,
moment_id: int,
content: str | None = None,
tags: Sequence[str] | None = None,
pinned: bool | None = None,
person_ids: Sequence[int] | None = None,
place_ids: Sequence[int] | None = None,
task_ids: Sequence[int] | None = None,
note_ids: Sequence[int] | None = None,
) -> Moment | None:
"""Update fields and (optionally) replace junction sets.
None for a junction parameter ⇒ leave unchanged. Empty sequence ⇒ clear.
Mirrors the Notes update convention.
"""
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
moment = result.scalar_one_or_none()
if moment is None:
return None
if content is not None:
moment.content = content
embedding_vector = await get_embedding(content)
await session.execute(
delete(MomentEmbedding).where(MomentEmbedding.moment_id == moment_id)
)
session.add(
MomentEmbedding(
moment_id=moment_id,
user_id=user_id,
embedding=embedding_vector,
)
)
if tags is not None:
moment.tags = list(tags)
if pinned is not None:
moment.pinned = pinned
await _set_links(
session,
moment_id=moment_id,
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
await session.commit()
await session.refresh(moment, ["people", "places", "tasks", "notes"])
return moment
async def delete_moment(*, user_id: int, moment_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
moment = result.scalar_one_or_none()
if moment is None:
return False
await session.delete(moment)
await session.commit()
return True
async def _set_links(
session: AsyncSession,
*,
moment_id: int,
person_ids: Sequence[int] | None,
place_ids: Sequence[int] | None,
task_ids: Sequence[int] | None,
note_ids: Sequence[int] | None,
) -> None:
"""Replace junction-table rows.
None ⇒ leave existing rows alone. Empty sequence ⇒ clear all rows.
"""
for ids, table, fk_col in [
(person_ids, moment_people, "person_id"),
(place_ids, moment_places, "place_id"),
(task_ids, moment_tasks, "task_id"),
(note_ids, moment_notes, "note_id"),
]:
if ids is None:
continue
await session.execute(delete(table).where(table.c.moment_id == moment_id))
if ids:
await session.execute(
table.insert(),
[{"moment_id": moment_id, fk_col: i} for i in ids],
)