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