Files
resume-agent/backend/app/agent.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

643 lines
28 KiB
Python

from __future__ import annotations
from copy import deepcopy
import logging
from typing import Any
from uuid import uuid4
from .database import Database
from .enrichment import prepare_rewrite_confirmation, process_rewrite_confirmation
from .fsm import (
FSMError,
assistant_turn,
component,
gate_allowed,
initial_turn,
missing_fields,
process_component_event,
required_fields,
)
from .llm_services import LLMServiceError, log_ai_event
from .models import (
ActionResponse,
AnchorType,
BusinessResume,
ComposerMode,
ComponentEventRequest,
CreateResumeRequest,
CreateResumeResponse,
CreateSessionRequest,
GateView,
MessageRequest,
Stage,
TimelineResponse,
)
from .resume_document import merge_ids, merge_profile_refresh, set_generated_profile_summary
from .resume_editing import ResumeEditingMixin
from .optimization_flow import OptimizationFlowMixin
from .target_position_suggester import TargetPositionSuggester
from .experience_optimizer import ExperienceOptimizer, RuleStructuredExperienceOptimizer
from .services import EntryExpander, ExperienceExtractor, ResumeRewriter
from .profile_summary import ProfileSummaryGenerator, RuleBasedProfileSummaryGenerator
from .skill_suggester import SkillSuggester
from .resume_skill_advisor import recommend_skill_candidates
from . import builder_conversation
class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin):
def __init__(
self,
database: Database,
extractor: ExperienceExtractor,
rewriter: ResumeRewriter,
expander: EntryExpander,
skill_suggester: SkillSuggester,
experience_optimizer: ExperienceOptimizer | None = None,
target_position_suggester: TargetPositionSuggester | None = None,
profile_summary_generator: ProfileSummaryGenerator | None = None,
) -> None:
self.database = database
self.extractor = extractor
self.rewriter = rewriter
self.expander = expander
self.skill_suggester = skill_suggester
self.experience_optimizer = experience_optimizer or RuleStructuredExperienceOptimizer()
self.target_position_suggester = target_position_suggester
self.profile_summary_generator = profile_summary_generator or RuleBasedProfileSummaryGenerator()
def recommend_skills(self, session_id: str, question: str) -> list[dict[str, Any]]:
with self.database.transaction() as connection:
session = self._session_or_404(connection, session_id)
resume = self._resume_or_409(connection, session_id)
existing = [
str(skill).strip()
for group in resume["content"].get("skill_groups") or []
for skill in group.get("skills") or []
if str(skill).strip()
]
return recommend_skill_candidates(session["profile"], existing, question, self.skill_suggester)
def create_session(self, request: CreateSessionRequest) -> TimelineResponse:
session_id = f"session_{uuid4().hex}"
profile: dict[str, Any] = {
"account_phone": request.account_phone,
"metadata": request.metadata,
"anchor": {},
"experiences": [],
}
self.database.create_session(
session_id,
Stage.PRIVACY_CONSENT,
profile,
initial_turn(),
)
return self.timeline(session_id)
def timeline(self, session_id: str) -> TimelineResponse:
session = self._require_session(session_id)
turns = self.database.list_turns(session_id)
gate = self._gate(session)
resume = self._resume_view(session)
return TimelineResponse(
session_id=session_id,
session=self.database.session_view(session),
turns=turns,
stage=session["stage"],
revision=session["revision"],
draft_id=session.get("draft_id"),
resume_id=session.get("resume_id"),
resume=resume,
missing_fields=gate.missing_fields,
gate=gate,
trace_id=self._trace_id(),
)
def component_event(
self, session_id: str, request: ComponentEventRequest
) -> ActionResponse:
with self.database.transaction(immediate=True) as connection:
session = self.database.fetch_session(connection, session_id)
if session is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
block = self.database.fetch_block(connection, session_id, request.component_id)
if block is None:
raise FSMError("component_not_found", "Component not found", status_code=404)
if block["type"] != "component":
raise FSMError("invalid_component", "Events can only target component blocks", status_code=422)
if block["lifecycle"] != "active":
raise FSMError("component_not_active", "Component was already handled")
if request.action == "create" and block["data"].get("component_name") in {
"CreateResumeCard",
"CreateRetryCard",
}:
raise FSMError(
"use_create_endpoint",
"Use POST /sessions/{session_id}/create for resume creation",
status_code=422,
)
if Stage(session["stage"]) == Stage.BUILDER_CONVERSATION:
resume = self.database.fetch_resume(connection, session_id)
if resume is None:
raise FSMError("resume_not_created", "Create the resume before using Builder cards")
transition = builder_conversation.process_component_event(
session["profile"],
block["data"],
request.action,
request.payload,
resume["content"],
self.skill_suggester,
)
elif Stage(session["stage"]) == Stage.CONTENT_READY and block["data"].get(
"confirmation_kind"
) == "rewrite":
transition = process_rewrite_confirmation(
session["profile"], request.action, request.payload
)
else:
transition = process_component_event(
stage=Stage(session["stage"]),
profile=session["profile"],
component_data=block["data"],
action=request.action,
payload=request.payload,
)
if getattr(transition, "polish_description", False):
self._polish_module_entry(transition)
if getattr(transition, "propose_anchor_optimization", False):
self._propose_anchor_optimization(transition)
if getattr(transition, "suggest_skills", False):
self._suggest_skills(transition)
if getattr(transition, "suggest_target_positions", False):
self._suggest_target_positions(transition)
anchor_proposal = transition.profile.get("anchor_proposal")
if transition.stage == Stage.MINIMUM_READY:
transition.profile.pop("anchor_proposal", None)
if (
isinstance(anchor_proposal, dict)
and isinstance(transition.profile.get("anchor"), dict)
and request.payload.get("use_optimized") is True
):
transition.profile["anchor"]["description"] = anchor_proposal[
"optimized_description"
]
transition.profile["anchor"]["provenance"] = anchor_proposal["source"]
elif transition.stage == Stage.ANCHOR_COLLECTING:
transition.profile.pop("anchor_proposal", None)
self.database.update_block(
connection,
block["id"],
lifecycle=transition.lifecycle,
)
draft_id = session.get("draft_id")
if transition.create_draft:
draft_id = draft_id or f"draft_{uuid4().hex}"
preview = merge_ids(None, self.rewriter.rewrite(transition.profile))
transition.turn["blocks"].insert(
-1,
{
"type": "resume_patch",
"lifecycle": "submitted",
"data": {"draft_id": draft_id, "operation": "replace", "value": preview},
},
)
resume_content = getattr(transition, "resume_content", None)
if resume_content is None and getattr(transition, "refresh_resume", False):
resume_content = self.rewriter.rewrite(transition.profile)
transition.resume_content = resume_content
resume = None
if resume_content is not None:
resume = self.database.fetch_resume(connection, session_id)
if resume is None:
raise FSMError("resume_not_created", "Create the resume before confirming content")
resume_content = (
merge_profile_refresh(resume["content"], resume_content)
if getattr(transition, "refresh_resume", False)
else merge_ids(resume["content"], resume_content)
)
if getattr(transition, "generate_profile_summary", False):
resume = resume or self.database.fetch_resume(connection, session_id)
if resume is None:
raise FSMError("resume_not_created", "Create the resume before finishing content")
base_content = resume_content if resume_content is not None else resume["content"]
summary = base_content.get("profile_summary")
should_generate_summary = not isinstance(summary, dict) or summary.get("stale") is True
if should_generate_summary:
try:
summary_text = self.profile_summary_generator.generate(base_content)
resume_content = set_generated_profile_summary(
base_content, summary_text, replace_stale=True
)
except Exception as exc:
log_ai_event(
"profile_summary_generation_failed",
level=logging.WARNING,
reason_code=getattr(exc, "reason_code", type(exc).__name__),
exception=type(exc).__name__,
)
if resume_content is not None:
assert resume is not None
resume = self.database.update_resume(connection, session_id, resume_content)
if transition.stage == Stage.BUILDER_CONVERSATION:
builder_conversation.reconcile_last_confirmed_entry(
transition.profile, resume["content"]
)
transition.turn["blocks"].insert(
-1,
{
"type": "resume_patch",
"lifecycle": "confirmed",
"data": {
"resume_id": resume["id"],
"revision": resume["revision"],
"operation": "replace",
"value": resume_content,
},
},
)
updated = self.database.update_session(
connection,
session_id,
stage=transition.stage,
profile=transition.profile,
draft_id=draft_id,
)
turn_id = self.database.insert_turn(
connection,
session_id=session_id,
**transition.turn,
)
turn = self.database.get_turn(turn_id)
response = self._action_response(updated, turn)
response.builder_stream_phases = list(
((transition.profile.get("builder") or {}).get("last_stream_phases") or [])
)
return response
def add_message(self, session_id: str, request: MessageRequest) -> ActionResponse:
with self.database.transaction(immediate=True) as connection:
session = self.database.fetch_session(connection, session_id)
if session is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
if Stage(session["stage"]) != Stage.BUILDER_CONVERSATION:
raise FSMError(
"message_not_allowed",
"Free-text messages are available after the resume is created or imported",
status_code=422,
missing_fields=missing_fields(session["profile"]),
)
resume = self.database.fetch_resume(connection, session_id)
if resume is None:
raise FSMError("resume_not_created", "Create the resume before using Builder chat")
self.database.insert_turn(
connection,
session_id=session_id,
role="user",
content=request.content,
composer_mode=ComposerMode.CHAT,
blocks=[{"type": "text", "lifecycle": "submitted", "data": {"text": request.content}}],
)
transition = builder_conversation.process_message(self, session["profile"], request.content, resume["content"])
self.database.supersede_active_components(connection, session_id)
updated = self.database.update_session(
connection,
session_id,
stage=transition.stage,
profile=transition.profile,
)
turn_id = self.database.insert_turn(connection, session_id=session_id, **transition.turn)
response = self._action_response(updated, self.database.get_turn(turn_id))
response.builder_stream_phases = list(
((transition.profile.get("builder") or {}).get("last_stream_phases") or [])
)
return response
def _polish_module_entry(self, transition: Any) -> None:
"""Generate a proposal without mutating the user's original description."""
draft = (transition.profile.get("enrichment") or {}).get("module_draft") or {}
entry = draft.get("entry")
if not isinstance(entry, dict):
return
description = str(entry.get("description") or "").strip()
if description:
extraction = self.extractor.extract(description)
if extraction.highlights:
entry["highlights"] = extraction.highlights
if extraction.metrics:
entry["metrics"] = extraction.metrics
context = {
"job_type": transition.profile.get("job_type"),
"target_position": transition.profile.get("target_position"),
"instruction": None,
"entry_type": entry.get("record_type"),
}
try:
proposal = self.expander.expand(deepcopy(entry), context=context)
except Exception as exc:
self._log_expansion_failure("module_entry_expansion_failed", exc, context)
proposal = {}
optimized = str(proposal.get("optimized_description") or "").strip()
saved = None
if optimized and optimized != description:
saved = self._entry_proposal_payload(proposal, optimized)
entry["pending_proposal"] = saved
unavailable = proposal.get("generation_source") == "unavailable"
for block in transition.turn.get("blocks", []):
data = block.get("data") or {}
if block.get("type") == "component" and data.get("confirmation_kind") == "module_entry":
data["ai_proposal"] = saved
if unavailable:
data["optimization_unavailable"] = True
data["optimization_retryable"] = True
data["optimization_reason"] = proposal.get("fallback_reason")
def _suggest_target_positions(self, transition: Any) -> None:
"""Populate exploratory roles without treating them as user-confirmed facts."""
if self.target_position_suggester is None:
return
try:
suggestions = self.target_position_suggester.suggest(
major=str(transition.profile.get("target_position_major") or ""),
job_type=str(transition.profile.get("job_type") or "") or None,
interests=transition.profile.get("target_position_interests"),
)
except Exception:
return
transition.profile["target_position_suggestions"] = suggestions
from .fsm_basics import target_position_recommendation_transition
transition.turn = target_position_recommendation_transition(transition.profile).turn
def _suggest_skills(self, transition: Any) -> None:
"""Refresh the skills card with real-model suggestions when configured."""
try:
suggestions = self.skill_suggester.suggest(transition.profile)
except Exception:
return
for block in transition.turn.get("blocks", []):
data = block.get("data") or {}
if (
block.get("type") == "component"
and data.get("component_name") == "TagsInput"
and data.get("field") == "skills"
):
data["suggestions"] = suggestions
def _propose_anchor_optimization(self, transition: Any) -> None:
"""Add an optional expansion proposal to an anchor confirmation card."""
anchor = transition.profile.get("anchor") or {}
if not anchor:
return
context = {
"job_type": transition.profile.get("job_type"),
"target_position": transition.profile.get("target_position"),
"instruction": None,
"entry_type": transition.profile.get("anchor_type"),
}
try:
proposal = self.expander.expand(deepcopy(anchor), context=context)
except Exception as exc:
self._log_expansion_failure("anchor_expansion_failed", exc, context)
return
optimized = str(proposal.get("optimized_description") or "").strip()
if not optimized or optimized == str(anchor.get("description") or "").strip():
return
saved = self._entry_proposal_payload(proposal, optimized)
transition.profile["anchor_proposal"] = saved
for block in transition.turn.get("blocks", []):
data = block.get("data") or {}
if (
block.get("type") == "component"
and data.get("component_name") == "ExperienceConfirmCard"
):
data["ai_proposal"] = saved
@staticmethod
def _entry_proposal_payload(
proposal: dict[str, Any], optimized_description: str
) -> dict[str, Any]:
saved: dict[str, Any] = {
"optimized_description": optimized_description,
"changes": proposal.get("changes") or [],
"source": proposal.get("source", "ai_expanded"),
}
for key in ("generation_source", "fallback_reason"):
if proposal.get(key):
saved[key] = proposal[key]
return saved
@staticmethod
def _log_expansion_failure(
event: str, exc: Exception, context: dict[str, Any]
) -> None:
reason = (
exc.reason_code
if isinstance(exc, LLMServiceError)
else type(exc).__name__.casefold()[:48]
)
log_ai_event(
event,
level=logging.ERROR,
entry_type=str(context.get("entry_type") or ""),
reason_code=reason,
trace_id=getattr(exc, "trace_id", None),
stage=getattr(exc, "stage", "entry_expansion"),
exception=type(exc).__name__,
)
def create_resume(
self, session_id: str, request: CreateResumeRequest
) -> CreateResumeResponse:
try:
return self._create_resume_transaction(session_id, request)
except FSMError:
raise
except Exception as exc:
self._record_creation_failure(session_id)
raise FSMError(
"resume_creation_failed",
"Resume creation failed; retry is available",
status_code=503,
) from exc
def _create_resume_transaction(
self, session_id: str, request: CreateResumeRequest
) -> CreateResumeResponse:
with self.database.transaction(immediate=True) as connection:
session = self.database.fetch_session(connection, session_id)
if session is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
existing = self.database.fetch_resume(connection, session_id)
if existing is not None:
turn = self._last_turn(session_id)
return self._create_response(session, existing, turn, created=False)
if Stage(session["stage"]) not in {Stage.MINIMUM_READY, Stage.CREATE_FAILED}:
raise FSMError(
"resume_not_ready",
"Confirm a complete first anchor before creating the resume",
missing_fields=missing_fields(session["profile"]),
)
if not gate_allowed(session["profile"]):
raise FSMError(
"anchor_incomplete",
"The first-anchor gate is not satisfied",
missing_fields=missing_fields(session["profile"]),
)
creating = self.database.update_session(
connection,
session_id,
stage=Stage.RESUME_CREATING,
profile=session["profile"],
)
self.database.supersede_active_components(connection, session_id)
creating_status = component("CreatingStatusCard", status="creating")
creating_status["lifecycle"] = "submitted"
self.database.insert_turn(
connection,
session_id=session_id,
**assistant_turn(
"Creating your resume.",
[creating_status],
),
)
content = merge_ids(None, self.rewriter.rewrite(creating["profile"]))
resume_id = f"resume_{uuid4().hex}"
resume = self.database.insert_resume(
connection,
resume_id=resume_id,
session_id=session_id,
idempotency_key=request.idempotency_key,
content=content,
)
profile, ready_turn = builder_conversation.welcome_turn(
deepcopy(creating["profile"]), resume_id, resume_content=content
)
updated = self.database.update_session(
connection,
session_id,
stage=Stage.BUILDER_CONVERSATION,
profile=profile,
resume_id=resume_id,
)
ready_turn["blocks"].insert(
1,
{
"type": "resume_patch",
"lifecycle": "submitted",
"data": {
"resume_id": resume_id,
"revision": 1,
"operation": "replace",
"value": content,
},
},
)
turn_id = self.database.insert_turn(
connection,
session_id=session_id,
**ready_turn,
)
turn = self.database.get_turn(turn_id)
return self._create_response(updated, resume, turn, created=True)
def _record_creation_failure(self, session_id: str) -> None:
with self.database.transaction(immediate=True) as connection:
session = self.database.fetch_session(connection, session_id)
if session is None or self.database.fetch_resume(connection, session_id):
return
self.database.update_session(
connection,
session_id,
stage=Stage.CREATE_FAILED,
profile=session["profile"],
)
self.database.insert_turn(
connection,
session_id=session_id,
**assistant_turn(
"Resume creation failed. Please try again.",
[component("CreateRetryCard", primary_action="create")],
),
)
def delete_session(self, session_id: str) -> None:
if not self.database.delete_session(session_id):
raise FSMError("session_not_found", "Session not found", status_code=404)
def _require_session(self, session_id: str) -> dict[str, Any]:
session = self.database.get_session(session_id)
if session is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
return session
def _gate(self, session: dict[str, Any]) -> GateView:
profile = session["profile"]
anchor = profile.get("anchor_type")
records = profile.get("records") or {}
has_confirmed_content = bool(profile.get("experiences")) or any(
records.get(kind) for kind in records
)
return GateView(
allowed=gate_allowed(profile),
formal_content_ready=bool(
session.get("resume_id")
and has_confirmed_content
and profile.get("ai_rewrites_confirmed")
),
anchor_type=AnchorType(anchor) if anchor else None,
required_fields=required_fields(profile),
missing_fields=missing_fields(profile),
)
def _action_response(self, session: dict[str, Any], turn: Any) -> ActionResponse:
gate = self._gate(session)
resume = self._resume_view(session)
return ActionResponse(
session_id=session["id"],
stage=session["stage"],
revision=session["revision"],
turn=turn,
draft_id=session.get("draft_id"),
resume_id=session.get("resume_id"),
resume=resume,
missing_fields=gate.missing_fields,
gate=gate,
trace_id=self._trace_id(),
)
def _resume_view(self, session: dict[str, Any]) -> BusinessResume | None:
if not session.get("resume_id"):
return None
with self.database.transaction() as connection:
resume = self.database.fetch_resume(connection, session["id"])
return self.database.resume_view(resume) if resume else None
def _create_response(
self,
session: dict[str, Any],
resume: dict[str, Any],
turn: Any,
*,
created: bool,
) -> CreateResumeResponse:
gate = self._gate(session)
return CreateResumeResponse(
session_id=session["id"],
stage=session["stage"],
revision=session["revision"],
turn=turn,
draft_id=session.get("draft_id"),
resume_id=resume["id"],
missing_fields=gate.missing_fields,
gate=gate,
trace_id=self._trace_id(),
created=created,
resume=self.database.resume_view(resume),
)
def _last_turn(self, session_id: str) -> Any:
turns = self.database.list_turns(session_id)
return turns[-1] if turns else None
@staticmethod
def _trace_id() -> str:
return f"trace_{uuid4().hex}"