diff --git a/docs/superpowers/specs/2026-03-21-survey-vue-design.md b/docs/superpowers/specs/2026-03-21-survey-vue-design.md new file mode 100644 index 0000000..e506dd3 --- /dev/null +++ b/docs/superpowers/specs/2026-03-21-survey-vue-design.md @@ -0,0 +1,224 @@ +# Survey Assistant Page — Vue SPA Design + +## Goal + +Port the Streamlit Survey Assistant page (`app/pages/7_Survey.py`) to a Vue 3 SPA view at `/survey/:id`, with a calm single-column layout, text paste and screenshot input, Quick/Detailed mode selection, LLM analysis, save-to-job, and response history. + +## Scope + +**In scope:** +- Routing at `/survey/:id` with redirect guard (job must be in `survey`, `phone_screen`, `interviewing`, or `offer`) +- `/survey` (no id) redirects to `/interviews` +- Calm single-column layout (max-width 760px, centered) with sticky job context bar +- Input: tabbed text paste / screenshot (paste Ctrl+V + drag-and-drop + file upload) +- Screenshot tab disabled (but visible) when vision service is unavailable +- Mode selection: two full-width labeled cards (Quick / Detailed) +- Synchronous LLM analysis via new backend endpoint +- Results rendered below input after analysis +- Save to job: optional survey name + reported score +- Response history: collapsible accordion, closed by default +- "Survey →" navigation link on kanban cards in `survey`, `phone_screen`, `interviewing`, `offer` stages + +**Explicitly deferred:** +- Streaming LLM responses (requires SSE endpoint — deferred to future sprint) +- Mock Q&A / interview practice chat (separate feature, requires streaming chat endpoint) + +--- + +## Architecture + +### Routing + +- `/survey/:id` — renders `SurveyView.vue` with the specified job +- `/survey` (no id) — redirects to `/interviews` +- On mount: if job id is missing, or job status not in `['survey', 'phone_screen', 'interviewing', 'offer']`, redirect to `/interviews` + +### Backend — `dev-api.py` (4 new endpoints) + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/vision/health` | Proxy to vision service health check; returns `{available: bool}` | +| `POST` | `/api/jobs/{id}/survey/analyze` | Accepts `{text?, image_b64?, mode}`; runs LLM synchronously; returns `{output, source}` | +| `POST` | `/api/jobs/{id}/survey/responses` | Saves survey response to `survey_responses` table; saves image file if `image_b64` provided | +| `GET` | `/api/jobs/{id}/survey/responses` | Returns all `survey_responses` rows for job, newest first | + +**Analyze endpoint details:** +- `mode`: `"quick"` or `"detailed"` (lowercase) — frontend sends lowercase; backend uses as-is to select prompt template (same as Streamlit `_build_text_prompt` / `_build_image_prompt`, which expect lowercase `mode`) +- If `image_b64` provided: routes through `vision_fallback_order`; **no system prompt** (matches Streamlit vision path); `source = "screenshot"` +- If `text` provided: routes through `research_fallback_order`; passes `system=_SURVEY_SYSTEM` (matches Streamlit text path); `source = "text_paste"` +- `_SURVEY_SYSTEM` constant: `"You are a job application advisor helping a candidate answer a culture-fit survey. The candidate values collaborative teamwork, clear communication, growth, and impact. Choose answers that present them in the best professional light."` +- Returns `{output: str, source: str}` on success; raises HTTP 500 on LLM failure + +**Save endpoint details:** +- Body: `{survey_name?, mode, source, raw_input?, image_b64?, llm_output, reported_score?}` +- Backend generates `received_at = datetime.now().isoformat()` — not passed by client +- If `image_b64` present: saves PNG to `data/survey_screenshots/{job_id}/{timestamp}.png`; stores path in `image_path` column; `image_b64` is NOT stored in DB +- Calls `scripts.db.insert_survey_response(db_path, job_id, survey_name, received_at, source, raw_input, image_path, mode, llm_output, reported_score)` — note `received_at` is the second positional arg after `job_id` +- `SurveyResponse.created_at` in the store interface maps the DB `created_at` column (SQLite auto-set on insert); `received_at` is a separate column storing the analysis timestamp — both are returned by `GET /survey/responses`; store interface exposes `received_at` for display +- Returns `{id: int}` of new row + +**Vision health endpoint:** +- Attempts `GET http://localhost:8002/health` with 2s timeout +- Returns `{available: true}` on 200, `{available: false}` on any error/timeout + +### Store — `web/src/stores/survey.ts` + +```ts +interface SurveyAnalysis { + output: string + source: 'text_paste' | 'screenshot' + mode: 'quick' | 'detailed' // retained so saveResponse can include it + rawInput: string | null // retained so saveResponse can include raw_input +} + +interface SurveyResponse { + id: number + survey_name: string | null + mode: 'quick' | 'detailed' + source: string + raw_input: string | null + image_path: string | null + llm_output: string + reported_score: string | null + received_at: string | null // analysis timestamp (from DB received_at column) + created_at: string | null // row insert timestamp (SQLite auto) +} +``` + +State: `analysis: SurveyAnalysis | null`, `history: SurveyResponse[]`, `loading: boolean`, `saving: boolean`, `error: string | null`, `visionAvailable: boolean`, `currentJobId: number | null` + +Methods: +- `fetchFor(jobId)` — clears state if `jobId !== currentJobId`; fires two parallel requests: `GET /api/jobs/{id}/survey/responses` and `GET /api/vision/health`; stores results +- `analyze(jobId, payload: {text?: string, image_b64?: string, mode: 'quick' | 'detailed'})` — sets `loading = true`; POST to analyze endpoint; stores result in `analysis` (including `mode` and `rawInput = payload.text ?? null` for later use by `saveResponse`); sets `error` on failure +- `saveResponse(jobId, {surveyName: string, reportedScore: string, image_b64?: string})` — sets `saving = true`; constructs full save body from current `analysis` (`mode`, `source`, `rawInput`, `llm_output`) + method args; POST to save endpoint; prepends new response to `history`; clears `analysis`; sets `error` on failure +- `clear()` — resets all state to initial values + +### Component — `web/src/views/SurveyView.vue` + +**Mount / unmount:** +- Reads `route.params.id`; redirects to `/interviews` if missing or non-numeric +- Looks up job in `interviewsStore.jobs` (fetches if empty); redirects if job status not in valid stages +- Calls `surveyStore.fetchFor(jobId)` on mount +- Calls `surveyStore.clear()` on unmount + +**Layout:** Single column, `max-width: 760px`, centered (`margin: 0 auto`), padding `var(--space-6)`. + +**1. Sticky context bar** +- Sticky top, low height (~40px), soft background color +- Shows: company name + job title + stage badge +- Always visible while scrolling + +**2. Input card** +- Tabs: "📝 Paste Text" (always active) / "📷 Screenshot" + - Screenshot tab: rendered but non-interactive (`aria-disabled`) when `!surveyStore.visionAvailable`; tooltip on hover: "Vision service not running — start it with: bash scripts/manage-vision.sh start" +- **Text tab:** `