697 lines
18 KiB
Python
697 lines
18 KiB
Python
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
|
|
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
|
|
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
|
|
opener: str = ""
|
|
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] = []
|
|
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
|
|
opener: Optional[str] = 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
|
|
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 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
|