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,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)
|
||||
Reference in New Issue
Block a user