generated from kgod/ai-review-template
495 lines
20 KiB
Python
495 lines
20 KiB
Python
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}"
|