Files
workspace/docs/PROMPT-onboarding.md
Roberto Musso 8ce3ade8ce feat(onboarding): implement first-run user onboarding wizard for profile setup
- Added a new onboarding wizard that runs on the first app launch post-login.
- Collects user personalization data (job role, industry, primary use case, tone preference, language) and stores it in encrypted core memory.
- Auto-detects and saves formatting preferences (timezone, time format, date format) in local electron-store.
- Normalizes user free-text inputs via a backend LLM call before persisting.
- Introduced new backend routes for memory updates and normalization.
- Updated frontend components to support the onboarding flow with a chat-bubble aesthetic.
- Added settings section for profile editing and re-running the onboarding process.
- Ensured that the onboarding process is skippable and editable in the settings.
- Implemented verification steps to ensure proper functionality and data handling.

chore: update submodules for waitlist and website
2026-04-12 00:36:11 +02:00

24 KiB
Raw Permalink Blame History

RALPH LOOP PROMPT — First-Run Onboarding Wizard

How to run:

/ralph-loop "Implement the onboarding wizard exactly as specified in docs/PROMPT-onboarding.md. Output <promise>ONBOARDING COMPLETE</promise> when all phases pass lint." --max-iterations 25 --completion-promise "ONBOARDING COMPLETE"

INSTRUCTIONS FOR CLAUDE

You are implementing a first-run onboarding wizard for the adiuvAI Electron app. This is a multi-file, multi-iteration task. On each iteration:

  1. Read this file in full.
  2. Inspect which tasks are already done by checking if the target files exist and contain the expected code.
  3. Pick the next incomplete task (always in phase order: Phase 1 → 2 → 3 → 4).
  4. Implement it, then run the relevant lint command before exiting.
  5. When ALL phases are complete AND both lint commands pass, output <promise>ONBOARDING COMPLETE</promise>.

DO NOT skip phases. DO NOT implement out of order — backend must exist before the FE can call it.

LINT COMMANDS (run after each phase):

  • Backend: cd api && ruff check . --fix
  • Frontend: cd adiuvAI && npx eslint . --fix

WHAT THIS FEATURE DOES

After login, new users see a chat-styled wizard that collects 5 fields:

  • job_role, industry, primary_use_case, tone_preference, language

These are stored encrypted in MemoryCore (backend) so the AI agents personalize responses. Three formatting prefs (timezone, date_format, time_format) are auto-detected from the OS and stored in electron-store (FE only) — the LLM never sees them. The FE formats all timestamp columns in tool-result rows before sending them back to the backend.

Storage split:

Field Where Why
job_role, industry, primary_use_case, tone_preference, language MemoryCore (backend, encrypted) LLM needs these for personalization
timezone, date_format, time_format electron-store (FE) FE formatter only — LLM must never see raw timestamps

Key architectural fact: memory_middleware.py enrich_context() already injects core_memory into every orchestrator call. Writing to MemoryCore is sufficient — no system-prompt changes needed.


PHASE 1 — Backend (api/)

TASK 1.1: Alembic migration — onboarding_completed_at column

File: api/alembic/versions/XXX_add_onboarding_completed_at.py (new)

Create a new Alembic migration that adds:

ALTER TABLE users ADD COLUMN onboarding_completed_at TIMESTAMPTZ NULL;

Use the existing migrations in api/alembic/versions/ as a pattern reference. The revision ID should be sequential (check the latest existing migration number and increment).

Done signal: File exists in api/alembic/versions/ with the column add.


TASK 1.2: Add column to User model

File: api/app/models.py

Find the User class (around line 63-94). Add:

onboarding_completed_at: Mapped[datetime | None] = mapped_column(
    DateTime(timezone=True), nullable=True, default=None
)

Import DateTime from sqlalchemy if not already imported.

Done signal: User model has onboarding_completed_at field.


TASK 1.3: Extend UserProfile schema

File: api/app/schemas.py

Find UserProfile (around line 27-33). Add two fields:

onboarding_completed_at: int | None = None   # epoch ms, null = not onboarded
memory: dict[str, str] = Field(default_factory=dict)  # decrypted core memory k/v

