Files
resume-agent/backend/app/resume_import_routes.py
T
hypandClaude ae2d9b128d feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 11:08:30 +08:00

215 lines
9.8 KiB
Python

"""HTTP routes for reviewable resume imports."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from uuid import uuid4
from fastapi import FastAPI, File, 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
) -> 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(...)) -> ResumeImportView:
_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) -> ResumeImportView:
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
) -> 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))
@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) -> ResumeImportView:
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)