Files
resume-agent/backend/app/resume_import_routes.py

253 lines
11 KiB
Python

"""HTTP routes for reviewable resume imports."""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Callable
from uuid import uuid4
from fastapi import FastAPI, File, Header, UploadFile, status
from .fsm import FSMError
from .models import ActionResponse, Stage
from .resume_document import merge_ids
from .resume_import_models import ApplyResumeImportRequest, ResumeImportView
from .resume_import_service import ResumeImportService
from .validators import mask_phone
from . import builder_conversation
def register_resume_import_routes(
application: FastAPI,
agent: Any,
service: ResumeImportService,
prefix: str,
*,
authorize_session_request: Callable[[str, str | None], str | None] | None = None,
) -> None:
authorize_session_request = authorize_session_request or (
lambda _session_id, _authorization: None
)
@application.post(
f"{prefix}/sessions/{{session_id}}/resume-imports",
response_model=ResumeImportView,
status_code=status.HTTP_201_CREATED,
tags=["resume-agent"],
)
async def create_resume_import(
session_id: str,
file: UploadFile = File(...),
authorization: str | None = Header(default=None),
) -> ResumeImportView:
authorize_session_request(session_id, authorization)
_require_import_path(agent, session_id)
with agent.database.transaction() as connection:
if agent.database.fetch_resume(connection, session_id) is not None:
raise FSMError(
"resume_import_not_allowed",
"当前简历预览已有内容,重新开始后才能导入新的简历。",
)
content = await file.read()
try:
prepared = service.prepare(
file_name=file.filename or "upload",
declared_mime=file.content_type,
content=content,
)
except ValueError as exc:
raise FSMError(str(exc), "Resume import could not be processed", status_code=422) from exc
with agent.database.transaction(immediate=True) as connection:
session = agent.database.fetch_session(connection, session_id)
if session is None:
service.remove(prepared["object_key"])
raise FSMError("session_not_found", "Session not found", status_code=404)
_require_import_profile(session["profile"])
if agent.database.fetch_resume(connection, session_id) is not None:
service.remove(prepared["object_key"])
raise FSMError(
"resume_import_not_allowed",
"当前简历预览已有内容,重新开始后才能导入新的简历。",
)
existing = agent.database.find_resume_import_by_sha256(connection, session_id, prepared["sha256"])
if existing is not None:
service.remove(prepared["object_key"])
record = existing
else:
record = agent.database.create_resume_import(
connection,
import_id=f"import_{uuid4().hex}",
session_id=session_id,
**prepared,
)
return ResumeImportView.model_validate(record)
@application.get(
f"{prefix}/sessions/{{session_id}}/resume-imports/{{import_id}}",
response_model=ResumeImportView,
tags=["resume-agent"],
)
def get_resume_import(
session_id: str,
import_id: str,
authorization: str | None = Header(default=None),
) -> ResumeImportView:
authorize_session_request(session_id, authorization)
with agent.database.transaction() as connection:
record = agent.database.fetch_resume_import(connection, session_id, import_id)
if record is None:
raise FSMError("resume_import_not_found", "Resume import not found", status_code=404)
return ResumeImportView.model_validate(record)
@application.post(
f"{prefix}/sessions/{{session_id}}/resume-imports/{{import_id}}/apply",
response_model=ActionResponse,
tags=["resume-agent"],
)
def apply_resume_import(
session_id: str,
import_id: str,
request: ApplyResumeImportRequest,
authorization: str | None = Header(default=None),
) -> ActionResponse:
external_token = authorize_session_request(session_id, authorization)
def mutate() -> ActionResponse:
with agent.database.transaction(immediate=True) as connection:
session = agent.database.fetch_session(connection, session_id)
if session is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
_require_import_profile(session["profile"])
record = agent.database.fetch_resume_import(connection, session_id, import_id)
if record is None:
raise FSMError("resume_import_not_found", "Resume import not found", status_code=404)
if record["status"] != "awaiting_review":
raise FSMError("resume_import_not_applicable", "Resume import is not awaiting review")
if agent.database.fetch_resume(connection, session_id) is not None:
raise FSMError(
"resume_import_not_allowed",
"当前简历预览已有内容,重新开始后才能导入新的简历。",
)
if request.expected_revision != 0:
raise FSMError("revision_conflict", "Resume was modified; refresh before importing", status_code=409)
resume_id = f"resume_{uuid4().hex}"
imported_document = record["document"]
persisted_document = deepcopy(imported_document)
persisted_basics = persisted_document.get("basics")
if isinstance(persisted_basics, dict):
raw_phone = persisted_basics.pop("phone", None)
masked_phone = mask_phone(raw_phone)
if masked_phone:
persisted_basics["masked_phone"] = masked_phone
agent.database.insert_resume(
connection,
resume_id=resume_id,
session_id=session_id,
idempotency_key=None,
content=merge_ids(None, persisted_document),
)
profile, welcome_turn = builder_conversation.welcome_turn(
_profile_for_imported_resume(session["profile"], imported_document),
resume_id,
imported=True,
)
session = agent.database.update_session(
connection,
session_id,
stage=Stage.BUILDER_CONVERSATION,
profile=profile,
resume_id=resume_id,
)
agent.database.supersede_active_components(connection, session_id)
turn_id = agent.database.insert_turn(
connection,
session_id=session_id,
**welcome_turn,
)
agent.database.update_resume_import_status(connection, session_id, import_id, "applied")
return agent._action_response(session, agent.database.get_turn(turn_id))
if external_token is None:
return mutate()
return agent.mutate_with_offerpai_sync(
session_id,
external_token,
mutate,
)
@application.delete(
f"{prefix}/sessions/{{session_id}}/resume-imports/{{import_id}}",
response_model=ResumeImportView,
tags=["resume-agent"],
)
def cancel_resume_import(
session_id: str,
import_id: str,
authorization: str | None = Header(default=None),
) -> ResumeImportView:
authorize_session_request(session_id, authorization)
with agent.database.transaction(immediate=True) as connection:
record = agent.database.fetch_resume_import(connection, session_id, import_id)
if record is None:
raise FSMError("resume_import_not_found", "Resume import not found", status_code=404)
if record["status"] == "applied":
raise FSMError("resume_import_not_cancellable", "Applied resume imports cannot be cancelled")
updated = agent.database.update_resume_import_status(connection, session_id, import_id, "cancelled")
service.remove(record.get("object_key"))
return ResumeImportView.model_validate(updated)
def _profile_for_imported_resume(profile: dict[str, Any], document: dict[str, Any]) -> dict[str, Any]:
"""Retain the session contract so the final supplement card can continue safely."""
updated = dict(profile)
basics = document.get("basics") if isinstance(document.get("basics"), dict) else {}
target = document.get("target") if isinstance(document.get("target"), dict) else {}
for field in ("name", "phone", "email", "city", "portfolio_url"):
if basics.get(field):
updated[field] = basics[field]
if basics.get("phone"):
updated["phone_source"] = "imported_resume"
if target.get("position"):
updated["target_position"] = target["position"]
job_type = _normalize_job_type(target.get("job_type"))
if job_type:
updated["job_type"] = job_type
updated.setdefault("records", {})
updated.setdefault("tags", {"skills": [], "certificates": []})
updated.setdefault("experiences", [])
updated["builder"] = {
"active_section": None,
"identity_draft": {},
"pending_entry": None,
"imported": True,
}
updated["imported_resume"] = True
return updated
def _normalize_job_type(value: Any) -> str | None:
normalized = str(value or "").strip().lower()
return {
"campus": "campus", "校招": "campus", "校园招聘": "campus",
"social": "social", "社招": "social", "社会招聘": "social",
"internship": "internship", "实习": "internship", "实习招聘": "internship",
}.get(normalized)
def _require_session(agent: Any, session_id: str) -> None:
if agent.database.get_session(session_id) is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
def _require_import_path(agent: Any, session_id: str) -> None:
session = agent.database.get_session(session_id)
if session is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
_require_import_profile(session["profile"])
def _require_import_profile(profile: dict[str, Any]) -> None:
if not profile.get("privacy_accepted"):
raise FSMError("privacy_consent_required", "Privacy consent is required before importing", status_code=409)
if profile.get("resume_source") != "import":
raise FSMError("resume_import_not_selected", "Select resume import before uploading", status_code=409)