merge: feat/77-ai-wizard into freeze/rc-1

AI profile wizard full implementation: backend interview endpoints, BYOK
tier flag, chat UI, store actions (skip/keepChatting), settings CTA,
quality review fixes.

Closes: #77
This commit is contained in:
pyr0ball 2026-06-14 12:16:49 -07:00
commit 71e8eeb090
9 changed files with 1530 additions and 4 deletions

View file

@ -41,6 +41,7 @@ FEATURES: dict[str, str] = {
"llm_voice_guidelines": "premium",
"llm_job_titles": "paid",
"llm_mission_notes": "paid",
"llm_ai_wizard": "paid",
# Orchestration — stays gated (background data pipeline, not just an LLM call)
"llm_keywords_blocklist": "paid",
@ -79,6 +80,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"llm_voice_guidelines",
"llm_job_titles",
"llm_mission_notes",
"llm_ai_wizard",
"company_research",
"interview_prep",
"survey_assistant",

View file

@ -2761,6 +2761,9 @@ def get_app_config():
except Exception:
wizard_complete = False
from app.wizard.tiers import has_configured_llm
byok_unlocked = has_configured_llm()
return {
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
"isDemo": os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"),
@ -2769,6 +2772,7 @@ def get_app_config():
"contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"),
"inferenceProfile": profile if profile in valid_profiles else "cpu",
"wizardComplete": wizard_complete,
"byokUnlocked": byok_unlocked,
}
@ -4580,6 +4584,124 @@ def wizard_complete():
raise HTTPException(status_code=500, detail=str(e))
# ── AI Interview Wizard (BSL 1.1) ─────────────────────────────────────────────
_AI_WIZARD_SYSTEM_PROMPT = """You are a friendly, patient assistant helping someone set up their job search profile. Your goal is to gather the following information through natural conversation:
- name (string): their full name
- email (string): their preferred contact email
- career_summary (string): 1-2 sentence background summary
- candidate_voice (string): their preferred writing voice/tone for cover letters
- mission_preferences (list of strings): industries or causes they care about
- candidate_accessibility_focus (bool): whether to include accessibility culture in company research
- candidate_lgbtq_focus (bool): whether to include LGBTQIA+ inclusion signals in company research
- linkedin (string, optional): their LinkedIn URL
Rules:
1. Ask one or two questions at a time never overwhelm
2. Always remind them they can skip any question
3. For candidate_voice, offer these options if they struggle: "professional and direct", "warm and conversational", "concise and clear", "enthusiastic and personable"
4. For candidate_accessibility_focus and candidate_lgbtq_focus, use plain language: "Would you like me to look into whether companies actively support employees with disabilities or neurodivergent needs?" and "Would you like me to check whether companies have strong LGBTQIA+ inclusion policies?"
5. When you have gathered enough information or the user says they are done, set complete to true
You must ALWAYS respond with valid JSON in this exact format:
{"reply": "your conversational message here", "extracted_fields": {"name": "...", ...}, "complete": false}
Only include fields in extracted_fields that you are confident about from the conversation. Do not include fields the user hasn't mentioned. Infer complete=true when all required fields (name, email, career_summary) are gathered or when user explicitly says done."""
class HistoryMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class WizardInterviewRequest(BaseModel):
history: list[HistoryMessage] = []
profile_so_far: dict = {}
class WizardFinalizeRequest(BaseModel):
profile: dict
_WIZARD_ALLOWED_FIELDS: frozenset[str] = frozenset({
"name",
"email",
"career_summary",
"candidate_voice",
"mission_preferences",
"candidate_accessibility_focus",
"candidate_lgbtq_focus",
"linkedin",
})
@app.post("/api/wizard/ai/interview")
def wizard_ai_interview(request: WizardInterviewRequest):
"""Conduct one turn of the AI-guided profile interview. Tier-gated (BYOK-unlockable)."""
from app.wizard.tiers import can_use, has_configured_llm
tier = _get_effective_tier()
if not can_use(tier, "llm_ai_wizard", has_byok=has_configured_llm()):
raise HTTPException(402, detail={"error": "tier_required"})
# Build conversation prompt from history
conversation_lines = []
for msg in request.history:
role = msg.role
content = msg.content.replace("\n", " ").replace("\r", "")
if role == "user":
conversation_lines.append(f"User: {content}")
else:
conversation_lines.append(f"Assistant: {content}")
history_block = "\n".join(conversation_lines) if conversation_lines else "User: (starting conversation)"
# Build profile summary to give LLM context about what's already known
if request.profile_so_far:
gathered = ", ".join(
f"{k}={repr(v)}"
for k, v in request.profile_so_far.items()
if v not in (None, "", [], {})
)
profile_context = f"\n\n[Already gathered: {gathered}]" if gathered else ""
else:
profile_context = ""
prompt = history_block + profile_context
try:
from scripts.llm_router import LLMRouter
response_text = LLMRouter().complete(prompt, system=_AI_WIZARD_SYSTEM_PROMPT)
except Exception as exc:
raise HTTPException(503, detail={"error": "llm_error", "message": str(exc)})
try:
parsed = json.loads(response_text)
return {
"reply": parsed.get("reply", ""),
"extracted_fields": parsed.get("extracted_fields", {}),
"complete": bool(parsed.get("complete", False)),
}
except (json.JSONDecodeError, AttributeError):
return {"reply": response_text, "extracted_fields": {}, "complete": False}
@app.post("/api/wizard/ai/finalize")
def wizard_ai_finalize(request: WizardFinalizeRequest):
"""Merge AI-collected wizard fields into user.yaml. Only allowed fields are written."""
yaml_path = _user_yaml_path()
try:
current = load_user_profile(yaml_path)
updates = {k: v for k, v in request.profile.items() if k in _WIZARD_ALLOWED_FIELDS}
merged = {**current, **updates}
save_user_profile(yaml_path, merged)
except Exception as exc:
raise HTTPException(500, detail={"error": "write_error", "message": str(exc)})
merged_keys = list(updates.keys())
return {"saved": True, "fields": merged_keys}
# ── Messaging models ──────────────────────────────────────────────────────────
class MessageCreateBody(BaseModel):

