# 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:** `