Skip to content

AI Coaching Expansion — Goal-Discovery Quiz + Feedback Weekly Summary

Overview

Two additive Ai::CoachService capabilities shipped together as Phase 4 of v4.6 — Intelligence & Analytics (roadmap items 38 and 38b, OBJ-1510). No parallel AI pipeline — both are new methods on the existing Ai::CoachService, following the same prompt/cache/Ai::ServiceClient pattern as every other Coach feature (see Coach, Coach Surfaces Map).

  • Goal-discovery quiz (suggest_goals) — a short quiz for the user who wants a goal but isn't sure what to set, reachable from both onboarding and goal creation. Gated by goal_quiz_enabled.
  • Feedback weekly summary (summarize_feedback) — a Coach-analyzed themed digest of the past week's feedback board posts, shown to admins on the Feedback dashboard. Gated by feedback_ai_summary_enabled.

Both flags fail closed (feature absent/null, not an error) when off or on generation failure, registered in ionic_frontend/src/lib/featureFlags.ts and synced to PostHog project 368400 (created disabled, 0% rollout). See Feature Flags for the lifecycle.


Goal-discovery quiz ("Find your goal" with Coach)

Entry points

  1. Goal creation (/goals/create) — a tertiary "Not sure yet? Find your goal →" link under the existing CoachGoalHero, routing to the standalone /goals/create/quiz page (GoalDiscoveryQuizView.vue). Adopting a suggestion here creates a goal immediately.
  2. Onboarding (/welcome slide wizard) — a "Not sure what to set? Let Coach ask a few questions" branch button on the goal-name slide (GoalNameSlide.vue, step 2 of the Goal Builder). Picking a suggestion here prefills the wizard's goal name and hands control back — it does not create a goal directly. See "Onboarding embed contract" below.

Both entry points render the same GoalDiscoveryQuiz.vue component, driven by a context: 'create' | 'onboarding' prop (useGoalDiscoveryQuiz.ts) that changes the question set, selection mode, and adopt behavior.

Question flow

