d9ab538ef4
- 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>
186 lines
5.5 KiB
Python
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],
|
|
)
|