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