From 75163b8e48717ab6867d51511c2ddc44a65fcb8e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 21 Mar 2026 00:09:02 -0700 Subject: [PATCH] feat(survey): add 4 backend survey endpoints with tests Add GET /api/vision/health, POST /api/jobs/{id}/survey/analyze, POST /api/jobs/{id}/survey/responses, and GET /api/jobs/{id}/survey/responses to dev-api.py. All 10 TDD tests pass; 549 total suite tests pass (0 regressions). --- dev-api.py | 126 +++++++++++++++++++++++++++ tests/test_dev_api_survey.py | 164 +++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 tests/test_dev_api_survey.py diff --git a/dev-api.py b/dev-api.py index e857555..fd2a869 100644 --- a/dev-api.py +++ b/dev-api.py @@ -18,6 +18,7 @@ from fastapi import FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional +import requests # Allow importing peregrine scripts for cover letter generation PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") @@ -366,6 +367,131 @@ def get_job_contacts(job_id: int): return [dict(r) for r in rows] +# ── Survey endpoints ───────────────────────────────────────────────────────── + +# Module-level imports so tests can patch dev_api.LLMRouter etc. +from scripts.llm_router import LLMRouter +from scripts.db import insert_survey_response, get_survey_responses + +_SURVEY_SYSTEM = ( + "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." +) + + +def _build_text_prompt(text: str, mode: str) -> str: + if mode == "quick": + return ( + "Answer each survey question below. For each, give ONLY the letter of the best " + "option and a single-sentence reason. Format exactly as:\n" + "1. B — reason here\n2. A — reason here\n\n" + f"Survey:\n{text}" + ) + return ( + "Analyze each survey question below. For each question:\n" + "- Briefly evaluate each option (1 sentence each)\n" + "- State your recommendation with reasoning\n\n" + f"Survey:\n{text}" + ) + + +def _build_image_prompt(mode: str) -> str: + if mode == "quick": + return ( + "This is a screenshot of a culture-fit survey. Read all questions and answer each " + "with the letter of the best option for a collaborative, growth-oriented candidate. " + "Format: '1. B — brief reason' on separate lines." + ) + return ( + "This is a screenshot of a culture-fit survey. For each question, evaluate each option " + "and recommend the best choice for a collaborative, growth-oriented candidate. " + "Include a brief breakdown per option and a clear recommendation." + ) + + +@app.get("/api/vision/health") +def vision_health(): + try: + r = requests.get("http://localhost:8002/health", timeout=2) + return {"available": r.status_code == 200} + except Exception: + return {"available": False} + + +class SurveyAnalyzeBody(BaseModel): + text: Optional[str] = None + image_b64: Optional[str] = None + mode: str # "quick" or "detailed" + + +@app.post("/api/jobs/{job_id}/survey/analyze") +def survey_analyze(job_id: int, body: SurveyAnalyzeBody): + try: + router = LLMRouter() + if body.image_b64: + prompt = _build_image_prompt(body.mode) + output = router.complete( + prompt, + images=[body.image_b64], + fallback_order=router.config.get("vision_fallback_order"), + ) + source = "screenshot" + else: + prompt = _build_text_prompt(body.text or "", body.mode) + output = router.complete( + prompt, + system=_SURVEY_SYSTEM, + fallback_order=router.config.get("research_fallback_order"), + ) + source = "text_paste" + return {"output": output, "source": source} + except Exception as e: + raise HTTPException(500, str(e)) + + +class SurveySaveBody(BaseModel): + survey_name: Optional[str] = None + mode: str + source: str + raw_input: Optional[str] = None + image_b64: Optional[str] = None + llm_output: str + reported_score: Optional[str] = None + + +@app.post("/api/jobs/{job_id}/survey/responses") +def save_survey_response(job_id: int, body: SurveySaveBody): + received_at = datetime.now().isoformat() + image_path = None + if body.image_b64: + import base64 + screenshots_dir = Path(DB_PATH).parent / "survey_screenshots" / str(job_id) + screenshots_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + img_path = screenshots_dir / f"{timestamp}.png" + img_path.write_bytes(base64.b64decode(body.image_b64)) + image_path = str(img_path) + row_id = insert_survey_response( + db_path=Path(DB_PATH), + job_id=job_id, + survey_name=body.survey_name, + received_at=received_at, + source=body.source, + raw_input=body.raw_input, + image_path=image_path, + mode=body.mode, + llm_output=body.llm_output, + reported_score=body.reported_score, + ) + return {"id": row_id} + + +@app.get("/api/jobs/{job_id}/survey/responses") +def get_survey_history(job_id: int): + return get_survey_responses(db_path=Path(DB_PATH), job_id=job_id) + + # ── GET /api/jobs/:id/cover_letter/pdf ─────────────────────────────────────── @app.get("/api/jobs/{job_id}/cover_letter/pdf") diff --git a/tests/test_dev_api_survey.py b/tests/test_dev_api_survey.py new file mode 100644 index 0000000..4a03336 --- /dev/null +++ b/tests/test_dev_api_survey.py @@ -0,0 +1,164 @@ +"""Tests for survey endpoints: vision health, analyze, save response, get history.""" +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + import sys + sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") + from dev_api import app + return TestClient(app) + + +# ── GET /api/vision/health ─────────────────────────────────────────────────── + +def test_vision_health_available(client): + """Returns available=true when vision service responds 200.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("dev_api.requests.get", return_value=mock_resp): + resp = client.get("/api/vision/health") + assert resp.status_code == 200 + assert resp.json() == {"available": True} + + +def test_vision_health_unavailable(client): + """Returns available=false when vision service times out or errors.""" + with patch("dev_api.requests.get", side_effect=Exception("timeout")): + resp = client.get("/api/vision/health") + assert resp.status_code == 200 + assert resp.json() == {"available": False} + + +# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── + +def test_analyze_text_quick(client): + """Text mode quick analysis returns output and source=text_paste.""" + mock_router = MagicMock() + mock_router.complete.return_value = "1. B — best option" + mock_router.config.get.return_value = ["claude_code", "vllm"] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "text": "Q1: Do you prefer teamwork?\nA. Solo B. Together", + "mode": "quick", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "text_paste" + assert "B" in data["output"] + # System prompt must be passed for text path + call_kwargs = mock_router.complete.call_args[1] + assert "system" in call_kwargs + assert "culture-fit survey" in call_kwargs["system"] + + +def test_analyze_text_detailed(client): + """Text mode detailed analysis passes correct prompt.""" + mock_router = MagicMock() + mock_router.complete.return_value = "Option A: good for... Option B: better because..." + mock_router.config.get.return_value = [] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "text": "Q1: Describe your work style.", + "mode": "detailed", + }) + assert resp.status_code == 200 + assert resp.json()["source"] == "text_paste" + + +def test_analyze_image(client): + """Image mode routes through vision path with NO system prompt.""" + mock_router = MagicMock() + mock_router.complete.return_value = "1. C — collaborative choice" + mock_router.config.get.return_value = ["vision_service", "claude_code"] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "image_b64": "aGVsbG8=", + "mode": "quick", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "screenshot" + # No system prompt on vision path + call_kwargs = mock_router.complete.call_args[1] + assert "system" not in call_kwargs + + +def test_analyze_llm_failure(client): + """Returns 500 when LLM raises an exception.""" + mock_router = MagicMock() + mock_router.complete.side_effect = Exception("LLM unavailable") + mock_router.config.get.return_value = [] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "text": "Q1: test", + "mode": "quick", + }) + assert resp.status_code == 500 + + +# ── POST /api/jobs/{id}/survey/responses ──────────────────────────────────── + +def test_save_response_text(client): + """Save text response writes to DB and returns id.""" + mock_db = MagicMock() + with patch("dev_api._get_db", return_value=mock_db): + with patch("dev_api.insert_survey_response", return_value=42) as mock_insert: + resp = client.post("/api/jobs/1/survey/responses", json={ + "mode": "quick", + "source": "text_paste", + "raw_input": "Q1: test question", + "llm_output": "1. B — good reason", + }) + assert resp.status_code == 200 + assert resp.json()["id"] == 42 + # received_at generated by backend — not None + call_args = mock_insert.call_args + assert call_args[1]["received_at"] is not None or call_args[0][3] is not None + + +def test_save_response_with_image(client, tmp_path, monkeypatch): + """Save image response writes PNG file and stores path in DB.""" + monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db")) + with patch("dev_api.insert_survey_response", return_value=7) as mock_insert: + with patch("dev_api.Path") as mock_path_cls: + mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o + resp = client.post("/api/jobs/1/survey/responses", json={ + "mode": "quick", + "source": "screenshot", + "image_b64": "aGVsbG8=", # valid base64 + "llm_output": "1. B — reason", + }) + assert resp.status_code == 200 + assert resp.json()["id"] == 7 + + +# ── GET /api/jobs/{id}/survey/responses ───────────────────────────────────── + +def test_get_history_empty(client): + """Returns empty list when no history exists.""" + with patch("dev_api.get_survey_responses", return_value=[]): + resp = client.get("/api/jobs/1/survey/responses") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_get_history_populated(client): + """Returns history rows newest first.""" + rows = [ + {"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste", + "raw_input": None, "image_path": None, "llm_output": "Option A is best", + "reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"}, + {"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste", + "raw_input": "Q1: test", "image_path": None, "llm_output": "1. B", + "reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"}, + ] + with patch("dev_api.get_survey_responses", return_value=rows): + resp = client.get("/api/jobs/1/survey/responses") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + assert data[0]["id"] == 2 + assert data[0]["survey_name"] == "Round 2"