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,206 @@
|
||||
"""Transactional resume patch and entry optimization orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable
|
||||
|
||||
from .fsm import FSMError
|
||||
from .llm_services import log_ai_event
|
||||
from .models import ActionResponse, OptimizeEntryRequest, OptimizeRequest, ResumePatchRequest
|
||||
from .resume_document import (
|
||||
DocumentError,
|
||||
apply_delete_bullet,
|
||||
apply_delete_entry,
|
||||
apply_update_basics,
|
||||
apply_update_bullet,
|
||||
apply_update_entry,
|
||||
apply_update_skill_groups,
|
||||
apply_update_profile_summary,
|
||||
confirm_profile_summary_proposal,
|
||||
reject_profile_summary_proposal,
|
||||
set_profile_summary_proposal,
|
||||
confirm_proposal,
|
||||
find_entry,
|
||||
reject_proposal,
|
||||
set_pending_proposal,
|
||||
undo_entry,
|
||||
)
|
||||
|
||||
DocumentOperation = Callable[[dict[str, Any], str], dict[str, Any]]
|
||||
|
||||
|
||||
class ResumeEditingMixin:
|
||||
database: Any
|
||||
expander: Any
|
||||
profile_summary_generator: Any
|
||||
|
||||
def patch_resume(self, session_id: str, request: ResumePatchRequest) -> ActionResponse:
|
||||
with self.database.transaction(immediate=True) as connection:
|
||||
session = self._session_or_404(connection, session_id)
|
||||
resume = self._resume_or_409(connection, session_id)
|
||||
self._expect_revision(resume, request.expected_revision)
|
||||
try:
|
||||
content = self._apply_patch(resume["content"], request)
|
||||
except DocumentError as exc:
|
||||
raise _to_fsm(exc) from exc
|
||||
self.database.update_resume(connection, session_id, content)
|
||||
phone = (request.operation.fields or {}).get("phone") if request.operation.type == "update_basics" else None
|
||||
if phone:
|
||||
profile = dict(session["profile"])
|
||||
profile["phone"] = str(phone).strip()
|
||||
profile["phone_source"] = "resume_edit"
|
||||
session = self.database.update_session(
|
||||
connection,
|
||||
session_id,
|
||||
stage=session["stage"],
|
||||
profile=profile,
|
||||
)
|
||||
return self._action_response(session, None)
|
||||
|
||||
def generate_profile_summary(self, session_id: str) -> ActionResponse:
|
||||
with self.database.transaction(immediate=True) as connection:
|
||||
session = self._session_or_404(connection, session_id)
|
||||
resume = self._resume_or_409(connection, session_id)
|
||||
try:
|
||||
summary_text = self.profile_summary_generator.generate(resume["content"])
|
||||
content = set_profile_summary_proposal(resume["content"], summary_text)
|
||||
except DocumentError as exc:
|
||||
raise _to_fsm(exc) from exc
|
||||
except Exception as exc:
|
||||
log_ai_event(
|
||||
"profile_summary_regeneration_failed",
|
||||
reason_code=getattr(exc, "reason_code", type(exc).__name__),
|
||||
exception=type(exc).__name__,
|
||||
)
|
||||
raise FSMError(
|
||||
"profile_summary_generation_failed",
|
||||
"\u4e2a\u4eba\u4ecb\u7ecd\u751f\u6210\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5",
|
||||
status_code=503,
|
||||
) from exc
|
||||
self.database.update_resume(connection, session_id, content)
|
||||
|
||||
return self._action_response(session, None)
|
||||
|
||||
def confirm_profile_summary(self, session_id: str) -> ActionResponse:
|
||||
return self._profile_summary_op(session_id, confirm_profile_summary_proposal)
|
||||
|
||||
def reject_profile_summary(self, session_id: str) -> ActionResponse:
|
||||
return self._profile_summary_op(session_id, reject_profile_summary_proposal)
|
||||
|
||||
def _profile_summary_op(
|
||||
self, session_id: str, operation: Callable[[dict[str, Any]], dict[str, Any]]
|
||||
) -> ActionResponse:
|
||||
with self.database.transaction(immediate=True) as connection:
|
||||
session = self._session_or_404(connection, session_id)
|
||||
resume = self._resume_or_409(connection, session_id)
|
||||
try:
|
||||
content = operation(resume["content"])
|
||||
except DocumentError as exc:
|
||||
raise _to_fsm(exc) from exc
|
||||
self.database.update_resume(connection, session_id, content)
|
||||
|
||||
return self._action_response(session, None)
|
||||
|
||||
def optimize_entry(self, session_id: str, request: OptimizeRequest) -> ActionResponse:
|
||||
with self.database.transaction(immediate=True) as connection:
|
||||
session = self._session_or_404(connection, session_id)
|
||||
resume = self._resume_or_409(connection, session_id)
|
||||
found = find_entry(resume["content"], request.entry_id)
|
||||
if found is None:
|
||||
raise FSMError("entry_not_found", "Entry not found in resume", status_code=404)
|
||||
profile = session["profile"]
|
||||
section, entry = found
|
||||
context = {
|
||||
"job_type": profile.get("job_type"),
|
||||
"target_position": profile.get("target_position"),
|
||||
"instruction": request.instruction,
|
||||
"entry_type": section.get("kind"),
|
||||
}
|
||||
proposal = self.expander.expand(deepcopy(entry), context=context)
|
||||
try:
|
||||
content = set_pending_proposal(
|
||||
resume["content"],
|
||||
request.entry_id,
|
||||
proposal.get("optimized_description") or "",
|
||||
source=proposal.get("source", "ai_expanded"),
|
||||
changes=proposal.get("changes") or [],
|
||||
)
|
||||
except DocumentError as exc:
|
||||
raise _to_fsm(exc) from exc
|
||||
self.database.update_resume(connection, session_id, content)
|
||||
|
||||
return self._action_response(session, None)
|
||||
|
||||
def confirm_optimize(
|
||||
self, session_id: str, request: OptimizeEntryRequest
|
||||
) -> ActionResponse:
|
||||
return self._proposal_op(session_id, request.entry_id, confirm_proposal)
|
||||
|
||||
def reject_optimize(
|
||||
self, session_id: str, request: OptimizeEntryRequest
|
||||
) -> ActionResponse:
|
||||
return self._proposal_op(session_id, request.entry_id, reject_proposal)
|
||||
|
||||
def undo_optimize(
|
||||
self, session_id: str, request: OptimizeEntryRequest
|
||||
) -> ActionResponse:
|
||||
return self._proposal_op(session_id, request.entry_id, undo_entry)
|
||||
|
||||
def _proposal_op(
|
||||
self, session_id: str, entry_id: str, operation: DocumentOperation
|
||||
) -> ActionResponse:
|
||||
with self.database.transaction(immediate=True) as connection:
|
||||
session = self._session_or_404(connection, session_id)
|
||||
resume = self._resume_or_409(connection, session_id)
|
||||
try:
|
||||
content = operation(resume["content"], entry_id)
|
||||
except DocumentError as exc:
|
||||
raise _to_fsm(exc) from exc
|
||||
self.database.update_resume(connection, session_id, content)
|
||||
|
||||
return self._action_response(session, None)
|
||||
|
||||
@staticmethod
|
||||
def _apply_patch(content: dict[str, Any], request: ResumePatchRequest) -> dict[str, Any]:
|
||||
op = request.operation
|
||||
if op.type == "update_basics":
|
||||
return apply_update_basics(content, op.fields or {})
|
||||
if op.type == "update_entry":
|
||||
return apply_update_entry(content, op.entry_id or "", op.fields or {})
|
||||
if op.type == "update_skill_groups":
|
||||
return apply_update_skill_groups(content, op.skills or [])
|
||||
if op.type == "update_profile_summary":
|
||||
return apply_update_profile_summary(content, (op.fields or {}).get("content", ""))
|
||||
if op.type == "update_bullet":
|
||||
return apply_update_bullet(content, op.entry_id or "", op.bullet_id or "", op.text or "")
|
||||
if op.type == "delete_entry":
|
||||
return apply_delete_entry(content, op.entry_id or "")
|
||||
return apply_delete_bullet(content, op.entry_id or "", op.bullet_id or "")
|
||||
|
||||
def _session_or_404(self, connection: Any, session_id: str) -> dict[str, Any]:
|
||||
session = self.database.fetch_session(connection, session_id)
|
||||
if session is None:
|
||||
raise FSMError("session_not_found", "Session not found", status_code=404)
|
||||
return session
|
||||
|
||||
def _resume_or_409(self, connection: Any, session_id: str) -> dict[str, Any]:
|
||||
resume = self.database.fetch_resume(connection, session_id)
|
||||
if resume is None:
|
||||
raise FSMError("resume_not_created", "Create the resume before editing it")
|
||||
return resume
|
||||
|
||||
@staticmethod
|
||||
def _expect_revision(resume: dict[str, Any], expected: int) -> None:
|
||||
if resume["revision"] != expected:
|
||||
raise FSMError("revision_conflict", "Resume was modified; refresh before editing")
|
||||
|
||||
|
||||
def _to_fsm(exc: DocumentError) -> FSMError:
|
||||
status = 404 if exc.code in {"entry_not_found", "bullet_not_found"} else 422
|
||||
if exc.code == "proposal_stale":
|
||||
status = 409
|
||||
return FSMError(exc.code, exc.message, status_code=status)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user