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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"""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],
|
||||
)
|
||||
Reference in New Issue
Block a user