generated from kgod/ai-review-template
494 lines
18 KiB
Python
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
|