Done signal: UserProfile has both new fields.


TASK 1.4: Extend get_current_user to return memory + onboarding flag

File: api/app/api/middleware/auth.py

In get_current_user(), after fetching the user row and resolving the tier:

  1. Read user.onboarding_completed_at — convert to epoch ms (int) or None.
  2. Use MemoryMiddleware(db).enrich_context(user.id) to load decrypted core memory. Extract the core dict → {label: value} pairs.
  3. Return UserProfile(..., onboarding_completed_at=..., memory=...).

This requires get_current_user to also receive the db: AsyncSession dependency. Check if it already does — if not, add Depends(get_session).

Done signal: GET /api/v1/auth/me returns onboarding_completed_at and memory fields.


TASK 1.5: New route — PUT /auth/me/memory

File: api/app/api/routes/auth.py

Add a new route (do NOT modify _UpdateProfileRequest):

class _UpdateMemoryRequest(BaseModel):
    memory: dict[str, str] = Field(default_factory=dict)
    mark_onboarded: bool = False

@router.put("/me/memory", response_model=UserProfile)
async def update_memory(
    body: _UpdateMemoryRequest,
    current_user: UserProfile = Depends(get_current_user),
    db: AsyncSession = Depends(get_session),
) -> UserProfile:
    mw = MemoryMiddleware(db)
    for key, value in body.memory.items():
        await mw.update_core(current_user.id, key, value)
    if body.mark_onboarded:
        result = await db.execute(select(User).where(User.id == current_user.id))
        user = result.scalar_one()
        user.onboarding_completed_at = datetime.now(timezone.utc)
        await db.commit()
    # Re-fetch profile and return
    return await get_current_user(...)  # use same logic as GET /me

Also add a companion route to reset onboarding (for "Re-run onboarding" in Settings):

@router.post("/me/onboarding/reset")
async def reset_onboarding(
    current_user: UserProfile = Depends(get_current_user),
    db: AsyncSession = Depends(get_session),
):
    result = await db.execute(select(User).where(User.id == current_user.id))
    user = result.scalar_one()
    user.onboarding_completed_at = None
    await db.commit()
    return {"status": "reset"}

Done signal: Both routes exist and are syntactically correct.


TASK 1.6: New route — POST /auth/onboarding/normalize

File: api/app/api/routes/auth.py

class _NormalizeRequest(BaseModel):
    inputs: dict[str, str]   # {"job_role": "i build websites"}

class _NormalizeResponse(BaseModel):
    normalized: dict[str, str]

@router.post("/onboarding/normalize", response_model=_NormalizeResponse)
async def normalize_onboarding(
    body: _NormalizeRequest,
    current_user: UserProfile = Depends(get_current_user),
) -> _NormalizeResponse:
    """One-shot LLM normalization for free-text onboarding answers."""
    if not body.inputs:
        return _NormalizeResponse(normalized={})
    try:
        llm = get_llm("gpt-4o-mini", temperature=0)
        prompt = (
            "You normalize user onboarding answers into clean, ≤3-word canonical labels.\n"
            "Return a JSON object with the same keys and normalized values.\n"
            "Examples: 'i build websites' → 'Web Developer', 'tech-ish stuff' → 'Technology'\n"
            f"Input: {json.dumps(body.inputs)}"
        )
        response = await llm.ainvoke(
            [{"role": "system", "content": "You normalize user inputs. Return JSON only."},
             {"role": "user", "content": prompt}],
        )
        normalized = json.loads(response.content)
        return _NormalizeResponse(normalized=normalized)
    except Exception:
        # LLM failure must never block onboarding — return inputs unchanged
        return _NormalizeResponse(normalized=body.inputs)

Use get_llm from app.core.llm. Use json stdlib. The try/except is critical — flaky LLM must never block the wizard.

Done signal: Route exists, has the safety try/except, returns inputs on failure.


TASK 1.7: Backend lint check

Run: cd api && ruff check . --fix

Fix any issues before proceeding to Phase 2.

Done signal: ruff check . exits 0.


