feat: add resume agent MVP

This commit is contained in:
OfferPai
2026-07-20 14:48:41 +08:00
commit 48599bf55b
65 changed files with 10988 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
from __future__ import annotations
from copy import deepcopy
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,
next_anchor_component,
process_component_event,
required_fields,
text_block,
)
from .models import (
ActionResponse,
AnchorType,
BusinessResume,
ComposerMode,
ComponentEventRequest,
CreateResumeRequest,
CreateResumeResponse,
CreateSessionRequest,
GateView,
MessageRequest,
Stage,
TimelineResponse,
)
from .services import ExperienceExtractor, ResumeRewriter
class ResumeAgent:
def __init__(
self,
database: Database,
extractor: ExperienceExtractor,
rewriter: ResumeRewriter,
) -> None:
self.database = database
self.extractor = extractor
self.rewriter = rewriter
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)
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"),
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.CONTENT_READY and block["data"].get(
"confirmation_kind"
) == "rewrite":
transition = process_rewrite_confirmation(
session["profile"], request.action
)
else:
transition = process_component_event(
stage=Stage(session["stage"]),
profile=session["profile"],
component_data=block["data"],
action=request.action,
payload=request.payload,
)
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 = 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 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 = self.database.update_resume(connection, session_id, 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)
return self._action_response(updated, turn)
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)
current_stage = Stage(session["stage"])
allowed = {
Stage.ANCHOR_COLLECTING,
Stage.CONTENT_READY,
Stage.RESUME_ENRICHING,
Stage.CONTENT_DISAMBIGUATION,
}
if current_stage not in allowed:
raise FSMError(
"message_not_allowed",
"Free-text messages are not available in the current UI-only stage",
missing_fields=missing_fields(session["profile"]),
)
if session["profile"].get("pending_experience"):
raise FSMError(
"rewrite_confirmation_required",
"Confirm or revise the proposed rewrite before sending more text",
)
self.database.insert_turn(
connection,
session_id=session_id,
role="user",
content=request.content,
composer_mode="chat",
blocks=[
{
"type": "text",
"lifecycle": "submitted",
"data": {"text": request.content},
}
],
)
if current_stage == Stage.ANCHOR_COLLECTING:
profile = deepcopy(session["profile"])
anchor_type = str(profile.get("anchor_type") or "")
before = missing_fields(profile)
patch = self.extractor.extract_anchor(request.content, anchor_type, before)
profile.setdefault("anchor", {}).update(patch)
profile.setdefault("anchor_source_messages", []).append(request.content)
remaining = missing_fields(profile)
self.database.supersede_active_components(connection, session_id)
if remaining:
turn_spec = assistant_turn(
"我已记录这段描述。还需要补充一项结构信息。",
[next_anchor_component(profile)],
mode=ComposerMode.HYBRID,
)
updated = self.database.update_session(
connection,
session_id,
stage=Stage.ANCHOR_COLLECTING,
profile=profile,
)
else:
turn_spec = assistant_turn(
"我已经整理出第一段必要经历,请确认信息是否准确。",
[
component(
"ExperienceConfirmCard",
anchor_type=profile["anchor_type"],
value=profile["anchor"],
)
],
mode=ComposerMode.UI_ONLY,
)
updated = self.database.update_session(
connection,
session_id,
stage=Stage.ANCHOR_CONFIRM,
profile=profile,
)
turn_id = self.database.insert_turn(
connection,
session_id=session_id,
**turn_spec,
)
turn = self.database.fetch_turn(connection, turn_id)
return self._action_response(updated, turn)
resume = self.database.fetch_resume(connection, session_id)
if resume is None:
raise FSMError("resume_not_created", "Create the resume before enriching it")
profile = deepcopy(session["profile"])
pending = profile.pop("pending_message", None)
source_text = f"{pending} {request.content}".strip() if pending else request.content
extraction = self.extractor.extract(source_text)
if len(source_text) < 8 or extraction.confidence <= 0.5:
profile["pending_message"] = source_text
turn_spec = assistant_turn(
"请再补充一下所在组织、你的角色或可量化结果。",
[
{
"type": "status",
"lifecycle": "active",
"data": {"status": "needs_disambiguation"},
}
],
mode="chat",
)
updated = self.database.update_session(
connection,
session_id,
stage=Stage.CONTENT_DISAMBIGUATION,
profile=profile,
)
self.database.supersede_active_components(connection, session_id)
turn_id = self.database.insert_turn(
connection, session_id=session_id, **turn_spec
)
else:
candidate_profile = deepcopy(profile)
candidate_profile.setdefault("experiences", []).append(extraction.to_dict())
rewritten = self.rewriter.rewrite(candidate_profile)
profile, turn_spec = prepare_rewrite_confirmation(
profile, extraction, rewritten
)
updated = self.database.update_session(
connection,
session_id,
stage=Stage.CONTENT_READY,
profile=profile,
)
self.database.supersede_active_components(connection, session_id)
turn_id = self.database.insert_turn(
connection, session_id=session_id, **turn_spec
)
turn = self.database.get_turn(turn_id)
return self._action_response(updated, turn)
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_status],
),
)
content = 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,
)
updated = self.database.update_session(
connection,
session_id,
stage=Stage.RESUME_ENRICHING,
profile=creating["profile"],
resume_id=resume_id,
)
ready_turn = assistant_turn(
"基础简历已创建。你可以现在退出,也可以继续补充经历内容。",
[
{
"type": "resume_patch",
"lifecycle": "submitted",
"data": {
"resume_id": resume_id,
"revision": 1,
"operation": "replace",
"value": content,
},
},
component(
"ContentReadyCard",
resume_id=resume_id,
formal_content_ready=False,
actions=["continue_enriching", "finish_enrichment"],
),
],
mode=ComposerMode.HYBRID,
)
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(
"创建失败,请重试。",
[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")
return GateView(
allowed=gate_allowed(profile),
formal_content_ready=bool(
session.get("resume_id")
and profile.get("experiences")
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)
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"),
missing_fields=gate.missing_fields,
gate=gate,
trace_id=self._trace_id(),
)
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}"