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:
2026-04-25 22:37:39 -04:00
parent 7602bf2293
commit d9ab538ef4
7 changed files with 863 additions and 3 deletions
@@ -0,0 +1,208 @@
"""Daily prep generator for the Journal.
Runs once per day per user (scheduled, or lazy on first journal-open of a
new day). Gathers raw data into a structured prep block that becomes the
first message of the day's journal Conversation.
The output shape lives on Message.msg_metadata as:
{ kind: 'daily_prep', sections: { tasks, events, weather, news,
projects, recent_moments, open_threads } }
Deliberately deterministic — no LLM call here. The journal LLM weaves
narrative into the conversation later, when the user actually starts
talking. Generating prose at scheduled-prep time would burn model time
on something the user may never read.
"""
from __future__ import annotations
import datetime
import logging
from sqlalchemy import select
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services.events import list_events
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.rss import get_recent_items
from fabledassistant.services.weather import get_cached_weather_rows
logger = logging.getLogger(__name__)
async def generate_daily_prep(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
) -> dict:
"""Gather all daily-prep sections and return them as a dict."""
sections: dict = {}
try:
tasks_today, _ = await list_notes(
user_id=user_id,
is_task=True,
status=["todo", "in_progress"],
due_before=day_date,
limit=20,
sort="due_date",
order="asc",
)
sections["tasks"] = [
{
"id": t.id,
"title": t.title,
"status": t.status,
"priority": t.priority,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks_today
]
except Exception:
logger.exception("daily_prep tasks section failed for user %d", user_id)
sections["tasks"] = []
try:
day_start = datetime.datetime.combine(day_date, datetime.time.min)
day_end = datetime.datetime.combine(day_date, datetime.time.max)
# list_events already returns dicts.
sections["events"] = await list_events(
user_id=user_id,
date_from=day_start,
date_to=day_end,
)
except Exception:
logger.exception("daily_prep events section failed for user %d", user_id)
sections["events"] = []
try:
weather_rows = await get_cached_weather_rows(user_id)
sections["weather"] = [w.to_dict() for w in weather_rows]
except Exception:
logger.exception("daily_prep weather section failed for user %d", user_id)
sections["weather"] = []
try:
news = await get_recent_items(user_id=user_id, limit=5)
sections["news"] = news
except Exception:
logger.exception("daily_prep news section failed for user %d", user_id)
sections["news"] = []
try:
projects = await list_projects(user_id=user_id, status="active")
sections["projects"] = [
{
"id": p.id,
"title": p.title,
"auto_summary": p.auto_summary,
}
for p in projects[:5]
]
except Exception:
logger.exception("daily_prep projects section failed for user %d", user_id)
sections["projects"] = []
try:
sections["recent_moments"] = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=3),
date_to=day_date - datetime.timedelta(days=1),
limit=10,
)
except Exception:
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
sections["recent_moments"] = []
try:
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
except Exception:
logger.exception("daily_prep open_threads section failed for user %d", user_id)
sections["open_threads"] = []
return sections
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
"""Heuristic: moments from the last 7 days that look unresolved.
Treated as 'unresolved' when they have no linked tasks/notes and aren't
pinned. Starting heuristic — refine empirically.
"""
candidates = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=7),
date_to=day_date - datetime.timedelta(days=1),
limit=50,
)
return [
m for m in candidates
if not m.get("task_ids")
and not m.get("note_ids")
and not m.get("pinned")
]
async def ensure_daily_prep_message(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
force: bool = False,
) -> Message:
"""Get or create today's journal Conversation, then ensure the prep message exists.
With force=True, regenerate even if a prep message already exists today
(used by the manual /api/journal/trigger-prep endpoint).
"""
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
)
conv = result.scalar_one_or_none()
if conv is None:
conv = Conversation(
user_id=user_id,
conversation_type="journal",
day_date=day_date,
title=day_date.isoformat(),
)
session.add(conv)
await session.flush()
prep_stmt = select(Message).where(
Message.conversation_id == conv.id,
Message.role == "system",
)
existing_prep = None
for msg in (await session.execute(prep_stmt)).scalars():
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
existing_prep = msg
break
if existing_prep and not force:
return existing_prep
sections = await generate_daily_prep(
user_id=user_id, day_date=day_date, user_timezone=user_timezone
)
if existing_prep:
existing_prep.msg_metadata = {"kind": "daily_prep", "sections": sections}
await session.commit()
return existing_prep
prep_msg = Message(
conversation_id=conv.id,
role="system",
content="",
msg_metadata={"kind": "daily_prep", "sections": sections},
)
session.add(prep_msg)
await session.commit()
return prep_msg