361
tests/test_wizard_ai.py Normal file
View file

@ -0,0 +1,361 @@
"""Tests for AI interview wizard endpoints (POST /api/wizard/ai/*)."""
import json
import sys
import yaml
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
# ── Path bootstrap ────────────────────────────────────────────────────────────
_REPO = Path(__file__).parent.parent
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
@pytest.fixture(scope="module")
def client():
from dev_api import app
from fastapi.testclient import TestClient
return TestClient(app)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _write_user_yaml(path: Path, data: dict | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = data if data is not None else {}
path.write_text(yaml.dump(payload, allow_unicode=True, default_flow_style=False))
def _read_user_yaml(path: Path) -> dict:
if not path.exists():
return {}
return yaml.safe_load(path.read_text()) or {}
# ── GET /api/config/app — byokUnlocked field ──────────────────────────────────
class TestAppConfigByokField:
def test_byok_unlocked_false_when_no_llm_configured(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
r = client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["byokUnlocked"] is False
def test_byok_unlocked_true_when_llm_configured(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
r = client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["byokUnlocked"] is True
# ── POST /api/wizard/ai/interview — tier gate ─────────────────────────────────
class TestWizardAIInterviewTierGate:
def test_returns_402_when_tier_blocked(self, client):
"""Free tier with no BYOK: expect 402."""
with patch("dev_api._get_effective_tier", return_value="free"):
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
r = client.post(
"/api/wizard/ai/interview",
json={"history": [{"role": "user", "content": "Hello"}]},
)
assert r.status_code == 402
assert r.json()["detail"]["error"] == "tier_required"
def test_returns_402_for_free_tier_without_byok(self, client):
"""Explicit check that free tier without LLM configured is gated."""
with patch("dev_api._get_effective_tier", return_value="free"):
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
r = client.post(
"/api/wizard/ai/interview",
json={"history": [], "profile_so_far": {}},
)
assert r.status_code == 402
def test_free_tier_with_byok_is_allowed(self, client):
"""Free tier with BYOK configured: tier gate passes (mocked LLM response)."""
llm_reply = json.dumps({
"reply": "Hello! What's your name?",
"extracted_fields": {},
"complete": False,
})
with patch("dev_api._get_effective_tier", return_value="free"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = llm_reply
r = client.post(
"/api/wizard/ai/interview",
json={"history": [], "profile_so_far": {}},
)
assert r.status_code == 200
# ── POST /api/wizard/ai/interview — LLM mocked responses ─────────────────────
class TestWizardAIInterviewLLM:
def _paid_byok_patches(self):
"""Context managers for paid tier + BYOK."""
return (
patch("dev_api._get_effective_tier", return_value="paid"),
patch("app.wizard.tiers.has_configured_llm", return_value=True),
)
def test_returns_valid_reply_structure(self, client):
llm_reply = json.dumps({
"reply": "Great to meet you! What's your preferred contact email?",
"extracted_fields": {"name": "Alex Rivera"},
"complete": False,
})
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = llm_reply
r = client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "My name is Alex Rivera"},
],
},
)
assert r.status_code == 200
body = r.json()
assert body["reply"] == "Great to meet you! What's your preferred contact email?"
assert body["extracted_fields"] == {"name": "Alex Rivera"}
assert body["complete"] is False
def test_returns_complete_true_when_llm_signals_done(self, client):
llm_reply = json.dumps({
"reply": "You're all set! Your profile is complete.",
"extracted_fields": {
"name": "Alex",
"email": "alex@example.com",
"career_summary": "Backend engineer with 5 years experience.",
},
"complete": True,
})
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = llm_reply
r = client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "I'm done"},
],
},
)
assert r.status_code == 200
body = r.json()
assert body["complete"] is True
assert "name" in body["extracted_fields"]
def test_fallback_when_llm_returns_non_json(self, client):
"""If LLM returns non-JSON, the endpoint still returns 200 with raw reply."""
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = "Hello, what is your name?"
r = client.post(
"/api/wizard/ai/interview",
json={"history": []},
)
assert r.status_code == 200
body = r.json()
assert body["reply"] == "Hello, what is your name?"
assert body["extracted_fields"] == {}
assert body["complete"] is False
def test_history_passed_to_llm(self, client):
"""Verify the history turns are included in the prompt sent to the LLM."""
llm_reply = json.dumps({"reply": "OK", "extracted_fields": {}, "complete": False})
captured_calls = []
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = (
lambda prompt, system=None: (captured_calls.append(prompt) or llm_reply)
)
client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "I am Alex"},
{"role": "assistant", "content": "Nice to meet you Alex!"},
{"role": "user", "content": "My email is alex@test.com"},
],
},
)
assert len(captured_calls) == 1
prompt = captured_calls[0]
assert "I am Alex" in prompt
assert "alex@test.com" in prompt
def test_profile_so_far_injected_into_prompt(self, client):
"""profile_so_far fields must appear in the prompt sent to the LLM."""
llm_reply = json.dumps({"reply": "Got it!", "extracted_fields": {}, "complete": False})
captured_calls = []
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = (
lambda prompt, system=None: (captured_calls.append(prompt) or llm_reply)
)
client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "I am Alex"},
],
"profile_so_far": {
"name": "Alex Rivera",
"email": "alex@example.com",
},
},
)
assert len(captured_calls) == 1
prompt = captured_calls[0]
assert "Alex Rivera" in prompt
assert "alex@example.com" in prompt
def test_llm_error_returns_503(self, client):
"""If LLM raises, the endpoint returns 503."""
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = RuntimeError("no backends")
r = client.post(
"/api/wizard/ai/interview",
json={"history": [{"role": "user", "content": "hi"}]},
)
assert r.status_code == 503
# ── POST /api/wizard/ai/finalize ──────────────────────────────────────────────
class TestWizardAIFinalize:
def test_merges_allowed_fields_into_user_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"tier": "paid", "wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={
"profile": {
"name": "Jordan Lee",
"email": "jordan@example.com",
"career_summary": "Full-stack developer with 8 years experience.",
"candidate_voice": "warm and conversational",
}
},
)
assert r.status_code == 200
body = r.json()
assert body["saved"] is True
assert set(body["fields"]) == {"name", "email", "career_summary", "candidate_voice"}
saved = _read_user_yaml(yaml_path)
assert saved["name"] == "Jordan Lee"
assert saved["email"] == "jordan@example.com"
assert saved["career_summary"] == "Full-stack developer with 8 years experience."
assert saved["candidate_voice"] == "warm and conversational"
def test_does_not_clobber_existing_non_wizard_keys(self, client, tmp_path):
"""Keys like tier, wizard_complete must not be overwritten by finalize."""
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {
"tier": "premium",
"wizard_complete": True,
"inference_profile": "single-gpu",
})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={
"profile": {
"name": "Sam Park",
"tier": "free", # attempt to downgrade — must be blocked
"wizard_complete": False, # attempt to reset — must be blocked
}
},
)
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
# Non-wizard keys are preserved
assert saved["tier"] == "premium"
assert saved["wizard_complete"] is True
assert saved["inference_profile"] == "single-gpu"
# Allowed wizard key is written
assert saved["name"] == "Sam Park"
def test_unknown_keys_are_silently_ignored(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={
"profile": {
"email": "test@example.com",
"injected_field": "should be ignored",
"admin": True,
}
},
)
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["email"] == "test@example.com"
assert "injected_field" not in saved
assert "admin" not in saved
def test_all_allowed_fields_are_written(self, client, tmp_path):
"""All allowed wizard fields are accepted when provided."""
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
full_profile = {
"name": "Casey Morgan",
"email": "casey@example.com",
"career_summary": "Designer turned product manager.",
"candidate_voice": "professional and direct",
"mission_preferences": ["education", "social_impact"],
"candidate_accessibility_focus": True,
"candidate_lgbtq_focus": True,
"linkedin": "https://linkedin.com/in/casey",
}
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/ai/finalize", json={"profile": full_profile})
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
for key, value in full_profile.items():
assert saved[key] == value, f"Expected {key}={value!r}, got {saved.get(key)!r}"
def test_empty_profile_returns_saved_true(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"name": "Existing"})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/ai/finalize", json={"profile": {}})
assert r.status_code == 200
assert r.json()["saved"] is True
assert r.json()["fields"] == []
# Existing data is preserved
assert _read_user_yaml(yaml_path)["name"] == "Existing"
def test_mission_preferences_list_written_correctly(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={"profile": {"mission_preferences": ["music", "animal_welfare"]}},
)
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["mission_preferences"] == ["music", "animal_welfare"]

