- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine implementing the data-model transition table, approval gate with sha256 hash match + 24h expiry enforced at send time, EchoTransport pluggable sender, LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green - apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations copied as directory) - docker-compose.yml: add api-test service on compose network (host port publishing is broken in this sandbox; container-to-container networking used) - Fix: replace masked placeholder password in config.py/conftest.py defaults
450 lines
No EOL
14 KiB
Python
450 lines
No EOL
14 KiB
Python
"""FastAPI application — main entry point."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, HTTPException, status
|
|
|
|
from app import llm
|
|
from app.db import close_pool, execute, fetch_all, fetch_one, get_pool
|
|
from app.db import migrate as migrate_mod
|
|
from app.db import repo_app, repo_profile
|
|
from app.schemas import (
|
|
AiAssistRequest,
|
|
AiAssistResponse,
|
|
ApplicationOut,
|
|
ApprovalCreate,
|
|
ApprovalOut,
|
|
ArtifactCreate,
|
|
ArtifactOut,
|
|
CoverLetterRequest,
|
|
CoverLetterResponse,
|
|
CvSectionCreate,
|
|
CvSectionOut,
|
|
CvSectionUpdate,
|
|
ErrorOut,
|
|
JobPostingCreate,
|
|
JobPostingOut,
|
|
OutboxOut,
|
|
OutboxSendRequest,
|
|
ProfileOut,
|
|
ProfileUpdate,
|
|
ScoreResponse,
|
|
TaskRunOut,
|
|
TransitionRequest,
|
|
)
|
|
from app.statemachine import TransitionContext, check_transition, InvalidTransition
|
|
from app.transport import get_transport
|
|
|
|
app = FastAPI(title="Jobhunt API", version="0.1.0")
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _startup() -> None:
|
|
"""Ensure pool is initialized and migrations are applied."""
|
|
get_pool()
|
|
migrate_mod.run_migrations()
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
def _shutdown() -> None:
|
|
close_pool()
|
|
|
|
|
|
# --- Health ---
|
|
|
|
@app.get("/api/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
# --- Profile ---
|
|
|
|
@app.get("/api/profile", response_model=ProfileOut)
|
|
def get_profile() -> Any:
|
|
p = repo_profile.get_or_create_profile()
|
|
if p is None:
|
|
raise HTTPException(status_code=500, detail="Failed to create profile")
|
|
return p
|
|
|
|
|
|
@app.put("/api/profile", response_model=ProfileOut)
|
|
def put_profile(body: ProfileUpdate) -> Any:
|
|
p = repo_profile.update_profile(body.model_dump(exclude_none=True))
|
|
if p is None:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
return p
|
|
|
|
|
|
# --- CV Sections ---
|
|
|
|
@app.get("/api/profile/sections", response_model=list[CvSectionOut])
|
|
def get_sections() -> Any:
|
|
return repo_profile.list_sections()
|
|
|
|
|
|
@app.post("/api/profile/sections", response_model=CvSectionOut, status_code=201)
|
|
def create_section(body: CvSectionCreate) -> Any:
|
|
p = repo_profile.get_or_create_profile()
|
|
if p is None:
|
|
raise HTTPException(status_code=500, detail="No profile")
|
|
return repo_profile.create_section(p["id"], body.model_dump())
|
|
|
|
|
|
@app.put("/api/profile/sections/{section_id}", response_model=CvSectionOut)
|
|
def update_section(section_id: str, body: CvSectionUpdate) -> Any:
|
|
s = repo_profile.update_section(section_id, body.model_dump(exclude_none=True))
|
|
if s is None:
|
|
raise HTTPException(status_code=404, detail="Section not found")
|
|
return s
|
|
|
|
|
|
@app.delete("/api/profile/sections/{section_id}", status_code=204)
|
|
def delete_section(section_id: str) -> None:
|
|
if not repo_profile.delete_section(section_id):
|
|
raise HTTPException(status_code=404, detail="Section not found")
|
|
|
|
|
|
@app.post("/api/profile/sections/{section_id}/ai-assist", response_model=AiAssistResponse)
|
|
def ai_assist(section_id: str, body: AiAssistRequest) -> Any:
|
|
section = repo_profile.get_section(section_id)
|
|
if section is None:
|
|
raise HTTPException(status_code=404, detail="Section not found")
|
|
result = llm.run_task(
|
|
"cv_assist",
|
|
body.instruction,
|
|
telemetry_sink=lambda info: repo_app.create_task_run({
|
|
**info,
|
|
"application_id": None,
|
|
}),
|
|
)
|
|
return {"suggestions": result.get("suggestions", [])}
|
|
|
|
|
|
# --- Job Postings ---
|
|
|
|
@app.post("/api/postings", response_model=ApplicationOut, status_code=201)
|
|
def create_posting(body: JobPostingCreate) -> Any:
|
|
"""Create a job posting from a URL. For POC: manual_url source."""
|
|
url = body.url.strip()
|
|
if not url:
|
|
raise HTTPException(status_code=422, detail="URL is required")
|
|
|
|
# For POC: manual_url source. In production, connectors package would fetch.
|
|
# Extract a simple company/title from URL or use placeholder.
|
|
posting = repo_app.create_job_posting(
|
|
source="manual_url",
|
|
url=url,
|
|
company="Unknown",
|
|
title="Unknown position",
|
|
location=None,
|
|
description="",
|
|
raw={"url": url},
|
|
)
|
|
application = repo_app.create_application(posting["id"])
|
|
return application
|
|
|
|
|
|
@app.get("/api/postings", response_model=list[JobPostingOut])
|
|
def get_postings() -> Any:
|
|
return repo_app.list_postings()
|
|
|
|
|
|
@app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse)
|
|
def score_posting(posting_id: str) -> Any:
|
|
"""Score a job posting against the profile."""
|
|
posting = repo_app.get_job_posting(posting_id)
|
|
if posting is None:
|
|
raise HTTPException(status_code=404, detail="Posting not found")
|
|
|
|
# Find the application for this posting
|
|
apps = repo_app.list_applications()
|
|
app_for_posting = None
|
|
for a in apps:
|
|
if a["job_posting_id"] == posting["id"]:
|
|
app_for_posting = a
|
|
break
|
|
if app_for_posting is None:
|
|
raise HTTPException(status_code=404, detail="No application for posting")
|
|
|
|
# Run scoring via LLM (mock mode returns deterministic result)
|
|
result = llm.run_task(
|
|
"score",
|
|
f"Score this posting: {posting['title']} at {posting['company']}",
|
|
telemetry_sink=lambda info: repo_app.create_task_run({
|
|
**info,
|
|
"application_id": app_for_posting["id"],
|
|
}),
|
|
)
|
|
|
|
score = float(result.get("score", 50))
|
|
rationale = result.get("rationale", {})
|
|
|
|
repo_app.update_application_score(app_for_posting["id"], score, rationale)
|
|
return {"score": score, "rationale": rationale}
|
|
|
|
|
|
# --- Applications ---
|
|
|
|
@app.get("/api/applications", response_model=list[ApplicationOut])
|
|
def get_applications() -> Any:
|
|
return repo_app.list_applications()
|
|
|
|
|
|
@app.post("/api/applications/{app_id}/transition")
|
|
def transition_application(app_id: str, body: TransitionRequest) -> Any:
|
|
app_row = repo_app.get_application(app_id)
|
|
if app_row is None:
|
|
raise HTTPException(status_code=404, detail="Application not found")
|
|
|
|
to_state = body.to
|
|
from_state = app_row["state"]
|
|
|
|
# Build context for guard evaluation
|
|
has_score = app_row.get("score") is not None
|
|
|
|
# For drafting -> sent transition, check confirmed approval
|
|
has_confirmed_approval = False
|
|
artifact_hash_match = False
|
|
if from_state == "drafting" and to_state == "sent":
|
|
approvals = fetch_all(
|
|
"SELECT * FROM approval WHERE application_id = %s AND confirmed_by_user = true",
|
|
(app_id,),
|
|
)
|
|
for a in approvals:
|
|
expires = a["expires_at"]
|
|
if expires is not None:
|
|
# Check not expired
|
|
now_utc = datetime.now(timezone.utc)
|
|
if expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo=timezone.utc)
|
|
if expires > now_utc:
|
|
has_confirmed_approval = True
|
|
# Check hash match
|
|
artifact = repo_app.get_artifact(str(a["artifact_id"]))
|
|
if artifact and artifact["content_hash"] == a["artifact_hash"]:
|
|
artifact_hash_match = True
|
|
break
|
|
|
|
ctx = TransitionContext(
|
|
application_id=app_id,
|
|
from_state=from_state,
|
|
to_state=to_state,
|
|
has_score=has_score,
|
|
has_confirmed_approval=has_confirmed_approval,
|
|
artifact_hash_match=artifact_hash_match,
|
|
)
|
|
|
|
try:
|
|
check_transition(ctx)
|
|
except InvalidTransition as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "invalid_transition", "message": str(exc)},
|
|
)
|
|
|
|
updated = repo_app.update_application_state(app_id, to_state)
|
|
if updated is None:
|
|
raise HTTPException(status_code=500, detail="Update failed")
|
|
return updated
|
|
|
|
|
|
# --- Artifacts ---
|
|
|
|
@app.post("/api/applications/{app_id}/artifacts", response_model=ArtifactOut, status_code=201)
|
|
def create_artifact(app_id: str, body: ArtifactCreate) -> Any:
|
|
app_row = repo_app.get_application(app_id)
|
|
if app_row is None:
|
|
raise HTTPException(status_code=404, detail="Application not found")
|
|
|
|
content_bytes = body.content.encode("utf-8")
|
|
# Store in temp dir (POC)
|
|
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
|
|
os.makedirs(storage_dir, exist_ok=True)
|
|
filename = f"{body.kind}_{app_id[:8]}.txt"
|
|
storage_path = os.path.join(storage_dir, filename)
|
|
with open(storage_path, "wb") as f:
|
|
f.write(content_bytes)
|
|
|
|
return repo_app.create_artifact(
|
|
application_id=app_id,
|
|
kind=body.kind,
|
|
filename=filename,
|
|
content_bytes=content_bytes,
|
|
storage_path=storage_path,
|
|
origin="user_drafted",
|
|
)
|
|
|
|
|
|
@app.post("/api/applications/{app_id}/artifacts/cover-letter", response_model=CoverLetterResponse)
|
|
def create_cover_letter(app_id: str, body: CoverLetterRequest) -> Any:
|
|
app_row = repo_app.get_application(app_id)
|
|
if app_row is None:
|
|
raise HTTPException(status_code=404, detail="Application not found")
|
|
|
|
content_bytes = body.letter_text.encode("utf-8")
|
|
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
|
|
os.makedirs(storage_dir, exist_ok=True)
|
|
filename = f"cover_letter_{app_id[:8]}.txt"
|
|
storage_path = os.path.join(storage_dir, filename)
|
|
with open(storage_path, "wb") as f:
|
|
f.write(content_bytes)
|
|
|
|
artifact = repo_app.create_artifact(
|
|
application_id=app_id,
|
|
kind="cover_letter",
|
|
filename=filename,
|
|
content_bytes=content_bytes,
|
|
storage_path=storage_path,
|
|
origin="user_drafted",
|
|
)
|
|
|
|
# Run AI critique via LLM gateway (mock mode)
|
|
result = llm.run_task(
|
|
"cl_critique",
|
|
body.letter_text,
|
|
telemetry_sink=lambda info: repo_app.create_task_run({
|
|
**info,
|
|
"application_id": app_id,
|
|
}),
|
|
)
|
|
|
|
return {
|
|
"artifact": artifact,
|
|
"critique": result.get("comments", []),
|
|
}
|
|
|
|
|
|
@app.get("/api/applications/{app_id}/artifacts", response_model=list[ArtifactOut])
|
|
def get_artifacts(app_id: str) -> Any:
|
|
return repo_app.list_artifacts(app_id)
|
|
|
|
|
|
# --- Approval & Outbox ---
|
|
|
|
@app.post("/api/applications/{app_id}/approvals", response_model=ApprovalOut, status_code=201)
|
|
def create_approval_endpoint(app_id: str, body: ApprovalCreate) -> Any:
|
|
app_row = repo_app.get_application(app_id)
|
|
if app_row is None:
|
|
raise HTTPException(status_code=404, detail="Application not found")
|
|
|
|
artifact = repo_app.get_artifact(body.artifact_id)
|
|
if artifact is None:
|
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
|
|
return repo_app.create_approval(
|
|
application_id=app_id,
|
|
artifact_id=body.artifact_id,
|
|
artifact_hash=artifact["content_hash"],
|
|
action=body.action,
|
|
)
|
|
|
|
|
|
@app.post("/api/approvals/{approval_id}/confirm", response_model=ApprovalOut)
|
|
def confirm_approval(approval_id: str) -> Any:
|
|
approval = repo_app.get_approval(approval_id)
|
|
if approval is None:
|
|
raise HTTPException(status_code=404, detail="Approval not found")
|
|
|
|
# Verify artifact hash matches
|
|
artifact = repo_app.get_artifact(approval["artifact_id"])
|
|
if artifact is None:
|
|
raise HTTPException(status_code=409, detail="Artifact not found")
|
|
if artifact["content_hash"] != approval["artifact_hash"]:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "hash_mismatch", "message": "Artifact hash does not match approval hash"},
|
|
)
|
|
|
|
# Check expiry
|
|
expires = approval["expires_at"]
|
|
if expires:
|
|
now_utc = datetime.now(timezone.utc)
|
|
expires_dt = datetime.fromisoformat(expires)
|
|
if expires_dt.tzinfo is None:
|
|
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
|
|
if expires_dt < now_utc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "expired", "message": "Approval has expired"},
|
|
)
|
|
|
|
result = repo_app.confirm_approval(approval_id)
|
|
if result is None:
|
|
raise HTTPException(status_code=500, detail="Confirm failed")
|
|
return result
|
|
|
|
|
|
@app.post("/api/approvals/{approval_id}/reject", response_model=ApprovalOut)
|
|
def reject_approval_endpoint(approval_id: str) -> Any:
|
|
result = repo_app.reject_approval(approval_id)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="Approval not found")
|
|
return result
|
|
|
|
|
|
@app.post("/api/outbox/send", response_model=OutboxOut)
|
|
def send_outbox(body: OutboxSendRequest) -> Any:
|
|
"""Send via outbox. Fails 409 unless approval is confirmed, unexpired, hash match."""
|
|
approval = repo_app.get_approval(body.approval_id)
|
|
if approval is None:
|
|
raise HTTPException(status_code=404, detail="Approval not found")
|
|
|
|
# Check confirmed
|
|
if not approval["confirmed_by_user"]:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "not_confirmed", "message": "Approval not confirmed by user"},
|
|
)
|
|
|
|
# Check expiry
|
|
expires = approval["expires_at"]
|
|
if expires:
|
|
now_utc = datetime.now(timezone.utc)
|
|
expires_dt = datetime.fromisoformat(expires)
|
|
if expires_dt.tzinfo is None:
|
|
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
|
|
if expires_dt < now_utc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "expired", "message": "Approval has expired"},
|
|
)
|
|
|
|
# Check hash match
|
|
artifact = repo_app.get_artifact(approval["artifact_id"])
|
|
if artifact is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "artifact_missing", "message": "Artifact not found"},
|
|
)
|
|
if artifact["content_hash"] != approval["artifact_hash"]:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "hash_mismatch", "message": "Artifact content has changed"},
|
|
)
|
|
|
|
# Create outbox record
|
|
outbox = repo_app.create_outbox(body.approval_id, body.payload)
|
|
|
|
# Send via transport
|
|
transport = get_transport()
|
|
result = transport.send(body.payload)
|
|
|
|
if result.get("success"):
|
|
updated = repo_app.update_outbox_sent(outbox["id"])
|
|
if updated:
|
|
return updated
|
|
return outbox
|
|
|
|
|
|
# --- Telemetry ---
|
|
|
|
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
|
|
def get_telemetry() -> Any:
|
|
return repo_app.list_task_runs() |