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 bygoal_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 byfeedback_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
- Goal creation (
/goals/create) — a tertiary "Not sure yet? Find your goal →" link under the existingCoachGoalHero, routing to the standalone/goals/create/quizpage (GoalDiscoveryQuizView.vue). Adopting a suggestion here creates a goal immediately. - Onboarding (
/welcomeslide wizard) — a "Not sure what to set? Let Coach ask a few questions" branch button on thegoal-nameslide (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
| Context | Questions asked | Notes |
|---|---|---|
create | All 4 — life areas (Q1, multi-select up to 3), pull (Q2), rhythm (Q3), why (Q4, skippable) | Full QUIZ_QUESTIONS array |
onboarding | 2 — pull (Q2), rhythm (Q3) only | Life 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:
SuggestedGoalCard's "Use this goal" button emitsprefillwith the suggestion's title.GoalNameSlide.onPrefill()sets the textarea value, emitsupdate:value(the same event the manual-typing path already uses, soflow.answers.goalNameis set through existing wizard plumbing), exits quiz mode, and calls the injectedslideNext()to advance togoal-shape.- The user continues
goal-shape → goal-why → coach-persona/-tone/-focus/-rhythm → done, identical to the manual-entry path.DoneSlidecreates the goal and completes onboarding exactly as it always has — noADD_GOAL_MUTATIONfires 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: questions → generating (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 viaAi::Prompts::GoalSuggestions.build, callsAi::ServiceClient.chaton thecoaching/milestonesmodel 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 returnsnilon failure/parse error (fail-closed, not a raised error).Mutations::Ai::SuggestGoals(rails_api/app/graphql/mutations/ai/suggest_goals.rb) —require_auth!, checksgoal_quiz_enabled, validates all required answers are present, capslife_areasat 3 server-side (INVALID_INPUTif 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, categorygoal_suggestions). Resolves each suggestion'scategory/kindname back to aGoalCategory/GoalTypeid viahydrate(), and turnstarget_monthsinto 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 inCoachService. - 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()inuseGoalDiscoveryQuiz.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.
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/quizredirects to/goals/createvia the route'sbeforeEnterguard (ionic_frontend/src/router/index.ts). - Backend:
Mutations::Ai::SuggestGoals#require_quiz_enabled!raises aFORBIDDENGraphQL error if the flag is off for the current user, independent of the frontend guard.
Key files
| File | Purpose |
|---|---|
ionic_frontend/src/composables/useGoalDiscoveryQuiz.ts | Question definitions, quiz state machine, answer→label mapping, mutation call |
ionic_frontend/src/components/goal/quiz/GoalDiscoveryQuiz.vue | Orchestrates questions → generating → suggestions/empty/error; context prop switches Create vs. onboarding behavior |
ionic_frontend/src/components/goal/quiz/QuizQuestionStep.vue | One question per screen (single- or multi-select), embedded prop for the compact onboarding progress tag |
ionic_frontend/src/components/goal/quiz/SuggestedGoalCard.vue | Suggested goal + rationale + Adopt/"Use this goal" |
ionic_frontend/src/views/GoalDiscoveryQuizView.vue | Standalone IonPage for the Create entry point (/goals/create/quiz) |
ionic_frontend/src/views/welcome/slides/GoalNameSlide.vue | Onboarding 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.rb | Prompt template |
rails_api/app/graphql/mutations/ai/suggest_goals.rb | suggestGoals mutation |
.planning/phases/v4.6-phase-4-ai-coaching-expansion/UI-SPEC.md | Desi'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 viaAi::Prompts::FeedbackSummary.build, callsAi::ServiceClient.chaton thebatch/insightsmodel route, cached 24 hours by prompt hash. Parses the model's JSON into{ themes: [{ title, summary, post_count, vote_total }], emerging_requests: [] }, ornilon failure/parse error.Resolvers::AdminQueries#feedback_weekly_summary(rails_api/app/graphql/resolvers/admin_queries.rb) —require_admin!, returnsniliffeedback_ai_summary_enabledis 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)); returnsnilif there are none. Feeds{ title, description, category, vote_count, created_at }per post intosummarize_feedback; returnsnilif 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_totaldescending;emerging_requestsmay be an empty array.
GraphQL
See the generated reference: feedbackWeeklySummary query, FeedbackWeeklySummary type.
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
| File | Purpose |
|---|---|
rails_api/app/services/ai/coach_service.rb | #summarize_feedback |
rails_api/app/services/ai/prompts/feedback_summary.rb | Prompt template |
rails_api/app/graphql/resolvers/admin_queries.rb | feedback_weekly_summary resolver |
rails_api/app/graphql/types/feedback_weekly_summary_type.rb | FeedbackWeeklySummary / FeedbackTheme GraphQL types |
admin_dashboard/src/components/FeedbackWeeklySummaryPanel.vue | Panel UI |
admin_dashboard/src/views/FeedbackView.vue | Admin Feedback page — fires the query, renders the panel when non-null |
Known limitations
- Both LLM calls can return
nilon parse failure — there's no automatic retry beyond whatAi::ServiceClientitself does; the quiz surfaces this as itserrorphase (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-shapeslide, by design (keeps the wizard's progress denominator and confirmation step intact; see the UI-SPEC's Open Questions).
Last updated: 2026-07-19