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:
parent
a8ff406955
commit
0f21733e41
2 changed files with 290 additions and 0 deletions
126
dev-api.py
126
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")
|
||||
|
|
|
|||
164
tests/test_dev_api_survey.py
Normal file
164
tests/test_dev_api_survey.py
Normal 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"
|
||||
Loading…
Reference in a new issue