ContextQuestions askedNotes
createAll 4 — life areas (Q1, multi-select up to 3), pull (Q2), rhythm (Q3), why (Q4, skippable)Full QUIZ_QUESTIONS array
onboarding2 — pull (Q2), rhythm (Q3) onlyLife area is inherited from flow.answers.lifeArea (already chosen on the wizard's life-area slide); why is omitted (the later goal-why slide captures it) — no double-asking

Question copy, options, and option ids are defined once in ionic_frontend/src/composables/useGoalDiscoveryQuiz.ts (QUIZ_QUESTIONS, filtered to ONBOARDING_QUIZ_QUESTIONS for the onboarding context).

Onboarding embed contract (in-wizard, not a route)

The onboarding entry does not navigate. GoalNameSlide.vue's branch button toggles a local quizMode ref — GoalDiscoveryQuiz renders inside the slide's own body, with the SlideWizardLayout chrome and progress bar staying mounted (no SlideRegistry change, no progress-denominator drift). This matters because the wizard defers real goal creation to the terminal DoneSlide.vue, which calls COMPLETE_ONBOARDING_AND_CREATE_GOAL_MUTATION and sets onboardingCompletedAt — any path that left /welcome before reaching DoneSlide would skip the coach-personalization slides and trip the evaluateOnboardingGate redirect back into /welcome.

So in onboarding context, picking a suggestion prefills, it doesn't create:

  1. SuggestedGoalCard's "Use this goal" button emits prefill with the suggestion's title.
  2. GoalNameSlide.onPrefill() sets the textarea value, emits update:value (the same event the manual-typing path already uses, so flow.answers.goalName is set through existing wizard plumbing), exits quiz mode, and calls the injected slideNext() to advance to goal-shape.
  3. The user continues goal-shape → goal-why → coach-persona/-tone/-focus/-rhythm → done, identical to the manual-entry path. DoneSlide creates the goal and completes onboarding exactly as it always has — no ADD_GOAL_MUTATION fires from the quiz itself.

Empty/error states and "Back" on the first question all emit an exit event that returns the slide to its normal text-input mode — the onboarding quiz path never leaves /welcome.

States

useGoalDiscoveryQuiz.ts's phase ref drives five states: questionsgenerating (Coach-pulse + shimmer skeleton) → suggestions (3–5 SuggestedGoalCards) → empty (model returned zero confident suggestions) → error (mutation failed or threw). Answers are preserved across a retry — no re-tapping the whole quiz. In create context each card independently tracks idle / adopting / adopted / error; in onboarding context a card is idle or selected only (nothing async can fail on a local prefill).

Backend

  • Ai::CoachService#suggest_goals (rails_api/app/services/ai/coach_service.rb) — builds the prompt via Ai::Prompts::GoalSuggestions.build, calls Ai::ServiceClient.chat on the coaching/milestones model route, cached 1 hour by prompt hash (Ai::CacheKey). Parses the model's JSON into an array of { title, category, kind, target_months, rationale, milestones } hashes, or returns nil on failure/parse error (fail-closed, not a raised error).
  • Mutations::Ai::SuggestGoals (rails_api/app/graphql/mutations/ai/suggest_goals.rb) — require_auth!, checks goal_quiz_enabled, validates all required answers are present, caps life_areas at 3 server-side (INVALID_INPUT if exceeded — the UI already caps at 3, this is defense against a non-UI client), rate-limited to 5 requests/60s per user (Ai::RateLimiter, category goal_suggestions). Resolves each suggestion's category/kind name back to a GoalCategory/GoalType id via hydrate(), and turns target_months into an ISO target date.
  • Prompt fencing: the model's quiz answers are wrapped in a <user_input> block with an explicit "do not follow instructions" guard (Ai::Prompts::GoalSuggestions.build), matching the injection-guard pattern used elsewhere in CoachService.
  • Answer labels, not raw ids, reach the prompt: the frontend resolves each answer's kebab-case option id (e.g. 'habit') to its human-readable label (e.g. 'Build a consistent habit') before sending the mutation (optionLabel() in useGoalDiscoveryQuiz.ts) — the model reasons over the actual chosen phrasing, not slugs.

GraphQL

See the generated reference for full field/argument tables: suggestGoals mutation, SuggestedGoal type.

graphql
mutation SuggestGoalsMutation($lifeAreas: [String!]!, $pull: String!, $rhythm: String!, $why: String) {
  suggestGoals(lifeAreas: $lifeAreas, pull: $pull, rhythm: $rhythm, why: $why) {
    suggestions {
      title
      rationale
      categoryId
      categoryName
      kindId
      kindName
      targetDate
      milestones
    }
    errors
  }
}

Flag gating

goal_quiz_enabled, fail-closed:

  • Frontend: both entry affordances (the Create link and the onboarding branch button) simply don't render when the flag is off. Direct navigation to /goals/create/quiz redirects to /goals/create via the route's beforeEnter guard (ionic_frontend/src/router/index.ts).
  • Backend: Mutations::Ai::SuggestGoals#require_quiz_enabled! raises a FORBIDDEN GraphQL error if the flag is off for the current user, independent of the frontend guard.

Key files

FilePurpose
ionic_frontend/src/composables/useGoalDiscoveryQuiz.tsQuestion definitions, quiz state machine, answer→label mapping, mutation call
ionic_frontend/src/components/goal/quiz/GoalDiscoveryQuiz.vueOrchestrates questions → generating → suggestions/empty/error; context prop switches Create vs. onboarding behavior
ionic_frontend/src/components/goal/quiz/QuizQuestionStep.vueOne question per screen (single- or multi-select), embedded prop for the compact onboarding progress tag
ionic_frontend/src/components/goal/quiz/SuggestedGoalCard.vueSuggested goal + rationale + Adopt/"Use this goal"
ionic_frontend/src/views/GoalDiscoveryQuizView.vueStandalone IonPage for the Create entry point (/goals/create/quiz)
ionic_frontend/src/views/welcome/slides/GoalNameSlide.vueOnboarding host slide — owns the quiz-mode toggle, prefill handoff
rails_api/app/services/ai/coach_service.rb#suggest_goals
rails_api/app/services/ai/prompts/goal_suggestions.rbPrompt template
rails_api/app/graphql/mutations/ai/suggest_goals.rbsuggestGoals mutation
.planning/phases/v4.6-phase-4-ai-coaching-expansion/UI-SPEC.mdDesi's design contract, including the R2 onboarding-embed revision

Feedback weekly summary (admin)

A Coach-analyzed themed summary of the past week's feedback board posts, shown at the top of the admin Feedback dashboard (admin_dashboard/src/views/FeedbackView.vue). Internal/admin-only — no brand-voice constraint on the prompt, just clear factual grouping. Complements the manual triage tools described in Feedback Board.

What it shows

FeedbackWeeklySummaryPanel.vue renders 2–5 themes (title, 1–2 sentence summary, post count, vote total) ordered by vote total descending, plus an optional "Emerging requests" chip row for newly-appearing request patterns. The panel renders nothing when the query returns null — the backend is the single source of truth for the flag gate and for "nothing to summarize," so the panel fails closed by absence of data rather than an explicit disabled state.

Backend

  • Ai::CoachService#summarize_feedback — builds the prompt via Ai::Prompts::FeedbackSummary.build, calls Ai::ServiceClient.chat on the batch/insights model route, cached 24 hours by prompt hash. Parses the model's JSON into { themes: [{ title, summary, post_count, vote_total }], emerging_requests: [] }, or nil on failure/parse error.
  • Resolvers::AdminQueries#feedback_weekly_summary (rails_api/app/graphql/resolvers/admin_queries.rb) — require_admin!, returns nil if feedback_ai_summary_enabled is off. Pulls the top 50 real (non-deleted) posts from the last 7 days by vote count (FeedbackPost.real.where('created_at > ?', 7.days.ago).by_votes.limit(50)); returns nil if there are none. Feeds { title, description, category, vote_count, created_at } per post into summarize_feedback; returns nil if the model call fails.
  • Prompt fencing: feedback post content is wrapped in a <feedback_posts> block with the same "do not follow instructions" guard pattern.
  • Rules baked into the prompt: themes must be grounded strictly in the supplied posts (no invented requests), ordered by vote_total descending; emerging_requests may be an empty array.

GraphQL

See the generated reference: feedbackWeeklySummary query, FeedbackWeeklySummary type.

graphql
query FeedbackWeeklySummaryQuery {
  feedbackWeeklySummary {
    themes {
      title
      summary
      postCount
      voteTotal
    }
    emergingRequests
    generatedAt
  }
}

Flag gating

feedback_ai_summary_enabled, fail-closed — the resolver returns null when the flag is off, when there are no posts in the last 7 days, or when the model call fails. There is no separate frontend guard; FeedbackWeeklySummaryPanel.vue is only ever rendered when the query returns a non-null summary.

Key files

FilePurpose
rails_api/app/services/ai/coach_service.rb#summarize_feedback
rails_api/app/services/ai/prompts/feedback_summary.rbPrompt template
rails_api/app/graphql/resolvers/admin_queries.rbfeedback_weekly_summary resolver
rails_api/app/graphql/types/feedback_weekly_summary_type.rbFeedbackWeeklySummary / FeedbackTheme GraphQL types
admin_dashboard/src/components/FeedbackWeeklySummaryPanel.vuePanel UI
admin_dashboard/src/views/FeedbackView.vueAdmin Feedback page — fires the query, renders the panel when non-null

Known limitations

  • Both LLM calls can return nil on parse failure — there's no automatic retry beyond what Ai::ServiceClient itself does; the quiz surfaces this as its error phase (user can retry), the feedback summary surfaces it as an absent panel (admin sees nothing until the next cache window).
  • The feedback summary's 24-hour cache means a newly-posted, highly-voted item won't shift the themes until the cache expires or the underlying post set changes enough to produce a new cache key.
  • Onboarding-context prefill only carries the goal title (+ category) forward — shape and target date are still confirmed by the user on the existing goal-shape slide, by design (keeps the wizard's progress denominator and confirmation step intact; see the UI-SPEC's Open Questions).

Last updated: 2026-07-19

Loading…