feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history

Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.

Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
  JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
  tables the scaffolder re-emitted; verified against the real dev DB.

Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).

DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.

AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.

Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.

Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
+30 -1
View File
@@ -1,4 +1,5 @@
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from cachetools import TTLCache
@@ -7,6 +8,7 @@ from pypdf import PdfReader
from docx import Document
import fitz
import hashlib
import hmac
import io
import json
import os
@@ -18,6 +20,33 @@ from urllib.error import URLError, HTTPError
app = FastAPI(title="Local AI Service")
# Shared secret for backend -> ai-service calls. This service has no user auth and can
# generate against a paid provider (gemini/groq), so an unauthenticated caller on the
# shared docker network could drain the API key. The port is no longer published to the
# host (compose uses `expose`), and this header is the second layer.
#
# Unset => open, so local dev and the test suite work keyless. Production cannot reach
# that state: docker-compose declares AI_SERVICE_TOKEN with `:?` so the stack refuses to
# start without it.
AI_SERVICE_TOKEN = os.getenv("AI_SERVICE_TOKEN", "").strip()
AI_SERVICE_TOKEN_HEADER = "X-Ai-Service-Token"
# /health stays open: the backend probe and the compose healthcheck both call it, and it
# exposes no user data and no generation path.
AI_SERVICE_OPEN_PATHS = {"/health"}
@app.middleware("http")
async def require_service_token(request: Request, call_next):
if AI_SERVICE_TOKEN and request.url.path not in AI_SERVICE_OPEN_PATHS:
supplied = request.headers.get(AI_SERVICE_TOKEN_HEADER, "")
# compare_digest to avoid leaking the token through response timing.
if not hmac.compare_digest(supplied, AI_SERVICE_TOKEN):
return JSONResponse(
{"detail": "Invalid or missing service token."},
status_code=401,
)
return await call_next(request)
MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
MAX_INPUT_CHARS = 20000
MAX_CONTEXT_CHARS = 2200
+60 -1
View File
@@ -11,12 +11,17 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None):
def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, service_token=None):
if skip_model_load:
monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1")
else:
monkeypatch.delenv("AI_SERVICE_SKIP_MODEL_LOAD", raising=False)
monkeypatch.delenv("AI_SERVICE_EAGER_MODEL_LOAD", raising=False)
# Default to keyless so the existing suite is unaffected by a token in the dev shell.
if service_token is None:
monkeypatch.delenv("AI_SERVICE_TOKEN", raising=False)
else:
monkeypatch.setenv("AI_SERVICE_TOKEN", service_token)
if ollama_model is None:
monkeypatch.delenv("OLLAMA_MODEL", raising=False)
else:
@@ -245,3 +250,57 @@ def test_health_reports_active_provider(monkeypatch):
assert payload["ai_provider"] == "gemini"
assert payload["ai_provider_configured"] is True
def test_service_token_rejects_calls_without_the_header(monkeypatch):
module = load_app_module(monkeypatch, service_token="s3cret")
client = TestClient(module.app)
response = client.post("/summarize", json={"text": "Platform engineering role."})
assert response.status_code == 401
assert "token" in response.json()["detail"].lower()
def test_service_token_rejects_a_wrong_header(monkeypatch):
module = load_app_module(monkeypatch, service_token="s3cret")
client = TestClient(module.app)
response = client.post(
"/summarize",
json={"text": "Platform engineering role."},
headers={"X-Ai-Service-Token": "wrong"},
)
assert response.status_code == 401
def test_service_token_allows_the_correct_header(monkeypatch):
module = load_app_module(monkeypatch, service_token="s3cret")
client = TestClient(module.app)
response = client.post(
"/summarize",
json={"text": "Platform engineering role."},
headers={"X-Ai-Service-Token": "s3cret"},
)
# 503 = passed the token gate and reached the handler, which is model-disabled here.
assert response.status_code == 503
def test_health_stays_open_so_probes_and_healthchecks_work(monkeypatch):
module = load_app_module(monkeypatch, service_token="s3cret")
client = TestClient(module.app)
assert client.get("/health").status_code == 200
def test_endpoints_stay_open_when_no_token_is_configured(monkeypatch):
module = load_app_module(monkeypatch)
client = TestClient(module.app)
# Keyless local dev: reaches the handler (503 model-disabled), not a 401.
response = client.post("/summarize", json={"text": "Platform engineering role."})
assert response.status_code == 503