From aa858bd2c1a6020c423743607dc8cd25b7c200e9 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 30 Jul 2026 21:40:14 +0000 Subject: [PATCH] Screenshot pipeline + fixes found under real render (CORS, batch state stomp, prompt-aware cv_tailor mock) - scripts/screenshots.py + scripts/Dockerfile.shots: playwright-in-compose runner, 7 demo shots against the seeded stack (DinD-safe, output volume) - docker-compose.yml: api + web services (nginx SPA + /api proxy-less via build-arg VITE_API_BASE), shots service with volume output - apps/api: add CORSMiddleware (SPA origin web:80 could not read api:8000), batch scoring now refuses to rewrite applications beyond discovered/scored (kanban page load previously reset sent/interviewing to scored), prompt-aware cv_tailor mock so demo passes hallucination guard, hallucination-guard tests repointed to monkeypatched run_task - apps/web: Applications kanban batches only unscored applications, red flag display falls back to stored rationale - 159 api tests + 14 web tests green after fixes --- apps/api/app/llm.py | 26 +++++++ apps/api/app/main.py | 31 +++++++- apps/api/tests/test_v11b_wb1.py | 108 ++++++++++++++-------------- apps/web/Dockerfile | 14 ++++ apps/web/nginx.conf | 15 ++++ apps/web/src/views/Applications.vue | 11 ++- docker-compose.yml | 38 +++++++++- scripts/Dockerfile.shots | 5 ++ scripts/screenshots.py | 84 ++++++++++++++++++++++ 9 files changed, 273 insertions(+), 59 deletions(-) create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/nginx.conf create mode 100644 scripts/Dockerfile.shots create mode 100644 scripts/screenshots.py diff --git a/apps/api/app/llm.py b/apps/api/app/llm.py index e72689d..dc68ad0 100644 --- a/apps/api/app/llm.py +++ b/apps/api/app/llm.py @@ -161,6 +161,30 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = { } +def _mock_cv_tailor(prompt: str) -> dict[str, Any]: + """Prompt-aware mock tailor: extracts source bullets from the prompt and + rephrases them deterministically, so the result always passes the + hallucination guard (which requires traceable source overlap).""" + import re + + bullets = re.findall(r'"([A-ZÅÄÖ][^"]{20,300})"', prompt) + bullets = [b for b in bullets if "{" not in b and ":" not in b][:4] + if not bullets: + bullets = ["Experienced backend developer focused on reliability"] + tailored = [] + change_log = [] + for b in bullets[:2]: + tailored.append(f"{b} (tailored for this posting)") + change_log.append({"action": "rephrased", "detail": f"Emphasized relevance of: {b[:60]}..."}) + for b in bullets[2:]: + tailored.append(b) + change_log.append({"action": "kept", "detail": f"Retained as-is: {b[:60]}..."}) + return { + "tailored_cv": {"summary": bullets[0][:160], "bullets": tailored}, + "change_log": change_log, + } + + def run_task( task: str, prompt: str, @@ -178,6 +202,8 @@ def run_task( # Mock mode time.sleep(0.01) # simulate latency result = MOCK_OUTPUTS.get(task, {"result": "mock"}) + if task == "cv_tailor": + result = _mock_cv_tailor(prompt) # Validate against schema if provided (basic check) # In real gateway this would be jsonschema validation diff --git a/apps/api/app/main.py b/apps/api/app/main.py index a74a8f2..ddad94b 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -87,6 +87,18 @@ except ImportError: app = FastAPI(title="Jobhunt API", version="0.2.0") +# Self-hosted single-user app; SPA origin differs from API origin (e.g. web:80 -> api:8000). +from fastapi.middleware.cors import CORSMiddleware + +_cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in _cors_origins if o.strip()], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + @app.on_event("startup") def _startup() -> None: @@ -858,7 +870,13 @@ def postings_fetch(body: PostingsFetchRequest) -> Any: # --- v1: Batch Scoring --- def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]: - """Internal: score multiple applications, return results with red_flags.""" + """Internal: score multiple applications, return results with red_flags. + + Safety rule (post-bug): batch scoring only touches applications in + 'discovered' or 'scored' state. Anything further down the pipeline + (approved/sent/interviewing/...) keeps its state and stored scores; + those rows are returned unchanged with skipped=True. + """ results: list[dict[str, Any]] = [] for app_id in application_ids: app_row = repo_app.get_application(app_id) @@ -868,6 +886,17 @@ def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]: if posting is None: continue + if app_row["state"] not in ("discovered", "scored"): + existing = app_row.get("score_rationale") or {} + results.append({ + "application_id": app_id, + "score": app_row.get("score"), + "rationale": existing, + "red_flags": existing.get("red_flags", []), + "skipped": True, + }) + continue + result = llm.run_task( "score", f"Score this posting: {posting['title']} at {posting['company']}", diff --git a/apps/api/tests/test_v11b_wb1.py b/apps/api/tests/test_v11b_wb1.py index 6a4c82d..a15f071 100644 --- a/apps/api/tests/test_v11b_wb1.py +++ b/apps/api/tests/test_v11b_wb1.py @@ -398,36 +398,35 @@ class TestTailorCvHappyPath: # ======================================================================== class TestTailorCvHallucinationGuard: - def test_hallucination_rejection_502(self, client): - """When mock returns bullet with no source mapping, return 502.""" - app_id = _create_app_with_profile_and_sections(client) - - # Patch the mock to return a fabricated bullet + def test_hallucination_rejection_502(self, client, monkeypatch): + """When the tailor output has bullets with no source mapping, return 502.""" import app.llm as llm_mod - original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy() - try: - llm_mod.MOCK_OUTPUTS["cv_tailor"] = { - "tailored_cv": { - "summary": "Developer", - "skills": ["Python"], - "experience": [ - { - "company": "FakeCorp", - "role": "Fake Role", - "bullets": [ - "Completely fabricated achievement that has no overlap with any source bullet xyzqwerty", - ], - }, - ], - }, - "change_log": [{"action": "invented", "detail": "Made up a bullet"}], - } - resp = client.post(f"/api/applications/{app_id}/tailor-cv") - assert resp.status_code == 502 - detail = resp.json()["detail"] - assert "hallucination_guard" in str(detail) - finally: - llm_mod.MOCK_OUTPUTS["cv_tailor"] = original + + app_id = _create_app_with_profile_and_sections(client) + fabricated = { + "tailored_cv": { + "summary": "Developer", + "skills": ["Python"], + "experience": [ + { + "company": "FakeCorp", + "role": "Fake Role", + "bullets": [ + "Completely fabricated achievement that has no overlap with any source bullet xyzqwerty", + ], + }, + ], + }, + "change_log": [{"action": "invented", "detail": "Made up a bullet"}], + } + monkeypatch.setattr( + llm_mod, "run_task", + lambda task, prompt, *a, **k: fabricated, + ) + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp.status_code == 502 + detail = resp.json()["detail"] + assert "hallucination_guard" in str(detail) def test_hallucination_rejection_with_empty_source_bullets(self, client): """When there are no source bullets, hallucination guard is not triggered (no source to map to).""" @@ -456,37 +455,36 @@ class TestTailorCvHallucinationGuard: # Should succeed since source_bullets is empty -> guard not triggered assert resp.status_code == 200 - def test_hallucination_rejection_preserves_existing_output(self, client): + def test_hallucination_rejection_preserves_existing_output(self, client, monkeypatch): """After a 502 hallucination rejection, a subsequent valid call works.""" - app_id = _create_app_with_profile_and_sections(client) - - # First: trigger hallucination import app.llm as llm_mod - original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy() - try: - llm_mod.MOCK_OUTPUTS["cv_tailor"] = { - "tailored_cv": { - "summary": "Dev", - "skills": ["Python"], - "experience": [ - { - "company": "X", - "role": "X", - "bullets": ["Fabricated xyzqwerty zzz new content"], - }, - ], - }, - "change_log": [], - } + + app_id = _create_app_with_profile_and_sections(client) + fabricated = { + "tailored_cv": { + "summary": "Dev", + "skills": ["Python"], + "experience": [ + { + "company": "X", + "role": "X", + "bullets": ["Fabricated xyzqwerty zzz new content"], + }, + ], + }, + "change_log": [], + } + with monkeypatch.context() as mp: + mp.setattr( + llm_mod, "run_task", + lambda task, prompt, *a, **k: fabricated, + ) resp1 = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp1.status_code == 502 - # Restore and retry - llm_mod.MOCK_OUTPUTS["cv_tailor"] = original - resp2 = client.post(f"/api/applications/{app_id}/tailor-cv") - assert resp2.status_code == 200 - finally: - llm_mod.MOCK_OUTPUTS["cv_tailor"] = original + # Default prompt-aware mock is guard-safe -> succeeds + resp2 = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp2.status_code == 200 # ======================================================================== diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..4ecc2b2 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,14 @@ +# Web production image: build the SPA, serve via nginx with SPA fallback +FROM node:22-alpine AS build +WORKDIR /w +ARG VITE_API_BASE=http://api:8000/api +ENV VITE_API_BASE=$VITE_API_BASE +COPY apps/web/package.json apps/web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY apps/web ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /w/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..119a54f --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8000/api/; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/apps/web/src/views/Applications.vue b/apps/web/src/views/Applications.vue index 6998b28..d73e26d 100644 --- a/apps/web/src/views/Applications.vue +++ b/apps/web/src/views/Applications.vue @@ -41,7 +41,10 @@ function hasRedFlags(app: Application): boolean { } function redFlagsFor(app: Application): string[] { - return redFlagsMap.value[app.id] ?? [] + const fromBatch = redFlagsMap.value[app.id] + if (fromBatch) return fromBatch + const stored = (app.score_rationale as { red_flags?: string[] } | null)?.red_flags + return stored ?? [] } function hasNudge(app: Application): boolean { @@ -53,7 +56,11 @@ async function loadApplications() { applications.value = await api.getApplications() // Load red flags via batch scoring and nudges via today endpoint const [batchResult, todayResult] = await Promise.allSettled([ - api.batchScore(applications.value.map((a) => a.id)), + api.batchScore( + applications.value + .filter((a) => a.state === 'discovered' || a.state === 'scored') + .map((a) => a.id) + ), api.getToday() ]) if (batchResult.status === 'fulfilled') { diff --git a/docker-compose.yml b/docker-compose.yml index 698a2b9..02240fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,42 @@ services: condition: service_healthy restart: "no" + api: + build: + context: . + dockerfile: apps/api/Dockerfile.test + image: jobhunt-platform-api-test + entrypoint: uvicorn app.main:app --host 0.0.0.0 --port 8000 + environment: + DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt + working_dir: /app/apps/api + depends_on: + postgres: + condition: service_healthy + restart: "no" + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + VITE_API_BASE: http://api:8000/api + depends_on: + - api + restart: "no" + + shots: + build: + context: . + dockerfile: scripts/Dockerfile.shots + volumes: + - shots_out:/out + depends_on: + - api + - web + restart: "no" + volumes: jobhunt_pgdata: - name: jobhunt_pgdata \ No newline at end of file + name: jobhunt_pgdata + shots_out: \ No newline at end of file diff --git a/scripts/Dockerfile.shots b/scripts/Dockerfile.shots new file mode 100644 index 0000000..c8dae38 --- /dev/null +++ b/scripts/Dockerfile.shots @@ -0,0 +1,5 @@ +FROM mcr.microsoft.com/playwright/python:v1.55.0-jammy +WORKDIR /shots +RUN pip install --no-cache-dir playwright==1.55.0 +COPY scripts/screenshots.py ./ +CMD ["python", "screenshots.py"] diff --git a/scripts/screenshots.py b/scripts/screenshots.py new file mode 100644 index 0000000..0031c7b --- /dev/null +++ b/scripts/screenshots.py @@ -0,0 +1,84 @@ +"""Screenshot runner: drives the seed demo UI and saves PNGs to /out. + +Runs inside the compose network; browser resolves 'web' and 'api' directly. +""" +import json +import sys +import time +import urllib.request + +from playwright.sync_api import sync_playwright + +BASE = "http://web" +API = "http://api:8000/api" +OUT = "/out" + + +def api(method, path, body=None): + req = urllib.request.Request( + API + path, + data=json.dumps(body).encode() if body else None, + method=method, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def main(): + # Idempotent: ensures full demo dataset exists + seed = api("POST", "/concierge/seed-demo") + print("seed:", json.dumps(seed)[:200]) + + apps = api("GET", "/applications") + interview = next((a for a in apps if a["state"] == "interviewing"), apps[0]) + detail_id = interview["id"] + print("detail id:", detail_id) + + shots = [ + ("welcome", "/", {}), # first-run wizard may redirect away; force below + ("welcome", "/welcome", {}), + ("today", "/today", {}), + ("cv", "/cv", {}), + ("research", "/research", {}), + ("applications", "/applications", {}), + ("detail", f"/applications/{detail_id}", {}), + ] + + with sync_playwright() as p: + browser = p.chromium.launch(args=["--disable-dev-shm-usage"]) + page = browser.new_page(viewport={"width": 1440, "height": 900}, + device_scale_factor=2) + for name, path, _opts in shots[1:]: # skip the "/" duplicate + try: + page.goto(BASE + path, wait_until="networkidle", timeout=30000) + page.wait_for_timeout(1200) + # close possible wizard redirect back + if name != "welcome" and page.url.endswith("/welcome"): + page.goto(BASE + path, wait_until="networkidle", timeout=30000) + page.wait_for_timeout(800) + page.screenshot(path=f"{OUT}/{name}.png") + print("shot:", name, "<-", page.url) + except Exception as e: + print("FAILED:", name, type(e).__name__, str(e)[:150]) + + # Interaction shot: open the Tailor CV panel on an approved application + approved = next((a for a in apps if a["state"] in ("approved", "interviewing")), apps[0]) + try: + page.goto(f"{BASE}/applications/{approved['id']}", wait_until="networkidle", timeout=30000) + page.wait_for_timeout(1000) + btn = page.locator('[data-testid="tailor-cv-btn"]') + btn.click(timeout=8000) + page.wait_for_selector('[data-testid="tailor-panel"]', timeout=20000) + page.wait_for_timeout(800) + page.screenshot(path=f"{OUT}/detail-tailor.png") + print("shot: detail-tailor") + except Exception as e: + print("FAILED: detail-tailor", type(e).__name__, str(e)[:150]) + + browser.close() + print("DONE") + + +if __name__ == "__main__": + sys.exit(main())