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