View file

@ -37,6 +37,8 @@ export const router = createRouter({
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
],
},
// AI profile wizard — post-setup settings entry point (correctly blocked by wizard gate during onboarding)
{ path: '/wizard/ai-profile', component: () => import('../views/wizard/WizardAIView.vue') },
// Onboarding wizard — full-page layout, no AppNav
{
path: '/setup',

View file

@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type Tier = 'free' | 'paid' | 'premium' | 'ultra'
export type Tier = 'free' | 'paid' | 'premium'
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export const useAppConfigStore = defineStore('appConfig', () => {
@ -13,6 +13,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const inferenceProfile = ref<InferenceProfile>('cpu')
const isDemo = ref(false)
const wizardComplete = ref(true) // optimistic default — guard corrects on load
const byokUnlocked = ref(false)
const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
@ -20,7 +21,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const { data } = await useApiFetch<{
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile
wizardComplete: boolean
wizardComplete: boolean; byokUnlocked: boolean
}>('/api/config/app')
if (!data) return
isCloud.value = data.isCloud
@ -30,6 +31,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile
wizardComplete.value = data.wizardComplete ?? true
byokUnlocked.value = data.byokUnlocked ?? false
loaded.value = true
}
@ -43,5 +45,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
}
}
return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
return { isCloud, isDemo, isDevMode, wizardComplete, byokUnlocked, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
})