PHASE 2 — Electron Main Process (adiuvAI/src/main/)

TASK 2.1: Extend UserProfileSchema

File: adiuvAI/src/shared/api-types.ts

Find UserProfileSchema (Zod schema). Add:

onboardingCompletedAt: z.number().int().nullable().optional(),
memory: z.record(z.string(), z.string()).default({}),

Done signal: Schema has both fields.


TASK 2.2: Add formatPrefs to electron-store

File: adiuvAI/src/main/store.ts

Extend the AppSettings interface:

formatPrefs: {
  timezone: string;
  dateFormat: string;         // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
  timeFormat: '12h' | '24h';
} | null;

Default to null in the store defaults.

Add helpers:

export function getFormatPrefs(): FormatPrefs | null {
  return getStore().get('formatPrefs', null);
}

export function setFormatPrefs(prefs: FormatPrefs): void {
  getStore().set('formatPrefs', prefs);
}

Export FormatPrefs as a type.

Done signal: getFormatPrefs() and setFormatPrefs() exported from store.ts.


TASK 2.3: Create locale-defaults helper

File: adiuvAI/src/main/auth/locale-defaults.ts (new)

import { app } from 'electron';

export interface FormatPrefs {
  timezone: string;
  dateFormat: string;
  timeFormat: '12h' | '24h';
}

export function detectFormatPrefs(): FormatPrefs {
  const locale = app.getLocale();
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
  const timeFormat = hour12 ? '12h' : '24h';
  const dateFormat = inferDateFormat(locale);
  return { timezone, timeFormat, dateFormat };
}

export function detectLanguage(): string {
  return app.getLocale();  // e.g. 'it-IT', 'en-US'
}

function inferDateFormat(locale: string): string {
  // MDY locales
  const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
  if (mdyLocales.some(l => locale.startsWith(l))) return 'MM/dd/yyyy';
  // YMD locales (CJK, ISO-oriented)
  const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
  if (ymdPrefixes.some(p => locale.startsWith(p))) return 'yyyy-MM-dd';
  // Default: DMY (most of the world)
  return 'dd/MM/yyyy';
}

Done signal: File exists with detectFormatPrefs() and detectLanguage() exported.


TASK 2.4: Create format-row helper

File: adiuvAI/src/main/api/format-row.ts (new)

import type { FormatPrefs } from '../auth/locale-defaults';

const TIMESTAMP_COLUMNS = new Set([
  'createdAt', 'updatedAt', 'dueDate', 'date', 'endDate',
  'lastRunAt', 'startedAt', 'completedAt',
]);

export function formatRows<T extends Record<string, unknown>>(
  rows: T[],
  prefs: FormatPrefs,
): T[] {
  return rows.map(row => formatRow(row, prefs));
}

export function formatRow<T extends Record<string, unknown>>(
  row: T,
  prefs: FormatPrefs,
): T {
  const result = { ...row };
  for (const col of TIMESTAMP_COLUMNS) {
    if (col in result && typeof result[col] === 'number') {
      (result as Record<string, unknown>)[col] = formatTimestamp(
        result[col] as number,
        prefs,
      );
    }
  }
  return result;
}

function formatTimestamp(epochMs: number, prefs: FormatPrefs): string {
  const date = new Date(epochMs);
  const hour12 = prefs.timeFormat === '12h';

  const opts: Intl.DateTimeFormatOptions = {
    timeZone: prefs.timezone,
    hour12,
    hour: '2-digit',
    minute: '2-digit',
  };

  const timePart = date.toLocaleTimeString('en-US', opts);

  const day = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, day: '2-digit' }).slice(-2);
  const month = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, month: '2-digit' }).slice(-2);
  const year = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, year: 'numeric' }).slice(0, 4);

  let datePart: string;
  switch (prefs.dateFormat) {
    case 'MM/dd/yyyy': datePart = `${month}/${day}/${year}`; break;
    case 'yyyy-MM-dd': datePart = `${year}-${month}-${day}`; break;
    default:           datePart = `${day}/${month}/${year}`; break;
  }

  return `${datePart} ${timePart}`;
}

Done signal: File exists with formatRow and formatRows exported.


