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

207 lines
8.8 KiB
Python

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