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 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", "TagsInput": "tags_input", "CompetitionFields": "competition_fields", "AddAnother": "add_another", "ProgressCard": "progress_card", "AnchorFields": "anchor_fields", "RecordFields": "record_fields", "CustomCardPicker": "custom_card_picker", } 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", ], "campus_experience": [ "organization", "role", "start_date", "end_date_or_present", ], } FIELD_LABELS = { "school": "学校名称", "major": "专业", "degree": "学历", "company": "公司名称", "position": "职位", "project_name": "项目名称", "project_role": "项目角色", "organization": "组织名称", "role": "担任角色", "start_date": "开始时间", "end_date_or_present": "结束时间", "description": "经历描述", } DEGREE_OPTIONS = ["博士", "硕士", "本科", "大专", "高中及以下"] ANCHOR_CARD_TITLES = { "education": "填写教育经历", "work_experience": "填写工作经历", "internship_experience": "填写实习经历", "project_experience": "填写项目经历", "campus_experience": "填写校园经历", } def anchor_field_specs(anchor_type: str | None) -> list[dict[str, Any]]: """按经历类型生成表单卡字段元数据(RecordFields/AnchorFields 共用契约)。""" specs: list[dict[str, Any]] = [] for field in ANCHOR_FIELDS.get(anchor_type, []): spec: dict[str, Any] = { "key": field, "label": FIELD_LABELS[field], "kind": "text", "required": True, } if field == "degree": spec["kind"] = "degree" spec["options"] = DEGREE_OPTIONS elif field == "start_date": spec["kind"] = "month" elif field == "end_date_or_present": spec["kind"] = "month_end" specs.append(spec) return specs STAGE_COMPONENTS: dict[Stage, set[str]] = { Stage.PRIVACY_CONSENT: {"PrivacyConsentCard"}, Stage.RESUME_SOURCE_SELECT: {"ChoiceChips"}, Stage.RESUME_IMPORT_UPLOAD: set(), Stage.PHONE_SELECTION: {"ResumePhoneSelector"}, Stage.MANUAL_PHONE_INPUT: {"ResumePhoneInput"}, Stage.PERSONAL_INFO: {"RecordFields"}, Stage.NAME_CAPTURE: {"ResumeNameInput"}, Stage.JOB_TYPE_SELECT: {"JobTypeCards"}, Stage.TARGET_POSITION: {"ChoiceChips", "RecordFields"}, Stage.TARGET_POSITION_MAJOR: {"RecordFields"}, Stage.TARGET_POSITION_RECOMMENDATION: {"ChoiceChips"}, Stage.ANCHOR_TYPE_SELECT: {"AnchorTypeCards"}, Stage.ANCHOR_COLLECTING: {"AnchorFields"}, Stage.ANCHOR_CONFIRM: {"ExperienceConfirmCard"}, Stage.MINIMUM_READY: {"CreateResumeCard"}, Stage.CONTENT_READY: {"ContentReadyCard", "ExperienceConfirmCard"}, Stage.RESUME_ENRICHING: { "ContentReadyCard", "ChoiceChips", "CompetitionFields", "TagsInput", "AddAnother", "ProgressCard", "ExperienceConfirmCard", "RecordFields", "CustomCardPicker", }, Stage.CREATE_FAILED: {"CreateRetryCard"}, Stage.BUILDER_CONVERSATION: {"RecordFields", "ExperienceConfirmCard", "ChoiceChips"}, } 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 resume_content: dict[str, Any] | None = None refresh_resume: bool = False polish_description: bool = False propose_anchor_optimization: bool = False suggest_skills: bool = False suggest_target_positions: bool = False generate_profile_summary: 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]: """The initial Builder resume only requires verified setup information.""" return [] 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 _anchor_card(profile: dict[str, Any], value: dict[str, Any] | None = None) -> dict[str, Any]: anchor_type = profile.get("anchor_type") return component( "AnchorFields", anchor_type=anchor_type, title=ANCHOR_CARD_TITLES.get(str(anchor_type), "填写核心经历"), fields=anchor_field_specs(anchor_type), show_description=True, skippable=True, skip_label="\u6682\u65e0\u6838\u5fc3\u7ecf\u5386\uff0c\u521b\u5efa\u57fa\u7840\u7b80\u5386", value=value, ) 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( "\u9700\u8981\u540c\u610f\u9690\u79c1\u8bf4\u660e\u540e\u624d\u80fd\u7ee7\u7eed\u3002", [component("PrivacyConsentCard", required=True)], ), ) _expect(action, "accept_privacy") updated["privacy_accepted"] = True return Transition( Stage.RESUME_SOURCE_SELECT, updated, assistant_turn( "\u8bf7\u9009\u62e9\u5f00\u59cb\u65b9\u5f0f\u3002", [ component( "ChoiceChips", eyebrow="\u5f00\u59cb\u521b\u5efa", title="\u9009\u62e9\u521b\u5efa\u65b9\u5f0f", description="\u5bfc\u5165\u4f1a\u5148\u63d0\u53d6\u6587\u6863\u5185\u5bb9\uff0c\u518d\u6620\u5c04\u4e3a\u53ef\u7f16\u8f91\u7684\u7b80\u5386\u7ed3\u6784\u3002", options=[ {"value": "import", "label": "\u5bfc\u5165\u5df2\u6709\u7b80\u5386", "description": "\u652f\u6301 PDF \u6216 DOCX"}, {"value": "manual", "label": "\u521b\u5efa\u65b0\u7b80\u5386", "description": "\u4ece\u57fa\u7840\u4fe1\u606f\u548c\u7ecf\u5386\u5f00\u59cb\u586b\u5199"}, ], ) ], ), ) if stage == Stage.RESUME_SOURCE_SELECT: _expect(action, "select_choice") source = str(payload.get("value") or "").strip() if source == "import": updated["resume_source"] = "import" return Transition( Stage.RESUME_IMPORT_UPLOAD, updated, assistant_turn("\u8bf7\u9009\u62e9\u9700\u8981\u5bfc\u5165\u7684 PDF \u6216 DOCX \u7b80\u5386\u3002", []), ) if source == "manual": updated["resume_source"] = "manual" return Transition( Stage.PHONE_SELECTION, updated, assistant_turn( "\u8bf7\u9009\u62e9\u624b\u673a\u53f7\u6765\u6e90\u3002", [ component( "ResumePhoneSelector", has_account_phone=bool(updated.get("account_phone")), masked_phone=mask_phone(updated.get("account_phone")), default_value=( "account" if updated.get("account_phone") else None ), ) ], ), ) raise FSMError("invalid_resume_source", "Select import or manual", status_code=422) 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" from .fsm_basics import personal_info_transition return personal_info_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" from .fsm_basics import personal_info_transition return personal_info_transition(updated) if stage == Stage.PERSONAL_INFO: _expect(action, "submit") from .fsm_basics import validate_personal_info updated.update(validate_personal_info(payload)) updated.pop("account_phone", None) return Transition( Stage.JOB_TYPE_SELECT, updated, assistant_turn( "请选择求职类型。", [component("JobTypeCards", options=["campus", "social", "internship"])], ), ) 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", "internship"])], ), ) 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 in {JobType.CAMPUS, JobType.INTERNSHIP}: updated["anchor_type"] = AnchorType.EDUCATION elif job_type == JobType.SOCIAL: updated["anchor_type"] = AnchorType.WORK_EXPERIENCE from .fsm_basics import target_position_transition return target_position_transition(updated) if stage == Stage.TARGET_POSITION: from .fsm_basics import target_position_input_transition, target_position_major_transition, validate_target_position if action == "skip": updated.pop("target_position", None) return _minimum_ready_transition(updated) if action == "select_choice": selected = str(payload.get("value") or "").strip() if selected == "known": return target_position_input_transition(updated) if selected == "explore": return target_position_major_transition(updated) raise FSMError("invalid_target_position_choice", "Select known or explore", status_code=422) _expect(action, "submit") updated["target_position"] = validate_target_position(payload) return _minimum_ready_transition(updated) if stage == Stage.TARGET_POSITION_MAJOR: from .fsm_basics import target_position_recommendation_transition _expect(action, "submit") major = str(payload.get("major") or "").strip() interests = str(payload.get("interests") or "").strip() if not major or len(major) > 80 or len(interests) > 120: raise FSMError( "invalid_target_position_context", "Major is required and the supplied text is too long", status_code=422, missing_fields=["major"] if not major else [], ) updated["target_position_major"] = major updated["target_position_interests"] = interests or None return Transition( Stage.TARGET_POSITION_RECOMMENDATION, updated, target_position_recommendation_transition(updated).turn, suggest_target_positions=True, ) if stage == Stage.TARGET_POSITION_RECOMMENDATION: from .fsm_basics import target_position_input_transition _expect(action, "select_choice") selected = str(payload.get("value") or "").strip() if selected == "manual": return target_position_input_transition(updated) suggestions = updated.get("target_position_suggestions") or [] titles = {str(item.get("title") or "") for item in suggestions if isinstance(item, dict)} if selected not in titles: raise FSMError("invalid_target_position_suggestion", "Select a recommended position or enter one manually", status_code=422) updated["target_position"] = selected return _minimum_ready_transition(updated) if stage == Stage.ANCHOR_TYPE_SELECT: _expect(action, "select_anchor_type") updated["anchor_type"] = _anchor_type(payload.get("anchor_type")) return _minimum_ready_transition(updated) if stage == Stage.ANCHOR_COLLECTING: return _collect_anchor(updated, component_data, action, payload) if stage == Stage.ANCHOR_CONFIRM: if action == "edit_anchor": return Transition( Stage.ANCHOR_COLLECTING, updated, assistant_turn( "请直接在卡片中修改这段经历。", [_anchor_card(updated, value=dict(updated.get("anchor") or {}))], ), ) _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", generate_profile_summary=True, ) _expect(action, "continue_enriching") if updated.get("imported_resume"): from .enrichment_custom import custom_card_picker_transition return custom_card_picker_transition(updated) from .fsm_enrichment import begin_enrichment return begin_enrichment(updated) 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", generate_profile_summary=True, ) if action == "continue_enriching": if updated.get("imported_resume"): from .enrichment_custom import custom_card_picker_transition return custom_card_picker_transition(updated) from .fsm_enrichment import begin_enrichment return begin_enrichment(updated) from .enrichment_collectors import process_module_event return process_module_event(updated, component_data, action, payload) 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) profile.pop("anchor_proposal", None) if action == "skip": profile["core_experience_skipped"] = True profile.pop("anchor_type", None) profile.pop("anchor", None) return Transition( Stage.MINIMUM_READY, profile, assistant_turn( "\u5df2\u8df3\u8fc7\u6838\u5fc3\u7ecf\u5386\uff0c\u53ef\u4ee5\u5148\u521b\u5efa\u57fa\u7840\u7b80\u5386\uff0c\u4e4b\u540e\u4ecd\u53ef\u5728\u9884\u89c8\u4e2d\u7ee7\u7eed\u8865\u5145\u3002", [component("CreateResumeCard", primary_action="create")], ), lifecycle="dismissed", create_draft=True, ) _expect(action, "submit") profile.pop("core_experience_skipped", None) required = required_fields(profile) anchor = {field: str(payload.get(field) or "").strip() for field in required} description = str(payload.get("description") or "").strip() if description: anchor["description"] = description profile["anchor"] = anchor missing = anchor_missing_fields(profile, required) if missing: raise FSMError( "invalid_anchor", "核心字段缺失或格式有误(时间需为 YYYY-MM,结束不早于开始)", status_code=422, missing_fields=missing, ) return Transition( Stage.ANCHOR_CONFIRM, profile, assistant_turn( "请确认这段经历。", [ component( "ExperienceConfirmCard", anchor_type=profile["anchor_type"], value=anchor, labels=FIELD_LABELS, ) ], ), propose_anchor_optimization=True, ) def _minimum_ready_transition(profile: dict[str, Any]) -> Transition: profile.pop("anchor", None) profile.pop("anchor_confirmed", None) profile.pop("anchor_proposal", None) profile.pop("core_experience_skipped", None) return Transition( Stage.MINIMUM_READY, profile, assistant_turn( "基础信息已经准备好。先生成简历,随后我会按你的求职方向建议优先补充的经历。", [component("CreateResumeCard", primary_action="create")], ), create_draft=True, ) 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, [_anchor_card(profile)], mode=ComposerMode.UI_ONLY), ) def _anchor_type_transition(profile: dict[str, Any]) -> Transition: return Transition( Stage.ANCHOR_TYPE_SELECT, profile, assistant_turn( "请选择最能代表你的首段经历。", [ component( "AnchorTypeCards", options=[item.value for item in AnchorType], ) ], ), ) 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 internship", 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