View file

@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAiInterviewStore } from '../aiInterview'
vi.mock('../../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
const LS_KEY = 'peregrine:wizard-draft'
describe('useAiInterviewStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
})
// ── restore() ──────────────────────────────────────────────────────────────
it('restore() loads messages, fields, and complete from localStorage', () => {
const draft = {
messages: [{ role: 'assistant', content: 'Hello!' }],
fields: { name: 'Alice' },
complete: true,
}
localStorage.setItem(LS_KEY, JSON.stringify(draft))
const store = useAiInterviewStore()
store.restore()
expect(store.messages).toEqual(draft.messages)
expect(store.fields).toEqual(draft.fields)
expect(store.complete).toBe(true)
})
it('restore() is a no-op when localStorage is empty', () => {
const store = useAiInterviewStore()
store.restore()
expect(store.messages).toEqual([])
expect(store.fields).toEqual({})
expect(store.complete).toBe(false)
})
it('restore() ignores corrupted localStorage data without throwing', () => {
localStorage.setItem(LS_KEY, '{not valid json}}}')
const store = useAiInterviewStore()
expect(() => store.restore()).not.toThrow()
expect(store.messages).toEqual([])
})
// ── send() ─────────────────────────────────────────────────────────────────
it('send() appends user message and assistant reply on success', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'Nice to meet you!', extracted_fields: {}, complete: false },
error: null,
})
const store = useAiInterviewStore()
await store.send('Hello')
expect(store.messages).toEqual([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Nice to meet you!' },
])
expect(store.complete).toBe(false)
expect(store.error).toBeNull()
})
it('send() does not add a user bubble for empty string (intro trigger)', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'Welcome!', extracted_fields: {}, complete: false },
error: null,
})
const store = useAiInterviewStore()
await store.send('')
expect(store.messages).toEqual([
{ role: 'assistant', content: 'Welcome!' },
])
})
it('send() merges extracted_fields into existing fields', async () => {
mockFetch.mockResolvedValueOnce({
data: { reply: 'Got it.', extracted_fields: { name: 'Alice' }, complete: false },
error: null,
})
mockFetch.mockResolvedValueOnce({
data: { reply: 'Thanks.', extracted_fields: { title: 'Engineer' }, complete: false },
error: null,
})
const store = useAiInterviewStore()
await store.send('My name is Alice')
await store.send('I am an engineer')
expect(store.fields).toEqual({ name: 'Alice', title: 'Engineer' })
})
it('send() sets complete flag when backend signals done', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'All done!', extracted_fields: { name: 'Alice' }, complete: true },
error: null,
})
const store = useAiInterviewStore()
await store.send('done')
expect(store.complete).toBe(true)
})
it('send() sets error and rolls back loading on API failure', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useAiInterviewStore()
await store.send('Hello')
expect(store.error).toBe('Could not reach the assistant. Please try again.')
expect(store.loading).toBe(false)
})
it('send() persists draft to localStorage on success', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'Hi!', extracted_fields: { name: 'Bob' }, complete: false },
error: null,
})
const store = useAiInterviewStore()
await store.send('Hello')
const stored = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}')
expect(stored.fields).toEqual({ name: 'Bob' })
})
// ── finalize() ─────────────────────────────────────────────────────────────
it('finalize() calls the finalize API and clears localStorage on success', async () => {
localStorage.setItem(LS_KEY, JSON.stringify({ messages: [], fields: { name: 'Alice' }, complete: true }))
mockFetch.mockResolvedValue({ data: {}, error: null })
const store = useAiInterviewStore()
const ok = await store.finalize()
expect(ok).toBe(true)
expect(localStorage.getItem(LS_KEY)).toBeNull()
expect(store.saving).toBe(false)
})
it('finalize() returns false and sets error on API failure', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useAiInterviewStore()
const ok = await store.finalize()
expect(ok).toBe(false)
expect(store.error).toBe('Failed to save profile. Please try again.')
})
// ── skip() ─────────────────────────────────────────────────────────────────
it('skip() sends the skip signal to the backend', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'No problem, moving on.', extracted_fields: {}, complete: false },
error: null,
})
const store = useAiInterviewStore()
await store.skip()
expect(mockFetch).toHaveBeenCalledWith(
'/api/wizard/ai/interview',
expect.objectContaining({ method: 'POST' }),
)
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)
expect(body.history[0]).toEqual({ role: 'user', content: 'skip' })
})
// ── keepChatting() ─────────────────────────────────────────────────────────
it('keepChatting() clears the complete flag without resetting messages', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'All done!', extracted_fields: { name: 'Alice' }, complete: true },
error: null,
})
const store = useAiInterviewStore()
await store.send('done')
expect(store.complete).toBe(true)
store.keepChatting()
expect(store.complete).toBe(false)
expect(store.messages.length).toBeGreaterThan(0)
expect(store.fields).toEqual({ name: 'Alice' })
})
// ── startOver() ────────────────────────────────────────────────────────────
it('startOver() resets all state and clears localStorage', async () => {
mockFetch.mockResolvedValue({
data: { reply: 'Hi!', extracted_fields: { name: 'Alice' }, complete: true },
error: null,
})
const store = useAiInterviewStore()
await store.send('test') // populates state and localStorage
store.startOver()
expect(store.messages).toEqual([])
expect(store.fields).toEqual({})
expect(store.complete).toBe(false)
expect(store.error).toBeNull()
expect(localStorage.getItem(LS_KEY)).toBeNull()
})
})

