Files
resume-agent/backend/app/fsm.py
T
2026-07-20 14:48:41 +08:00

494 lines
18 KiB
Python

from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from .models import AnchorType, ComposerMode, JobType, Stage
from .validators import anchor_missing_fields, can_create_resume, mask_phone, strict_phone, valid_month
COMPONENT_SLUGS = {
"PrivacyConsentCard": "privacy_consent_card",
"ResumePhoneSelector": "resume_phone_selector",
"ResumePhoneInput": "resume_phone_input",
"ResumeNameInput": "resume_name_input",
"JobTypeCards": "job_type_cards",
"AnchorTypeCards": "anchor_type_cards",
"ShortTextInput": "short_text_input",
"DegreeSelector": "degree_selector",
"DateRangeSelector": "date_range_selector",
"ChoiceChips": "choice_chips",
"ExperienceConfirmCard": "experience_confirm_card",
"CreateResumeCard": "create_resume_card",
"CreatingStatusCard": "creating_status_card",
"ContentReadyCard": "content_ready_card",
"CreateRetryCard": "create_retry_card",
}
ANCHOR_FIELDS: dict[str, list[str]] = {
AnchorType.EDUCATION: [
"school",
"major",
"degree",
"start_date",
"end_date_or_present",
],
AnchorType.WORK_EXPERIENCE: [
"company",
"position",
"start_date",
"end_date_or_present",
],
AnchorType.INTERNSHIP_EXPERIENCE: [
"company",
"position",
"start_date",
"end_date_or_present",
],
AnchorType.PROJECT_EXPERIENCE: [
"project_name",
"project_role",
"start_date",
"end_date_or_present",
],
}
FIELD_LABELS = {
"school": "学校名称",
"major": "专业",
"degree": "学历",
"company": "公司名称",
"position": "职位",
"project_name": "项目名称",
"project_role": "项目角色",
"start_date": "开始时间",
"end_date_or_present": "结束时间",
}
STAGE_COMPONENTS: dict[Stage, set[str]] = {
Stage.PRIVACY_CONSENT: {"PrivacyConsentCard"},
Stage.PHONE_SELECTION: {"ResumePhoneSelector"},
Stage.MANUAL_PHONE_INPUT: {"ResumePhoneInput"},
Stage.NAME_CAPTURE: {"ResumeNameInput"},
Stage.JOB_TYPE_SELECT: {"JobTypeCards"},
Stage.ANCHOR_TYPE_SELECT: {"AnchorTypeCards"},
Stage.ANCHOR_COLLECTING: {
"ShortTextInput",
"DegreeSelector",
"DateRangeSelector",
"ChoiceChips",
},
Stage.ANCHOR_CONFIRM: {"ExperienceConfirmCard"},
Stage.MINIMUM_READY: {"CreateResumeCard"},
Stage.CONTENT_READY: {"ContentReadyCard", "ExperienceConfirmCard"},
Stage.RESUME_ENRICHING: {"ContentReadyCard"},
Stage.CREATE_FAILED: {"CreateRetryCard"},
}
class FSMError(Exception):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 409,
missing_fields: list[str] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.missing_fields = missing_fields or []
@dataclass(slots=True)
class Transition:
stage: Stage
profile: dict[str, Any]
turn: dict[str, Any]
lifecycle: str = "submitted"
block_data_updates: dict[str, Any] | None = None
create_draft: bool = False
def component(name: str, **props: Any) -> dict[str, Any]:
return {
"type": "component",
"lifecycle": "active",
"data": {
"component": COMPONENT_SLUGS[name],
"component_name": name,
**props,
},
}
def text_block(text: str, *, block_type: str = "text") -> dict[str, Any]:
return {"type": block_type, "lifecycle": "active", "data": {"text": text}}
def assistant_turn(
content: str,
blocks: list[dict[str, Any]],
*,
mode: ComposerMode = ComposerMode.UI_ONLY,
) -> dict[str, Any]:
return {
"role": "assistant",
"content": content,
"composer_mode": mode,
"blocks": [text_block(content), *blocks],
}
def initial_turn() -> dict[str, Any]:
return assistant_turn(
"在开始前,请阅读并同意隐私说明。",
[component("PrivacyConsentCard", required=True)],
)
def required_fields(profile: dict[str, Any]) -> list[str]:
return list(ANCHOR_FIELDS.get(profile.get("anchor_type"), []))
def missing_fields(profile: dict[str, Any]) -> list[str]:
return anchor_missing_fields(profile, required_fields(profile))
def gate_allowed(profile: dict[str, Any]) -> bool:
return can_create_resume(profile, missing_fields(profile))
def next_anchor_component(profile: dict[str, Any], field: str | None = None) -> dict[str, Any]:
target = field or (missing_fields(profile)[0] if missing_fields(profile) else None)
if target is None:
return component(
"ExperienceConfirmCard",
anchor_type=profile["anchor_type"],
value=profile.get("anchor", {}),
)
if target == "degree":
return component(
"DegreeSelector",
field="degree",
label=FIELD_LABELS[target],
options=["博士", "硕士", "本科", "大专", "高中及以下"],
)
if target in {"start_date", "end_date_or_present"}:
return component(
"DateRangeSelector",
fields=["start_date", "end_date_or_present"],
start_date=profile.get("anchor", {}).get("start_date"),
end_date_or_present=profile.get("anchor", {}).get("end_date_or_present"),
)
return component("ShortTextInput", field=target, label=FIELD_LABELS[target])
def process_component_event(
*,
stage: Stage,
profile: dict[str, Any],
component_data: dict[str, Any],
action: str,
payload: dict[str, Any],
) -> Transition:
name = component_data.get("component_name")
if name not in STAGE_COMPONENTS.get(stage, set()):
raise FSMError("stale_component", "This component is not active for the current stage")
action = _canonical_action(name, action, payload)
updated = deepcopy(profile)
if stage == Stage.PRIVACY_CONSENT:
if action == "decline_privacy":
return Transition(
stage=stage,
profile=updated,
lifecycle="dismissed",
turn=assistant_turn(
"需要同意隐私说明后才能继续。",
[component("PrivacyConsentCard", required=True)],
),
)
_expect(action, "accept_privacy")
updated["privacy_accepted"] = True
return Transition(
Stage.PHONE_SELECTION,
updated,
assistant_turn(
"请选择手机号来源。",
[
component(
"ResumePhoneSelector",
has_account_phone=bool(updated.get("account_phone")),
masked_phone=mask_phone(updated.get("account_phone")),
)
],
),
)
if stage == Stage.PHONE_SELECTION:
if action == "use_other_phone":
return Transition(
Stage.MANUAL_PHONE_INPUT,
updated,
assistant_turn("请输入手机号。", [component("ResumePhoneInput")]),
)
_expect(action, "use_account_phone")
phone = updated.get("account_phone") or payload.get("phone")
if not phone:
raise FSMError("account_phone_unavailable", "No account phone is available", status_code=422)
updated["phone"] = phone
updated["phone_source"] = "account"
return _name_transition(updated)
if stage == Stage.MANUAL_PHONE_INPUT:
_expect(action, "submit_manual_phone")
phone = payload.get("phone")
if not isinstance(phone, str) or not strict_phone(phone):
raise FSMError(
"invalid_phone",
"phone must match ^1[3-9]\\d{9}$",
status_code=422,
)
updated["phone"] = phone
updated["phone_source"] = "manual"
return _name_transition(updated)
if stage == Stage.NAME_CAPTURE:
_expect(action, "submit_name")
name_value = payload.get("name")
if not isinstance(name_value, str) or not name_value.strip() or len(name_value.strip()) > 64:
raise FSMError("invalid_name", "name must contain 1 to 64 characters", status_code=422)
updated["name"] = name_value.strip()
return Transition(
Stage.JOB_TYPE_SELECT,
updated,
assistant_turn(
"请选择求职类型。",
[component("JobTypeCards", options=["campus", "social", "other"])],
),
)
if stage == Stage.JOB_TYPE_SELECT:
_expect(action, "select_job_type")
job_type = _job_type(payload.get("job_type"))
updated["job_type"] = job_type
if job_type == JobType.CAMPUS:
updated["anchor_type"] = AnchorType.EDUCATION
return _begin_anchor(updated)
if job_type == JobType.SOCIAL:
updated["anchor_type"] = AnchorType.WORK_EXPERIENCE
return _begin_anchor(updated)
return Transition(
Stage.ANCHOR_TYPE_SELECT,
updated,
assistant_turn(
"请选择最能代表你的首段经历。",
[
component(
"AnchorTypeCards",
options=[item.value for item in AnchorType],
)
],
),
)
if stage == Stage.ANCHOR_TYPE_SELECT:
_expect(action, "select_anchor_type")
updated["anchor_type"] = _anchor_type(payload.get("anchor_type"))
return _begin_anchor(updated)
if stage == Stage.ANCHOR_COLLECTING:
return _collect_anchor(updated, component_data, action, payload)
if stage == Stage.ANCHOR_CONFIRM:
if action == "edit_anchor":
field = payload.get("field") or required_fields(updated)[0]
if field not in required_fields(updated):
raise FSMError("invalid_field", "field is not part of this anchor", status_code=422)
updated["editing_field"] = field
return Transition(
Stage.ANCHOR_COLLECTING,
updated,
assistant_turn("请修改这项信息。", [next_anchor_component(updated, field)]),
)
_expect(action, "confirm_anchor")
missing = missing_fields(updated)
if missing:
raise FSMError("anchor_incomplete", "The first anchor is incomplete", missing_fields=missing)
updated["anchor_confirmed"] = True
return Transition(
Stage.MINIMUM_READY,
updated,
assistant_turn(
"首段经历已确认,可以创建简历。",
[component("CreateResumeCard", primary_action="create")],
),
lifecycle="confirmed",
create_draft=True,
)
if stage == Stage.CONTENT_READY:
if action == "finish_enrichment":
updated["enrichment_finished"] = True
return Transition(
Stage.CONTENT_READY,
updated,
assistant_turn("简历内容已保存。", [], mode=ComposerMode.UI_ONLY),
lifecycle="confirmed",
)
_expect(action, "continue_enriching")
return Transition(
Stage.RESUME_ENRICHING,
updated,
assistant_turn("继续告诉我更多经历,我会实时更新简历。", [], mode=ComposerMode.CHAT),
)
if stage == Stage.RESUME_ENRICHING:
if action == "finish_enrichment":
updated["enrichment_finished"] = True
return Transition(
Stage.CONTENT_READY,
updated,
assistant_turn(
"补充完成,简历已更新。",
[component("ContentReadyCard", can_continue=True)],
),
lifecycle="confirmed",
)
_expect(action, "continue_enriching")
return Transition(stage, updated, assistant_turn("请继续补充。", [], mode=ComposerMode.CHAT))
raise FSMError("invalid_transition", f"No component event is allowed in {stage}")
def _collect_anchor(
profile: dict[str, Any],
component_data: dict[str, Any],
action: str,
payload: dict[str, Any],
) -> Transition:
profile.pop("anchor_confirmed", None)
name = component_data["component_name"]
anchor = profile.setdefault("anchor", {})
if name == "ShortTextInput":
_expect(action, "submit_field")
expected_field = component_data.get("field")
if payload.get("field", expected_field) != expected_field:
raise FSMError("invalid_field", "payload field does not match the active field", status_code=422)
value = payload.get("value")
if not isinstance(value, str) or not value.strip():
raise FSMError("invalid_value", "value cannot be blank", status_code=422)
anchor[expected_field] = value.strip()
elif name == "DegreeSelector":
_expect(action, "select_choice")
value = payload.get("degree") or payload.get("value")
if not isinstance(value, str) or not value.strip():
raise FSMError("invalid_degree", "degree is required", status_code=422)
anchor["degree"] = value.strip()
elif name == "DateRangeSelector":
_expect(action, "submit_date_range")
start = payload.get("start_date")
end = "present" if payload.get("current") else payload.get("end_date_or_present", payload.get("end_date"))
if not valid_month(start) or not (end == "present" or valid_month(end)):
raise FSMError("invalid_date_range", "dates must use YYYY-MM or present", status_code=422)
if end != "present" and end < start:
raise FSMError("invalid_date_range", "end date cannot be before start date", status_code=422)
anchor["start_date"] = start
anchor["end_date_or_present"] = end
else:
_expect(action, "select_choice")
anchor[component_data.get("field", "choice")] = payload.get("value", payload.get("values"))
profile.pop("editing_field", None)
missing = missing_fields(profile)
if missing:
block = next_anchor_component(profile)
return Transition(
Stage.ANCHOR_COLLECTING,
profile,
assistant_turn(f"还需要 {FIELD_LABELS[missing[0]]}。", [block]),
)
return Transition(
Stage.ANCHOR_CONFIRM,
profile,
assistant_turn(
"请确认这段经历。",
[component("ExperienceConfirmCard", anchor_type=profile["anchor_type"], value=anchor)],
),
)
def _begin_anchor(profile: dict[str, Any]) -> Transition:
profile["anchor"] = {}
prompt = {
AnchorType.EDUCATION: "请介绍当前或最高的一段教育经历,包括学校、专业、学历和就读时间。",
AnchorType.WORK_EXPERIENCE: "请介绍一段最近或最有代表性的工作,包括公司、职位和任职时间。",
AnchorType.INTERNSHIP_EXPERIENCE: "请介绍一段实习经历,包括公司、职位和实习时间。",
AnchorType.PROJECT_EXPERIENCE: "请介绍一个代表性项目,包括项目名、你的角色和项目时间。",
}.get(profile.get("anchor_type"), "请介绍一段最能代表你的经历。")
return Transition(
Stage.ANCHOR_COLLECTING,
profile,
assistant_turn(prompt, [], mode=ComposerMode.CHAT),
)
def _name_transition(profile: dict[str, Any]) -> Transition:
profile.pop("account_phone", None)
return Transition(
Stage.NAME_CAPTURE,
profile,
assistant_turn("怎么称呼你?", [component("ResumeNameInput")]),
)
def _canonical_action(name: str, action: str, payload: dict[str, Any]) -> str:
action = action.lower().strip()
if action == "consent":
return "accept_privacy" if payload.get("accepted", True) else "decline_privacy"
if action == "accept":
return "accept_privacy" if payload.get("accepted", True) else "decline_privacy"
if action == "confirm":
return "confirm_anchor" if payload.get("confirmed", True) else "edit_anchor"
if action == "edit":
return "edit_anchor"
if action == "select":
if name == "ResumePhoneSelector":
source = payload.get("source") or payload.get("value")
return "use_other_phone" if source in {"other", "manual"} else "use_account_phone"
if name == "JobTypeCards":
return "select_job_type"
if name == "AnchorTypeCards":
return "select_anchor_type"
return "select_choice"
if action == "submit":
return {
"ResumePhoneInput": "submit_manual_phone",
"ResumeNameInput": "submit_name",
"ShortTextInput": "submit_field",
"DegreeSelector": "select_choice",
"DateRangeSelector": "submit_date_range",
}.get(name, action)
return action
def _expect(actual: str, expected: str) -> None:
if actual != expected:
raise FSMError("invalid_event", f"Expected event '{expected}', got '{actual}'", status_code=422)
def _job_type(value: Any) -> JobType:
aliases = {"experienced": "social", "professional": "social", "student": "campus"}
try:
return JobType(aliases.get(str(value), str(value)))
except ValueError as exc:
raise FSMError("invalid_job_type", "job_type must be campus, social, or other", status_code=422) from exc
def _anchor_type(value: Any) -> AnchorType:
try:
return AnchorType(str(value))
except ValueError as exc:
choices = ", ".join(item.value for item in AnchorType)
raise FSMError("invalid_anchor_type", f"anchor_type must be one of: {choices}", status_code=422) from exc