Files
resume-agent/backend/app/main.py
T

401 lines
15 KiB
Python

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