generated from kgod/ai-review-template
feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user