View file

@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '../../composables/useApi'
const LS_KEY = 'peregrine:wizard-draft'
const SKIP_SIGNAL = 'skip'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export const useAiInterviewStore = defineStore('aiInterview', () => {
const messages = ref<ChatMessage[]>([])
const fields = ref<Record<string, unknown>>({})
const complete = ref(false)
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
function _persist() {
localStorage.setItem(LS_KEY, JSON.stringify({
messages: messages.value,
fields: fields.value,
complete: complete.value,
}))
}
function restore() {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return
const d = JSON.parse(raw) as { messages?: ChatMessage[]; fields?: Record<string, unknown>; complete?: boolean }
messages.value = d.messages ?? []
fields.value = d.fields ?? {}
complete.value = d.complete ?? false
} catch { /* ignore corrupted draft */ }
}
async function send(userText: string) {
if (loading.value) return
if (userText !== '') {
messages.value = [...messages.value, { role: 'user', content: userText }]
_persist()
}
loading.value = true
error.value = null
const { data, error: err } = await useApiFetch<{
reply: string
extracted_fields: Record<string, unknown>
complete: boolean
}>('/api/wizard/ai/interview', {
method: 'POST',
body: JSON.stringify({ history: messages.value, profile_so_far: fields.value }),
})
loading.value = false
if (err || !data) {
error.value = 'Could not reach the assistant. Please try again.'
return
}
messages.value = [...messages.value, { role: 'assistant', content: data.reply }]
fields.value = { ...fields.value, ...data.extracted_fields }
complete.value = data.complete
_persist()
}
async function finalize(): Promise<boolean> {
saving.value = true
error.value = null
const { error: err } = await useApiFetch('/api/wizard/ai/finalize', {
method: 'POST',
body: JSON.stringify({ profile: fields.value }),
})
saving.value = false
if (err) {
error.value = 'Failed to save profile. Please try again.'
return false
}
localStorage.removeItem(LS_KEY)
return true
}
function skip() {
return send(SKIP_SIGNAL)
}
function keepChatting() {
complete.value = false
}
function startOver() {
messages.value = []
fields.value = {}
complete.value = false
error.value = null
localStorage.removeItem(LS_KEY)
}
return { messages, fields, complete, loading, saving, error, restore, send, skip, finalize, keepChatting, startOver }
})

