from __future__ import annotations import re from datetime import datetime from enum import StrEnum from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$") class Stage(StrEnum): PRIVACY_CONSENT = "PRIVACY_CONSENT" PHONE_SELECTION = "PHONE_SELECTION" MANUAL_PHONE_INPUT = "MANUAL_PHONE_INPUT" NAME_CAPTURE = "NAME_CAPTURE" JOB_TYPE_SELECT = "JOB_TYPE_SELECT" ANCHOR_TYPE_SELECT = "ANCHOR_TYPE_SELECT" ANCHOR_COLLECTING = "ANCHOR_COLLECTING" CONTENT_DISAMBIGUATION = "CONTENT_DISAMBIGUATION" ANCHOR_CONFIRM = "ANCHOR_CONFIRM" MINIMUM_READY = "MINIMUM_READY" RESUME_CREATING = "RESUME_CREATING" CREATE_FAILED = "CREATE_FAILED" CONTENT_READY = "CONTENT_READY" RESUME_ENRICHING = "RESUME_ENRICHING" class JobType(StrEnum): CAMPUS = "campus" SOCIAL = "social" OTHER = "other" class AnchorType(StrEnum): EDUCATION = "education" WORK_EXPERIENCE = "work_experience" INTERNSHIP_EXPERIENCE = "internship_experience" PROJECT_EXPERIENCE = "project_experience" class TurnRole(StrEnum): USER = "user" ASSISTANT = "assistant" SYSTEM = "system" class ComposerMode(StrEnum): UI_ONLY = "ui_only" CHAT = "chat" HYBRID = "hybrid" class BlockType(StrEnum): TEXT = "text" COMPONENT = "component" RESUME_PATCH = "resume_patch" STATUS = "status" ERROR = "error" class ComponentLifecycle(StrEnum): ACTIVE = "active" SUBMITTED = "submitted" CONFIRMED = "confirmed" DISMISSED = "dismissed" SUPERSEDED = "superseded" FAILED = "failed" class ComponentBlock(BaseModel): id: str type: BlockType lifecycle: ComponentLifecycle data: dict[str, Any] = Field(default_factory=dict) version: int = 1 created_at: datetime updated_at: datetime class ConversationTurn(BaseModel): id: str sequence: int role: TurnRole content: str | None = None composer_mode: ComposerMode blocks: list[ComponentBlock] = Field(default_factory=list) created_at: datetime class SessionView(BaseModel): id: str stage: Stage revision: int job_type: JobType | None = None anchor_type: AnchorType | None = None masked_phone: str | None = None phone_source: str | None = None name: str | None = None draft_id: str | None = None resume_id: str | None = None created_at: datetime updated_at: datetime class GateView(BaseModel): allowed: bool formal_content_ready: bool = False anchor_type: AnchorType | None = None required_fields: list[str] = Field(default_factory=list) missing_fields: list[str] = Field(default_factory=list) class TimelineResponse(BaseModel): session_id: str session: SessionView turns: list[ConversationTurn] stage: Stage revision: int draft_id: str | None = None resume_id: str | None = None missing_fields: list[str] = Field(default_factory=list) gate: GateView trace_id: str class ActionResponse(BaseModel): session_id: str stage: Stage revision: int turn: ConversationTurn | None = None timeline: list[ConversationTurn] | None = None draft_id: str | None = None resume_id: str | None = None missing_fields: list[str] = Field(default_factory=list) gate: GateView trace_id: str class CreateSessionRequest(BaseModel): model_config = ConfigDict(extra="forbid") account_phone: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) @field_validator("account_phone") @classmethod def validate_account_phone(cls, value: str | None) -> str | None: if value is None: return None normalized = re.sub(r"[\s-]", "", value) if normalized.startswith("+86"): normalized = normalized[3:] if not PHONE_PATTERN.fullmatch(normalized): raise ValueError("phone must be a valid mainland China mobile number") return normalized class ComponentEventRequest(BaseModel): model_config = ConfigDict(extra="forbid") component_id: str event: str | None = None event_type: str | None = None payload: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") def require_event(self) -> "ComponentEventRequest": if not (self.event or self.event_type): raise ValueError("event is required") return self @property def action(self) -> str: return (self.event or self.event_type or "").strip().lower() class MessageRequest(BaseModel): model_config = ConfigDict(extra="forbid") content: str = Field(min_length=1, max_length=8_000) @field_validator("content") @classmethod def strip_content(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("content cannot be blank") return value class CreateResumeRequest(BaseModel): model_config = ConfigDict(extra="forbid") idempotency_key: str | None = Field(default=None, max_length=128) class BusinessResume(BaseModel): id: str session_id: str revision: int content: dict[str, Any] created_at: datetime updated_at: datetime class CreateResumeResponse(ActionResponse): created: bool resume: BusinessResume class ErrorDetail(BaseModel): code: str message: str stage: Stage | None = None missing_fields: list[str] = Field(default_factory=list) trace_id: str