from __future__ import annotations import logging import os from pathlib import Path from typing import Any from uuid import uuid4 from fastapi import FastAPI, Header, HTTPException, Response, UploadFile, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from .agent import ResumeAgent from .experience_optimizer import ExperienceOptimizer, build_experience_optimizer from .target_position_suggester import TargetPositionSuggester, build_target_position_suggester from .resume_expansion import build_expander from .profile_summary import ProfileSummaryGenerator, build_profile_summary_generator from .database import Database from .postgres_database import PostgresDatabase from .fsm import FSMError from .builder_sse import stream_builder_message from .llm_services import OpenAICompatibleStructuredClient, build_services from .models import ( ActionResponse, ComponentEventRequest, CreateResumeRequest, CreateResumeResponse, CreateSessionRequest, ErrorDetail, MessageRequest, TimelineResponse, ) from .resume_routes import register_resume_routes from .resume_import_routes import register_resume_import_routes from .resume_import_service import ResumeImportService, RuleBasedResumeImportParser from .import_parser import OpenAIResumeImportParser from .rate_limit import SlidingWindowRateLimiter from .services import ( EntryExpander, ExperienceExtractor, ResumeRewriter, RuleBasedEntryExpander, ) from .settings import Settings, load_settings from .skill_suggester import SkillSuggester, build_skill_suggester API_PREFIX = "/ai-api/resume-agent" def create_app( *, database_path: str | Path | None = None, extractor: ExperienceExtractor | None = None, rewriter: ResumeRewriter | None = None, expander: EntryExpander | None = None, skill_suggester: SkillSuggester | None = None, experience_optimizer: ExperienceOptimizer | None = None, target_position_suggester: TargetPositionSuggester | None = None, cors_origins: list[str] | None = None, settings: Settings | None = None, openai_client: Any | None = None, resume_import_service: ResumeImportService | None = None, profile_summary_generator: ProfileSummaryGenerator | None = None, ) -> FastAPI: resolved_settings = settings or load_settings() if database_path is not None: database = Database(database_path) else: if not resolved_settings.database_url: raise ValueError("DATABASE_URL is required; pass database_path explicitly for SQLite tests") database = PostgresDatabase( resolved_settings.database_url, schema=os.getenv("RESUME_AGENT_DATABASE_SCHEMA", "resume_agent"), ) database.initialize() if extractor is None or rewriter is None: default_extractor, default_rewriter = build_services( resolved_settings, openai_client ) extractor = extractor or default_extractor rewriter = rewriter or default_rewriter configured_default_tier = os.environ.get("RESUME_AGENT_DEFAULT_TIER", "free").strip().lower() if configured_default_tier != "free": logging.getLogger(__name__).warning( "TIER BACKDOOR ACTIVE: all sessions default to %s", configured_default_tier, ) if expander is None: expander = build_expander(resolved_settings, openai_client) skill_suggester = skill_suggester or build_skill_suggester(resolved_settings, openai_client) experience_optimizer = experience_optimizer or build_experience_optimizer( resolved_settings, openai_client ) target_position_suggester = target_position_suggester or build_target_position_suggester( resolved_settings, openai_client ) profile_summary_generator = profile_summary_generator or build_profile_summary_generator( resolved_settings, openai_client ) agent = ResumeAgent( database=database, extractor=extractor, rewriter=rewriter, expander=expander, skill_suggester=skill_suggester, experience_optimizer=experience_optimizer, target_position_suggester=target_position_suggester, profile_summary_generator=profile_summary_generator, ) application = FastAPI( title="Resume Agent MVP", version="0.1.0", description="SQLite-backed resume workflow implemented as an explicit finite-state machine.", ) origins = cors_origins or _cors_origins_from_environment() application.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials="*" not in origins, allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["*"], ) application.state.database = database application.state.resume_agent = agent if resume_import_service is None: import_fallback = RuleBasedResumeImportParser() import_parser = import_fallback if resolved_settings.use_openai: import_parser = OpenAIResumeImportParser( completion=OpenAICompatibleStructuredClient(resolved_settings, openai_client), fallback=import_fallback, ) resume_import_service = ResumeImportService( storage_root=Path(__file__).resolve().parent.parent / "data" / "resume_imports", parser=import_parser, ) application.state.resume_import_service = resume_import_service application.state.light_opt_limiter = SlidingWindowRateLimiter( limit=resolved_settings.light_opt_rate_limit, window_seconds=resolved_settings.light_opt_rate_window_seconds, ) @application.exception_handler(FSMError) async def handle_fsm_error(_request: Any, exc: FSMError) -> JSONResponse: trace_id = f"trace_{uuid4().hex}" detail = ErrorDetail( code=exc.code, message=exc.message, missing_fields=exc.missing_fields, trace_id=trace_id, ) return JSONResponse( status_code=exc.status_code, content={"error": detail.model_dump(mode="json"), "trace_id": trace_id}, ) @application.get("/health", tags=["system"]) def health() -> dict[str, str]: return {"status": "ok"} @application.post( f"{API_PREFIX}/sessions", response_model=TimelineResponse, status_code=status.HTTP_201_CREATED, tags=["resume-agent"], ) def create_session(request: CreateSessionRequest | None = None) -> TimelineResponse: return agent.create_session(request or CreateSessionRequest()) @application.get( f"{API_PREFIX}/sessions/{{session_id}}/timeline", response_model=TimelineResponse, tags=["resume-agent"], ) def get_timeline(session_id: str) -> TimelineResponse: return agent.timeline(session_id) @application.post( f"{API_PREFIX}/sessions/{{session_id}}/component-events", response_model=ActionResponse, tags=["resume-agent"], ) def post_component_event( session_id: str, request: ComponentEventRequest ) -> ActionResponse: return agent.component_event(session_id, request) @application.post( f"{API_PREFIX}/sessions/{{session_id}}/messages", response_model=ActionResponse, tags=["resume-agent"], ) def post_message(session_id: str, request: MessageRequest) -> ActionResponse: return agent.add_message(session_id, request) @application.post( f"{API_PREFIX}/sessions/{{session_id}}/messages/stream", tags=["resume-agent"], ) def post_message_stream(session_id: str, request: MessageRequest): return stream_builder_message(lambda: agent.add_message(session_id, request)) @application.post( f"{API_PREFIX}/sessions/{{session_id}}/create", response_model=CreateResumeResponse, tags=["resume-agent"], ) def create_resume( session_id: str, request: CreateResumeRequest | None = None ) -> CreateResumeResponse: return agent.create_resume(session_id, request or CreateResumeRequest()) register_resume_routes(application, agent, API_PREFIX) register_resume_import_routes( application, agent, application.state.resume_import_service, API_PREFIX ) @application.delete( f"{API_PREFIX}/sessions/{{session_id}}", status_code=status.HTTP_204_NO_CONTENT, tags=["resume-agent"], ) def delete_session(session_id: str) -> Response: agent.delete_session(session_id) return Response(status_code=status.HTTP_204_NO_CONTENT) return application def _cors_origins_from_environment() -> list[str]: configured = os.getenv("RESUME_AGENT_CORS_ORIGINS") if configured: return [origin.strip() for origin in configured.split(",") if origin.strip()] return ["http://localhost:5173", "http://127.0.0.1:5173"] app = create_app()