Add analysis feature
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
/api/webrtc/ice-servers WebRTC STUN/TURN 配置
|
/api/webrtc/ice-servers WebRTC STUN/TURN 配置
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# NLTK 3.9+ blocks dependency imports when .venv lives under cwd (local dev layout).
|
# NLTK 3.9+ blocks dependency imports when .venv lives under cwd (local dev layout).
|
||||||
@@ -24,6 +25,7 @@ import settings
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from db.session import sync_default_tools, sync_interface_definitions
|
from db.session import sync_default_tools, sync_interface_definitions
|
||||||
from services.knowledge import recover_interrupted_documents
|
from services.knowledge import recover_interrupted_documents
|
||||||
|
from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -48,9 +50,15 @@ async def lifespan(_app: FastAPI):
|
|||||||
await sync_interface_definitions()
|
await sync_interface_definitions()
|
||||||
await sync_default_tools()
|
await sync_default_tools()
|
||||||
await recover_interrupted_documents()
|
await recover_interrupted_documents()
|
||||||
|
await recover_interrupted_analyses()
|
||||||
|
analysis_worker = asyncio.create_task(
|
||||||
|
run_analysis_worker(), name="post-call-analysis-worker"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
analysis_worker.cancel()
|
||||||
|
await asyncio.gather(analysis_worker, return_exceptions=True)
|
||||||
await voice_webrtc.shutdown_active_sessions()
|
await voice_webrtc.shutdown_active_sessions()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ class Assistant(Base):
|
|||||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||||
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
|
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
analysis_config: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode
|
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode
|
||||||
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
|
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
|
||||||
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
|
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
|
||||||
@@ -313,6 +314,11 @@ class ConversationSession(Base):
|
|||||||
ended_at: Mapped[datetime | None] = mapped_column(
|
ended_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
analysis_status: Mapped[str] = mapped_column(
|
||||||
|
String(16), index=True, default="none"
|
||||||
|
)
|
||||||
|
analysis_data: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
analysis_error: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""add post-call analysis configuration and results
|
||||||
|
|
||||||
|
Revision ID: 20260807_0013
|
||||||
|
Revises: 20260804_0012
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260807_0013"
|
||||||
|
down_revision: str | Sequence[str] | None = "20260804_0012"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"assistants",
|
||||||
|
sa.Column(
|
||||||
|
"analysis_config",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'{}'::jsonb"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"conversation_sessions",
|
||||||
|
sa.Column(
|
||||||
|
"analysis_status",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
server_default="none",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"conversation_sessions",
|
||||||
|
sa.Column(
|
||||||
|
"analysis_data",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'{}'::jsonb"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"conversation_sessions",
|
||||||
|
sa.Column(
|
||||||
|
"analysis_error",
|
||||||
|
sa.String(length=2048),
|
||||||
|
nullable=False,
|
||||||
|
server_default="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_conversation_sessions_analysis_status",
|
||||||
|
"conversation_sessions",
|
||||||
|
["analysis_status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_conversation_sessions_analysis_status",
|
||||||
|
table_name="conversation_sessions",
|
||||||
|
)
|
||||||
|
op.drop_column("conversation_sessions", "analysis_error")
|
||||||
|
op.drop_column("conversation_sessions", "analysis_data")
|
||||||
|
op.drop_column("conversation_sessions", "analysis_status")
|
||||||
|
op.drop_column("assistants", "analysis_config")
|
||||||
@@ -107,6 +107,7 @@ class AssistantConfig(BaseModel):
|
|||||||
enableInterrupt: bool = True
|
enableInterrupt: bool = True
|
||||||
turnConfig: dict = Field(default_factory=dict)
|
turnConfig: dict = Field(default_factory=dict)
|
||||||
startup: dict = Field(default_factory=dict)
|
startup: dict = Field(default_factory=dict)
|
||||||
|
analysis_config: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
# ``tools`` is the complete runtime pool (conversation + lifecycle actions).
|
# ``tools`` is the complete runtime pool (conversation + lifecycle actions).
|
||||||
# ``llm_tool_ids`` limits which tools are advertised to a Prompt model. None
|
# ``llm_tool_ids`` limits which tools are advertised to a Prompt model. None
|
||||||
|
|||||||
@@ -277,6 +277,17 @@ async def _validate_vision_model(
|
|||||||
raise HTTPException(400, "视觉模型必须支持图片输入")
|
raise HTTPException(400, "视觉模型必须支持图片输入")
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_analysis_model(
|
||||||
|
session: AsyncSession, body: AssistantUpsert
|
||||||
|
) -> None:
|
||||||
|
config = body.analysis_config
|
||||||
|
if not config.enabled:
|
||||||
|
return
|
||||||
|
resource = await session.get(ModelResource, config.model_resource_id)
|
||||||
|
if not resource or not resource.enabled or resource.capability != "LLM":
|
||||||
|
raise HTTPException(400, "分析模型必须引用已启用的 LLM 模型资源")
|
||||||
|
|
||||||
|
|
||||||
async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) -> None:
|
async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) -> None:
|
||||||
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
|
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
|
||||||
body.knowledge_base_id = None
|
body.knowledge_base_id = None
|
||||||
@@ -384,6 +395,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|||||||
tool_ids=await _tool_ids(session, assistant.id),
|
tool_ids=await _tool_ids(session, assistant.id),
|
||||||
prompt=assistant.prompt,
|
prompt=assistant.prompt,
|
||||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||||
|
analysis_config=assistant.analysis_config or {},
|
||||||
api_url=assistant.api_url,
|
api_url=assistant.api_url,
|
||||||
api_key=mask(assistant.api_key),
|
api_key=mask(assistant.api_key),
|
||||||
app_id=assistant.app_id,
|
app_id=assistant.app_id,
|
||||||
@@ -413,6 +425,7 @@ async def create_assistant(
|
|||||||
await _validate_system_tool_selection(session, body)
|
await _validate_system_tool_selection(session, body)
|
||||||
await _validate_startup_actions(session, body)
|
await _validate_startup_actions(session, body)
|
||||||
await _validate_vision_model(session, body)
|
await _validate_vision_model(session, body)
|
||||||
|
await _validate_analysis_model(session, body)
|
||||||
await _validate_knowledge_base(session, body)
|
await _validate_knowledge_base(session, body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
resource_ids = data.pop("model_resource_ids")
|
resource_ids = data.pop("model_resource_ids")
|
||||||
@@ -459,6 +472,7 @@ async def duplicate_assistant(
|
|||||||
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
||||||
prompt=source.prompt,
|
prompt=source.prompt,
|
||||||
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
||||||
|
analysis_config=dict(source.analysis_config or {}),
|
||||||
api_url=source.api_url,
|
api_url=source.api_url,
|
||||||
api_key=source.api_key,
|
api_key=source.api_key,
|
||||||
app_id=source.app_id,
|
app_id=source.app_id,
|
||||||
@@ -489,6 +503,7 @@ async def update_assistant(
|
|||||||
await _validate_system_tool_selection(session, body)
|
await _validate_system_tool_selection(session, body)
|
||||||
await _validate_startup_actions(session, body)
|
await _validate_startup_actions(session, body)
|
||||||
await _validate_vision_model(session, body)
|
await _validate_vision_model(session, body)
|
||||||
|
await _validate_analysis_model(session, body)
|
||||||
await _validate_knowledge_base(session, body)
|
await _validate_knowledge_base(session, body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
resource_ids = data.pop("model_resource_ids")
|
resource_ids = data.pop("model_resource_ids")
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from schemas import (
|
from schemas import (
|
||||||
ConversationArtifactOut,
|
ConversationArtifactOut,
|
||||||
|
ConversationAnalysisFieldOut,
|
||||||
|
ConversationAnalysisOut,
|
||||||
ConversationDetailOut,
|
ConversationDetailOut,
|
||||||
ConversationListOut,
|
ConversationListOut,
|
||||||
ConversationMessageOut,
|
ConversationMessageOut,
|
||||||
@@ -40,6 +42,33 @@ def _session_out(row: ConversationSession) -> ConversationOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_out(row: ConversationSession) -> ConversationAnalysisOut:
|
||||||
|
data = row.analysis_data or {}
|
||||||
|
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
|
||||||
|
definitions = plan.get("fields") if isinstance(plan.get("fields"), list) else []
|
||||||
|
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||||
|
fields = []
|
||||||
|
for definition in definitions:
|
||||||
|
if not isinstance(definition, dict):
|
||||||
|
continue
|
||||||
|
name = str(definition.get("name") or "")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
fields.append(
|
||||||
|
ConversationAnalysisFieldOut(
|
||||||
|
name=name,
|
||||||
|
type=str(definition.get("type") or "string"),
|
||||||
|
value=result.get(name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ConversationAnalysisOut(
|
||||||
|
status=row.analysis_status or "none",
|
||||||
|
fields=fields,
|
||||||
|
error=row.analysis_error or "",
|
||||||
|
completed_at=data.get("completedAt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ConversationListOut)
|
@router.get("", response_model=ConversationListOut)
|
||||||
async def list_conversations(
|
async def list_conversations(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -119,6 +148,7 @@ async def get_conversation(
|
|||||||
return ConversationDetailOut(
|
return ConversationDetailOut(
|
||||||
**_session_out(conversation).model_dump(),
|
**_session_out(conversation).model_dump(),
|
||||||
extra=conversation.extra or {},
|
extra=conversation.extra or {},
|
||||||
|
analysis=_analysis_out(conversation),
|
||||||
messages=[
|
messages=[
|
||||||
ConversationMessageOut(
|
ConversationMessageOut(
|
||||||
id=message.id,
|
id=message.id,
|
||||||
|
|||||||
@@ -90,6 +90,45 @@ class KnowledgeRetrievalConfig(CamelModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisField(CamelModel):
|
||||||
|
id: str = Field(min_length=1, max_length=64)
|
||||||
|
name: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||||
|
type: Literal["string", "boolean", "integer", "number", "enum"] = "string"
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
enum_values: list[str] = Field(default_factory=list, max_length=30)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_enum_values(self):
|
||||||
|
normalized = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
value.strip() for value in self.enum_values if value.strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.type == "enum" and not normalized:
|
||||||
|
raise ValueError("enum 字段必须至少配置一个枚举值")
|
||||||
|
self.enum_values = normalized if self.type == "enum" else []
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisConfig(CamelModel):
|
||||||
|
enabled: bool = False
|
||||||
|
model_resource_id: str = ""
|
||||||
|
fields: list[AnalysisField] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_enabled_config(self):
|
||||||
|
if not self.enabled:
|
||||||
|
return self
|
||||||
|
if not self.model_resource_id:
|
||||||
|
raise ValueError("开启通话后分析时必须选择分析模型")
|
||||||
|
if not self.fields:
|
||||||
|
raise ValueError("开启通话后分析时必须配置至少一个关键信息字段")
|
||||||
|
names = [field.name for field in self.fields]
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
raise ValueError("关键信息字段名不能重复")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
||||||
ALLOWED_FIELDS: dict[str, set[str]] = {
|
ALLOWED_FIELDS: dict[str, set[str]] = {
|
||||||
"prompt": {"prompt"},
|
"prompt": {"prompt"},
|
||||||
@@ -171,6 +210,7 @@ class AssistantUpsert(CamelModel):
|
|||||||
dynamic_variable_definitions: dict[str, "DynamicVariableDefinition"] = Field(
|
dynamic_variable_definitions: dict[str, "DynamicVariableDefinition"] = Field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
)
|
)
|
||||||
|
analysis_config: AnalysisConfig = Field(default_factory=AnalysisConfig)
|
||||||
api_url: str = ""
|
api_url: str = ""
|
||||||
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
||||||
app_id: str = ""
|
app_id: str = ""
|
||||||
@@ -500,9 +540,25 @@ class ConversationOut(CamelModel):
|
|||||||
ended_at: datetime | None
|
ended_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationAnalysisFieldOut(CamelModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
value: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationAnalysisOut(CamelModel):
|
||||||
|
status: Literal[
|
||||||
|
"none", "pending", "processing", "completed", "failed"
|
||||||
|
] = "none"
|
||||||
|
fields: list[ConversationAnalysisFieldOut] = Field(default_factory=list)
|
||||||
|
error: str = ""
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConversationDetailOut(ConversationOut):
|
class ConversationDetailOut(ConversationOut):
|
||||||
extra: dict[str, Any] = Field(default_factory=dict)
|
extra: dict[str, Any] = Field(default_factory=dict)
|
||||||
messages: list[ConversationMessageOut] = Field(default_factory=list)
|
messages: list[ConversationMessageOut] = Field(default_factory=list)
|
||||||
|
analysis: ConversationAnalysisOut = Field(default_factory=ConversationAnalysisOut)
|
||||||
|
|
||||||
|
|
||||||
class ConversationListOut(CamelModel):
|
class ConversationListOut(CamelModel):
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ async def resolve_runtime_config(
|
|||||||
enableInterrupt=assistant.enable_interrupt,
|
enableInterrupt=assistant.enable_interrupt,
|
||||||
turnConfig=assistant.turn_config or {},
|
turnConfig=assistant.turn_config or {},
|
||||||
startup=assistant.startup or {},
|
startup=assistant.startup or {},
|
||||||
|
analysis_config=assistant.analysis_config or {},
|
||||||
tools=runtime_tools,
|
tools=runtime_tools,
|
||||||
llm_tool_ids=llm_tool_ids,
|
llm_tool_ids=llm_tool_ids,
|
||||||
knowledge_base_id=assistant.knowledge_base_id,
|
knowledge_base_id=assistant.knowledge_base_id,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ def _parse_timestamp(value: object) -> datetime:
|
|||||||
class ConversationRecorder:
|
class ConversationRecorder:
|
||||||
"""按事件顺序写入一通会话;写库失败不应中断实时通话。"""
|
"""按事件顺序写入一通会话;写库失败不应中断实时通话。"""
|
||||||
|
|
||||||
def __init__(self, session_id: str):
|
def __init__(self, session_id: str, analysis_plan: dict | None = None):
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
|
self._analysis_plan = deepcopy(analysis_plan or {})
|
||||||
self._sequence = 0
|
self._sequence = 0
|
||||||
self._trace_sequence = 0
|
self._trace_sequence = 0
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
@@ -49,6 +50,7 @@ class ConversationRecorder:
|
|||||||
runtime_mode: str,
|
runtime_mode: str,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
extra: dict | None = None,
|
extra: dict | None = None,
|
||||||
|
analysis_plan: dict | None = None,
|
||||||
) -> "ConversationRecorder | None":
|
) -> "ConversationRecorder | None":
|
||||||
session_id = session_id or f"conv_{uuid4().hex[:20]}"
|
session_id = session_id or f"conv_{uuid4().hex[:20]}"
|
||||||
try:
|
try:
|
||||||
@@ -62,11 +64,17 @@ class ConversationRecorder:
|
|||||||
runtime_mode=runtime_mode,
|
runtime_mode=runtime_mode,
|
||||||
status="active",
|
status="active",
|
||||||
message_count=0,
|
message_count=0,
|
||||||
|
analysis_status="none",
|
||||||
|
analysis_data=(
|
||||||
|
{"plan": deepcopy(analysis_plan)}
|
||||||
|
if analysis_plan and analysis_plan.get("enabled")
|
||||||
|
else {}
|
||||||
|
),
|
||||||
extra=deepcopy(extra or {}),
|
extra=deepcopy(extra or {}),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return cls(session_id)
|
return cls(session_id, analysis_plan)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"创建对话历史会话失败,不影响本次通话: {exc}")
|
logger.error(f"创建对话历史会话失败,不影响本次通话: {exc}")
|
||||||
return None
|
return None
|
||||||
@@ -289,6 +297,16 @@ class ConversationRecorder:
|
|||||||
conversation.status = status
|
conversation.status = status
|
||||||
conversation.ended_at = datetime.now(UTC)
|
conversation.ended_at = datetime.now(UTC)
|
||||||
conversation.message_count = self._sequence
|
conversation.message_count = self._sequence
|
||||||
|
if (
|
||||||
|
status == "completed"
|
||||||
|
and self._sequence > 0
|
||||||
|
and self._analysis_plan.get("enabled")
|
||||||
|
):
|
||||||
|
conversation.analysis_status = "pending"
|
||||||
|
conversation.analysis_error = ""
|
||||||
|
conversation.analysis_data = {
|
||||||
|
"plan": deepcopy(self._analysis_plan)
|
||||||
|
}
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"结束对话历史会话失败: {exc}")
|
logger.error(f"结束对话历史会话失败: {exc}")
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ async def run_pipeline(
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
runtime_mode=cfg.runtimeMode,
|
runtime_mode=cfg.runtimeMode,
|
||||||
session_id=cfg.conversation_id or None,
|
session_id=cfg.conversation_id or None,
|
||||||
|
analysis_plan=cfg.analysis_config,
|
||||||
extra=(workflow_engine.session_metadata() if workflow_engine else None),
|
extra=(workflow_engine.session_metadata() if workflow_engine else None),
|
||||||
)
|
)
|
||||||
pipeline = Pipeline(
|
pipeline = Pipeline(
|
||||||
@@ -952,6 +953,7 @@ async def run_realtime_pipeline(
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
runtime_mode=cfg.runtimeMode,
|
runtime_mode=cfg.runtimeMode,
|
||||||
session_id=cfg.conversation_id or None,
|
session_id=cfg.conversation_id or None,
|
||||||
|
analysis_plan=cfg.analysis_config,
|
||||||
extra=(
|
extra=(
|
||||||
WorkflowEngine(cfg.graph).session_metadata()
|
WorkflowEngine(cfg.graph).session_metadata()
|
||||||
if cfg.type == "workflow"
|
if cfg.type == "workflow"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Post-call structured analysis, independent from the realtime pipeline."""
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Extract configured structured fields from one completed conversation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from db.models import ConversationMessage, ConversationSession, ModelResource
|
||||||
|
from db.session import SessionLocal
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
|
ANALYSIS_TIMEOUT_SECONDS = 60.0
|
||||||
|
MAX_TRANSCRIPT_CHARS = 120_000
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(base_url: str, path: str) -> str:
|
||||||
|
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_schema(fields: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
properties: dict[str, Any] = {}
|
||||||
|
for field in fields:
|
||||||
|
field_type = str(field.get("type") or "string")
|
||||||
|
schema: dict[str, Any] = {
|
||||||
|
"description": str(field.get("description") or ""),
|
||||||
|
}
|
||||||
|
if field_type == "enum":
|
||||||
|
schema.update(
|
||||||
|
{
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": [*(field.get("enum_values") or []), None],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
schema["type"] = [field_type, "null"]
|
||||||
|
properties[str(field["name"])] = schema
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": list(properties),
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript(messages: list[ConversationMessage]) -> str:
|
||||||
|
lines = [
|
||||||
|
f"[{message.role}] {message.content.strip()}"
|
||||||
|
for message in messages
|
||||||
|
if message.content_type == "text" and message.content.strip()
|
||||||
|
]
|
||||||
|
transcript = "\n".join(lines)
|
||||||
|
if len(transcript) <= MAX_TRANSCRIPT_CHARS:
|
||||||
|
return transcript
|
||||||
|
return "[较早内容已截断]\n" + transcript[-MAX_TRANSCRIPT_CHARS:]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_content(content: object) -> dict[str, Any]:
|
||||||
|
text = str(content or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.splitlines()
|
||||||
|
if lines and lines[0].startswith("```"):
|
||||||
|
lines = lines[1:]
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
text = "\n".join(lines).strip()
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("分析模型必须返回 JSON 对象")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_result(
|
||||||
|
raw: dict[str, Any], fields: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for field in fields:
|
||||||
|
name = str(field["name"])
|
||||||
|
field_type = str(field.get("type") or "string")
|
||||||
|
value = raw.get(name)
|
||||||
|
valid = value is None
|
||||||
|
if field_type == "string":
|
||||||
|
valid = valid or isinstance(value, str)
|
||||||
|
elif field_type == "boolean":
|
||||||
|
valid = valid or isinstance(value, bool)
|
||||||
|
elif field_type == "integer":
|
||||||
|
valid = valid or (isinstance(value, int) and not isinstance(value, bool))
|
||||||
|
elif field_type == "number":
|
||||||
|
valid = valid or (
|
||||||
|
isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
)
|
||||||
|
elif field_type == "enum":
|
||||||
|
valid = valid or (
|
||||||
|
isinstance(value, str)
|
||||||
|
and value in list(field.get("enum_values") or [])
|
||||||
|
)
|
||||||
|
result[name] = value if valid else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_analysis(
|
||||||
|
resource: ModelResource,
|
||||||
|
fields: list[dict[str, Any]],
|
||||||
|
transcript: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
values = resource.values or {}
|
||||||
|
secrets = resource.secrets or {}
|
||||||
|
api_url = str(values.get("apiUrl") or "")
|
||||||
|
api_key = str(secrets.get("apiKey") or "")
|
||||||
|
model_id = str(values.get("modelId") or "")
|
||||||
|
if resource.interface_type != "openai-llm":
|
||||||
|
raise ValueError(f"分析暂不支持模型接口:{resource.interface_type}")
|
||||||
|
if not api_url or not api_key or not model_id:
|
||||||
|
raise ValueError("分析模型资源缺少 apiUrl、apiKey 或 modelId")
|
||||||
|
|
||||||
|
schema = _json_schema(fields)
|
||||||
|
system_prompt = (
|
||||||
|
"你是通话关键信息提取器。只能使用对话中明确出现的信息,禁止猜测、"
|
||||||
|
"补全或编造。无法确定的字段必须返回 null。严格按照给定 JSON Schema "
|
||||||
|
"返回一个 JSON 对象,不要输出解释或 Markdown。\n\nJSON Schema:\n"
|
||||||
|
+ json.dumps(schema, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=ANALYSIS_TIMEOUT_SECONDS) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_endpoint(api_url, "chat/completions"),
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
json={
|
||||||
|
"model": model_id,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": transcript},
|
||||||
|
],
|
||||||
|
"temperature": 0,
|
||||||
|
"stream": False,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
choices = payload.get("choices") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(choices, list) or not choices:
|
||||||
|
raise ValueError("分析模型没有返回 choices")
|
||||||
|
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
||||||
|
content = message.get("content") if isinstance(message, dict) else None
|
||||||
|
return _validated_result(_parse_json_content(content), fields)
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_conversation(conversation_id: str) -> None:
|
||||||
|
"""Analyze one claimed conversation and persist its terminal state."""
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
conversation = await session.get(ConversationSession, conversation_id)
|
||||||
|
if not conversation or conversation.analysis_status != "processing":
|
||||||
|
return
|
||||||
|
data = dict(conversation.analysis_data or {})
|
||||||
|
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
|
||||||
|
fields = plan.get("fields") if isinstance(plan.get("fields"), list) else []
|
||||||
|
resource_id = str(plan.get("model_resource_id") or "")
|
||||||
|
resource = await session.get(ModelResource, resource_id)
|
||||||
|
if not resource or not resource.enabled or resource.capability != "LLM":
|
||||||
|
raise ValueError("分析模型不存在、未启用或不是 LLM 资源")
|
||||||
|
messages = (
|
||||||
|
await session.execute(
|
||||||
|
select(ConversationMessage)
|
||||||
|
.where(ConversationMessage.session_id == conversation_id)
|
||||||
|
.order_by(ConversationMessage.sequence)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
transcript = _transcript(list(messages))
|
||||||
|
if not transcript:
|
||||||
|
raise ValueError("会话没有可分析的文本转写")
|
||||||
|
result = await _request_analysis(resource, list(fields), transcript)
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
conversation = await session.get(ConversationSession, conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
return
|
||||||
|
data = dict(conversation.analysis_data or {})
|
||||||
|
data["result"] = result
|
||||||
|
data["completedAt"] = datetime.now(UTC).isoformat()
|
||||||
|
conversation.analysis_data = data
|
||||||
|
conversation.analysis_status = "completed"
|
||||||
|
conversation.analysis_error = ""
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
conversation = await session.get(ConversationSession, conversation_id)
|
||||||
|
if conversation:
|
||||||
|
conversation.analysis_status = "failed"
|
||||||
|
conversation.analysis_error = str(exc)[:2048]
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Small PostgreSQL-backed worker for post-call analysis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from db.models import ConversationSession
|
||||||
|
from db.session import SessionLocal
|
||||||
|
from loguru import logger
|
||||||
|
from services.post_call.analyzer import analyze_conversation
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
|
||||||
|
POLL_INTERVAL_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
async def recover_interrupted_analyses() -> None:
|
||||||
|
"""Return work interrupted by a previous process shutdown to the queue."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(
|
||||||
|
update(ConversationSession)
|
||||||
|
.where(ConversationSession.analysis_status == "processing")
|
||||||
|
.values(analysis_status="pending", analysis_error="")
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def claim_pending_analysis() -> str | None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(ConversationSession)
|
||||||
|
.where(ConversationSession.analysis_status == "pending")
|
||||||
|
.order_by(ConversationSession.ended_at)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
row.analysis_status = "processing"
|
||||||
|
row.analysis_error = ""
|
||||||
|
await session.commit()
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
async def run_analysis_worker() -> None:
|
||||||
|
logger.info("通话后分析 worker 已启动")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conversation_id = await claim_pending_analysis()
|
||||||
|
if conversation_id:
|
||||||
|
await analyze_conversation(conversation_id)
|
||||||
|
continue
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"通话后分析 worker 暂时不可用:{exc}")
|
||||||
|
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
||||||
@@ -9,6 +9,45 @@ from services.conversation_history import ConversationRecorder
|
|||||||
|
|
||||||
|
|
||||||
class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_completed_conversation_queues_enabled_analysis(self):
|
||||||
|
conversation = SimpleNamespace(
|
||||||
|
status="active",
|
||||||
|
ended_at=None,
|
||||||
|
message_count=0,
|
||||||
|
analysis_status="none",
|
||||||
|
analysis_data={},
|
||||||
|
analysis_error="",
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, _model, _session_id):
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
plan = {
|
||||||
|
"enabled": True,
|
||||||
|
"model_resource_id": "model_001",
|
||||||
|
"fields": [{"name": "customer_name", "type": "string"}],
|
||||||
|
}
|
||||||
|
recorder = ConversationRecorder("conv_test", plan)
|
||||||
|
recorder._sequence = 1
|
||||||
|
with patch(
|
||||||
|
"services.conversation_history.SessionLocal",
|
||||||
|
return_value=FakeSession(),
|
||||||
|
):
|
||||||
|
await recorder._finish(status="completed")
|
||||||
|
|
||||||
|
self.assertEqual(conversation.analysis_status, "pending")
|
||||||
|
self.assertEqual(conversation.analysis_data, {"plan": plan})
|
||||||
|
|
||||||
async def test_finish_waits_for_database_cleanup_when_cancelled(self):
|
async def test_finish_waits_for_database_cleanup_when_cancelled(self):
|
||||||
recorder = ConversationRecorder("conv_test")
|
recorder = ConversationRecorder("conv_test")
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from schemas import AnalysisConfig
|
||||||
|
from services.post_call.analyzer import (
|
||||||
|
_json_schema,
|
||||||
|
_parse_json_content,
|
||||||
|
_validated_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
{
|
||||||
|
"id": "intent",
|
||||||
|
"name": "customer_intent",
|
||||||
|
"type": "enum",
|
||||||
|
"description": "客户意向",
|
||||||
|
"enum_values": ["high", "low"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "follow_up",
|
||||||
|
"name": "need_follow_up",
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否需要跟进",
|
||||||
|
"enum_values": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisConfigTest(unittest.TestCase):
|
||||||
|
def test_enabled_analysis_requires_model_and_fields(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
AnalysisConfig(enabled=True)
|
||||||
|
|
||||||
|
def test_field_names_must_be_unique(self):
|
||||||
|
field = {
|
||||||
|
"id": "one",
|
||||||
|
"name": "customer_name",
|
||||||
|
"type": "string",
|
||||||
|
"description": "客户姓名",
|
||||||
|
}
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
AnalysisConfig(
|
||||||
|
enabled=True,
|
||||||
|
model_resource_id="model_001",
|
||||||
|
fields=[field, {**field, "id": "two"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredExtractionTest(unittest.TestCase):
|
||||||
|
def test_schema_marks_every_field_nullable_and_required(self):
|
||||||
|
schema = _json_schema(FIELDS)
|
||||||
|
self.assertEqual(
|
||||||
|
schema["required"], ["customer_intent", "need_follow_up"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
schema["properties"]["customer_intent"]["enum"],
|
||||||
|
["high", "low", None],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_values_are_normalized_to_null(self):
|
||||||
|
result = _validated_result(
|
||||||
|
{"customer_intent": "medium", "need_follow_up": "yes"},
|
||||||
|
FIELDS,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{"customer_intent": None, "need_follow_up": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_json_parser_accepts_fenced_model_output(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_parse_json_content('```json\n{"need_follow_up": true}\n```'),
|
||||||
|
{"need_follow_up": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { Loader2, Plus, Send, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
|
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,47 +12,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import type {
|
||||||
|
AnalysisConfig,
|
||||||
|
AnalysisField,
|
||||||
|
AnalysisFieldType,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
export type AnalysisFieldType =
|
export type { AnalysisConfig, AnalysisField, AnalysisFieldType } from "@/lib/api";
|
||||||
| "string"
|
|
||||||
| "boolean"
|
|
||||||
| "integer"
|
|
||||||
| "number"
|
|
||||||
| "enum";
|
|
||||||
|
|
||||||
export type AnalysisField = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: AnalysisFieldType;
|
|
||||||
description: string;
|
|
||||||
enumValues: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AnalysisConfig = {
|
|
||||||
enabled: boolean;
|
|
||||||
modelResourceId: string;
|
|
||||||
fields: AnalysisField[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebhookConfig = {
|
|
||||||
url: string;
|
|
||||||
secret: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function defaultAnalysisConfig(): AnalysisConfig {
|
|
||||||
return {
|
|
||||||
enabled: false,
|
|
||||||
modelResourceId: "",
|
|
||||||
fields: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultWebhookConfig(): WebhookConfig {
|
|
||||||
return {
|
|
||||||
url: "",
|
|
||||||
secret: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIELD_TYPE_OPTIONS: Array<{
|
const FIELD_TYPE_OPTIONS: Array<{
|
||||||
value: AnalysisFieldType;
|
value: AnalysisFieldType;
|
||||||
@@ -76,37 +41,6 @@ function createField(): AnalysisField {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMockPayload(fields: AnalysisField[]) {
|
|
||||||
const extracted: Record<string, unknown> = {};
|
|
||||||
for (const field of fields) {
|
|
||||||
if (!field.name.trim()) continue;
|
|
||||||
switch (field.type) {
|
|
||||||
case "boolean":
|
|
||||||
extracted[field.name] = true;
|
|
||||||
break;
|
|
||||||
case "integer":
|
|
||||||
extracted[field.name] = 1;
|
|
||||||
break;
|
|
||||||
case "number":
|
|
||||||
extracted[field.name] = 1.5;
|
|
||||||
break;
|
|
||||||
case "enum":
|
|
||||||
extracted[field.name] = field.enumValues[0] ?? "option_a";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
extracted[field.name] = "示例值";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
event: "call.analysis.completed",
|
|
||||||
conversation_id: "mock-conv-001",
|
|
||||||
assistant_id: "mock-assistant-001",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
analysis: extracted,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type AnalysisConfigEditorProps = {
|
type AnalysisConfigEditorProps = {
|
||||||
config: AnalysisConfig;
|
config: AnalysisConfig;
|
||||||
onChange: (config: AnalysisConfig) => void;
|
onChange: (config: AnalysisConfig) => void;
|
||||||
@@ -186,7 +120,7 @@ export function AnalysisConfigEditor({
|
|||||||
还没有关键信息字段
|
还没有关键信息字段
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
点击右上角加号添加字段,例如「客户意向」「是否预约」等。
|
点击右上角加号添加字段,例如 customer_intent、booked。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -201,7 +135,7 @@ export function AnalysisConfigEditor({
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateField(field.id, { name: event.target.value })
|
updateField(field.id, { name: event.target.value })
|
||||||
}
|
}
|
||||||
placeholder="字段名"
|
placeholder="字段名,如 customer_intent"
|
||||||
aria-label={`字段 ${index + 1} 名称`}
|
aria-label={`字段 ${index + 1} 名称`}
|
||||||
className="h-9 border-hairline-strong bg-background"
|
className="h-9 border-hairline-strong bg-background"
|
||||||
/>
|
/>
|
||||||
@@ -276,111 +210,3 @@ export function AnalysisConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebhookConfigEditorProps = {
|
|
||||||
config: WebhookConfig;
|
|
||||||
onChange: (config: WebhookConfig) => void;
|
|
||||||
analysisFields: AnalysisField[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WebhookConfigEditor({
|
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
analysisFields,
|
|
||||||
}: WebhookConfigEditorProps) {
|
|
||||||
const [testStatus, setTestStatus] = useState<
|
|
||||||
"idle" | "loading" | "success" | "error"
|
|
||||||
>("idle");
|
|
||||||
const [testMessage, setTestMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function patch(partial: Partial<WebhookConfig>) {
|
|
||||||
onChange({ ...config, ...partial });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTestEvent() {
|
|
||||||
if (!config.url.trim()) {
|
|
||||||
setTestStatus("error");
|
|
||||||
setTestMessage("请先填写 Webhook URL。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus("loading");
|
|
||||||
setTestMessage(null);
|
|
||||||
|
|
||||||
const payload = buildMockPayload(analysisFields);
|
|
||||||
|
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
|
||||||
|
|
||||||
setTestStatus("success");
|
|
||||||
setTestMessage(
|
|
||||||
`测试事件已模拟发送至 ${config.url.trim()}(Mock,未实际请求网络)。示例 payload:${JSON.stringify(payload)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
|
||||||
Webhook URL
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={config.url}
|
|
||||||
onChange={(event) => patch({ url: event.target.value })}
|
|
||||||
placeholder="https://example.com/webhooks/call-analysis"
|
|
||||||
className="border-hairline-strong bg-background"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
|
||||||
签名密钥
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={config.secret}
|
|
||||||
onChange={(event) => patch({ secret: event.target.value })}
|
|
||||||
placeholder="可选,用于验证 Webhook 请求来源"
|
|
||||||
className="border-hairline-strong bg-background"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-t border-hairline pt-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2 border-hairline-strong"
|
|
||||||
disabled={testStatus === "loading"}
|
|
||||||
onClick={() => void sendTestEvent()}
|
|
||||||
>
|
|
||||||
{testStatus === "loading" ? (
|
|
||||||
<Loader2 size={15} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send size={15} />
|
|
||||||
)}
|
|
||||||
发送测试事件
|
|
||||||
</Button>
|
|
||||||
{testStatus === "success" && (
|
|
||||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
|
||||||
模拟发送成功
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{testStatus === "error" && (
|
|
||||||
<span className="text-xs text-destructive">发送失败</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{testMessage && (
|
|
||||||
<p
|
|
||||||
role="status"
|
|
||||||
className={`rounded-xl border px-3.5 py-3 text-xs leading-5 ${
|
|
||||||
testStatus === "error"
|
|
||||||
? "border-destructive/30 bg-destructive/5 text-destructive"
|
|
||||||
: "border-hairline bg-canvas-soft text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AnalysisConfigEditor,
|
AnalysisConfigEditor,
|
||||||
WebhookConfigEditor,
|
|
||||||
defaultAnalysisConfig,
|
|
||||||
defaultWebhookConfig,
|
|
||||||
type AnalysisConfig,
|
|
||||||
type WebhookConfig,
|
|
||||||
} from "@/components/assistant-editor/analysis-config";
|
} from "@/components/assistant-editor/analysis-config";
|
||||||
import {
|
import {
|
||||||
Braces,
|
Braces,
|
||||||
@@ -22,7 +17,6 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
Webhook,
|
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -105,7 +99,6 @@ const promptSections = [
|
|||||||
{ id: "interaction", label: "交互策略" },
|
{ id: "interaction", label: "交互策略" },
|
||||||
{ id: "variables", label: "动态变量" },
|
{ id: "variables", label: "动态变量" },
|
||||||
{ id: "analysis", label: "分析" },
|
{ id: "analysis", label: "分析" },
|
||||||
{ id: "webhook", label: "Webhook" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PromptSectionId = (typeof promptSections)[number]["id"];
|
type PromptSectionId = (typeof promptSections)[number]["id"];
|
||||||
@@ -169,14 +162,7 @@ export function PromptEditor({
|
|||||||
interaction: null,
|
interaction: null,
|
||||||
variables: null,
|
variables: null,
|
||||||
analysis: null,
|
analysis: null,
|
||||||
webhook: null,
|
|
||||||
});
|
});
|
||||||
const [analysisConfig, setAnalysisConfig] = useState<AnalysisConfig>(
|
|
||||||
defaultAnalysisConfig,
|
|
||||||
);
|
|
||||||
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>(
|
|
||||||
defaultWebhookConfig,
|
|
||||||
);
|
|
||||||
const selectedAnchorRef = useRef<PromptSectionId | null>(null);
|
const selectedAnchorRef = useRef<PromptSectionId | null>(null);
|
||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<PromptSectionId>("conversation");
|
useState<PromptSectionId>("conversation");
|
||||||
@@ -781,31 +767,14 @@ export function PromptEditor({
|
|||||||
description="通话结束后自动提取关键信息"
|
description="通话结束后自动提取关键信息"
|
||||||
>
|
>
|
||||||
<AnalysisConfigEditor
|
<AnalysisConfigEditor
|
||||||
config={analysisConfig}
|
config={form.analysisConfig}
|
||||||
onChange={setAnalysisConfig}
|
onChange={(analysisConfig) =>
|
||||||
|
updateForm("analysisConfig", analysisConfig)
|
||||||
|
}
|
||||||
modelOptions={llmOptions}
|
modelOptions={llmOptions}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
ref={(element) => {
|
|
||||||
sectionRefs.current.webhook = element;
|
|
||||||
}}
|
|
||||||
className="scroll-mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
<SectionCard
|
|
||||||
icon={<Webhook size={15} />}
|
|
||||||
title="Webhook"
|
|
||||||
description="通话分析完成后,将结果 POST 到指定地址"
|
|
||||||
>
|
|
||||||
<WebhookConfigEditor
|
|
||||||
config={webhookConfig}
|
|
||||||
onChange={setWebhookConfig}
|
|
||||||
analysisFields={analysisConfig.fields}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnalysisConfig,
|
||||||
DynamicVariableDefinition,
|
DynamicVariableDefinition,
|
||||||
KnowledgeRetrievalConfig,
|
KnowledgeRetrievalConfig,
|
||||||
StartupConfig,
|
StartupConfig,
|
||||||
@@ -12,6 +13,7 @@ export type AssistantForm = {
|
|||||||
greeting: string;
|
greeting: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||||
|
analysisConfig: AnalysisConfig;
|
||||||
runtimeMode: RuntimeMode;
|
runtimeMode: RuntimeMode;
|
||||||
realtimeModel: string;
|
realtimeModel: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ function blankPromptForm(name: string): AssistantForm {
|
|||||||
greeting: "",
|
greeting: "",
|
||||||
prompt: "",
|
prompt: "",
|
||||||
dynamicVariableDefinitions: {},
|
dynamicVariableDefinitions: {},
|
||||||
|
analysisConfig: {
|
||||||
|
enabled: false,
|
||||||
|
modelResourceId: "",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
runtimeMode: "pipeline",
|
runtimeMode: "pipeline",
|
||||||
realtimeModel: "",
|
realtimeModel: "",
|
||||||
model: "",
|
model: "",
|
||||||
@@ -406,6 +411,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
greeting: a.greeting,
|
greeting: a.greeting,
|
||||||
prompt: a.prompt,
|
prompt: a.prompt,
|
||||||
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
|
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
|
||||||
|
analysisConfig: a.analysisConfig ?? {
|
||||||
|
enabled: false,
|
||||||
|
modelResourceId: "",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
runtimeMode: a.runtimeMode,
|
runtimeMode: a.runtimeMode,
|
||||||
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
||||||
model: a.modelResourceIds.LLM ?? "",
|
model: a.modelResourceIds.LLM ?? "",
|
||||||
@@ -481,6 +491,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
toolIds: [],
|
toolIds: [],
|
||||||
prompt: "",
|
prompt: "",
|
||||||
dynamicVariableDefinitions: {},
|
dynamicVariableDefinitions: {},
|
||||||
|
analysisConfig: {
|
||||||
|
enabled: false,
|
||||||
|
modelResourceId: "",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
apiUrl: "",
|
apiUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
appId: "",
|
appId: "",
|
||||||
@@ -545,6 +560,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
toolIds: form.toolIds,
|
toolIds: form.toolIds,
|
||||||
prompt: form.prompt,
|
prompt: form.prompt,
|
||||||
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
|
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
|
||||||
|
analysisConfig: form.analysisConfig,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
|||||||
toolIds: [],
|
toolIds: [],
|
||||||
prompt: "",
|
prompt: "",
|
||||||
dynamicVariableDefinitions: {},
|
dynamicVariableDefinitions: {},
|
||||||
|
analysisConfig: {
|
||||||
|
enabled: false,
|
||||||
|
modelResourceId: "",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
apiUrl: "",
|
apiUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
appId: "",
|
appId: "",
|
||||||
|
|||||||
@@ -425,6 +425,35 @@ export function HistoryPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!dialogOpen ||
|
||||||
|
!detail ||
|
||||||
|
!["pending", "processing"].includes(detail.analysis.status)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stopped = false;
|
||||||
|
let loadingLatest = false;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (loadingLatest) return;
|
||||||
|
loadingLatest = true;
|
||||||
|
void conversationsApi
|
||||||
|
.get(detail.id)
|
||||||
|
.then((latest) => {
|
||||||
|
if (!stopped) setDetail(latest);
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
loadingLatest = false;
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [detail, dialogOpen]);
|
||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
async (conversation: Conversation) => {
|
async (conversation: Conversation) => {
|
||||||
const label = conversation.assistantName || "调试会话";
|
const label = conversation.assistantName || "调试会话";
|
||||||
@@ -729,7 +758,9 @@ export function HistoryPage() {
|
|||||||
{detail && detailTab === "session" && (
|
{detail && detailTab === "session" && (
|
||||||
<SessionInfoPanel detail={detail} />
|
<SessionInfoPanel detail={detail} />
|
||||||
)}
|
)}
|
||||||
{detail && detailTab === "analysis" && <AnalysisPanel />}
|
{detail && detailTab === "analysis" && (
|
||||||
|
<AnalysisPanel analysis={detail.analysis} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -879,7 +910,79 @@ function SessionInfoPanel({ detail }: { detail: ConversationDetail }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnalysisPanel() {
|
function analysisValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "未提取到";
|
||||||
|
if (typeof value === "boolean") return value ? "是" : "否";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisPanel({
|
||||||
|
analysis,
|
||||||
|
}: {
|
||||||
|
analysis: ConversationDetail["analysis"];
|
||||||
|
}) {
|
||||||
|
if (analysis.status === "pending" || analysis.status === "processing") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
正在分析通话内容
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.status === "failed") {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-destructive/25 bg-destructive/5 px-4 py-4">
|
||||||
|
<div className="text-sm font-medium text-destructive">分析失败</div>
|
||||||
|
<p className="mt-1.5 break-words text-xs leading-5 text-muted-foreground">
|
||||||
|
{analysis.error || "分析服务没有返回有效结果。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.status === "completed") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="caption-label text-muted-soft">关键信息</div>
|
||||||
|
{analysis.completedAt && (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
完成于 {formatDate(analysis.completedAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-hairline overflow-hidden rounded-2xl border border-hairline bg-background">
|
||||||
|
{analysis.fields.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] gap-4 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="break-all text-sm font-medium text-foreground">
|
||||||
|
{field.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted-soft">
|
||||||
|
{field.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"break-words text-sm text-foreground",
|
||||||
|
(field.value === null || field.value === undefined) &&
|
||||||
|
"text-muted-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{analysisValue(field.value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-48 flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-5 py-10 text-center">
|
<div className="flex min-h-48 flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-5 py-10 text-center">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||||
@@ -889,7 +992,7 @@ function AnalysisPanel() {
|
|||||||
暂无分析结果
|
暂无分析结果
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
|
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
|
||||||
通话后分析接入后,将在这里展示从对话中提取的关键信息字段。
|
该会话未开启通话后分析,或没有可分析的对话内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ function baseUpsertFromTemplate(
|
|||||||
toolIds: [],
|
toolIds: [],
|
||||||
prompt: template.prompt,
|
prompt: template.prompt,
|
||||||
dynamicVariableDefinitions: {},
|
dynamicVariableDefinitions: {},
|
||||||
|
analysisConfig: {
|
||||||
|
enabled: false,
|
||||||
|
modelResourceId: "",
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
apiUrl: "",
|
apiUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
appId: "",
|
appId: "",
|
||||||
|
|||||||
@@ -246,6 +246,27 @@ export type SystemToolKind =
|
|||||||
| "skip_turn"
|
| "skip_turn"
|
||||||
| "request_human_handoff";
|
| "request_human_handoff";
|
||||||
|
|
||||||
|
export type AnalysisFieldType =
|
||||||
|
| "string"
|
||||||
|
| "boolean"
|
||||||
|
| "integer"
|
||||||
|
| "number"
|
||||||
|
| "enum";
|
||||||
|
|
||||||
|
export type AnalysisField = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: AnalysisFieldType;
|
||||||
|
description: string;
|
||||||
|
enumValues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
modelResourceId: string;
|
||||||
|
fields: AnalysisField[];
|
||||||
|
};
|
||||||
|
|
||||||
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
||||||
export type Assistant = {
|
export type Assistant = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -264,6 +285,7 @@ export type Assistant = {
|
|||||||
toolIds: string[];
|
toolIds: string[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||||
|
analysisConfig: AnalysisConfig;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
@@ -354,6 +376,16 @@ export type ConversationDetail = Conversation & {
|
|||||||
};
|
};
|
||||||
workflowTrace?: Array<Record<string, unknown>>;
|
workflowTrace?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
analysis: {
|
||||||
|
status: "none" | "pending" | "processing" | "completed" | "failed";
|
||||||
|
fields: Array<{
|
||||||
|
name: string;
|
||||||
|
type: AnalysisFieldType;
|
||||||
|
value: unknown;
|
||||||
|
}>;
|
||||||
|
error: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
};
|
||||||
messages: ConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user