From b73ff9ab6fff437f6d9eb2c39f371f5a79ec9231 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 17:57:11 -0700 Subject: [PATCH] [eric] onboarding: reveal shows what it saw and why it acted (per-job personalized reasons) --- backend/apps/onboarding/models.py | 1 + backend/apps/onboarding/prep.py | 11 ++++++++--- backend/apps/settings/models.py | 2 ++ backend/tests/test_onboarding.py | 14 ++++++++++++++ .../components/OnboardingV3/onboardingV3Api.ts | 1 + .../OnboardingV3/useOnboardingV3Pipeline.ts | 8 ++++---- .../hooks/lifecycle/useOnboardingRevealSeed.ts | 15 ++++++++------- frontend/src/shared/state/onboardingV3Slice.ts | 2 ++ frontend/src/shared/state/settingsSlice.ts | 1 + 9 files changed, 41 insertions(+), 14 deletions(-) diff --git a/backend/apps/onboarding/models.py b/backend/apps/onboarding/models.py index aba38d62..c57f6c83 100644 --- a/backend/apps/onboarding/models.py +++ b/backend/apps/onboarding/models.py @@ -56,4 +56,5 @@ class PrepResponse(BaseModel): starters: List[PersonalizedStarter] = Field(default_factory=list) app_title: str = "" app_prompt: str = "" + app_reason: str = "" automations: List[PersonalizedAutomation] = Field(default_factory=list) diff --git a/backend/apps/onboarding/prep.py b/backend/apps/onboarding/prep.py index b4cc113b..0092268b 100644 --- a/backend/apps/onboarding/prep.py +++ b/backend/apps/onboarding/prep.py @@ -27,7 +27,7 @@ P_SYSTEM = ( "You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can " "organize local files, browse the web in a real browser, build small apps, and run agents in parallel. " "Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: " - '{"greeting": string, "starters": [{"title": string, "prompt": string}], "app_title": string, "app_prompt": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. ' + '{"greeting": string, "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. ' "First, silently infer a short profile of this user. If usage_summary is present it is the STRONGEST signal (it is " "what they actually ask their AI about and facts their AI remembers about them); weight it above everything else, " "then apps, folders, plan tier, email domain. Tune every task and the personal app to that profile; do not output the profile. " @@ -38,9 +38,13 @@ P_SYSTEM = ( "modify or delete existing files, because it may be run automatically on the user's behalf. Every starter " "must produce a tangible result the user can see (a sorted " "folder, a report, a working page); never propose setup, documentation of preferences, or planning-only tasks. " + "Each starter's 'reason' is ONE short standalone clause (max 12 words, no leading 'because') naming the SPECIFIC " + "real thing you observed (a folder, a file count, a picked app, a usage fact) that makes this task useful for THIS " + "user; it must be grounded in the input facts, never invented, and read like a person pointing at what they saw. " "Also design ONE small personal app for this user: app_title is 2-4 words, app_prompt starts with 'Build me' and " "describes a small, immediately useful single-page app tailored to the profile (their files, habits, or picked " - "apps), self-contained with no accounts or API keys. " + "apps), self-contained with no accounts or API keys. app_reason follows the same one-clause grounded-observation " + "rule as a starter reason. " "Also propose 2-3 automations: recurring routines worth running on a schedule for THIS user, drawn from their " "profile and habits (for example a daily morning brief, a weekly folder cleanup, a weekday summary of their " "connected apps). Each automation title is 2-4 words, prompt is one runnable instruction, cadence is exactly " @@ -58,7 +62,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]: try: data = json.loads(match.group(0)) starters = [ - PersonalizedStarter(title=str(s.get("title", "")).strip(), prompt=str(s.get("prompt", "")).strip()) + PersonalizedStarter(title=str(s.get("title", "")).strip(), prompt=str(s.get("prompt", "")).strip(), reason=str(s.get("reason", "")).strip()) for s in data.get("starters", []) if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip() ] @@ -78,6 +82,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]: starters=starters[:4], app_title=str(data.get("app_title", "")).strip(), app_prompt=str(data.get("app_prompt", "")).strip(), + app_reason=str(data.get("app_reason", "")).strip(), automations=automations[:3], ) except Exception: diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 0c3dc256..db37fc8b 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -111,6 +111,8 @@ class CustomProvider(BaseModel): class PersonalizedStarter(BaseModel): title: str prompt: str + # One short clause tying this task to something real we saw about the user; the reveal shows it off. + reason: str = "" class PersonalizedAutomation(BaseModel): diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index 07fd81a8..c277becd 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -84,10 +84,24 @@ def test_parse_prep_strict_and_lenient(): assert parsed is not None assert parsed.greeting == "Hey!" assert [s.title for s in parsed.starters] == ["A", "B"] + # Reason is optional; missing reason defaults to empty, never drops the starter. + assert parsed.starters[0].reason == "" assert parse_prep("no json here") is None assert parse_prep('{"greeting": "hi", "starters": []}') is None +def test_parse_prep_carries_reasons(): + rich = ( + '{"greeting": "Hey!", "app_title": "Lift Log", "app_prompt": "Build me a lifting tracker", ' + '"app_reason": "you plan lifts with ChatGPT daily", ' + '"starters": [{"title": "Audit Downloads", "prompt": "audit it", "reason": "1,305 files piling up there"}]}' + ) + parsed = parse_prep(rich) + assert parsed is not None + assert parsed.starters[0].reason == "1,305 files piling up there" + assert parsed.app_reason == "you plan lifts with ChatGPT daily" + + @pytest.mark.asyncio async def test_build_prep_fails_open_without_provider(monkeypatch): async def boom(*args, **kwargs): diff --git a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts index ee69cb47..6c5c119f 100644 --- a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts +++ b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts @@ -27,6 +27,7 @@ export interface PrepResponse { starters: PersonalizedStarter[]; app_title: string; app_prompt: string; + app_reason: string; automations: PersonalizedAutomation[]; } diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index e4cb28a1..6b8fd7ba 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -71,7 +71,7 @@ export function useOnboardingV3Pipeline() { }, []); // Fire one real background agent; the session exists in redux without a card until the reveal composes the canvas. - const launchJob = useCallback((title: string, prompt: string, kind: 'audit' | 'app') => { + const launchJob = useCallback((title: string, prompt: string, kind: 'audit' | 'app', reason: string) => { const { model: liveModel } = launchCtxRef.current; const dashboardId = getLastDashboardId() ?? undefined; const config: AgentConfig = { name: title, model: liveModel, mode: 'agent', dashboard_id: dashboardId }; @@ -79,7 +79,7 @@ export function useOnboardingV3Pipeline() { void dispatch(launchAndSendFirstMessage({ draftId, config, prompt, mode: 'agent', model: liveModel })) .then((action) => { if (launchAndSendFirstMessage.fulfilled.match(action)) { - dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind })); + dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind, reason })); } }) .catch(() => {}); @@ -96,8 +96,8 @@ export function useOnboardingV3Pipeline() { void prepRef.current.then((prep) => { if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return; launchedRef.current = true; - if (prep.starters.length > 0) launchJob(prep.starters[0].title, prep.starters[0].prompt, 'audit'); - if (prep.app_title && prep.app_prompt) launchJob(prep.app_title, prep.app_prompt, 'app'); + if (prep.starters.length > 0) launchJob(prep.starters[0].title, prep.starters[0].prompt, 'audit', prep.starters[0].reason ?? ''); + if (prep.app_title && prep.app_prompt) launchJob(prep.app_title, prep.app_prompt, 'app', prep.app_reason ?? ''); }); }, [launchJob]); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts index fb4c3dab..c6f1e05d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts @@ -34,14 +34,15 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession const cy = (vr.height / 2 - cs.panY) / cs.zoom; const audit = prepped.find((j) => j.kind === 'audit'); const app = prepped.find((j) => j.kind === 'app'); - const lines: string[] = ['Here is what I set up for you.']; - if (scanSummary) lines.push(`Looked around: ${scanSummary}.`); + const jobLine = (j: typeof prepped[number]) => (j.reason ? `- ${j.title}: ${j.reason}` : `- ${j.title}`); + const lines: string[] = ['While you were setting up, I got a head start.']; + if (scanSummary) lines.push(`I looked around your Mac and saw ${scanSummary}.`); const cards: string[] = []; - if (audit) cards.push(`- ${audit.title} (running now, on the left)`); - if (app) cards.push(`- ${app.title} (building now)`); - if (cards.length > 0) lines.push(`Working on:\n${cards.join('\n')}`); - if (starters.length > 0) lines.push(`Ready when you are:\n${starters.slice(audit ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`); - lines.push('Nothing is saved or deleted without you. Keep or discard anytime.'); + if (audit) cards.push(jobLine(audit)); + if (app) cards.push(jobLine(app)); + if (cards.length > 0) lines.push(`So here is what I already have going (cards on your left):\n${cards.join('\n')}`); + if (starters.length > 0) lines.push(`Ready whenever you want:\n${starters.slice(audit ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`); + lines.push('Nothing is saved or deleted without you. Keep it going or clear it anytime.'); dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + 48, y: cy - 140, color: 'yellow', content: lines.join('\n\n') })); prepped.forEach((job, i) => { dispatch(placeCard({ diff --git a/frontend/src/shared/state/onboardingV3Slice.ts b/frontend/src/shared/state/onboardingV3Slice.ts index fa208262..cff6a0bd 100644 --- a/frontend/src/shared/state/onboardingV3Slice.ts +++ b/frontend/src/shared/state/onboardingV3Slice.ts @@ -7,6 +7,8 @@ export interface PreppedJob { sessionId: string; title: string; kind: 'audit' | 'app'; + /** The one-clause "why we started this for you", shown in the reveal note. */ + reason?: string; } export interface OnboardingV3State { diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index f1d85a1f..87b4a479 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -90,6 +90,7 @@ export interface AppSettings { export interface PersonalizedStarter { title: string; prompt: string; + reason?: string; } export interface PersonalizedAutomation {