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

274 lines
12 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 fastapi.concurrency import run_in_threadpool
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 MAX_IMPORT_BYTES, ResumeImportService
from .validators import mask_phone
from . import builder_conversation
_UPLOAD_READ_CHUNK_BYTES = 64 * 1024
async def _read_upload_limited(file: UploadFile) -> bytes:
content = bytearray()
while True:
chunk = await file.read(_UPLOAD_READ_CHUNK_BYTES)
if not chunk:
return bytes(content)
if len(content) + len(chunk) > MAX_IMPORT_BYTES:
await file.close()
raise FSMError(
"import_file_too_large",
"Resume import file exceeds the 10 MB limit",
status_code=413,
)
content.extend(chunk)
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 _read_upload_limited(file)
try:
prepared = await run_in_threadpool(
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)