The journal UI was over-stripped earlier — weather panel, current-conditions poll, and the upcoming-events sidebar were all dropped. Restored those (calls the new /api/journal/weather, /api/journal/weather/current, /api/journal/weather/refresh, /api/journal/weather/geocode endpoints). Also: the daily-prep system message was rendering as flat text inside ChatPanel because there's no .role-system bubble styling. Added a hideMessage guard in ChatMessage so daily-prep system messages don't render in the chat stream — they're already shown above as a structured prep card. News / RSS reactions / article-discuss are intentionally NOT in the journal (scoped out per user direction). The broader RSS infrastructure cleanup is a separate, larger task. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
201 lines
6.5 KiB
Python
201 lines
6.5 KiB
Python
"""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,
|
|
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.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:
|
|
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
|