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