← Back to all projects
Ready Created 2026-05-29 0/39 tasks

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 via subprocess.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 # optional All 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:

  1. POST /api/run → returns {"run_id": "uuid"} immediately, starts subprocess in background task
  2. GET /api/run/{run_id}/stream → SSE stream (works with EventSource)

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 .md file from OUTPUTS_DIR
  • Used for the download button
  • Validates filename is a .md file in OUTPUTS_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() and PERSONA_ROSTER from app.py into api/data.py
  • Verify app.py still imports and works after extraction (both import from data.py)

Phase 1 — Backend (api/)

  • Create api/venv, install fastapi uvicorn python-multipart
  • main.py: FastAPI app, CORS for http://localhost:5173 only
  • POST /api/run:
  • Validate inputs
  • 409 if run already active
  • Write image to /tmp if provided
  • Build CLI args list (matching current app.py command construction exactly)
  • subprocess.Popen with stdout=PIPE, stderr=STDOUT
  • StreamingResponse that yields SSE log lines
  • After process.wait(): find output file (newest .md after run_start_time), parse with parse_results(), emit complete or error event
  • POST /api/cancel: kill active subprocess
  • GET /api/result/{filename}: serve .md file (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 /apihttp://localhost:8003
  • lib/types.ts: TypeScript interfaces for API responses
  • lib/api.ts:
  • streamRun(params, onLog, onComplete, onError) — uses fetch + ReadableStream + manual SSE line parsing
  • getPersonas() — simple fetch
  • cancelRun() — POST to /api/cancel
  • resultDownloadUrl(filename) — returns /api/result/${filename}

Phase 3 — UI Components

Build in this order (each independently testable with mock data):

  1. CopyForm.tsx — textarea, business/type dropdowns, iterations slider, image upload, Run + Cancel buttons; Cancel only visible during active run
  2. ProgressLog.tsx — scrolling <pre> of SSE log lines; auto-scrolls to bottom
  3. ScoreHero.tsx — large score, color-coded (green ≥75%, amber 50-74%, red <50%), winner badge
  4. ScoreBreakdown.tsx — colored horizontal bars, label + fraction
  5. WinnerCopy.tsx — green left-border card
  6. PersonaCritiques.tsx — collapsible; OVERALL REACTION in purple callout at top; persona initial avatar
  7. VariationTabs.tsx — three tabs (PAS/AIDA/BAB), read-only <pre> block
  8. PersonaRoster.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) from complete event
  • End-to-end test with a real run (fabric-quilting, ad, 1 iteration)
  • Confirm output .md lands 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 .md report
  • Output .md file 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.py deleted after all above criteria pass
  • No hardcoded localhost:8003 in 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 + ReadableStream on POST, not EventSource (which is GET-only)
  • Subprocess orphan/cancellation handled: _active_runs dict, POST /api/cancel, 409 guard
  • image_base64 clarified: backend writes to /tmp, passes --image path to runner
  • Race condition fixed: parse file after process.wait(), not on stdout EOF
  • URL strategy locked: always relative /api/*, never hardcode localhost:8003
  • GET /api/result/{filename} added for download button
  • Error state acceptance criteria added
  • Port availability check added to pre-flight