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).
This commit is contained in:
pyr0ball 2026-03-21 00:09:02 -07:00
parent b1a32ab207
commit 75163b8e48
2 changed files with 290 additions and 0 deletions

View file

@ -18,6 +18,7 @@ from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import requests
# Allow importing peregrine scripts for cover letter generation # Allow importing peregrine scripts for cover letter generation
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") 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] 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 ─────────────────────────────────────── # ── GET /api/jobs/:id/cover_letter/pdf ───────────────────────────────────────
@app.get("/api/jobs/{job_id}/cover_letter/pdf") @app.get("/api/jobs/{job_id}/cover_letter/pdf")

View file

@ -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"