Focus Group UI — Port to FastAPI + React
Priority: P2 Category: PREP
Executive Summary
Port the Focus Group UI from Streamlit to a local FastAPI + React app running on the Mac at http://localhost:5173. The runner (run_focus_group.py) is unchanged — the FastAPI backend wraps it as a subprocess. No server deployment in this phase; Cole is the only user and the runner reads local secrets and writes to iCloud. The new UI uses Tailwind + lucide-react and will be noticeably more polished than the Streamlit version.
Research Phase
Current State
- Runner:
~/ai-projects-local/focus-group/run_focus_group.py— 34 personas, all logic, stays 100% unchanged - Streamlit UI:
~/ai-projects-local/focus-group/app.py— wraps runner viasubprocess.Popen, streams stdout, parses output .md file - Outputs: written to
~/ai-projects/Marketing-Plans/focus-group-outputs/*.md - Runner CLI interface (verified):
python run_focus_group.py \ --copy "text" \ --business enterprise-ai|fabric-quilting|dreamy-blankets|true-commission|all \ --type ad|sales-page|linkedin|email|google-search|video-script \ --iterations 3 \ --image /path/to/image.jpg # optionalAll parameters are CLI args. Image is passed as a file path, not base64.
Architecture Decision: Where Does the API Run?
Chosen: Local full-stack (Option B)
- FastAPI at
localhost:8003 - React Vite dev server at
localhost:5173 - No server deploy in Phase 1
Rationale: Runner uses local secrets + writes to iCloud, Cole is the only user. rb.alpineanalytica.com is valuable for multi-user tools (financials, reorder) — not for a single-user tool that would require syncing personas, secrets, and output files to the server.
Future path to Option C (server deploy): Move runner to server, use encrypted secrets on server, write outputs to server filesystem, add GET /api/result/{filename} file download endpoint. Nothing in Phase 1 blocks this — the architecture is identical, just different host.
Pre-execution checks
- ☐ Confirm port 8003 is free:
lsof -i :8003 - ☐ Confirm port 5173 is free:
lsof -i :5173
API Design
Key decision: Two-step SSE (POST → GET stream)
EventSource in browsers only supports GET requests. You cannot POST to an SSE endpoint via EventSource. The correct pattern:
POST /api/run→ returns{"run_id": "uuid"}immediately, starts subprocess in background taskGET /api/run/{run_id}/stream→ SSE stream (works withEventSource)
The frontend POSTs the run params, gets a run_id, then opens an EventSource to the stream URL.
Alternatively: use fetch with ReadableStream on the POST endpoint directly (no EventSource). This is simpler but requires manual SSE parsing. Chosen approach: fetch + ReadableStream on POST — avoids the two-step complexity and lib/api.ts handles the parsing. EventSource not used.
Endpoints
POST /api/run
Request body:
{
"copy": "...",
"business": "fabric-quilting",
"type": "ad",
"iterations": 3,
"image_base64": null,
"image_ext": null
}
Note: if image_base64 is provided, the backend writes it to /tmp/fg_upload_<uuid>.<ext> and passes the path to --image. The runner already supports --image; this is just the transport layer.
Response: text/event-stream via StreamingResponse
Event types:
data: {"type": "log", "line": "Running 10 persona critiques..."}
data: {"type": "complete", "result": {...parsed...}, "filename": "20260529_120000_ad_fabric-quilting.md"}
data: {"type": "error", "message": "Runner exited with code 1. Last lines: ..."}
The complete event includes filename so the frontend can construct the download URL.
Subprocess lifecycle:
- Run is tracked in a module-level dict
_active_runs: dict[str, subprocess.Popen] - Single-run guard: if a run is already active, return HTTP 409 immediately
- After
process.wait()completes (success or failure), remove from_active_runs - File is read after
process.wait()— no race condition with file flush
POST /api/cancel
- Kills the active subprocess if one is running
- Returns 200 if killed, 404 if nothing running
GET /api/result/{filename}
- Serves the raw
.mdfile fromOUTPUTS_DIR - Used for the download button
- Validates filename is a
.mdfile inOUTPUTS_DIR(no path traversal)
GET /api/personas
Returns PERSONA_ROSTER dict.
GET /api/health
Returns {"status": "ok", "run_active": bool}.
File Structure
~/ai-projects-local/focus-group/
run_focus_group.py ← unchanged
app.py ← Streamlit (delete after React acceptance criteria met)
personas/ ← unchanged
PERSONAS.md ← unchanged
api/
main.py ← FastAPI app
data.py ← parse_results(), PERSONA_ROSTER (extracted from app.py)
requirements.txt
venv/ ← Python 3.11 venv
ui/
package.json
vite.config.ts ← dev proxy: /api → http://localhost:8003
tailwind.config.js
index.html
src/
main.tsx
App.tsx
components/
CopyForm.tsx
ScoreHero.tsx
ScoreBreakdown.tsx
WinnerCopy.tsx
PersonaCritiques.tsx
VariationTabs.tsx
ProgressLog.tsx
PersonaRoster.tsx
lib/
api.ts ← always uses relative /api/* paths (works in dev + prod-local)
types.ts
URL strategy: lib/api.ts always uses relative paths (/api/run, /api/personas). In dev the Vite proxy forwards them to localhost:8003. In production-local (FastAPI serves StaticFiles), same origin — no proxy needed. Never hardcode localhost:8003 in frontend code.
Implementation Plan
Phase 0 — Pre-flight
- ☐
lsof -i :8003— confirm port free - ☐ Extract
parse_results()andPERSONA_ROSTERfromapp.pyintoapi/data.py - ☐ Verify
app.pystill imports and works after extraction (both import fromdata.py)
Phase 1 — Backend (api/)
- ☐ Create
api/venv, installfastapi uvicorn python-multipart - ☐
main.py: FastAPI app, CORS forhttp://localhost:5173only - ☐
POST /api/run: - Validate inputs
- 409 if run already active
- Write image to
/tmpif provided - Build CLI args list (matching current
app.pycommand construction exactly) subprocess.Popenwithstdout=PIPE, stderr=STDOUTStreamingResponsethat yields SSE log lines- After
process.wait(): find output file (newest.mdafter run_start_time), parse withparse_results(), emitcompleteorerrorevent - ☐
POST /api/cancel: kill active subprocess - ☐
GET /api/result/{filename}: serve.mdfile (path-traversal safe) - ☐
GET /api/personas: return roster - ☐
GET /api/health: return status + run_active - ☐ Manual test:
curl -X POST localhost:8003/api/run -H 'Content-Type: application/json' -d '{"copy":"test copy","business":"fabric-quilting","type":"ad","iterations":1}'
Phase 2 — Frontend scaffold (ui/)
- ☐
npm create vite@latest ui -- --template react-ts - ☐ Install:
tailwindcss postcss autoprefixer lucide-react - ☐ Init Tailwind, configure content paths
- ☐
vite.config.ts: dev proxy/api→http://localhost:8003 - ☐
lib/types.ts: TypeScript interfaces for API responses - ☐
lib/api.ts: streamRun(params, onLog, onComplete, onError)— usesfetch+ReadableStream+ manual SSE line parsinggetPersonas()— simple fetchcancelRun()— POST to/api/cancelresultDownloadUrl(filename)— returns/api/result/${filename}
Phase 3 — UI Components
Build in this order (each independently testable with mock data):
CopyForm.tsx— textarea, business/type dropdowns, iterations slider, image upload, Run + Cancel buttons; Cancel only visible during active runProgressLog.tsx— scrolling<pre>of SSE log lines; auto-scrolls to bottomScoreHero.tsx— large score, color-coded (green ≥75%, amber 50-74%, red <50%), winner badgeScoreBreakdown.tsx— colored horizontal bars, label + fractionWinnerCopy.tsx— green left-border cardPersonaCritiques.tsx— collapsible; OVERALL REACTION in purple callout at top; persona initial avatarVariationTabs.tsx— three tabs (PAS/AIDA/BAB), read-only<pre>blockPersonaRoster.tsx— sidebar accordion with colored avatar circles, fetched from/api/personas
Phase 4 — App layout (App.tsx)
Two-column layout:
- Left (40%): CopyForm at top, PersonaRoster below (fetched on mount)
- Right (60%): ProgressLog during run; replaced by ScoreHero + ScoreBreakdown + WinnerCopy + PersonaCritiques + VariationTabs + download link after complete
- Error banner if
type: "error"received
Phase 5 — Polish + Handoff
- ☐ Loading skeletons during run
- ☐ Error states: non-zero exit, missing output file, parse failure
- ☐ Download link using
resultDownloadUrl(filename)fromcompleteevent - ☐ End-to-end test with a real run (fabric-quilting, ad, 1 iteration)
- ☐ Confirm output
.mdlands in iCloud outputs dir - ☐ Once all acceptance criteria pass: delete
app.py
Starting the App
# Terminal 1: API
cd ~/ai-projects-local/focus-group/api
source venv/bin/activate
uvicorn main:app --reload --port 8003
# Terminal 2: UI
cd ~/ai-projects-local/focus-group/ui
npm run dev
# Opens at http://localhost:5173
Acceptance Criteria
Happy path:
- ☐ Paste copy, select business + type + iterations, click Run → live log appears in right panel
- ☐ After run: score hero shows correct number with correct color
- ☐ Score breakdown bars render with correct fills and colors
- ☐ Winner copy appears in green left-border card
- ☐ Persona critiques: each expands, OVERALL REACTION shown in purple callout at top
- ☐ VERSION A/B/C tabs each show their variation text
- ☐ Download link fetches the raw
.mdreport - ☐ Output
.mdfile lands in~/ai-projects/Marketing-Plans/focus-group-outputs/ - ☐ PersonaRoster sidebar loads personas from API on page load
Error states:
- ☐ Runner exits non-zero → error banner with last 10 log lines shown
- ☐ Second Run click while run is active → 409 handled gracefully (button disabled or toast)
- ☐ Cancel button mid-run → subprocess killed, UI returns to ready state
Cleanup:
- ☐
app.pydeleted after all above criteria pass - ☐ No hardcoded
localhost:8003in any frontend file
Execution Log
2026-05-29
- Plan created. Architecture decision: local full-stack, no server deploy in Phase 1.
- Opus 4.7 critic review completed. Key fixes applied:
- Executive summary corrected (was contradicting chosen approach)
- SSE approach clarified:
fetch+ReadableStreamon POST, notEventSource(which is GET-only) - Subprocess orphan/cancellation handled:
_active_runsdict,POST /api/cancel, 409 guard image_base64clarified: backend writes to/tmp, passes--imagepath to runner- Race condition fixed: parse file after
process.wait(), not on stdout EOF - URL strategy locked: always relative
/api/*, never hardcodelocalhost:8003 GET /api/result/{filename}added for download button- Error state acceptance criteria added
- Port availability check added to pre-flight
~/ai-projects/mission-control/plans/focus-group-react-port.md