docs: add Survey Assistant Vue SPA design spec
This commit is contained in:
parent
e4f4b0c67f
commit
437a9c3f55
1 changed files with 224 additions and 0 deletions
224
docs/superpowers/specs/2026-03-21-survey-vue-design.md
Normal file
224
docs/superpowers/specs/2026-03-21-survey-vue-design.md
Normal file
|
|
@ -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:** `<textarea>` with placeholder showing example Q&A format, min-height 200px
|
||||
- **Screenshot tab:** Combined drop zone with three affordances:
|
||||
- Paste: listens for `paste` event on the zone (Ctrl+V); accepts `image/*` items from `ClipboardEvent.clipboardData`
|
||||
- Drag-and-drop: `dragover` / `drop` events; accepts image files
|
||||
- File upload: `<input type="file" accept="image/*">` button within the zone
|
||||
- Preview: shows thumbnail of loaded image with "✕ Remove" button
|
||||
- Stores image as base64 string in component state
|
||||
|
||||
**3. Mode selection**
|
||||
- Two full-width stacked cards, one per mode:
|
||||
- ⚡ **Quick** — "Best answer + one-liner per question"
|
||||
- 📋 **Detailed** — "Option-by-option breakdown with reasoning"
|
||||
- Selected card: border highlight + subtle background fill
|
||||
- Reactive `selectedMode` ref, default `'quick'`
|
||||
|
||||
**4. Analyze button**
|
||||
- Full-width primary button
|
||||
- Disabled when: no text input AND no image loaded
|
||||
- While `surveyStore.loading`: shows spinner + "Analyzing…" label, disabled
|
||||
- On click: calls `surveyStore.analyze(jobId, {text?, image_b64?, mode: selectedMode})`
|
||||
|
||||
**5. Results card** (rendered when `surveyStore.analysis` is set)
|
||||
- Appears below the Analyze button (pushes history further down)
|
||||
- LLM output rendered with `whitespace-pre-wrap`
|
||||
- Inline save form below output:
|
||||
- Optional "Survey name" text input (placeholder: "e.g. Culture Fit Round 1")
|
||||
- Optional "Reported score" text input (placeholder: "e.g. 82% or 4.2/5")
|
||||
- "💾 Save to job" button — calls `surveyStore.saveResponse()`; shows spinner while `surveyStore.saving`
|
||||
- Inline success message on save; clears results card
|
||||
|
||||
**6. History accordion**
|
||||
- Header: "Survey history (N responses)" — closed by default
|
||||
- Low visual weight (muted header style)
|
||||
- Each entry: survey name (fallback "Survey response") + date + score if present
|
||||
- Expandable per entry: shows full LLM output + mode + source + `received_at` timestamp
|
||||
- `raw_input` and `image_path` are intentionally not shown in history — raw input can be long and images are not served by the API
|
||||
- Empty state if no history
|
||||
|
||||
**Error display:**
|
||||
- Analyze error: inline below Analyze button
|
||||
- Save error: inline below save form (analysis output preserved)
|
||||
- Store-level load error (history/vision fetch): subtle banner below context bar
|
||||
|
||||
**Mobile:** identical — already single column.
|
||||
|
||||
### Navigation addition — `InterviewsView.vue` / `InterviewCard.vue`
|
||||
|
||||
Follow the existing `InterviewCard.vue` emit pattern (same as "Prep →"):
|
||||
- Add `emit('survey', job.id)` button to `InterviewCard.vue` with `v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"`
|
||||
- Add `@survey="router.push('/survey/' + $event)"` handler in `InterviewsView.vue` on the relevant column card instances
|
||||
|
||||
Do NOT use a `RouterLink` directly on the card — the established pattern is event emission to the parent view for navigation.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User navigates to /survey/:id (from kanban "Survey →" link)
|
||||
→ SurveyView mounts
|
||||
→ redirect check (job in valid stage?)
|
||||
→ surveyStore.fetchFor(id)
|
||||
├─ GET /api/jobs/{id}/survey/responses (parallel)
|
||||
└─ GET /api/vision/health (parallel)
|
||||
→ user pastes text OR uploads/pastes/drags screenshot
|
||||
→ user selects mode (Quick / Detailed)
|
||||
→ user clicks Analyze
|
||||
→ POST /api/jobs/{id}/survey/analyze
|
||||
→ surveyStore.analysis set with output
|
||||
→ user reviews output
|
||||
→ user optionally fills survey name + reported score
|
||||
→ user clicks Save
|
||||
→ POST /api/jobs/{id}/survey/responses
|
||||
→ new entry prepended to surveyStore.history
|
||||
→ results card cleared
|
||||
User navigates away
|
||||
→ surveyStore.clear() resets state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Vision health check fails → `visionAvailable = false`; screenshot tab disabled; text input unaffected
|
||||
- Analyze POST fails → `error` set; inline error below button; input preserved for retry
|
||||
- Save POST fails → `saving` error set; inline error on save form; analysis output preserved
|
||||
- Job not found / wrong stage → redirect to `/interviews`
|
||||
- History fetch fails → empty history, inline error banner; does not block analyze flow
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
New test files:
|
||||
- `tests/test_dev_api_survey.py` — covers all 4 endpoints: vision health (up/down), analyze text (quick/detailed), analyze image, analyze LLM failure, save response (with/without image), get history (empty/populated)
|
||||
- `web/src/stores/survey.test.ts` — unit tests: `fetchFor` parallel loads, job change clears state, `analyze` stores result, `analyze` sets error on failure, `saveResponse` prepends to history and clears analysis, `clear` resets all state
|
||||
|
||||
No new DB migrations. All DB access uses existing `scripts/db.py` helpers (`insert_survey_response`, `get_survey_responses`).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Modify | `dev-api.py` — 4 new endpoints |
|
||||
| Create | `tests/test_dev_api_survey.py` |
|
||||
| Create | `web/src/stores/survey.ts` |
|
||||
| Create | `web/src/stores/survey.test.ts` |
|
||||
| Create | `web/src/views/SurveyView.vue` — full implementation (replaces placeholder stub) |
|
||||
| Modify | `web/src/components/InterviewCard.vue` — add "Survey →" link |
|
||||
Loading…
Reference in a new issue