generated from kgod/ai-review-template
feat: add resume agent MVP
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user