TASK 2.5: Wire format-row into drizzle-executor

File: adiuvAI/src/main/api/drizzle-executor.ts

Import formatRows, formatRow from ./format-row and getFormatPrefs from ../store and detectFormatPrefs from ../auth/locale-defaults.

Find every place that returns { rows } or { row } results. Wrap them:

  • rowsformatRows(rows, getFormatPrefs() ?? detectFormatPrefs())
  • rowformatRow(row, getFormatPrefs() ?? detectFormatPrefs())

The ?? detectFormatPrefs() fallback handles the edge case where executor runs before first auth.status seed.

Important: Only format on handleList, handleGet, handleInsert, handleUpdate return paths — NOT on delete. Do not mutate the original rows — formatRow returns a new object.

Done signal: All select/get/insert/update returns pass through formatRow(s).


TASK 2.6: Add auth methods to auth-manager

File: adiuvAI/src/main/auth/auth-manager.ts

Add two methods to the AuthManager class:

async updateMemory(
  memory: Record<string, string>,
  markOnboarded = false,
): Promise<UserProfile> {
  return this.put('/api/v1/auth/me/memory', {
    memory,
    mark_onboarded: markOnboarded,
  });
}

async normalizeOnboarding(
  inputs: Record<string, string>,
): Promise<Record<string, string>> {
  const res = await this.post('/api/v1/auth/onboarding/normalize', { inputs });
  return res.normalized;
}

async resetOnboarding(): Promise<void> {
  await this.post('/api/v1/auth/me/onboarding/reset', {});
}

Use the existing this.put() / this.post() helpers (they handle auth headers and camelCase/snakeCase conversion).

Done signal: All three methods exist on AuthManager.


TASK 2.7: Extend tRPC authRouter

File: adiuvAI/src/main/router/index.ts

In the authRouter, add these procedures:

updateMemory: t.procedure
  .input(z.object({
    memory: z.record(z.string(), z.string()),
    markOnboarded: z.boolean().optional().default(false),
  }))
  .mutation(async ({ input }) => {
    return authManager.updateMemory(input.memory, input.markOnboarded);
  }),

normalizeOnboarding: t.procedure
  .input(z.object({
    inputs: z.record(z.string(), z.string()),
  }))
  .mutation(async ({ input }) => {
    return authManager.normalizeOnboarding(input.inputs);
  }),

resetOnboarding: t.procedure
  .mutation(async () => {
    return authManager.resetOnboarding();
  }),

