"""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()