Add analysis feature
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
/api/webrtc/ice-servers WebRTC STUN/TURN 配置
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# NLTK 3.9+ blocks dependency imports when .venv lives under cwd (local dev layout).
|
||||
@@ -24,6 +25,7 @@ import settings
|
||||
import uvicorn
|
||||
from db.session import sync_default_tools, sync_interface_definitions
|
||||
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.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -48,9 +50,15 @@ async def lifespan(_app: FastAPI):
|
||||
await sync_interface_definitions()
|
||||
await sync_default_tools()
|
||||
await recover_interrupted_documents()
|
||||
await recover_interrupted_analyses()
|
||||
analysis_worker = asyncio.create_task(
|
||||
run_analysis_worker(), name="post-call-analysis-worker"
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
analysis_worker.cancel()
|
||||
await asyncio.gather(analysis_worker, return_exceptions=True)
|
||||
await voice_webrtc.shutdown_active_sessions()
|
||||
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ class Assistant(Base):
|
||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||
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_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
|
||||
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
|
||||
@@ -313,6 +314,11 @@ class ConversationSession(Base):
|
||||
ended_at: Mapped[datetime | None] = mapped_column(
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
turnConfig: 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).
|
||||
# ``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, "视觉模型必须支持图片输入")
|
||||
|
||||
|
||||
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:
|
||||
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
|
||||
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),
|
||||
prompt=assistant.prompt,
|
||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||
analysis_config=assistant.analysis_config or {},
|
||||
api_url=assistant.api_url,
|
||||
api_key=mask(assistant.api_key),
|
||||
app_id=assistant.app_id,
|
||||
@@ -413,6 +425,7 @@ async def create_assistant(
|
||||
await _validate_system_tool_selection(session, body)
|
||||
await _validate_startup_actions(session, body)
|
||||
await _validate_vision_model(session, body)
|
||||
await _validate_analysis_model(session, body)
|
||||
await _validate_knowledge_base(session, body)
|
||||
data = body.model_dump()
|
||||
resource_ids = data.pop("model_resource_ids")
|
||||
@@ -459,6 +472,7 @@ async def duplicate_assistant(
|
||||
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
||||
prompt=source.prompt,
|
||||
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
||||
analysis_config=dict(source.analysis_config or {}),
|
||||
api_url=source.api_url,
|
||||
api_key=source.api_key,
|
||||
app_id=source.app_id,
|
||||
@@ -489,6 +503,7 @@ async def update_assistant(
|
||||
await _validate_system_tool_selection(session, body)
|
||||
await _validate_startup_actions(session, body)
|
||||
await _validate_vision_model(session, body)
|
||||
await _validate_analysis_model(session, body)
|
||||
await _validate_knowledge_base(session, body)
|
||||
data = body.model_dump()
|
||||
resource_ids = data.pop("model_resource_ids")
|
||||
|
||||
@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from loguru import logger
|
||||
from schemas import (
|
||||
ConversationArtifactOut,
|
||||
ConversationAnalysisFieldOut,
|
||||
ConversationAnalysisOut,
|
||||
ConversationDetailOut,
|
||||
ConversationListOut,
|
||||
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)
|
||||
async def list_conversations(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -119,6 +148,7 @@ async def get_conversation(
|
||||
return ConversationDetailOut(
|
||||
**_session_out(conversation).model_dump(),
|
||||
extra=conversation.extra or {},
|
||||
analysis=_analysis_out(conversation),
|
||||
messages=[
|
||||
ConversationMessageOut(
|
||||
id=message.id,
|
||||
|
||||
@@ -90,6 +90,45 @@ class KnowledgeRetrievalConfig(CamelModel):
|
||||
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 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
||||
ALLOWED_FIELDS: dict[str, set[str]] = {
|
||||
"prompt": {"prompt"},
|
||||
@@ -171,6 +210,7 @@ class AssistantUpsert(CamelModel):
|
||||
dynamic_variable_definitions: dict[str, "DynamicVariableDefinition"] = Field(
|
||||
default_factory=dict
|
||||
)
|
||||
analysis_config: AnalysisConfig = Field(default_factory=AnalysisConfig)
|
||||
api_url: str = ""
|
||||
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
||||
app_id: str = ""
|
||||
@@ -500,9 +540,25 @@ class ConversationOut(CamelModel):
|
||||
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):
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
messages: list[ConversationMessageOut] = Field(default_factory=list)
|
||||
analysis: ConversationAnalysisOut = Field(default_factory=ConversationAnalysisOut)
|
||||
|
||||
|
||||
class ConversationListOut(CamelModel):
|
||||
|
||||
@@ -266,6 +266,7 @@ async def resolve_runtime_config(
|
||||
enableInterrupt=assistant.enable_interrupt,
|
||||
turnConfig=assistant.turn_config or {},
|
||||
startup=assistant.startup or {},
|
||||
analysis_config=assistant.analysis_config or {},
|
||||
tools=runtime_tools,
|
||||
llm_tool_ids=llm_tool_ids,
|
||||
knowledge_base_id=assistant.knowledge_base_id,
|
||||
|
||||
@@ -31,8 +31,9 @@ def _parse_timestamp(value: object) -> datetime:
|
||||
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._analysis_plan = deepcopy(analysis_plan or {})
|
||||
self._sequence = 0
|
||||
self._trace_sequence = 0
|
||||
self._lock = asyncio.Lock()
|
||||
@@ -49,6 +50,7 @@ class ConversationRecorder:
|
||||
runtime_mode: str,
|
||||
session_id: str | None = None,
|
||||
extra: dict | None = None,
|
||||
analysis_plan: dict | None = None,
|
||||
) -> "ConversationRecorder | None":
|
||||
session_id = session_id or f"conv_{uuid4().hex[:20]}"
|
||||
try:
|
||||
@@ -62,11 +64,17 @@ class ConversationRecorder:
|
||||
runtime_mode=runtime_mode,
|
||||
status="active",
|
||||
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 {}),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return cls(session_id)
|
||||
return cls(session_id, analysis_plan)
|
||||
except Exception as exc:
|
||||
logger.error(f"创建对话历史会话失败,不影响本次通话: {exc}")
|
||||
return None
|
||||
@@ -289,6 +297,16 @@ class ConversationRecorder:
|
||||
conversation.status = status
|
||||
conversation.ended_at = datetime.now(UTC)
|
||||
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()
|
||||
except Exception as exc:
|
||||
logger.error(f"结束对话历史会话失败: {exc}")
|
||||
|
||||
@@ -623,6 +623,7 @@ async def run_pipeline(
|
||||
channel=channel,
|
||||
runtime_mode=cfg.runtimeMode,
|
||||
session_id=cfg.conversation_id or None,
|
||||
analysis_plan=cfg.analysis_config,
|
||||
extra=(workflow_engine.session_metadata() if workflow_engine else None),
|
||||
)
|
||||
pipeline = Pipeline(
|
||||
@@ -952,6 +953,7 @@ async def run_realtime_pipeline(
|
||||
channel=channel,
|
||||
runtime_mode=cfg.runtimeMode,
|
||||
session_id=cfg.conversation_id or None,
|
||||
analysis_plan=cfg.analysis_config,
|
||||
extra=(
|
||||
WorkflowEngine(cfg.graph).session_metadata()
|
||||
if cfg.type == "workflow"
|
||||
|
||||
1
backend/services/post_call/__init__.py
Normal file
1
backend/services/post_call/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Post-call structured analysis, independent from the realtime pipeline."""
|
||||
195
backend/services/post_call/analyzer.py
Normal file
195
backend/services/post_call/analyzer.py
Normal file
@@ -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()
|
||||
|
||||
59
backend/services/post_call/worker.py
Normal file
59
backend/services/post_call/worker.py
Normal file
@@ -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):
|
||||
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):
|
||||
recorder = ConversationRecorder("conv_test")
|
||||
started = asyncio.Event()
|
||||
|
||||
81
backend/tests/test_post_call_analysis.py
Normal file
81
backend/tests/test_post_call_analysis.py
Normal file
@@ -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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Plus, Send, Trash2 } from "lucide-react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,47 +12,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type {
|
||||
AnalysisConfig,
|
||||
AnalysisField,
|
||||
AnalysisFieldType,
|
||||
} from "@/lib/api";
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
export type WebhookConfig = {
|
||||
url: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export function defaultAnalysisConfig(): AnalysisConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWebhookConfig(): WebhookConfig {
|
||||
return {
|
||||
url: "",
|
||||
secret: "",
|
||||
};
|
||||
}
|
||||
export type { AnalysisConfig, AnalysisField, AnalysisFieldType } from "@/lib/api";
|
||||
|
||||
const FIELD_TYPE_OPTIONS: Array<{
|
||||
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 = {
|
||||
config: AnalysisConfig;
|
||||
onChange: (config: AnalysisConfig) => void;
|
||||
@@ -186,7 +120,7 @@ export function AnalysisConfigEditor({
|
||||
还没有关键信息字段
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
点击右上角加号添加字段,例如「客户意向」「是否预约」等。
|
||||
点击右上角加号添加字段,例如 customer_intent、booked。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -201,7 +135,7 @@ export function AnalysisConfigEditor({
|
||||
onChange={(event) =>
|
||||
updateField(field.id, { name: event.target.value })
|
||||
}
|
||||
placeholder="字段名"
|
||||
placeholder="字段名,如 customer_intent"
|
||||
aria-label={`字段 ${index + 1} 名称`}
|
||||
className="h-9 border-hairline-strong bg-background"
|
||||
/>
|
||||
@@ -276,111 +210,3 @@ export function AnalysisConfigEditor({
|
||||
</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 {
|
||||
AnalysisConfigEditor,
|
||||
WebhookConfigEditor,
|
||||
defaultAnalysisConfig,
|
||||
defaultWebhookConfig,
|
||||
type AnalysisConfig,
|
||||
type WebhookConfig,
|
||||
} from "@/components/assistant-editor/analysis-config";
|
||||
import {
|
||||
Braces,
|
||||
@@ -22,7 +17,6 @@ import {
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Webhook,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -105,7 +99,6 @@ const promptSections = [
|
||||
{ id: "interaction", label: "交互策略" },
|
||||
{ id: "variables", label: "动态变量" },
|
||||
{ id: "analysis", label: "分析" },
|
||||
{ id: "webhook", label: "Webhook" },
|
||||
] as const;
|
||||
|
||||
type PromptSectionId = (typeof promptSections)[number]["id"];
|
||||
@@ -169,14 +162,7 @@ export function PromptEditor({
|
||||
interaction: null,
|
||||
variables: null,
|
||||
analysis: null,
|
||||
webhook: null,
|
||||
});
|
||||
const [analysisConfig, setAnalysisConfig] = useState<AnalysisConfig>(
|
||||
defaultAnalysisConfig,
|
||||
);
|
||||
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>(
|
||||
defaultWebhookConfig,
|
||||
);
|
||||
const selectedAnchorRef = useRef<PromptSectionId | null>(null);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PromptSectionId>("conversation");
|
||||
@@ -781,31 +767,14 @@ export function PromptEditor({
|
||||
description="通话结束后自动提取关键信息"
|
||||
>
|
||||
<AnalysisConfigEditor
|
||||
config={analysisConfig}
|
||||
onChange={setAnalysisConfig}
|
||||
config={form.analysisConfig}
|
||||
onChange={(analysisConfig) =>
|
||||
updateForm("analysisConfig", analysisConfig)
|
||||
}
|
||||
modelOptions={llmOptions}
|
||||
/>
|
||||
</SectionCard>
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnalysisConfig,
|
||||
DynamicVariableDefinition,
|
||||
KnowledgeRetrievalConfig,
|
||||
StartupConfig,
|
||||
@@ -12,6 +13,7 @@ export type AssistantForm = {
|
||||
greeting: string;
|
||||
prompt: string;
|
||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||
analysisConfig: AnalysisConfig;
|
||||
runtimeMode: RuntimeMode;
|
||||
realtimeModel: string;
|
||||
model: string;
|
||||
|
||||
@@ -157,6 +157,11 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
greeting: "",
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
@@ -406,6 +411,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
greeting: a.greeting,
|
||||
prompt: a.prompt,
|
||||
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
|
||||
analysisConfig: a.analysisConfig ?? {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
runtimeMode: a.runtimeMode,
|
||||
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
||||
model: a.modelResourceIds.LLM ?? "",
|
||||
@@ -481,6 +491,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
@@ -545,6 +560,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
toolIds: form.toolIds,
|
||||
prompt: form.prompt,
|
||||
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
|
||||
analysisConfig: form.analysisConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
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(
|
||||
async (conversation: Conversation) => {
|
||||
const label = conversation.assistantName || "调试会话";
|
||||
@@ -729,7 +758,9 @@ export function HistoryPage() {
|
||||
{detail && detailTab === "session" && (
|
||||
<SessionInfoPanel detail={detail} />
|
||||
)}
|
||||
{detail && detailTab === "analysis" && <AnalysisPanel />}
|
||||
{detail && detailTab === "analysis" && (
|
||||
<AnalysisPanel analysis={detail.analysis} />
|
||||
)}
|
||||
</div>
|
||||
</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 (
|
||||
<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">
|
||||
@@ -889,7 +992,7 @@ function AnalysisPanel() {
|
||||
暂无分析结果
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
|
||||
通话后分析接入后,将在这里展示从对话中提取的关键信息字段。
|
||||
该会话未开启通话后分析,或没有可分析的对话内容。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -102,6 +102,11 @@ function baseUpsertFromTemplate(
|
||||
toolIds: [],
|
||||
prompt: template.prompt,
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
|
||||
@@ -246,6 +246,27 @@ export type SystemToolKind =
|
||||
| "skip_turn"
|
||||
| "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 读时打码 */
|
||||
export type Assistant = {
|
||||
id: string;
|
||||
@@ -264,6 +285,7 @@ export type Assistant = {
|
||||
toolIds: string[];
|
||||
prompt: string;
|
||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||
analysisConfig: AnalysisConfig;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
@@ -354,6 +376,16 @@ export type ConversationDetail = Conversation & {
|
||||
};
|
||||
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[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user