Files
resume-agent/backend/app/models.py
T

230 lines
5.9 KiB
Python

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
from .resume_api_models import (
BusinessResume,
OptimizeEntryRequest,
OptimizeRequest,
ResumePatchOperation,
ResumePatchRequest,
)
PHONE_PATTERN = re.compile(r"^1[3-9]\d{9}$")
class Stage(StrEnum):
PRIVACY_CONSENT = "PRIVACY_CONSENT"
RESUME_SOURCE_SELECT = "RESUME_SOURCE_SELECT"
RESUME_IMPORT_UPLOAD = "RESUME_IMPORT_UPLOAD"
PHONE_SELECTION = "PHONE_SELECTION"
MANUAL_PHONE_INPUT = "MANUAL_PHONE_INPUT"
PERSONAL_INFO = "PERSONAL_INFO"
NAME_CAPTURE = "NAME_CAPTURE"
JOB_TYPE_SELECT = "JOB_TYPE_SELECT"
TARGET_POSITION = "TARGET_POSITION"
TARGET_POSITION_MAJOR = "TARGET_POSITION_MAJOR"
TARGET_POSITION_RECOMMENDATION = "TARGET_POSITION_RECOMMENDATION"
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"
BUILDER_CONVERSATION = "BUILDER_CONVERSATION"
class JobType(StrEnum):
CAMPUS = "campus"
SOCIAL = "social"
INTERNSHIP = "internship"
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
resume: BusinessResume | 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
resume: BusinessResume | None = None
missing_fields: list[str] = Field(default_factory=list)
builder_stream_phases: 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 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