Files
FabledGrimoire/src/fabledassistant/services/journal_pipeline.py
T
bvandeusenandClaude Opus 4.7 03d725ea3e fix(calendar-tool): anchor today's weekday in prompts + verify expected_weekday on create/update
A user asked Fable to schedule "this Friday at 8am" on Wednesday 4/29
2026. The model picked 4/30 (Thursday) and confidently labeled it
"Friday." The TZ pipeline did everything correctly given the model's
date — the bug was upstream: the model was guessing weekdays from ISO
dates without an anchor, and the calendar tools had no way to verify.

Three layered fixes:

1. **System prompts now name the weekday alongside the ISO date.**
   Both the journal-conversation prompt and the general chat prompt
   used to say "Today is 2026-04-29 (America/New_York)." They now say
   "Today is Wednesday, 2026-04-29 (...)." LLMs are unreliable at
   deriving weekday names from ISO dates; supplying the name removes
   the guess.

2. **`expected_weekday` parameter on create_event / update_event.**
   When the model passes `expected_weekday="friday"`, the backend
   computes the resolved start_date's weekday in the user's local
   timezone and rejects mismatches with a self-correcting error
   ("Date 2026-04-30 falls on Thursday, not Friday. Recompute..."),
   without creating the event. The check is local-aware: a Friday
   23:00 event in Tokyo crosses midnight UTC but the local view
   stays Friday, and the validator respects that.

3. **Tool descriptions instruct echo-and-confirm.** create_event and
   update_event descriptions now tell the model: when the user names
   a weekday, state the resolved date in the reply BEFORE calling
   the tool, and pass `expected_weekday`. Costs nothing in code,
   reinforces the validator.

6 new tests — match success, mismatch rejection (with create/update
not invoked), omitted-param backcompat, invalid weekday name, local-
not-UTC weekday computation, and the update_event variant. All 18
calendar-tool tests + 33 event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:43:32 -04:00

179 lines
6.8 KiB
Python

"""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 the user's assistant. They've opened their journal — a day-anchored "
"conversation surface where they record their day. Behave like the rest of "
"the app's chat: respond conversationally, ask follow-up questions about what "
"they just said, verify details from earlier in the conversation when "
"relevant, and use tools naturally to act on their behalf when it makes sense. "
"The day's prep message at the top of the conversation is your context — "
"build on it, don't restate it."
)
JOURNAL_CALIBRATION = """\
JOURNAL-SPECIFIC TOOL GUIDANCE:
PEOPLE / PLACES — ask before creating new entries.
- If the user mentions a name you don't already know about, ASK them in plain
language ("Who's Sarah to you?") and WAIT for the reply before calling
save_person or save_place.
- For ambiguous references (multiple matches in their existing people/places),
ask which one. Never guess.
- For unambiguous references to people they've already established, no need
to ask — proceed normally.
MOMENTS — recording them is your primary job, not a "nice to have."
After every substantive user message, BEFORE you compose your reply,
check: did the user describe ANY of these?
- An event that happened ("I went grocery shopping")
- An encounter with a person ("had coffee with Sarah")
- A decision ("I'm going to switch jobs")
- An observation about themselves or the world ("the new place is loud")
- A plan or commitment ("watching a show with Victoria tonight")
- A feeling or state ("I'm tired", "feeling decompressed")
- A small accomplishment or change they made ("installed the new AP")
If the answer is YES to ANY of those — CALL record_moment FIRST, before
composing your reply. This is not optional. The journal exists to capture
these beats; if you skip the call, the beat is lost.
Multiple distinct beats in one message → multiple record_moment calls,
one per beat.
WHEN LINKING ENTITIES: use the *_names parameters (person_names,
place_names, task_titles, note_titles). Server resolves them to IDs by
lookup. Do NOT pass *_ids unless you have an exact ID returned from
another tool call in this same turn. Never invent IDs.
The ONLY messages where you skip record_moment are purely meta-conversational
ones — about the journal itself or about a prior tool result ("thanks",
"no priority needed", "can you also add X to that one", "I meant tasks not
notes"). Those aren't journal beats; they're chat about the chat.
STATE-CHANGING TOOLS — use the confirmation flow.
- update_task / update_note that change state (status, completion, deletion)
follow the standard confirmation pattern: pass `confirmed=false` first; the
frontend shows a confirm UI; call again with `confirmed=true` after the
user confirms.
- Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.)
don't need confirmation.
OTHER:
- Do NOT call set_rag_scope. The journal scope is implicit.
- Notes are not auto-retrieved here. If you need to reference a note, call
search_notes explicitly.
RESPONSE STYLE:
- Don't apologize for the user's feelings ("I'm sorry you're feeling…"). Engage
with what they said directly.
- Don't produce multi-option menus ("1. Show your calendar 2. List your tasks
3. ..."). They feel like a help-desk bot. Ask one specific follow-up or take
one specific action.
- Don't repeat a prior reply verbatim. If the user circles back on a theme,
pick a specific concrete detail from the new message to react to.
- Match the user's length. Short message → short reply. Don't pad.
"""
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()
# Include the day-of-week explicitly. LLMs are unreliable at deriving
# weekday names from ISO dates, which causes "this Friday" / "next
# Monday" to land on the wrong calendar day.
weekday = day_date.strftime("%A")
tz_block = f"Today is {weekday}, {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)