"""FastAPI application -- main entry point.""" from __future__ import annotations import base64 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, BatchScoreRequest, BatchScoreResponse, BatchScoreResult, CoverLetterRequest, CoverLetterResponse, CvImportConfirmRequest, CvImportConfirmResponse, CvImportRequest, CvImportResponse, CvSectionCreate, CvSectionOut, CvSectionUpdate, DigestItem, EmailSuggestionOut, ErrorOut, InterviewPrepResponse, JobPostingCreate, JobPostingOut, NudgeItem, NotificationLogOut, OutboxOut, OutboxSendRequest, PostingsFetchRequest, PostingsFetchResponse, ProfileOut, ProfileUpdate, ScoreResponse, SeedDemoResponse, TaskRunOut, TodayResponse, TransitionRequest, ) from app.statemachine import TransitionContext, check_transition, InvalidTransition from app.transport import get_transport, reset_transport # Defensive import for connectors (may not exist yet) try: from packages.connectors.arbetsformedlingen import ArbetsformedlingenConnector # type: ignore from packages.connectors.base import SearchQuery # type: ignore CONNECTORS_AVAILABLE = True except ImportError: CONNECTORS_AVAILABLE = False app = FastAPI(title="Jobhunt API", version="0.2.0") @app.on_event("startup") def _startup() -> None: """Ensure pool is initialized and migrations are applied.""" get_pool() migrate_mod.run_migrations() # Start scheduler if enabled from app import scheduler scheduler.start_scheduler() @app.on_event("shutdown") def _shutdown() -> None: from app import scheduler scheduler.stop_scheduler() 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() # --- v1: CV Import --- def _extract_text_from_pdf(raw_bytes: bytes) -> str: """Extract text from PDF bytes using pypdf.""" try: from pypdf import PdfReader except ImportError: raise HTTPException( status_code=500, detail={"code": "missing_dependency", "message": "pypdf not installed"}, ) import io reader = PdfReader(io.BytesIO(raw_bytes)) parts: list[str] = [] for page in reader.pages: text = page.extract_text() if text: parts.append(text) return "\n".join(parts) def _extract_text_from_docx(raw_bytes: bytes) -> str: """Extract text from DOCX bytes using python-docx.""" try: import docx except ImportError: raise HTTPException( status_code=500, detail={"code": "missing_dependency", "message": "python-docx not installed"}, ) import io doc = docx.Document(io.BytesIO(raw_bytes)) parts: list[str] = [] for para in doc.paragraphs: if para.text.strip(): parts.append(para.text) return "\n".join(parts) @app.post("/api/cv/import", response_model=CvImportResponse) def cv_import(body: CvImportRequest) -> Any: """Extract text from uploaded file and generate draft CV sections via LLM. Does NOT write to cv_section -- returns drafts for user review. """ try: raw_bytes = base64.b64decode(body.content_base64) except Exception: raise HTTPException( status_code=422, detail={"code": "invalid_base64", "message": "content_base64 is not valid base64"}, ) if not raw_bytes: raise HTTPException( status_code=422, detail={"code": "empty_file", "message": "File is empty or contains no data"}, ) filename = body.filename.lower() text = "" if filename.endswith(".pdf"): text = _extract_text_from_pdf(raw_bytes) elif filename.endswith(".docx"): text = _extract_text_from_docx(raw_bytes) elif filename.endswith(".txt") or filename.endswith(".md"): text = raw_bytes.decode("utf-8", errors="replace") else: raise HTTPException( status_code=422, detail={ "code": "unsupported_format", "message": f"Unsupported file format: {body.filename}. Supported: .pdf, .docx, .txt, .md", }, ) text = text.strip() if not text: raise HTTPException( status_code=422, detail={"code": "empty_file", "message": "File contains no extractable text"}, ) # Run LLM extraction (cheap class) result = llm.run_task( "cv_extract", text, telemetry_sink=lambda info: repo_app.create_task_run({ **info, "application_id": None, }), ) drafts = result.get("drafts", []) return {"drafts": drafts} @app.post("/api/cv/import/confirm", response_model=CvImportConfirmResponse, status_code=201) def cv_import_confirm(body: CvImportConfirmRequest) -> Any: """Create cv_section rows from drafts returned by /cv/import.""" profile = repo_profile.get_or_create_profile() if profile is None: raise HTTPException(status_code=500, detail="Failed to create profile") created_sections: list[dict[str, Any]] = [] for draft in body.drafts: section = repo_profile.create_section(profile["id"], { "kind": draft.kind, "title": draft.title, "org": draft.org, "location": draft.location, "start_date": draft.start_date, "end_date": draft.end_date, "bullets": draft.bullets, "tags": draft.tags, "sort_order": 0, }) created_sections.append(section) return {"created": len(created_sections), "sections": created_sections} # --- v1: Postings Fetch --- def _get_connectors_enabled() -> bool: return os.environ.get("CONNECTORS_ENABLED", "true").lower() in ( "true", "1", "yes", ) def _get_arbetsformedlingen_connector(): """Get the AF connector instance, or None if not available.""" if not CONNECTORS_AVAILABLE: return None return ArbetsformedlingenConnector() def _fetch_and_create_postings(query: str, region: str | None = None) -> dict[str, Any]: """Internal: fetch postings via connector and create applications for new ones.""" connector = _get_arbetsformedlingen_connector() if connector is None: return {"new": 0, "dupes": 0} # Build search query -- use a dict if SearchQuery is not available if CONNECTORS_AVAILABLE and "SearchQuery" in globals(): sq = SearchQuery(query=query, region=region) if region else SearchQuery(query=query) else: # Fallback: pass a simple dict-like object sq = {"query": query, "region": region} raw_postings = connector.fetch(sq) new_count = 0 dupe_count = 0 for rp in raw_postings: # Check if (source, url) already exists existing = fetch_one( "SELECT id FROM job_posting WHERE source = %s AND url = %s", (rp.get("source", "arbetsformedlingen"), rp.get("url", "")), ) if existing: dupe_count += 1 continue posting = repo_app.create_job_posting( source=rp.get("source", "arbetsformedlingen"), url=rp.get("url", ""), company=rp.get("company", "Unknown"), title=rp.get("title", "Unknown"), location=rp.get("location"), description=rp.get("description", ""), external_id=rp.get("external_id"), raw=rp.get("raw", {}), ) repo_app.create_application(posting["id"]) new_count += 1 return {"new": new_count, "dupes": dupe_count} @app.post("/api/postings/fetch", response_model=PostingsFetchResponse) def postings_fetch(body: PostingsFetchRequest) -> Any: """Fetch job postings via the Arbetsformedlingen connector. Behind env flag CONNECTORS_ENABLED (default true). """ if not _get_connectors_enabled(): raise HTTPException( status_code=503, detail={ "code": "connectors_disabled", "message": "Connectors are not enabled. Set CONNECTORS_ENABLED=true to enable.", }, ) if not CONNECTORS_AVAILABLE: raise HTTPException( status_code=503, detail={ "code": "connectors_unavailable", "message": "Connectors package is not installed.", }, ) return _fetch_and_create_postings(body.query, body.region) # --- v1: Batch Scoring --- def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]: """Internal: score multiple applications, return results with red_flags.""" results: list[dict[str, Any]] = [] for app_id in application_ids: app_row = repo_app.get_application(app_id) if app_row is None: continue posting = repo_app.get_job_posting(app_row["job_posting_id"]) if posting is None: continue result = llm.run_task( "score", f"Score this posting: {posting['title']} at {posting['company']}", telemetry_sink=lambda info, aid=app_id: repo_app.create_task_run({ **info, "application_id": aid, }), ) score = float(result.get("score", 50)) rationale = result.get("rationale", {}) red_flags = result.get("red_flags", []) repo_app.update_application_score(app_id, score, rationale) results.append({ "application_id": app_id, "score": score, "rationale": rationale, "red_flags": red_flags, }) return results @app.post("/api/scoring/batch", response_model=BatchScoreResponse) def batch_score(body: BatchScoreRequest) -> Any: """Score multiple applications in one call (cheap class).""" results = _batch_score_internal(body.application_ids) return {"results": results} # --- v1: Today --- @app.get("/api/today", response_model=TodayResponse) def get_today() -> Any: """Return daily digest: ranked postings, follow-up nudges, pending approvals.""" # Digest: scored applications ordered by score desc digest_apps = repo_app.get_digest(limit=20) digest = [ DigestItem( application_id=a["id"], title=a.get("title", ""), company=a.get("company", ""), score=a.get("score"), ) for a in digest_apps ] # Nudges: sent applications past follow_up_after_days and not snoozed nudge_apps = repo_app.get_nudge_applications() nudges = [ NudgeItem( application_id=a["id"], days_since_sent=a.get("days_since_sent", 0) or 0, suggestion=( "Consider sending a follow-up email asking about the status " "of your application." ), ) for a in nudge_apps ] # Pending approvals count pending = repo_app.count_pending_approvals() return { "digest": digest, "nudges": nudges, "pending_approvals": pending, } # --- v1: Interview Prep --- @app.post("/api/applications/{app_id}/interview-prep", response_model=InterviewPrepResponse) def interview_prep(app_id: str) -> Any: """Generate interview prep Q&A (strong class), stored as artifact.""" app_row = repo_app.get_application(app_id) if app_row is None: raise HTTPException(status_code=404, detail="Application not found") posting = repo_app.get_job_posting(app_row["job_posting_id"]) if posting is None: raise HTTPException(status_code=404, detail="Posting not found") prompt = ( f"Generate interview prep for: {posting['title']} at {posting['company']}. " f"Description: {posting.get('description', '')}" ) result = llm.run_task( "interview_prep", prompt, telemetry_sink=lambda info: repo_app.create_task_run({ **info, "application_id": app_id, }), ) content = result.get("content", "") content_bytes = content.encode("utf-8") # Store as artifact storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts") os.makedirs(storage_dir, exist_ok=True) filename = f"interview_prep_{app_id[:8]}.md" 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="other", filename=filename, content_bytes=content_bytes, storage_path=storage_path, origin="ai_drafted", ) # Link to application repo_app.set_interview_prep_artifact(app_id, artifact["id"]) return { "artifact_id": artifact["id"], "content": content, } # --- v1: Concierge / Demo Seed --- @app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse) def seed_demo() -> Any: """Idempotent demo seed: profile + 6 postings + varied application states.""" # Check if demo profile already exists existing = fetch_one( "SELECT * FROM profile WHERE full_name = 'Demo Demosson'" ) if existing: # Already seeded -- return current counts profile = repo_profile._normalize_profile(existing) postings = repo_app.list_postings() apps = repo_app.list_applications() sections = repo_profile.list_sections() return { "profile": profile["full_name"], "postings": len(postings), "applications": len(apps), "sections": len(sections), } # Create demo profile with Swedish characters repo_profile.get_or_create_profile() profile = repo_profile.update_profile({ "full_name": "Demo Demosson", "email": "demo@example.com", "location": "Malmo", "headline": "Software Developer", "summary": "Experienced developer looking for opportunities in Skane.", "languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}], }) if profile is None: raise HTTPException(status_code=500, detail="Failed to create demo profile") # Create demo CV sections demo_sections = [ {"kind": "experience", "title": "Backend Developer", "org": "TechSkane AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]}, {"kind": "experience", "title": "Junior Developer", "org": "Lund Software", "bullets": ["Maintained web apps"], "tags": ["javascript"]}, {"kind": "education", "title": "MSc Computer Science", "org": "Lunds Universitet", "bullets": ["Distributed systems specialization"], "tags": ["algorithms"]}, {"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]}, ] for s in demo_sections: repo_profile.create_section(profile["id"], s) # Create 6 demo postings with varied states demo_postings = [ {"company": "Skane Tech AB", "title": "Senior Python Developer", "location": "Malmo", "url": "https://example.com/af/1", "state": "scored", "score": 85}, {"company": "Lund Systems", "title": "Fullstack Engineer", "location": "Lund", "url": "https://example.com/af/2", "state": "discovered", "score": None}, {"company": "Copenhagen Digital", "title": "Backend Developer", "location": "Copenhagen", "url": "https://example.com/af/3", "state": "approved", "score": 70}, {"company": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65}, {"company": "Malmo Startup", "title": "Software Engineer", "location": "Malmo", "url": "https://example.com/af/5", "state": "rejected", "score": 30}, {"company": "Angelholm Tech", "title": "Data Engineer", "location": "Angelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None}, ] for dp in demo_postings: posting = repo_app.create_job_posting( source="arbetsformedlingen", url=dp["url"], company=dp["company"], title=dp["title"], location=dp["location"], description=f"Job description for {dp['title']} at {dp['company']}", raw={}, ) app_row = repo_app.create_application(posting["id"]) # Set state and score if dp["score"] is not None: repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}}) if dp["state"] != "discovered" and dp["state"] != "scored": # Transition through states if dp["state"] in ("approved", "rejected"): # First set to scored if needed if dp["score"] is not None: repo_app.update_application_state(app_row["id"], "scored") repo_app.update_application_state(app_row["id"], dp["state"]) elif dp["state"] == "sent": # approved -> drafting -> sent if dp["score"] is not None: repo_app.update_application_state(app_row["id"], "scored") repo_app.update_application_state(app_row["id"], "approved") repo_app.update_application_state(app_row["id"], "drafting") # We need confirmed approval for drafting->sent, so directly set state execute( "UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s", (app_row["id"],), ) # Backdate the 'sent' application ( posting 4) to 8 days ago for nudge demo from datetime import timedelta backdated = datetime.now(timezone.utc) - timedelta(days=8) execute( "UPDATE application SET last_activity_at = %s WHERE state = 'sent'", (backdated,), ) # Count results postings_count = len(repo_app.list_postings()) apps_count = len(repo_app.list_applications()) sections_count = len(repo_profile.list_sections()) return { "profile": "Demo Demosson", "postings": postings_count, "applications": apps_count, "sections": sections_count, } # --- v1.1: Email Suggestions --- @app.get("/api/suggestions", response_model=list[EmailSuggestionOut]) def get_suggestions() -> Any: """Return all pending email suggestions, newest first.""" from app.imap_watch import list_pending_suggestions return list_pending_suggestions() @app.post("/api/suggestions/{suggestion_id}/accept") def accept_suggestion(suggestion_id: str) -> Any: """Accept a suggestion: apply state_proposal via guarded transition. If the suggestion has an application_id and a state_proposal, apply the state transition through the normal guard path. Updates last_activity_at. Marks the suggestion as 'accepted'. """ from app.imap_watch import get_suggestion, update_suggestion_status suggestion = get_suggestion(suggestion_id) if suggestion is None: raise HTTPException(status_code=404, detail="Suggestion not found") if suggestion["status"] != "pending": raise HTTPException( status_code=409, detail={"code": "not_pending", "message": "Suggestion is not pending"}, ) app_id = suggestion.get("application_id") state_proposal = suggestion.get("state_proposal") if app_id and state_proposal: # Apply guarded transition app_row = repo_app.get_application(app_id) if app_row is None: raise HTTPException(status_code=404, detail="Linked application not found") from_state = app_row["state"] to_state = state_proposal from app.statemachine import TransitionContext, check_transition, InvalidTransition has_score = app_row.get("score") is not None has_confirmed_approval = False artifact_hash_match = False 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="State update failed") # Mark suggestion as accepted result = update_suggestion_status(suggestion_id, "accepted") if result is None: raise HTTPException(status_code=500, detail="Failed to update suggestion") return result @app.post("/api/suggestions/{suggestion_id}/dismiss") def dismiss_suggestion(suggestion_id: str) -> Any: """Dismiss a suggestion (mark as dismissed).""" from app.imap_watch import get_suggestion, update_suggestion_status suggestion = get_suggestion(suggestion_id) if suggestion is None: raise HTTPException(status_code=404, detail="Suggestion not found") if suggestion["status"] != "pending": raise HTTPException( status_code=409, detail={"code": "not_pending", "message": "Suggestion is not pending"}, ) result = update_suggestion_status(suggestion_id, "dismissed") if result is None: raise HTTPException(status_code=500, detail="Failed to update suggestion") return result # --- v1.1: Notification Log --- @app.get("/api/notifications/log", response_model=list[NotificationLogOut]) def get_notification_log() -> Any: """Return last 50 notification log entries.""" from app.notify import list_notification_log return list_notification_log(limit=50)