Also, in the existing auth.status procedure, add silent auto-seeding logic:

  • After fetching the profile, if getFormatPrefs() returns null → call setFormatPrefs(detectFormatPrefs()).
  • If profile.memory.language is missing/empty → call authManager.updateMemory({ language: detectLanguage() }) silently (fire-and-forget, don't block the return).

Done signal: Three new procedures exist. Status procedure auto-seeds format prefs and language.


TASK 2.8: Add settings routes for formatPrefs

File: adiuvAI/src/main/router/index.ts

In settingsRouter (or create one if it doesn't exist), add:

getFormatPrefs: t.procedure.query(() => {
  return getFormatPrefs();
}),

setFormatPrefs: t.procedure
  .input(z.object({
    timezone: z.string(),
    dateFormat: z.string(),
    timeFormat: z.enum(['12h', '24h']),
  }))
  .mutation(({ input }) => {
    setFormatPrefs(input);
    return input;
  }),

Done signal: Both procedures exist.


TASK 2.9: Frontend lint check

Run: cd adiuvAI && npx eslint . --fix

Fix any TypeScript/ESLint issues before proceeding to Phase 3.

Done signal: ESLint exits 0.


PHASE 3 — Electron Renderer (adiuvAI/src/renderer/)

TASK 3.1: Create onboarding chip options

File: adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts (new)

export const JOB_ROLES = ['Developer', 'Designer', 'Consultant', 'Founder', 'Project Manager'] as const;
export const INDUSTRIES = ['Tech', 'Design', 'Consulting', 'Legal', 'Marketing', 'Education'] as const;
export const USE_CASES = ['Solo freelancer', 'Client manager', 'Team lead', 'Personal productivity'] as const;
export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;

Done signal: File exists with all four arrays exported.


TASK 3.2: Create OnboardingFlow component

File: adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx (new)

This is the most complex file. Key requirements:

State machine:

type Step = 'welcome' | 'jobRole' | 'industry' | 'useCase' | 'tone' | 'language' | 'reviewing' | 'done';

Props:

interface OnboardingFlowProps {
  profile: UserProfile;
}

Visual style — must match AIChatPanel:

  • Chat bubble layout: AI messages in rounded-2xl bubbles with a Sparkles icon (from lucide-react).
  • Use glassmorphism: bg-white/5 backdrop-blur-md border border-white/10.
  • Spring transitions (framer-motion) for each step entering.
  • Use shadcn components: Button, Input, Card.

Each wizard step shows:

  1. An "AI bubble" with the question text.
  2. 36 preset chip buttons (from onboardingOptions.ts).
  3. An optional "Type your own" text input (for job_role and industry only).
  4. A "Skip" link at the bottom.
  5. Previous answers appear above as "user bubbles" (right-aligned).

Step details:

Step Question Chips Free text?
welcome "Hi {name}! I'm your AI assistant. Let me learn a few things about you so I can help better." Just a "Let's go" button No
jobRole "What's your role?" JOB_ROLES Yes
industry "What industry do you work in?" INDUSTRIES Yes
useCase "How will you mainly use adiuvAI?" USE_CASES No
tone "How should I talk to you?" TONES No
language "I'll respond in {detected}. Want to change it?" Show detected language pre-selected; allow typing a different one Yes
reviewing Review screen (see below)
done Redirect (never renders)

Reviewing step logic:

  1. Partition answers: chip selections (already clean) vs free-text answers (need normalization).
  2. If free-text map is non-empty → call trpc.auth.normalizeOnboarding.useMutation. Show "Tidying up…" spinner on those fields only.
  3. Show a Card titled "Here's what I'll save" with all 5 fields as rows.
  4. Each row has an Edit pencil icon → converts to inline input → Enter saves → back to read-only.
  5. If LLM changed a value, show grey hint: auto-tidied from "original text".
  6. Primary button: "Looks good — save" → calls trpc.auth.updateMemory.useMutation({ memory: finalMap, markOnboarded: true })utils.auth.status.invalidate().
  7. Secondary link: "Back to wizard" → resets to jobRole step with values pre-filled.
  8. Failure modes:
    • Normalization fails → show raw values + banner "Couldn't auto-tidy — review and save". Save still works.
    • Save fails → toast error, stay on review screen.

Skip behavior: Clicking Skip on any step → calls updateMemory({}, markOnboarded: true) with empty map → wizard closes. Language was already auto-seeded by auth.status.

Done signal: Component file exists, renders a multi-step wizard, handles reviewing + save.


TASK 3.3: Gate OnboardingFlow in AppShell

File: adiuvAI/src/renderer/components/layout/AppShell.tsx

After the authenticated === falseLoginForm branch, add:

if (
  authStatusQuery.data?.profile &&
  authStatusQuery.data.profile.onboardingCompletedAt == null
) {
  return <OnboardingFlow profile={authStatusQuery.data.profile} />;
}

Import OnboardingFlow from ../onboarding/OnboardingFlow.

Done signal: AppShell conditionally renders OnboardingFlow when onboardingCompletedAt is null.


TASK 3.4: Add 'profile' to settings sections

File: adiuvAI/src/renderer/components/settings/types.ts

Add 'profile' to the SectionId type and { id: 'profile', label: 'Profile' } to SECTIONS array — insert it before 'account'.

Done signal: SECTIONS includes 'profile' as the first or second entry.


TASK 3.5: Create ProfileSection component

File: adiuvAI/src/renderer/components/settings/ProfileSection.tsx (new)

Plain form (no chat aesthetic — this is Settings, not the wizard). Two cards:

Card 1 — "About you" (writes to MemoryCore via auth.updateMemory):

  • Fields: job_role, industry, primary_use_case (select from USE_CASES), tone_preference (select from TONES), language (text input).
  • Pre-populate from authStatusQuery.data.profile.memory.
  • Save button → trpc.auth.updateMemory.useMutation.
  • "Re-run onboarding" button → trpc.auth.resetOnboarding.useMutationutils.auth.status.invalidate() (triggers wizard via AppShell gate).

Card 2 — "Display preferences" (writes to electron-store via trpc.settings.setFormatPrefs):

  • Timezone: searchable select, populated from Intl.supportedValuesOf('timeZone').
  • Date format: select with options: dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd.
  • Time format: radio group — 12h / 24h.
  • Pre-populate from trpc.settings.getFormatPrefs query.
  • Save button → trpc.settings.setFormatPrefs.useMutation.

Use shadcn Card, Input, Select, Button, Label, RadioGroup components.

Done signal: Component exists with both cards, save functionality wired.


TASK 3.6: Wire ProfileSection into settings route

File: adiuvAI/src/renderer/routes/settings.tsx

Add the import and the conditional render:

{section === 'profile' && <ProfileSection />}

Done signal: ProfileSection renders when section=profile.


TASK 3.7: Final lint check

Run both:

cd api && ruff check . --fix
cd adiuvAI && npx eslint . --fix

Done signal: Both exit 0.


PHASE 4 — Completion

TASK 4.1: Verify all files exist

Check that these files exist:

  • api/alembic/versions/*_add_onboarding_completed_at.py
  • adiuvAI/src/main/auth/locale-defaults.ts
  • adiuvAI/src/main/api/format-row.ts
  • adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts
  • adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx
  • adiuvAI/src/renderer/components/settings/ProfileSection.tsx

Check that these files were modified:

  • api/app/models.py (has onboarding_completed_at)
  • api/app/schemas.py (UserProfile has memory + onboarding_completed_at)
  • api/app/api/middleware/auth.py (get_current_user returns memory)
  • api/app/api/routes/auth.py (3 new routes)
  • adiuvAI/src/shared/api-types.ts (UserProfileSchema extended)
  • adiuvAI/src/main/store.ts (formatPrefs + helpers)
  • adiuvAI/src/main/auth/auth-manager.ts (3 new methods)
  • adiuvAI/src/main/router/index.ts (5 new procedures + auto-seed in status)
  • adiuvAI/src/main/api/drizzle-executor.ts (formatRow wiring)
  • adiuvAI/src/renderer/components/layout/AppShell.tsx (onboarding gate)
  • adiuvAI/src/renderer/components/settings/types.ts (profile section)
  • adiuvAI/src/renderer/routes/settings.tsx (ProfileSection render)

TASK 4.2: Output completion promise

If everything above is done and lint passes:

<promise>ONBOARDING COMPLETE</promise>

REFERENCE — Existing patterns to reuse

DO NOT reinvent these. Copy their patterns:

Pattern Source file Reuse for
Chat bubble + Sparkles + glass src/renderer/components/ai/AIChatPanel.tsx OnboardingFlow bubbles
Stepper state machine InlineAgentCreationStepper in renderer Wizard step transitions
MemoryMiddleware.update_core api/app/core/memory_middleware.py:137-173 PUT /me/memory route
get_llm() api/app/core/llm.py Normalize route
electron-store helpers src/main/store.ts (getDeviceId pattern) getFormatPrefs/setFormatPrefs
tRPC procedure pattern src/main/router/index.ts (auth.status) New procedures
shadcn form components Existing settings sections ProfileSection
toCamelCase / toSnakeCase auth-manager.ts proxy helpers Automatic key conversion

DO NOT

  • Add features not described here (no avatar upload, no i18n framework, no animation library beyond framer-motion if already installed).
  • Modify the orchestrator or system prompts — MemoryCore injection is already handled.
  • Add foreign key constraints to the migration.
  • Store formatting prefs in MemoryCore.
  • Let the LLM normalization route throw on failure — it MUST return inputs unchanged.
  • Skip the reviewing step in the wizard.
  • Run both lint checks and fix issues before claiming completion.