View file

@ -5,6 +5,26 @@
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
</header>
<!-- AI wizard entry point -->
<div class="wizard-cta" :class="hasWizardAccess ? 'wizard-cta--unlocked' : 'wizard-cta--locked'">
<div class="wizard-cta__body">
<span class="wizard-cta__icon" aria-hidden="true"></span>
<div>
<p class="wizard-cta__heading">Set up your profile with AI</p>
<p class="wizard-cta__desc">
<template v-if="hasWizardAccess">Answer a few questions and the assistant fills in your profile automatically.</template>
<template v-else>Upgrade to Paid, or bring your own LLM key, to use the AI profile assistant.</template>
</p>
</div>
</div>
<RouterLink v-if="hasWizardAccess" to="/wizard/ai-profile" class="btn-wizard">
Start AI setup
</RouterLink>
<RouterLink v-else to="/settings/license" class="btn-wizard btn-wizard--upgrade">
Upgrade
</RouterLink>
</div>
<div v-if="store.loading" class="loading-state">Loading profile</div>
<template v-else>
@ -204,7 +224,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useProfileStore } from '../../stores/settings/profile'
import { useAppConfigStore } from '../../stores/appConfig'
@ -214,6 +235,8 @@ const store = useProfileStore()
const { loadError } = storeToRefs(store)
const config = useAppConfigStore()
const hasWizardAccess = computed(() => config.tier !== 'free' || config.byokUnlocked)
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
@ -290,7 +313,106 @@ async function generateVoice() {
}
.page-header {
margin-bottom: var(--space-4);
}
/* ── AI wizard callout ─────────────────────────────── */
.wizard-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-md);
margin-bottom: var(--space-6);
border: 1px solid var(--color-border-light);
}
.wizard-cta--unlocked {
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.wizard-cta--locked {
background: var(--color-surface-raised);
}
.wizard-cta__body {
display: flex;
align-items: flex-start;
gap: var(--space-3);
flex: 1;
}
.wizard-cta__icon {
font-size: 1.25rem;
color: var(--color-primary);
flex-shrink: 0;
margin-top: 2px;
}
.wizard-cta--locked .wizard-cta__icon {
color: var(--color-text-muted);
}
.wizard-cta__heading {
margin: 0 0 var(--space-1);
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
}
.wizard-cta__desc {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.btn-wizard {
display: inline-flex;
align-items: center;
padding: var(--space-2) var(--space-5);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
min-height: 40px;
transition: background var(--transition);
flex-shrink: 0;
}
.btn-wizard:hover {
background: var(--color-primary-hover);
}
.btn-wizard--upgrade {
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.btn-wizard--upgrade:hover {
background: var(--color-surface-raised);
color: var(--color-text);
}
@media (max-width: 600px) {
.wizard-cta {
flex-direction: column;
align-items: flex-start;
}
.btn-wizard {
width: 100%;
justify-content: center;
}
}
.page-header h2 {

View file

@ -0,0 +1,598 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAiInterviewStore } from '../../stores/wizard/aiInterview'
import { useAppConfigStore } from '../../stores/appConfig'
import { RouterLink } from 'vue-router'
const router = useRouter()
const store = useAiInterviewStore()
const config = useAppConfigStore()
const hasAccess = computed(() => config.tier !== 'free' || config.byokUnlocked)
const inputText = ref('')
const messageList = ref<HTMLElement | null>(null)
const TOTAL_FIELDS = 8
const progressPct = computed(() =>
Math.min(100, (Object.keys(store.fields).length / TOTAL_FIELDS) * 100)
)
const TONE_CHIPS = [
'Professional and direct',
'Warm and conversational',
'Concise and clear',
'Enthusiastic and personable',
]
const lastAssistantMsg = computed(() => {
const msgs = store.messages
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'assistant') return msgs[i].content
}
return ''
})
const showToneChips = computed(() => {
if (store.messages.length === 0) return false
const lower = lastAssistantMsg.value.toLowerCase()
return lower.includes('writing') || lower.includes('voice') || lower.includes('cover letter')
})
async function scrollToBottom() {
await nextTick()
if (messageList.value) {
messageList.value.scrollTop = messageList.value.scrollHeight
}
}
watch(() => store.messages.length, () => scrollToBottom())
async function handleSend() {
const text = inputText.value.trim()
if (!text || store.loading) return
inputText.value = ''
await store.send(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
function applyToneChip(chip: string) {
inputText.value = chip
}
async function handleSave() {
const ok = await store.finalize()
if (ok) router.push('/settings/my-profile')
}
onMounted(async () => {
if (!config.loaded) await config.load()
store.restore()
if (store.messages.length === 0) {
await store.send('')
}
scrollToBottom()
})
</script>
<template>
<div class="ai-view">
<!-- Tier gate -->
<div v-if="!hasAccess" class="ai-locked">
<div class="ai-locked__icon" aria-hidden="true">🔒</div>
<h2 class="ai-locked__heading">AI Profile Assistant</h2>
<p class="ai-locked__body">
The AI profile assistant is available on the Paid plan, or for free when you bring your own LLM.
You can
<RouterLink to="/settings/my-profile" class="ai-locked__link">set up your profile manually</RouterLink>
instead.
</p>
</div>
<!-- Chat UI -->
<div v-else class="ai-chat">
<header class="ai-chat__header">
<h1 class="ai-chat__title">Set up your profile with AI</h1>
<p class="ai-chat__subtitle">I'll ask you a few questions. You can skip anything.</p>
<!-- Progress bar -->
<div class="ai-progress" role="progressbar"
:aria-valuenow="Object.keys(store.fields).length"
:aria-valuemax="TOTAL_FIELDS"
aria-label="Profile fields completed">
<div class="ai-progress__bar" :style="{ width: progressPct + '%' }"></div>
</div>
<p class="ai-progress__label">
{{ Object.keys(store.fields).length }} of {{ TOTAL_FIELDS }} fields captured
</p>
</header>
<!-- Message list -->
<div class="ai-messages" ref="messageList">
<div
v-for="(msg, idx) in store.messages"
:key="idx"
class="ai-bubble"
:class="msg.role === 'user' ? 'ai-bubble--user' : 'ai-bubble--assistant'"
>
<span class="ai-bubble__text">{{ msg.content }}</span>
</div>
<div v-if="store.loading" class="ai-bubble ai-bubble--assistant ai-bubble--typing">
<span class="ai-typing-dots" aria-label="Thinking">
<span></span><span></span><span></span>
</span>
</div>
</div>
<!-- Completion panel -->
<div v-if="store.complete" class="ai-complete">
<p class="ai-complete__msg">Your profile is ready to save.</p>
<div class="ai-complete__actions">
<button
class="btn-primary"
:disabled="store.saving"
@click="handleSave"
>
{{ store.saving ? 'Saving…' : 'Save Profile' }}
</button>
<button
class="btn-ghost"
:disabled="store.loading || store.saving"
@click="store.keepChatting()"
>
Keep chatting
</button>
</div>
</div>
<!-- Input area -->
<div class="ai-input-area">
<!-- Tone chips -->
<div v-if="showToneChips" class="ai-tone-chips" role="group" aria-label="Writing tone suggestions">
<button
v-for="chip in TONE_CHIPS"
:key="chip"
class="ai-tone-chip"
@click="applyToneChip(chip)"
>{{ chip }}</button>
</div>
<div class="ai-input-row">
<textarea
v-model="inputText"
class="ai-input"
placeholder="Type your answer…"
rows="2"
:disabled="store.loading || store.saving"
@keydown="handleKeydown"
aria-label="Chat input"
></textarea>
<div class="ai-input-btns">
<button
class="btn-primary ai-send-btn"
:disabled="store.loading || store.saving || !inputText.trim()"
@click="handleSend"
>
Send
</button>
<button
class="btn-ghost ai-skip-btn"
:disabled="store.loading || store.saving || store.complete"
@click="store.skip()"
>
Skip
</button>
</div>
</div>
<p v-if="store.error" class="ai-error" role="alert">{{ store.error }}</p>
<div v-if="store.messages.length > 0" class="ai-startover-row">
<button class="btn-startover" @click="store.startOver()">Start over</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Page container ────────────────────────────────── */
.ai-view {
min-height: 100vh;
background: var(--color-surface);
display: flex;
justify-content: center;
padding: var(--space-8) var(--space-4);
}
/* ── Locked state ──────────────────────────────────── */
.ai-locked {
max-width: 480px;
width: 100%;
margin: auto;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.ai-locked__icon {
font-size: 3rem;
}
.ai-locked__heading {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.ai-locked__body {
font-size: 0.95rem;
color: var(--color-text-muted);
line-height: 1.6;
margin: 0;
}
.ai-locked__link {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 3px;
}
/* ── Chat container ────────────────────────────────── */
.ai-chat {
width: 100%;
max-width: 680px;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* ── Header ────────────────────────────────────────── */
.ai-chat__header {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ai-chat__title {
font-family: var(--font-display);
font-size: 1.375rem;
font-weight: 700;
color: var(--color-primary);
margin: 0;
}
.ai-chat__subtitle {
font-size: 0.9rem;
color: var(--color-text-muted);
margin: 0;
}
/* ── Progress bar ──────────────────────────────────── */
.ai-progress {
height: 6px;
background: var(--color-border-light);
border-radius: var(--radius-full);
overflow: hidden;
}
.ai-progress__bar {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
.ai-progress__label {
font-size: 0.78rem;
color: var(--color-text-muted);
margin: 0;
}
/* ── Message list ──────────────────────────────────── */
.ai-messages {
flex: 1;
min-height: 320px;
max-height: 480px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
scroll-behavior: smooth;
}
/* ── Chat bubbles ──────────────────────────────────── */
.ai-bubble {
display: flex;
max-width: 80%;
}
.ai-bubble--user {
align-self: flex-end;
}
.ai-bubble--assistant {
align-self: flex-start;
}
.ai-bubble__text {
display: block;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: 0.9rem;
line-height: 1.55;
white-space: pre-wrap;
}
.ai-bubble--user .ai-bubble__text {
background: var(--color-primary);
color: var(--color-text-inverse);
border-bottom-right-radius: var(--radius-sm);
}
.ai-bubble--assistant .ai-bubble__text {
background: var(--color-surface-alt);
color: var(--color-text);
border: 1px solid var(--color-border-light);
border-bottom-left-radius: var(--radius-sm);
}
/* ── Typing indicator ──────────────────────────────── */
.ai-bubble--typing .ai-bubble__text {
padding: var(--space-3) var(--space-4);
}
.ai-typing-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.ai-typing-dots span {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-muted);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.ai-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.ai-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-4px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ai-typing-dots span { animation: none; opacity: 0.7; }
}
/* ── Completion panel ──────────────────────────────── */
.ai-complete {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.ai-complete__msg {
flex: 1;
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-success);
}
.ai-complete__actions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
/* ── Input area ────────────────────────────────────── */
.ai-input-area {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ai-input-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
}
.ai-input {
flex: 1;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.9rem;
line-height: 1.5;
resize: none;
transition: border-color var(--transition);
}
.ai-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.ai-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ai-input-btns {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ai-send-btn,
.ai-skip-btn {
white-space: nowrap;
min-width: 72px;
}
/* ── Tone chips ────────────────────────────────────── */
.ai-tone-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.ai-tone-chip {
padding: var(--space-1) var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
border-radius: var(--radius-full);
background: var(--color-accent-light);
color: var(--color-accent);
font-family: var(--font-body);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: background var(--transition), border-color var(--transition);
}
.ai-tone-chip:hover {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-color: var(--color-accent);
}
/* ── Error ─────────────────────────────────────────── */
.ai-error {
font-size: 0.875rem;
color: var(--color-error);
margin: 0;
padding: var(--space-2) var(--space-3);
background: color-mix(in srgb, var(--color-error) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
border-radius: var(--radius-md);
}
/* ── Start over ────────────────────────────────────── */
.ai-startover-row {
display: flex;
justify-content: flex-end;
}
.btn-startover {
background: none;
border: none;
font-family: var(--font-body);
font-size: 0.8rem;
color: var(--color-text-muted);
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: color var(--transition);
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-startover:hover {
color: var(--color-error);
}
/* ── Button styles (local defs matching wizard.css) ── */
.btn-primary {
padding: var(--space-2) var(--space-6);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition);
min-height: 44px;
}
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
padding: var(--space-2) var(--space-4);
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
min-height: 44px;
}
.btn-ghost:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-border);
}
.btn-ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Mobile ────────────────────────────────────────── */
@media (max-width: 600px) {
.ai-view {
padding: var(--space-4) var(--space-3);
}
.ai-messages {
min-height: 240px;
max-height: 360px;
}
.ai-input-row {
flex-direction: column;
align-items: stretch;
}
.ai-input-btns {
flex-direction: row;
}
.ai-bubble {
max-width: 92%;
}
.ai-complete {
flex-direction: column;
align-items: flex-start;
}
}
</style>