"""基本信息阶段的卡片与校验:个人信息(PERSONAL_INFO)与意向职位(TARGET_POSITION)。 独立成模块以控制 fsm.py 行数;fsm.py 通过函数内 import 调用,避免循环依赖。 """ from __future__ import annotations from typing import Any from .fsm import FSMError, Transition, assistant_turn, component from .models import ComposerMode, Stage from .validators import valid_email, valid_url PERSONAL_INFO_FIELDS: list[dict[str, Any]] = [ {"key": "name", "label": "姓名", "kind": "text", "required": True}, {"key": "email", "label": "邮箱", "kind": "text", "required": True}, {"key": "city", "label": "所在地", "kind": "text", "required": False}, {"key": "portfolio_url", "label": "作品集链接", "kind": "text", "required": False}, ] TARGET_POSITION_FIELDS: list[dict[str, Any]] = [ {"key": "target_position", "label": "意向职位", "kind": "text", "required": True}, ] TARGET_POSITION_MAJOR_FIELDS: list[dict[str, Any]] = [ {"key": "major", "label": "专业", "kind": "text", "required": True}, {"key": "interests", "label": "感兴趣的方向", "kind": "text", "required": False}, ] def personal_info_transition(profile: dict[str, Any]) -> Transition: return Transition( Stage.PERSONAL_INFO, profile, assistant_turn( "先填写你的个人信息,姓名和邮箱将用于简历抬头。", [ component( "RecordFields", title="填写个人信息", fields=PERSONAL_INFO_FIELDS, show_description=False, skippable=False, ) ], mode=ComposerMode.UI_ONLY, ), ) def validate_personal_info(payload: dict[str, Any]) -> dict[str, Any]: """姓名和邮箱必填;所在地和作品集链接选填。""" name = str(payload.get("name") or "").strip() email = str(payload.get("email") or "").strip() city = str(payload.get("city") or "").strip() portfolio_url = str(payload.get("portfolio_url") or "").strip() missing: list[str] = [] if not name or len(name) > 64: missing.append("name") if not valid_email(email): missing.append("email") if portfolio_url and not valid_url(portfolio_url): missing.append("portfolio_url") if missing: raise FSMError( "invalid_personal_info", "请填写姓名、有效邮箱,并确认作品集链接以 http(s):// 开头", status_code=422, missing_fields=missing, ) return { "name": name, "email": email, "city": city or None, "portfolio_url": portfolio_url or None, } def target_position_transition(profile: dict[str, Any]) -> Transition: return Transition( Stage.TARGET_POSITION, profile, assistant_turn( "先确认你是否已有明确的意向职位。", [ component( "ChoiceChips", title="意向职位", description="已有方向可直接填写;暂不确定时,先根据专业获得职位建议。", options=[ {"value": "known", "label": "我有明确意向职位"}, {"value": "explore", "label": "暂不确定,想先获取建议"}, ], skippable=False, ) ], mode=ComposerMode.UI_ONLY, ), ) def target_position_input_transition(profile: dict[str, Any]) -> Transition: return Transition( Stage.TARGET_POSITION, profile, assistant_turn( "填写一个意向职位,后续的优化和技能推荐会参考它。", [component("RecordFields", title="填写意向职位", fields=TARGET_POSITION_FIELDS, show_description=False)], mode=ComposerMode.UI_ONLY, ), ) def target_position_major_transition(profile: dict[str, Any]) -> Transition: return Transition( Stage.TARGET_POSITION_MAJOR, profile, assistant_turn( "填写专业和可选兴趣方向,我会给出可选择的职位建议。", [component("RecordFields", title="专业与方向", fields=TARGET_POSITION_MAJOR_FIELDS, show_description=False)], mode=ComposerMode.UI_ONLY, ), ) def target_position_recommendation_transition(profile: dict[str, Any]) -> Transition: suggestions = profile.get("target_position_suggestions") or [] options = [ {"value": item["title"], "label": item["title"], "description": item.get("reason")} for item in suggestions if isinstance(item, dict) and item.get("title") ] options.append({"value": "manual", "label": "手动填写职位"}) return Transition( Stage.TARGET_POSITION_RECOMMENDATION, profile, assistant_turn( "以下职位仅供探索参考,选择后仍可在简历预览中修改。", [component("ChoiceChips", title="职位建议", options=options, skippable=False)], mode=ComposerMode.UI_ONLY, ), ) def validate_target_position(payload: dict[str, Any]) -> str: value = str(payload.get("target_position") or "").strip() if not value or len(value) > 32: raise FSMError( "invalid_target_position", "请填写意向职位(32 字以内),或点击「暂时跳过」。", status_code=422, missing_fields=["target_position"], ) return value