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
co-authored by Claude Opus 4.7
parent 7602bf2293
commit d9ab538ef4
7 changed files with 863 additions and 3 deletions
@@ -0,0 +1,117 @@
"""System prompt + ambient context injection for journal conversations.
Distinct from the chat pipeline (services/llm.py) in three ways:
1. New persona: warm, curious listener; not a task manager.
2. Calibration rules: ask before structural changes; record_moment freely.
3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG).
Notes-RAG auto-injection MUST be disabled for journal sessions — the
journal's ambient context replaces it. Failing to disable would let notes
leak into journal sessions, violating the design's isolation invariant.
"""
from __future__ import annotations
import datetime
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = (
"You are a warm, curious listener helping the user record their day. "
"You are NOT a task manager. Your role is to listen, ask gentle "
"follow-up questions when something seems significant or underspecified, "
"and quietly maintain structure as a byproduct of conversation."
)
JOURNAL_CALIBRATION = """\
CALIBRATION (very important):
- When the user mentions a person you don't know, ask: "Is <Name> someone I should remember?" Confirm before calling update_person.
- When a person reference is ambiguous (multiple matches), ask which one. Never guess.
- When the user says something action-implying (e.g., "I finished X"), ASK before calling update_task. Silent updates feel derailing.
- Use the existing tool's `confirmed=false` -> user-confirms -> `confirmed=true` pattern. The frontend renders the inline confirm UI automatically.
- record_moment is the EXCEPTION: call it freely and silently when the user mentions a meaningful beat. Moments are cheap and user-correctable in the timeline view; gating them would kill the flow.
- Do NOT call set_rag_scope. The journal scope is implicit.
- Notes are not auto-retrieved here. If you need to reference notes, call search_notes explicitly.
"""
PHASE_GREETINGS = {
"morning": "Morning. What's the day looking like for you?",
"midday": "How's it going so far?",
"evening": "Wrapping up — how'd the day shake out?",
}
def determine_phase(
*,
now_local: datetime.datetime,
day_rollover_hour: int = 4,
morning_end_hour: int = 12,
midday_end_hour: int = 18,
) -> str:
"""Return 'morning' | 'midday' | 'evening' for a local datetime."""
h = now_local.hour
if h < day_rollover_hour:
return "evening"
if h < morning_end_hour:
return "morning"
if h < midday_end_hour:
return "midday"
return "evening"
def phase_greeting(phase: str) -> str:
return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"])
async def build_journal_system_prompt(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
) -> str:
"""Static-then-dynamic system prompt.
Static prefix (persona + calibration) is identical on every request,
preserving Ollama KV cache. Dynamic suffix changes per-day.
"""
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
today_iso = day_date.isoformat()
tz_block = f"Today is {today_iso} ({user_timezone})."
profile_context = await build_profile_context(user_id)
profile_section = f"\n\n{profile_context}" if profile_context else ""
ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date)
ambient_section = (
f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else ""
)
return f"{static_block}\n\n{tz_block}{profile_section}{ambient_section}"
async def _ambient_moments_block(
*, user_id: int, day_date: datetime.date
) -> str:
"""Render last 48h of moments as a compact text block.
Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval.
"""
moments = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=2),
date_to=day_date,
limit=20,
)
if not moments:
return ""
lines: list[str] = []
total_chars = 0
for m in moments:
line = f"- [{m['occurred_at']}] {m['content']}"
if total_chars + len(line) > 1500:
break
lines.append(line)
total_chars += len(line)
return "\n".join(lines)