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 .offerpai_auth import OfferPaiAuthClient, OfferPaiIdentityProvider from .offerpai_resume import OfferPaiResumeClient, OfferPaiResumeProvider 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, Stage, 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 _bearer_token(authorization: str | None) -> str | None: if authorization is None: return None scheme, separator, value = authorization.partition(" ") token = value.strip() if separator != " " or scheme.lower() != "bearer" or not token: raise FSMError( "external_auth_header_invalid", "登录凭证格式无效,请重新从 OfferPai 进入。", status_code=401, ) return token 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, offerpai_identity_provider: OfferPaiIdentityProvider | None = None, offerpai_resume_provider: OfferPaiResumeProvider | 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 ) offerpai_identity_provider = offerpai_identity_provider or OfferPaiAuthClient( resolved_settings.offerpai_auth_base_url, timeout_seconds=resolved_settings.offerpai_auth_timeout_seconds, ) offerpai_resume_provider = offerpai_resume_provider or OfferPaiResumeClient( resolved_settings.offerpai_resume_api_base_url, timeout_seconds=resolved_settings.offerpai_resume_timeout_seconds, ) 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, offerpai_identity_provider=offerpai_identity_provider, offerpai_resume_provider=offerpai_resume_provider, ) 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 application.state.offerpai_identity_provider = offerpai_identity_provider application.state.offerpai_resume_provider = offerpai_resume_provider 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, ) def authorize_session_request( session_id: str, authorization: str | None ) -> str | None: external_token = _bearer_token(authorization) requires_external_auth = resolved_settings.offerpai_auth_required if not requires_external_auth and external_token is None: existing_session = agent.database.get_session(session_id) external_account = ( existing_session["profile"].get("external_account") if existing_session is not None else None ) requires_external_auth = isinstance(external_account, dict) if requires_external_auth and external_token is None: raise FSMError( "external_auth_required", "缺少 OfferPai 登录凭证,请从 OfferPai 重新进入。", status_code=401, ) if external_token is not None: agent.authorize_session(session_id, external_token) return external_token @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, authorization: str | None = Header(default=None), ) -> TimelineResponse: external_token = _bearer_token(authorization) if resolved_settings.offerpai_auth_required and external_token is None: raise FSMError( "external_auth_required", "缺少 OfferPai 登录凭证,请从 OfferPai 重新进入。", status_code=401, ) response = agent.create_session( request or CreateSessionRequest(), external_token=external_token, ) if ( external_token is not None and response.stage == Stage.MINIMUM_READY and response.resume_id is None ): agent.mutate_with_offerpai_sync( response.session_id, external_token, lambda: agent.create_resume( response.session_id, CreateResumeRequest() ), ) response = agent.timeline(response.session_id) if external_token is not None: agent.reconcile_resume_with_offerpai( response.session_id, external_token ) response = agent.timeline(response.session_id) return response @application.get( f"{API_PREFIX}/sessions/{{session_id}}/timeline", response_model=TimelineResponse, tags=["resume-agent"], ) def get_timeline( session_id: str, authorization: str | None = Header(default=None), ) -> TimelineResponse: external_token = authorize_session_request(session_id, authorization) if external_token is not None: agent.reconcile_resume_with_offerpai(session_id, external_token) 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, authorization: str | None = Header(default=None), ) -> ActionResponse: external_token = authorize_session_request(session_id, authorization) def mutate() -> ActionResponse: response = agent.component_event(session_id, request) if ( external_token is not None and response.stage == Stage.MINIMUM_READY and response.resume_id is None ): response = agent.create_resume(session_id, CreateResumeRequest()) return response if external_token is None: return mutate() return agent.mutate_with_offerpai_sync( session_id, external_token, mutate, ) @application.post( f"{API_PREFIX}/sessions/{{session_id}}/messages", response_model=ActionResponse, tags=["resume-agent"], ) def post_message( session_id: str, request: MessageRequest, authorization: str | None = Header(default=None), ) -> ActionResponse: external_token = authorize_session_request(session_id, authorization) if external_token is None: return agent.add_message(session_id, request) return agent.mutate_with_offerpai_sync( session_id, external_token, lambda: 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, authorization: str | None = Header(default=None), ): external_token = authorize_session_request(session_id, authorization) def add_and_sync() -> ActionResponse: if external_token is None: return agent.add_message(session_id, request) return agent.mutate_with_offerpai_sync( session_id, external_token, lambda: agent.add_message(session_id, request), ) return stream_builder_message(add_and_sync) @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, authorization: str | None = Header(default=None), ) -> CreateResumeResponse: external_token = authorize_session_request(session_id, authorization) create_request = request or CreateResumeRequest() if external_token is None: return agent.create_resume(session_id, create_request) return agent.mutate_with_offerpai_sync( session_id, external_token, lambda: agent.create_resume(session_id, create_request), ) register_resume_routes( application, agent, API_PREFIX, authorize_session_request=authorize_session_request, ) register_resume_import_routes( application, agent, application.state.resume_import_service, API_PREFIX, authorize_session_request=authorize_session_request, ) @application.delete( f"{API_PREFIX}/sessions/{{session_id}}", status_code=status.HTTP_204_NO_CONTENT, tags=["resume-agent"], ) def delete_session( session_id: str, authorization: str | None = Header(default=None), ) -> Response: external_token = authorize_session_request(session_id, authorization) agent.delete_session(session_id, external_token=external_token) 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()