Skip to content

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

  1. Give Coach enough data to reference specific patterns, not just current state
  2. Move context assembly out of the frontend and into the backend resolver, where live DB data is available
  3. Keep token cost bounded -- richer context, but not a firehose

Success Metrics

MetricTarget (30 days post-launch)
% of coaching responses that reference a specific event, streak, or habit60%+ (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 latencyNo 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

DataWhySource
All active goals (name, progress %, days remaining, status)Coach needs the full picture to advise prioritization, not just the currently viewed goalGoal.active
Whether each goal is a habit-goal or a regular goalHabit-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 goalsMost emotionally salient data point for habit coachinggoal.habit_streak
Recurrence schedule for habit-type goalsCoach 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 changeuser.coaching_preferences
Days since last activity on focused goalCritical for re-engagement framingDerived from HabitCompletion or GoalEvent max date

Exclude (Phase 1)

DataWhy Excluded
Full event history (>14 days)Noise; the LLM doesn't need to know a user checked in 47 days ago
Badge / gamification detailsNot relevant to coaching advice unless the user asks
Community dataDifferent context entirely; unrelated to goal coaching
Mood logsHigh-sensitivity data; requires separate privacy decision before including in LLM context
Milestones listUseful for milestone-specific prompts, but adds token cost for most coaching queries; reconsider in Phase 2
Completed goalsHistorical 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:

  1. Update getAdvice mutation signature

    Change userContext: String! to goalId: 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: true
  2. Create BuildCoachContext interaction

    New interaction at app/interactions/ai/build_coach_context.rb:

    ruby
    class 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
    end

    Note on forward compatibility: When Habit eventually becomes a child model of Goal (similar to Milestone), build_goals_summary will need to change — a goal may then have multiple habits rather than being a habit itself. BuildCoachContext is the single place that change needs to happen; CoachService and the GraphQL mutation are unaffected.

  3. Update CoachService#get_advice call in resolver

    Resolver calls Ai::BuildCoachContext, gets back the context string, then calls CoachService#get_advice with both context and coaching_preferences.

Frontend changes:

  1. Update GET_ADVICE mutation constant in ionic_frontend/src/constants/graphql/ai.js

    New arguments: goalId, page, userMessage. Remove userContext.

  2. Update useAiCoach.ts

    Replace buildContextPrefix() usage in the advice call with the new userMessage-only payload. The composable no longer assembles context -- it just passes the user's message and current page/goal identifiers.

    buildContextPrefix can 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 streak field on Goal

7. Critical Files

FileChange
rails_api/app/graphql/mutations/ai/get_advice.rbUpdate arguments: goal_id, page, user_message; call BuildCoachContext
rails_api/app/interactions/ai/build_coach_context.rbNew interaction — assembles UDG context string
rails_api/app/services/ai/coach_service.rbNo change to interface; context string format changes
ionic_frontend/src/constants/graphql/ai.jsUpdate GET_ADVICE mutation arguments
ionic_frontend/src/composables/useAiCoach.tsRemove buildContextPrefix() call in advice flow; pass goalId, page, userMessage

8. Edge Cases

#ScenarioResolution
EC-1User has no active goalsContext block omits goals section; Coach gives goal-setting advice
EC-2Focused goal has no recent events"No activity in the last 14 days" included explicitly -- Coach uses this
EC-3goalId not provided (general chat)Skip focused goal detail; include cross-goal summary only
EC-4Goal has >20 recent events in 14 daysSummarize by event type counts rather than listing all events
EC-5Context block exceeds 400 tokensTruncate oldest events first; always keep goals summary and days-inactive data
EC-6Page is not goal-detailOnly include cross-goal summary; no focused goal block

9. Open Questions

  1. Does Goal have a streak method/field today? Resolved. Goal has habit_streak (stored integer, updated by update_habit_streak!) and longest_habit_streak. Streak only applies to habit-type goals (recurrence_type present). The implementation sketch uses g.habit_streak directly.

  2. Is Habit a separate model? Resolved. Habits are Goals with recurrence_type set. The Goal.habits scope (where.not(recurrence_type: nil)) and goal.habit? predicate distinguish them from regular goals. A Habit child model may exist in future (similar to how Milestone is a child of Goal); BuildCoachContext is the single place that change will land.

  3. 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.

  4. Caching implications: The current getAdvice call 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

Loading…