generated from kgod/ai-review-template
376 lines
14 KiB
Python
376 lines
14 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 build_services
|
|
from .models import (
|
|
ActionResponse,
|
|
ComponentEventRequest,
|
|
CreateResumeRequest,
|
|
CreateResumeResponse,
|
|
CreateSessionRequest,
|
|
ErrorDetail,
|
|
MessageRequest,
|
|
Stage,
|
|
TimelineResponse,
|
|
)
|
|
from .resume_routes import register_resume_routes
|
|
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,
|
|
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
|
|
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,
|
|
)
|
|
@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()
|