Commit Graph

10 Commits

Author SHA1 Message Date
bvandeusen 1b65c44339 ux: rename model fields + enforce serial curator execution
Three coordinated changes per operator request 2026-05-24:

1. Settings UI rename matching the language we actually use:
   - Chat Model -> Chat & Voice Model
   - Worker Model -> Curator Model
   Setting KEYS (default_model / background_model) unchanged on
   purpose; renaming them requires a migration touching 50+ call
   sites for purely UX-facing benefit.

2. Settings UI help text rewritten:
   - Chat & Voice: documents that it handles chat AND small
     conversational automations (titles, tags). Recommends
     OLLAMA_NUM_PARALLEL=2+ on the Ollama server so background
     automations get their own KV-cache slot and don't evict
     the chat model's working state.
   - Curator: notes the app enforces SERIAL execution regardless
     of NUM_PARALLEL — only one curator pass runs at a time. This
     matters most for 70b CPU models where a second instance
     would waste system RAM.

3. Enforce serial curator execution globally:
   - New module-level _CURATOR_RUN_LOCK in services/curator.py.
   - run_curator_for_conversation now wraps its body in 'async
     with _CURATOR_RUN_LOCK' — every entry point (scheduler sweep,
     manual route trigger, future hooks) is serialized through it.
   - is_curator_running() helper exposes the lock state.
   - routes/journal.py manual trigger checks is_curator_running()
     first and returns 409 {busy: true} immediately rather than
     blocking the HTTP request for minutes waiting for a 70b CPU
     pass to finish. The user can retry once the curator clears.

   Why a 409 instead of queue: a curator pass on a 70b CPU model
   can take 5+ minutes. Tying up an HTTP worker that long is bad;
   making the user wait without feedback is worse. 409 surfaces
   the busy state immediately and the user retries when they want.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:30:42 -04:00
bvandeusen 4048a771d2 feat(curator): pending-action API routes + client helpers (C4/5)
The HTTP surface for the review queue. Three endpoints, all under
the existing /api/journal blueprint to keep the journal-related routes
together:

- GET    /api/journal/pending — list current user's pending actions.
- POST   /api/journal/pending/<id>/approve — replay the proposed tool
         call via execute_tool(authority='user'). On success, marks
         the row 'approved'; on replay error, leaves it pending so
         the user can retry.
- POST   /api/journal/pending/<id>/reject — marks 'rejected' with no
         execution.

Each route is a thin wrapper around services/pending_actions and
delegates user-scoping to the service (which checks user_id on every
load — actions are private to the proposer).

api/client.ts:
- PendingCuratorAction interface mirroring the backend dict shape:
  id, user_id, conv_id, action_type, target_type/id/label, payload,
  current_snapshot, status, timestamps.
- listPendingActions / approvePendingAction / rejectPendingAction
  helpers for the upcoming Needs Review panel.

C5 next: the panel itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:09:40 -04:00
bvandeusen fa97ade8e3 feat(journal): curator summary feeds back into chat context (Phase 3)
The architecture loop closes. Curator extracts beats and writes a
≤240-char summary; the next chat turn loads that summary into the
journal system prompt so the chat model — which has no tools and
cannot retrieve anything itself — gains awareness of recent topics
captured by the curator.

Migration 0049:
- conversations.curator_summary (text, nullable). Last-write-wins; no
  history of prior summaries.

models/conversation.py:
- New curator_summary column on Conversation.

services/curator_scheduler.py:
- _stamp_last_run() takes an optional summary kwarg; persists it when
  non-empty (clobbering the previous summary). Empty summary keeps
  the existing one rather than overwriting useful context with "".
- _sweep() passes result.summary through.

routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> writes curator_summary
  alongside last_curator_run_at on success.

services/journal_pipeline.py:
- build_journal_system_prompt() gains an optional `conv_id` param.
  When provided, appends a "CURATOR NOTES" block at the end of the
  system prompt with the conversation's stored summary. Positioned
  after ambient context so the chat model treats it as current
  awareness rather than background.

services/llm.py:
- Threads conv_id through to build_journal_system_prompt.

