Coach User Data Graph (UDG) -- Context Architecture PRD
Product: Objectuve (codename: Enkidu) Feature: Coach UDG -- richer, server-side user data graph for coaching context Status: Proposed / Awaiting greenlight Date: May 2026 Author: Penny (product lead)
1. Problem Statement
Coach gives generic advice because it doesn't know enough about the user.
The current getAdvice mutation accepts a userContext: String! assembled on the frontend by buildContextPrefix() in useAiCoach.ts. That string is a thin snapshot of whatever page the user happens to be on:
[Context: Viewing goal "Learn Spanish", 42% complete, 5 days left]That's it. No habit history. No event patterns. No cross-goal picture. No recent activity.
The result: Coach can't give meaningfully personalized advice. It knows the user is 42% through a goal but not that they haven't checked in for 8 days, that they've been most consistent on Wednesday mornings, or that they have two other goals also falling behind. The persona and tone are personalized (via coaching_preferences), but the underlying data is too shallow to produce coaching that feels like it knows you.
Partial progress (Phase 117, OBJ-1099): the specific "hasn't checked in for 8 days" gap is now proactively surfaced — the at-risk-goal nudge fires a once-per-goal-per-period Coach message when a recurring habit goal is measurably behind its own cadence with little time left in the period. This addresses the detection and surfacing half of the gap for habit goals specifically; it does not touch the broader UDG context-injection architecture proposed below (still Proposed / Awaiting greenlight), and it doesn't yet cover the "most consistent on Wednesday mornings" or "two other goals also falling behind" cross-goal picture. See docs/features/coach.md § At-risk-goal nudge (Phase 117).
The fix is not retrieval. A request-first-then-retrieve pattern (where the LLM calls tools to fetch its own data) adds 500--1,500ms of latency to produce a 2-sentence reply. That's the wrong trade for a coaching chat interface. The domain is narrow and predictable -- every coaching query is about goals, habits, and streaks -- so the right data can always be determined before the LLM sees the message.
The fix is better upfront injection, assembled server-side.
2. Goals & Success Metrics
Primary Goals
- Give Coach enough data to reference specific patterns, not just current state
- Move context assembly out of the frontend and into the backend resolver, where live DB data is available
- Keep token cost bounded -- richer context, but not a firehose
Success Metrics
| Metric | Target (30 days post-launch) |
|---|---|
| % of coaching responses that reference a specific event, streak, or habit | 60%+ (up from ~30% estimated today) |
| Coach modal session length (messages per session) | +0.5 messages/session vs. baseline |
| User rating on Coach helpfulness (if in-modal thumbs added) | Baseline establishment only in first 30 days |
p99 getAdvice latency | No regression vs. current |
Guardrails
- No latency regression on the coaching request. The context assembly adds one DB query (eager-loaded); it must not exceed 50ms added overhead.
- Token budget: UDG context block capped at ~400 tokens per request. Beyond that, Coach is reading noise.
- No PII beyond what's already in the app. Mood logs are excluded from Phase 1 (separate privacy decision required).
- No change to the 2-sentence response cap or any user-visible Coach behavior. This is a context plumbing change.
3. Architecture Decision: Upfront Injection vs. Retrieval
This is the key design question, documented here for future reference.
Option A: Upfront Injection (chosen)
The backend resolver assembles the full UDG snapshot before calling CoachService. The LLM sees everything it needs in one shot.
Why this is right for Coach:
- Domain is narrow and predictable. Every coaching query is about the user's goals, habits, and streaks. We can always determine the relevant data before the LLM sees the message.
- Responses are short (2 sentences max). Retrieval round-trips would cost more time than the response itself.
- Context is bounded. A user has at most ~20 active goals and ~14 days of recent events we care about. This fits comfortably in a token budget.
- Caching is simpler. Fixed, deterministic context enables caching for insight cards and check-ins. Retrieval-driven context makes every call unique.
Option B: Request-First, Then Retrieve (rejected for now)
The LLM receives a minimal prompt and uses tool calls to fetch the data it needs.
Why it's wrong for Coach today:
- Adds 500--1,500ms of latency per coaching message.
- Requires a tool-use-capable model and structured tool definitions -- more infrastructure for marginal benefit.
- The domain is not open-ended enough to justify it. If Coach ever needs to answer questions like "find me research on habit formation" or "compare my progress to other users," retrieval becomes worth reconsidering.
When to reconsider: If Coach evolves into a conversational assistant that can answer questions outside the goal/habit domain, or if the UDG grows large enough that injecting all of it is prohibitively expensive, revisit retrieval.
4. The UDG: What Belongs In, What Stays Out
Include
| Data | Why | Source |
|---|---|---|
| All active goals (name, progress %, days remaining, status) | Coach needs the full picture to advise prioritization, not just the currently viewed goal | Goal.active |
| Whether each goal is a habit-goal or a regular goal | Habit-goals and regular goals need structurally different coaching — streaks and consistency advice only apply to habit-type goals (recurrence_type present) | goal.habit? |
Current habit_streak for habit-type goals | Most emotionally salient data point for habit coaching | goal.habit_streak |
| Recurrence schedule for habit-type goals | Coach can reference "you set this up as a daily habit" or "you committed to 3x/week" | goal.recurrence_type, goal.recurrence_days, goal.recurrence_interval |
| Recent completions for habit-type goals (last 14 days) | Pattern detection — "you've been most consistent on weekdays" | goal.habit_completions.where(completed_date: 14.days.ago..) |
| Recent events for regular goals (last 14 days) | Progress logging frequency — "you haven't logged in 8 days" | goal.events.where(created_at: 14.days.ago..) |
| Coaching preferences (persona, tone_brevity, tone_warmth, focus_primary, focus_secondary) | Already included; no change | user.coaching_preferences |
| Days since last activity on focused goal | Critical for re-engagement framing | Derived from HabitCompletion or GoalEvent max date |
Exclude (Phase 1)
| Data | Why Excluded |
|---|---|
| Full event history (>14 days) | Noise; the LLM doesn't need to know a user checked in 47 days ago |
| Badge / gamification details | Not relevant to coaching advice unless the user asks |
| Community data | Different context entirely; unrelated to goal coaching |
| Mood logs | High-sensitivity data; requires separate privacy decision before including in LLM context |
| Milestones list | Useful for milestone-specific prompts, but adds token cost for most coaching queries; reconsider in Phase 2 |
| Completed goals | Historical noise for day-to-day advice; may add for long-term coaching in Phase 2 |
5. Proposed Context Block Format
The backend builds a structured plain-text block (not JSON, which wastes tokens on syntax). Target: ~400 tokens max.
## User context
Active goals: 3
- "Learn Spanish" [habit, daily] — streak: 0 days, last completed 8 days ago
- "Run a 5K" [habit, 3x/week] — streak: 4 days, last completed yesterday
- "Read 12 books this year" [goal] — 30% complete, 210 days left, last activity 3 days ago
## Focused goal: "Learn Spanish"
Type: Habit (daily)
Current streak: 0 days
Longest streak: 12 days
Days since last completion: 8
Recent completions (last 14 days): 6 of 14 expected days
## User question
[user's message prepended here]This gives the LLM:
- Cross-goal awareness for prioritization queries
- Specific pattern data ("you've been inactive for 8 days") for re-engagement framing
- Habit configuration context for habit-specific advice
- Enough without being overwhelming
6. Implementation Plan
Phase 1: Server-side context assembly (1 sprint)
Backend changes:
Update
getAdvicemutation signatureChange
userContext: String!togoalId: String, page: String!. The resolver takes over context assembly.ruby# app/graphql/mutations/ai/get_advice.rb argument :goal_id, String, required: false argument :page, String, required: true argument :user_message, String, required: trueCreate
BuildCoachContextinteractionNew interaction at
app/interactions/ai/build_coach_context.rb:rubyclass Ai::BuildCoachContext < Interaction::Base delegate_input :user, :goal_id, :page def call focused_goal = goal_id ? user.goals.active.public_find(goal_id) : nil active_goals = user.goals.active .includes(:habit_completions, :events) .order(:target_date) context_parts = [] context_parts << build_goals_summary(active_goals) context_parts << build_focused_goal_detail(focused_goal) if focused_goal result.details = { context: context_parts.compact.join("\n\n") } end private def build_goals_summary(goals) lines = goals.map do |g| if g.habit? last_completion = g.habit_completions.maximum(:completed_date) days_inactive = last_completion ? (Date.current - last_completion).to_i : nil parts = ["\"#{g.name}\" [habit, #{g.recurrence_type}]"] parts << "streak: #{g.habit_streak} day#{g.habit_streak == 1 ? '' : 's'}" parts << "last completed #{days_inactive} day#{days_inactive == 1 ? '' : 's'} ago" if days_inactive else last_event = g.events.maximum(:created_at) days_inactive = last_event ? (Date.current - last_event.to_date).to_i : nil days_left = g.target_date ? (g.target_date.to_date - Date.current).to_i : nil parts = ["\"#{g.name}\" [goal]"] parts << "#{g.progress.round}% complete" parts << "#{days_left} days left" if days_left parts << "last activity #{days_inactive} day#{days_inactive == 1 ? '' : 's'} ago" if days_inactive end "- #{parts.join(', ')}" end "Active goals: #{goals.size}\n#{lines.join("\n")}" end def build_focused_goal_detail(goal) lines = ["Focused goal: \"#{goal.name}\""] if goal.habit? completions_14d = goal.habit_completions .where(completed_date: 14.days.ago..) .count last_completion = goal.habit_completions.maximum(:completed_date) days_since = last_completion ? (Date.current - last_completion).to_i : nil lines << "Type: Habit (#{goal.recurrence_type})" lines << "Current streak: #{goal.habit_streak} days" lines << "Longest streak: #{goal.longest_habit_streak} days" lines << "Days since last completion: #{days_since}" if days_since lines << "Recent completions (last 14 days): #{completions_14d}" else events_14d = goal.events.where(created_at: 14.days.ago..).count last_event = goal.events.maximum(:created_at) days_since = last_event ? (Date.current - last_event.to_date).to_i : nil lines << "Type: Goal" lines << "Progress: #{goal.progress.round}%" lines << "Days left: #{(goal.target_date.to_date - Date.current).to_i}" if goal.target_date lines << "Days since last activity: #{days_since}" if days_since lines << "Activity entries (last 14 days): #{events_14d}" end lines.join("\n") end endNote on forward compatibility: When
Habiteventually becomes a child model ofGoal(similar toMilestone),build_goals_summarywill need to change — a goal may then have multiple habits rather than being a habit itself.BuildCoachContextis the single place that change needs to happen;CoachServiceand the GraphQL mutation are unaffected.Update
CoachService#get_advicecall in resolverResolver calls
Ai::BuildCoachContext, gets back the context string, then callsCoachService#get_advicewith both context andcoaching_preferences.
Frontend changes:
Update
GET_ADVICEmutation constant inionic_frontend/src/constants/graphql/ai.jsNew arguments:
goalId,page,userMessage. RemoveuserContext.Update
useAiCoach.tsReplace
buildContextPrefix()usage in the advice call with the newuserMessage-only payload. The composable no longer assembles context -- it just passes the user's message and current page/goal identifiers.buildContextPrefixcan be removed or kept as a legacy fallback during transition.
Phase 2: Richer context (future sprint)
- Add milestones for the focused goal (top 3 upcoming)
- Add mood log correlation when mood logging feature matures
- Add completed goals summary for long-term trend coaching
- Reconsider streak calculation -- currently derived ad-hoc; may need a dedicated
streakfield onGoal
7. Critical Files
| File | Change |
|---|---|
rails_api/app/graphql/mutations/ai/get_advice.rb | Update arguments: goal_id, page, user_message; call BuildCoachContext |
rails_api/app/interactions/ai/build_coach_context.rb | New interaction — assembles UDG context string |
rails_api/app/services/ai/coach_service.rb | No change to interface; context string format changes |
ionic_frontend/src/constants/graphql/ai.js | Update GET_ADVICE mutation arguments |
ionic_frontend/src/composables/useAiCoach.ts | Remove buildContextPrefix() call in advice flow; pass goalId, page, userMessage |
8. Edge Cases
| # | Scenario | Resolution |
|---|---|---|
| EC-1 | User has no active goals | Context block omits goals section; Coach gives goal-setting advice |
| EC-2 | Focused goal has no recent events | "No activity in the last 14 days" included explicitly -- Coach uses this |
| EC-3 | goalId not provided (general chat) | Skip focused goal detail; include cross-goal summary only |
| EC-4 | Goal has >20 recent events in 14 days | Summarize by event type counts rather than listing all events |
| EC-5 | Context block exceeds 400 tokens | Truncate oldest events first; always keep goals summary and days-inactive data |
| EC-6 | Page is not goal-detail | Only include cross-goal summary; no focused goal block |
9. Open Questions
DoesResolved.Goalhave astreakmethod/field today?Goalhashabit_streak(stored integer, updated byupdate_habit_streak!) andlongest_habit_streak. Streak only applies to habit-type goals (recurrence_typepresent). The implementation sketch usesg.habit_streakdirectly.IsResolved. Habits are Goals withHabita separate model?recurrence_typeset. TheGoal.habitsscope (where.not(recurrence_type: nil)) andgoal.habit?predicate distinguish them from regular goals. AHabitchild model may exist in future (similar to howMilestoneis a child ofGoal);BuildCoachContextis the single place that change will land.Token budget validation: The 400-token cap on the context block should be tested with a user who has 20 active goals and active event logs. Adjust cap if needed.
Caching implications: The current
getAdvicecall is not cached (unlike insights). The server-side context assembly means each call hits the DB. This is intentional -- advice is interactive and cache-inappropriate -- but confirm with the team.
10. Suggested Next Step
Route to Orion to plan. Phase 1 (server-side context assembly) is a self-contained backend refactor with a small frontend change -- 1 sprint, no migration needed, no schema change. Fits cleanly in current active development without blocking other work.
Last updated: 2026-07-08