from datetime import datetime from enum import Enum from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field, model_validator # ============ Enums ============ class AssistantConfigMode(str, Enum): PLATFORM = "platform" DIFY = "dify" FASTGPT = "fastgpt" NONE = "none" class LLMModelType(str, Enum): TEXT = "text" EMBEDDING = "embedding" RERANK = "rerank" class ASRLanguage(str, Enum): ZH = "zh" EN = "en" MULTILINGUAL = "Multi-lingual" class VoiceGender(str, Enum): MALE = "Male" FEMALE = "Female" class CallRecordSource(str, Enum): DEBUG = "debug" EXTERNAL = "external" class CallRecordStatus(str, Enum): CONNECTED = "connected" MISSED = "missed" FAILED = "failed" # ============ Voice ============ class VoiceBase(BaseModel): name: str vendor: str gender: str # "Male" | "Female" language: str # "zh" | "en" description: str = "" class VoiceCreate(VoiceBase): id: Optional[str] = None model: Optional[str] = None # 厂商语音模型标识 voice_key: Optional[str] = None # 厂商voice_key api_key: Optional[str] = None base_url: Optional[str] = None speed: float = 1.0 gain: int = 0 pitch: int = 0 enabled: bool = True class VoiceUpdate(BaseModel): name: Optional[str] = None vendor: Optional[str] = None gender: Optional[str] = None language: Optional[str] = None description: Optional[str] = None model: Optional[str] = None voice_key: Optional[str] = None api_key: Optional[str] = None base_url: Optional[str] = None speed: Optional[float] = None gain: Optional[int] = None pitch: Optional[int] = None enabled: Optional[bool] = None class VoiceOut(VoiceBase): id: str user_id: Optional[int] = None model: Optional[str] = None voice_key: Optional[str] = None api_key: Optional[str] = None base_url: Optional[str] = None speed: float = 1.0 gain: int = 0 pitch: int = 0 enabled: bool = True is_system: bool = False created_at: Optional[datetime] = None class Config: from_attributes = True class VoicePreviewRequest(BaseModel): text: str api_key: Optional[str] = None speed: Optional[float] = None gain: Optional[int] = None pitch: Optional[int] = None class VoicePreviewResponse(BaseModel): success: bool audio_url: Optional[str] = None duration_ms: Optional[int] = None error: Optional[str] = None # ============ LLM Model ============ class LLMModelBase(BaseModel): name: str vendor: str type: LLMModelType base_url: str api_key: str model_name: Optional[str] = None temperature: Optional[float] = None context_length: Optional[int] = None enabled: bool = True class LLMModelCreate(LLMModelBase): id: Optional[str] = None class LLMModelUpdate(BaseModel): name: Optional[str] = None vendor: Optional[str] = None type: Optional[LLMModelType] = None base_url: Optional[str] = None api_key: Optional[str] = None model_name: Optional[str] = None temperature: Optional[float] = None context_length: Optional[int] = None enabled: Optional[bool] = None class LLMModelOut(LLMModelBase): id: str user_id: int created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class Config: from_attributes = True class LLMModelTestResponse(BaseModel): success: bool latency_ms: Optional[int] = None message: Optional[str] = None class LLMPreviewRequest(BaseModel): message: str system_prompt: Optional[str] = None max_tokens: Optional[int] = None temperature: Optional[float] = None api_key: Optional[str] = None class LLMPreviewResponse(BaseModel): success: bool reply: Optional[str] = None usage: Optional[dict] = None latency_ms: Optional[int] = None error: Optional[str] = None # ============ ASR Model ============ class ASRModelBase(BaseModel): name: str vendor: str language: str # "zh" | "en" | "Multi-lingual" base_url: str api_key: str model_name: Optional[str] = None enabled: bool = True class ASRModelCreate(ASRModelBase): id: Optional[str] = None hotwords: List[str] = [] enable_punctuation: bool = True enable_normalization: bool = True class ASRModelUpdate(BaseModel): name: Optional[str] = None language: Optional[str] = None base_url: Optional[str] = None api_key: Optional[str] = None model_name: Optional[str] = None hotwords: Optional[List[str]] = None enable_punctuation: Optional[bool] = None enable_normalization: Optional[bool] = None enabled: Optional[bool] = None class ASRModelOut(ASRModelBase): id: str user_id: int hotwords: List[str] = [] enable_punctuation: bool = True enable_normalization: bool = True created_at: Optional[datetime] = None class Config: from_attributes = True class ASRTestRequest(BaseModel): audio_url: Optional[str] = None audio_data: Optional[str] = None # base64 encoded class ASRTestResponse(BaseModel): success: bool transcript: Optional[str] = None language: Optional[str] = None confidence: Optional[float] = None duration_ms: Optional[int] = None latency_ms: Optional[int] = None message: Optional[str] = None error: Optional[str] = None # ============ Tool Resource ============ class ToolResourceBase(BaseModel): name: str description: str = "" category: str = "system" # system/query icon: str = "Wrench" http_method: str = "GET" http_url: Optional[str] = None http_headers: Dict[str, str] = Field(default_factory=dict) http_timeout_ms: int = 10000 parameter_schema: Dict[str, Any] = Field(default_factory=dict) parameter_defaults: Dict[str, Any] = Field(default_factory=dict) wait_for_response: bool = False enabled: bool = True class ToolResourceCreate(ToolResourceBase): id: Optional[str] = None class ToolResourceUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None category: Optional[str] = None icon: Optional[str] = None http_method: Optional[str] = None http_url: Optional[str] = None http_headers: Optional[Dict[str, str]] = None http_timeout_ms: Optional[int] = None parameter_schema: Optional[Dict[str, Any]] = None parameter_defaults: Optional[Dict[str, Any]] = None wait_for_response: Optional[bool] = None enabled: Optional[bool] = None class ToolResourceOut(ToolResourceBase): id: str user_id: Optional[int] = None is_system: bool = False created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class Config: from_attributes = True # ============ Assistant ============ class AssistantBase(BaseModel): name: str firstTurnMode: str = "bot_first" opener: str = "" manualOpenerToolCalls: List[Dict[str, Any]] = [] generatedOpenerEnabled: bool = False openerAudioEnabled: bool = False prompt: str = "" knowledgeBaseId: Optional[str] = None language: str = "zh" voiceOutputEnabled: bool = True voice: Optional[str] = None speed: float = 1.0 hotwords: List[str] = [] tools: List[str] = [] botCannotBeInterrupted: bool = False interruptionSensitivity: int = 500 configMode: str = "platform" apiUrl: Optional[str] = None apiKey: Optional[str] = None # 模型关联 llmModelId: Optional[str] = None asrModelId: Optional[str] = None embeddingModelId: Optional[str] = None rerankModelId: Optional[str] = None class AssistantCreate(AssistantBase): pass class AssistantUpdate(BaseModel): name: Optional[str] = None firstTurnMode: Optional[str] = None opener: Optional[str] = None manualOpenerToolCalls: Optional[List[Dict[str, Any]]] = None generatedOpenerEnabled: Optional[bool] = None openerAudioEnabled: Optional[bool] = None prompt: Optional[str] = None knowledgeBaseId: Optional[str] = None language: Optional[str] = None voiceOutputEnabled: Optional[bool] = None voice: Optional[str] = None speed: Optional[float] = None hotwords: Optional[List[str]] = None tools: Optional[List[str]] = None botCannotBeInterrupted: Optional[bool] = None interruptionSensitivity: Optional[int] = None configMode: Optional[str] = None apiUrl: Optional[str] = None apiKey: Optional[str] = None llmModelId: Optional[str] = None asrModelId: Optional[str] = None embeddingModelId: Optional[str] = None rerankModelId: Optional[str] = None class AssistantOut(AssistantBase): id: str callCount: int = 0 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None class Config: from_attributes = True class AssistantRuntimeMetadata(BaseModel): """Canonical runtime metadata payload consumed by engine session.start.""" model_config = ConfigDict(extra="allow") systemPrompt: str = "" firstTurnMode: str = "bot_first" greeting: str = "" generatedOpenerEnabled: bool = False manualOpenerToolCalls: List[Dict[str, Any]] = Field(default_factory=list) output: Dict[str, Any] = Field(default_factory=dict) bargeIn: Dict[str, Any] = Field(default_factory=dict) services: Dict[str, Dict[str, Any]] = Field(default_factory=dict) tools: List[Any] = Field(default_factory=list) knowledgeBaseId: Optional[str] = None knowledge: Dict[str, Any] = Field(default_factory=dict) history: Dict[str, Any] = Field(default_factory=dict) openerAudio: Dict[str, Any] = Field(default_factory=dict) assistantId: Optional[str] = None configVersionId: Optional[str] = None class AssistantEngineConfigResponse(BaseModel): assistantId: str configVersionId: Optional[str] = None assistant: AssistantRuntimeMetadata sessionStartMetadata: AssistantRuntimeMetadata sources: Dict[str, Optional[str]] = Field(default_factory=dict) warnings: List[str] = Field(default_factory=list) class AssistantOpenerAudioGenerateRequest(BaseModel): text: Optional[str] = None class AssistantOpenerAudioOut(BaseModel): enabled: bool = False ready: bool = False encoding: str = "pcm_s16le" sample_rate_hz: int = 16000 channels: int = 1 duration_ms: int = 0 updated_at: Optional[datetime] = None text_hash: Optional[str] = None tts_fingerprint: Optional[str] = None class AssistantStats(BaseModel): assistant_id: str total_calls: int = 0 connected_calls: int = 0 missed_calls: int = 0 avg_duration_seconds: float = 0.0 today_calls: int = 0 # ============ Knowledge Base ============ class KnowledgeDocument(BaseModel): id: str name: str size: str fileType: str = "txt" storageUrl: Optional[str] = None status: str = "pending" chunkCount: int = 0 uploadDate: str class KnowledgeDocumentCreate(BaseModel): name: str size: str fileType: str = "txt" storageUrl: Optional[str] = None class KnowledgeDocumentUpdate(BaseModel): status: Optional[str] = None chunkCount: Optional[int] = None errorMessage: Optional[str] = None class KnowledgeBaseBase(BaseModel): name: str description: str = "" embeddingModel: str = "text-embedding-3-small" chunkSize: int = 500 chunkOverlap: int = 50 class KnowledgeBaseCreate(KnowledgeBaseBase): pass class KnowledgeBaseUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None embeddingModel: Optional[str] = None chunkSize: Optional[int] = None chunkOverlap: Optional[int] = None status: Optional[str] = None class KnowledgeBaseOut(KnowledgeBaseBase): id: str docCount: int = 0 chunkCount: int = 0 status: str = "active" createdAt: Optional[datetime] = None updatedAt: Optional[datetime] = None documents: List[KnowledgeDocument] = [] class Config: from_attributes = True # ============ Knowledge Search ============ class KnowledgeSearchQuery(BaseModel): query: str kb_id: str nResults: int = 5 class KnowledgeSearchResult(BaseModel): query: str results: List[dict] class DocumentIndexRequest(BaseModel): document_id: str content: str class KnowledgeStats(BaseModel): kb_id: str docCount: int chunkCount: int # ============ Workflow ============ class WorkflowNode(BaseModel): model_config = ConfigDict(extra="allow") id: Optional[str] = None name: str = "" type: str = "assistant" isStart: Optional[bool] = None metadata: Dict[str, Any] = Field(default_factory=dict) prompt: Optional[str] = None messagePlan: Optional[Dict[str, Any]] = None variableExtractionPlan: Optional[Dict[str, Any]] = None tool: Optional[Dict[str, Any]] = None globalNodePlan: Optional[Dict[str, Any]] = None assistantId: Optional[str] = None assistant: Optional[Dict[str, Any]] = None @model_validator(mode="before") @classmethod def _normalize_legacy_node(cls, data: Any) -> Any: if not isinstance(data, dict): return data raw = dict(data) node_id = raw.get("id") or raw.get("name") if not node_id: node_id = f"node_{abs(hash(str(raw))) % 100000}" raw["id"] = str(node_id) raw["name"] = str(raw.get("name") or raw["id"]) node_type = str(raw.get("type") or "assistant").lower() if node_type == "conversation": node_type = "assistant" elif node_type == "human": node_type = "human_transfer" elif node_type not in {"start", "assistant", "tool", "human_transfer", "end"}: node_type = "assistant" raw["type"] = node_type metadata = raw.get("metadata") if not isinstance(metadata, dict): metadata = {} if "position" not in metadata and isinstance(raw.get("position"), dict): metadata["position"] = raw.get("position") raw["metadata"] = metadata if raw.get("isStart") is None and node_type == "start": raw["isStart"] = True return raw class WorkflowEdge(BaseModel): model_config = ConfigDict(extra="allow") id: Optional[str] = None fromNodeId: str toNodeId: str label: Optional[str] = None condition: Optional[Dict[str, Any]] = None priority: int = 100 @model_validator(mode="before") @classmethod def _normalize_legacy_edge(cls, data: Any) -> Any: if not isinstance(data, dict): return data raw = dict(data) from_node = raw.get("fromNodeId") or raw.get("from") or raw.get("from_") or raw.get("source") to_node = raw.get("toNodeId") or raw.get("to") or raw.get("target") raw["fromNodeId"] = str(from_node or "") raw["toNodeId"] = str(to_node or "") if raw.get("id") is None: raw["id"] = f"e_{raw['fromNodeId']}_{raw['toNodeId']}" if raw.get("condition") is None: if raw.get("label"): raw["condition"] = {"type": "contains", "source": "user", "value": str(raw["label"])} else: raw["condition"] = {"type": "always"} return raw class WorkflowBase(BaseModel): name: str nodeCount: int = 0 createdAt: str = "" updatedAt: str = "" globalPrompt: Optional[str] = None nodes: List[WorkflowNode] = Field(default_factory=list) edges: List[WorkflowEdge] = Field(default_factory=list) class WorkflowCreate(WorkflowBase): @model_validator(mode="after") def _validate_graph(self) -> "WorkflowCreate": _validate_workflow_graph(self.nodes, self.edges) return self class WorkflowUpdate(BaseModel): name: Optional[str] = None nodeCount: Optional[int] = None nodes: Optional[List[WorkflowNode]] = None edges: Optional[List[WorkflowEdge]] = None globalPrompt: Optional[str] = None @model_validator(mode="after") def _validate_partial_graph(self) -> "WorkflowUpdate": if self.nodes is not None and self.edges is not None: _validate_workflow_graph(self.nodes, self.edges) return self class WorkflowOut(WorkflowBase): id: str @model_validator(mode="before") @classmethod def _normalize_db_fields(cls, data: Any) -> Any: if isinstance(data, dict): raw = dict(data) else: raw = { "id": getattr(data, "id", None), "name": getattr(data, "name", None), "node_count": getattr(data, "node_count", None), "created_at": getattr(data, "created_at", None), "updated_at": getattr(data, "updated_at", None), "global_prompt": getattr(data, "global_prompt", None), "nodes": getattr(data, "nodes", None), "edges": getattr(data, "edges", None), } if "nodeCount" not in raw and raw.get("node_count") is not None: raw["nodeCount"] = raw["node_count"] if "createdAt" not in raw and raw.get("created_at") is not None: raw["createdAt"] = raw["created_at"] if "updatedAt" not in raw and raw.get("updated_at") is not None: raw["updatedAt"] = raw["updated_at"] if "globalPrompt" not in raw and raw.get("global_prompt") is not None: raw["globalPrompt"] = raw["global_prompt"] return raw class Config: from_attributes = True def _validate_workflow_graph(nodes: List[WorkflowNode], edges: List[WorkflowEdge]) -> None: if not nodes: raise ValueError("Workflow must include at least one node") node_ids = [node.id for node in nodes if node.id] if len(node_ids) != len(set(node_ids)): raise ValueError("Workflow node ids must be unique") starts = [node for node in nodes if node.isStart or node.type == "start"] if not starts: raise ValueError("Workflow must define a start node (isStart=true or type=start)") known = set(node_ids) for edge in edges: if edge.fromNodeId not in known: raise ValueError(f"Workflow edge fromNodeId not found: {edge.fromNodeId}") if edge.toNodeId not in known: raise ValueError(f"Workflow edge toNodeId not found: {edge.toNodeId}") # ============ Call Record ============ class TranscriptSegment(BaseModel): turnIndex: int speaker: str # human/ai content: str confidence: Optional[float] = None startMs: int endMs: int durationMs: Optional[int] = None audioUrl: Optional[str] = None emotion: Optional[str] = None class CallRecordCreate(BaseModel): user_id: int assistant_id: Optional[str] = None source: str = "debug" status: Optional[str] = None cost: Optional[float] = None class CallRecordUpdate(BaseModel): status: Optional[str] = None summary: Optional[str] = None duration_seconds: Optional[int] = None ended_at: Optional[str] = None cost: Optional[float] = None metadata: Optional[dict] = None class CallRecordOut(BaseModel): id: str user_id: int assistant_id: Optional[str] = None source: str status: str started_at: str ended_at: Optional[str] = None duration_seconds: Optional[int] = None summary: Optional[str] = None cost: float = 0.0 metadata: dict = {} created_at: Optional[datetime] = None transcripts: List[TranscriptSegment] = [] class Config: from_attributes = True # ============ Call Transcript ============ class TranscriptCreate(BaseModel): turn_index: int speaker: str content: str confidence: Optional[float] = None start_ms: int end_ms: int duration_ms: Optional[int] = None emotion: Optional[str] = None class TranscriptOut(TranscriptCreate): id: int audio_url: Optional[str] = None class Config: from_attributes = True # ============ History Stats ============ class HistoryStats(BaseModel): total_calls: int = 0 connected_calls: int = 0 missed_calls: int = 0 failed_calls: int = 0 avg_duration_seconds: float = 0.0 total_cost: float = 0.0 by_status: dict = {} by_source: dict = {} daily_trend: List[dict] = [] # ============ Dashboard ============ class DashboardStats(BaseModel): totalCalls: int answerRate: int avgDuration: str humanTransferCount: int trend: List[dict] # ============ API Response ============ class Message(BaseModel): message: str class DocumentIndexRequest(BaseModel): content: str class ListResponse(BaseModel): total: int page: int limit: int list: List class SearchResult(BaseModel): id: str started_at: str matched_content: Optional[str] = None