This is the last commit of the conversation+curator architecture
arc (Fable #172):
- Phase 1a (a7002a8): chat=tools[], curator service backend
- Phase 1b (a73dd17): right-rail captures panel + manual trigger
- Phase 2   (83f1676): auto-scheduler every 15 min
- Phase 3   (this): curator summary → chat context feedback loop

Operator can now device-test the architecture end-to-end: have a
journal conversation (model can't lie about tool calls because it
has none), wait for the scheduler or hit "Process captures", see
moments appear in the right rail, then continue the conversation
and notice the chat model staying topic-aware via the summary block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:09:33 -04:00
bvandeusen 83f1676d72 feat(journal): auto-scheduler for curator (Phase 2)
The curator now runs automatically every 15 minutes against any
journal conversation that has user messages newer than its last
curator run. Manual triggers from Phase 1b still work and now also
stamp the timestamp so the scheduler doesn't double-process.

Migration 0048:
- conversations.last_curator_run_at (timestamptz, nullable).
- Partial index ix_conversations_journal_last_curator on the column
  filtered to conversation_type='journal'. The scheduler's candidate
  query is "journal AND (NULL OR stale)" so an index narrowed to
  journal rows is the right shape — index size stays small even on
  instances with many non-journal conversations.

models/conversation.py:
- New `last_curator_run_at` column on Conversation. DateTime imported.

services/curator_scheduler.py (new):
- IntervalTrigger every 15 min via BackgroundScheduler (same pattern
  as journal_scheduler.py).
- _candidate_conversations(): SELECT journal conversations where the
  newest user message is newer than last_curator_run_at (or NULL).
  Capped at 20 per sweep so a backlog after downtime doesn't stall
  the scheduler.
- _sweep() processes candidates sequentially under an asyncio.Lock
  so overlapping ticks can't double-fire on the same conversation.
  Failed runs leave the timestamp alone — natural retry on next sweep.
- start_/stop_curator_scheduler() wired into app.py boot/shutdown.

routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> stamps last_curator_run_at
  on success. Errors don't stamp so the scheduler retries.

What's still pending:
- Phase 3: feedback loop (curator summary into chat context). Currently
  the curator's summary lives in the run result but doesn't reach the
  chat model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:07:12 -04:00
bvandeusen a7002a89a0 feat(journal): chat model has no tools; curator runs them async (Phase 1a)
Backend half of the conversation+curator architecture (Fable #172).
Decouples the journal chat surface from tool calling: the chat model
now sees `tools=[]` and just talks, while a separate curator pass
extracts beats and fires the tool calls.

services/generation_task.py:
- When conversation_type == "journal", pass `tools=[]` to Ollama
  regardless of what the journal tool set would normally provide.
  The chat model literally cannot fire record_moment / create_task /
  etc., so it cannot lie about firing them — the primary failure
  mode this architecture removes.

services/curator.py (new):
- `run_curator_for_conversation(conv_id, since=None)` loads recent
  messages, builds a curator-specific system prompt (extract beats,
  emit tool calls, optionally a one-line summary), and iterates the
  Ollama tool-call loop using the user's background_model so the
  chat model's KV cache survives.
- Same tool registry as a normal journal conversation
  (record_moment, search_notes, update_task, create_task,
  save_person, save_place, etc.). The curator chooses naturally
  among them; no need for a separate curator-specific filter.
- Returns CuratorRunResult with per-call status + a summary line.
- Caps at 4 tool-call rounds — bounded task (extract beats from a
  fixed transcript), shouldn't need more.
- Errors land in result.error rather than raising; the manual
  trigger surface (and later the scheduler) want a structured
  result, not exceptions.

routes/journal.py:
- New POST /api/journal/curator/run/<conv_id> for manual triggers.
  Validates conv ownership before running. Returns the
  CuratorRunResult dict so the UI can show what was captured.

What's not in this commit (deferred to later phases):
- The scheduler that auto-runs the curator (phase 2 — adds the
  `conversations.last_curator_run_at` column + APScheduler job).
- Curator → chat feedback loop (phase 3 — summary gets injected
  into subsequent chat system prompts).
- Right-rail captures panel in JournalView (phase 1b — pure frontend
  work, separate commit for clean review).
- Research surface separation (phase 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:03:24 -04:00
bvandeusen 5d2d27c499 fix(journal): anti-hallucination hardening + message_count fix
Prep prose (services/journal_prep.py):
- Emit explicit "WEATHER: none available — do NOT mention weather"
  absent-marker so a small model can't invent partly-cloudy/temperature
  prose when both configured locations have empty addresses.
- Replace negative-only system rule with positive-anchored guidance
  forbidding weather/temp/precip mentions unless a numeric WEATHER
  section is present; also bans echoing parenthetical labels verbatim.
- Reword overdue header to "(past their due date, still open — backlog,
  not today's work)" and render lines as "was due <date>, N day(s)
  overdue" with correct singular/plural. Supersedes the wording noted
  in Fable task #159.
- Deterministic fabricated-weather reconciler: low-false-positive regex
  detects fabricated weather phrasing; on trip with an empty section,
  regenerate once with a corrective. Persistent fabrication logs ERROR
  rather than mangling prose.

Journal route (routes/journal.py):
- Override message_count with len(messages) in _day_payload. The chat
  path already does this; the journal path was hitting the
  Conversation.to_dict() fallback to 0 because messages aren't
  eager-loaded on that instance.

Tests:
- tests/test_journal_message_count.py — pins the model-level trap and
  the override contract (3 cases).
- tests/test_journal_prep_hardening.py — 11 cases covering the
  fabricated-weather reconciler and absent-marker rendering.
- tests/test_journal_prep_filtering.py — updated one stale assertion.

Tracks Fable task #171.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:54:35 -04:00
bvandeusen c33cab7020 fix(journal): wire weather refresh on config save; drop orphaned cache rows
Two related gaps in the journal weather panel:

1. Saving locations via PUT /journal/config didn't trigger a weather
   fetch, so newly-entered sites had no cache row (or a stale one) until
   the user manually clicked the panel's refresh button. The panel
   rendered "two sites with empty values" against pre-existing cache
   rows that no longer matched what the user had configured.

2. get_cached_weather_rows returned every WeatherCache row for the user
   regardless of whether the location was still in journal_config.
   Briefing-era rows survived migration 0040 (which only deleted the
   briefing_config setting, not the cache table) and showed up as
   ghost tabs in the UI.

Changes:
- get_cached_weather_rows accepts an optional valid_keys filter; rows
  whose location_key is not in the set are excluded.
- routes/journal.py:
  - put_config kicks off a background refresh_location_cache for any
    saved location with valid lat/lon.
  - GET /weather and POST /weather/refresh both pass valid_keys derived
    from the current config so orphaned rows don't surface.
- services/journal_prep.py filters the weather section to currently-
  configured locations as well; uses a lazy import of get_journal_config
  to avoid a cycle (journal_scheduler imports journal_prep).

153 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:37:23 -04:00
bvandeusen de4b1d7c7e fix(weather): match prep behavior — serve cached weather regardless of age
The /api/journal/weather route was filtering out cache rows older than 24
hours via parse_weather_card_data, while journal_prep.py read the same
rows raw without freshness checking. Result: the daily prep referenced
"home" and "work" temperatures while the right-rail UI showed nothing —
two surfaces, same backing data, inconsistent visibility.

Two changes:

1. parse_weather_card_data no longer returns None for stale data.
   WeatherCard already exposes fetched_at and gracefully hides
   today_high / forecast fields when they're absent, so old data renders
   with whatever fields the cached forecast still covers.

2. The /weather route opportunistically schedules a background refresh
   for any cache row older than 4 hours. If the user's journal_config
   has lat/lon for that location_key, the refresh runs and the next
   page load gets fresh data; if no usable config, the refresh is a
   silent no-op and the stale cache is still served.

This makes prep and UI consistent. It also self-heals over time — once
locations are configured, stale caches get refreshed on the next page
load instead of waiting indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:19:57 -04:00
bvandeusen cacfcac86a fix(journal): restore weather + events panels; hide daily-prep system msg in chat
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>
2026-04-26 12:22:51 -04:00
bvandeusen ce76a003f7 feat(journal): /api/journal/* routes blueprint + cosine helper unit tests
Endpoints:
- GET/PUT /api/journal/config — per-user journal config
- GET /api/journal/today — today's journal conversation, generates daily prep on demand
- GET /api/journal/day/<iso_date> — past day's journal
- GET /api/journal/days — list of dates with journal content
- POST /api/journal/trigger-prep — manual regeneration of prep
- GET /api/journal/moments — list/search moments with filters
- PATCH /api/journal/moments/<id> — edit content/tags/pinned + junctions
- DELETE /api/journal/moments/<id>

Blueprint registered in app.py.
tests/test_journal_search.py — cosine helper coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:40:56 -04:00