Compare commits
31 Commits
e36ca308b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc026e765e | ||
|
|
86639692ba | ||
|
|
cf32ea605d | ||
|
|
19e8c8c108 | ||
|
|
5b8b9fd097 | ||
|
|
ba6c7c6be3 | ||
|
|
e988f9c133 | ||
|
|
433ff0b255 | ||
|
|
21a25874a9 | ||
|
|
398b20778d | ||
|
|
2f755969d3 | ||
|
|
36117b976e | ||
|
|
b2e99baa19 | ||
|
|
39ee5ef96e | ||
|
|
a9d54e7c2b | ||
|
|
bdbda8047d | ||
|
|
7575afaeee | ||
|
|
95e04d8c94 | ||
|
|
56a32e81ca | ||
|
|
485ad623d4 | ||
|
|
87b6392eea | ||
|
|
b51b768c29 | ||
|
|
723ee84925 | ||
|
|
b6e2ac5765 | ||
|
|
4d00e719c4 | ||
|
|
4a07abd7fc | ||
|
|
aaca128f82 | ||
|
|
5cb13a3cac | ||
|
|
90bb27a377 | ||
|
|
8bd781242a | ||
|
|
0c0a51da95 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
# Local state created by docker-compose services.
|
||||
/data/
|
||||
/logs/
|
||||
|
||||
# Local editor and UX review artifacts.
|
||||
.DS_Store
|
||||
/artifacts/
|
||||
|
||||
@@ -9,11 +9,19 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
|
||||
# ---- 服务监听 & 跨域 ----
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
# NLTK 3.9+ import guard vs local .venv under backend/ (set in app.py by default; override here if needed)
|
||||
# NLTK_DISABLE_IMPORT_SECURITY=1
|
||||
# 前端开发地址,允许跨域(公网部署时加上实际前端 origin)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# ---- OpenAI-compatible Realtime API ----
|
||||
# 用独立随机值签名长期 API Key 摘要和短期 Client Secret;生产环境必须修改。
|
||||
REALTIME_TOKEN_SECRET=replace-with-a-long-random-secret
|
||||
REALTIME_CLIENT_SECRET_TTL_SECONDS=60
|
||||
|
||||
# ---- RustFS / S3-compatible storage ----
|
||||
S3_ENDPOINT_URL=http://localhost:9000
|
||||
# Use 127.0.0.1 (not localhost) if HTTP_PROXY is set — proxies often return 502 for localhost.
|
||||
S3_ENDPOINT_URL=http://127.0.0.1:9000
|
||||
S3_ACCESS_KEY=rustfsadmin
|
||||
S3_SECRET_KEY=rustfsadmin
|
||||
S3_BUCKET=ai-video
|
||||
|
||||
@@ -12,12 +12,25 @@
|
||||
/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).
|
||||
# pipecat -> nltk -> regex hits this false positive; disable before any nltk import.
|
||||
os.environ.setdefault("NLTK_DISABLE_IMPORT_SECURITY", "1")
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
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 services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
|
||||
from services.test_runs.orchestrator import (
|
||||
recover_interrupted_test_runs,
|
||||
test_run_orchestrator,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -31,6 +44,11 @@ from routes import (
|
||||
mcp_servers,
|
||||
model_registry,
|
||||
node_types,
|
||||
openai_realtime,
|
||||
realtime_api_keys,
|
||||
site_settings,
|
||||
test_cases,
|
||||
test_runs,
|
||||
tools,
|
||||
voice_webrtc,
|
||||
voice_ws,
|
||||
@@ -42,9 +60,24 @@ async def lifespan(_app: FastAPI):
|
||||
await sync_interface_definitions()
|
||||
await sync_default_tools()
|
||||
await recover_interrupted_documents()
|
||||
await recover_interrupted_analyses()
|
||||
await recover_interrupted_webhooks()
|
||||
await recover_interrupted_test_runs()
|
||||
analysis_worker = asyncio.create_task(
|
||||
run_analysis_worker(), name="post-call-analysis-worker"
|
||||
)
|
||||
webhook_worker = asyncio.create_task(
|
||||
run_webhook_worker(), name="analysis-webhook-worker"
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
analysis_worker.cancel()
|
||||
webhook_worker.cancel()
|
||||
await asyncio.gather(
|
||||
analysis_worker, webhook_worker, return_exceptions=True
|
||||
)
|
||||
await test_run_orchestrator.shutdown()
|
||||
await voice_webrtc.shutdown_active_sessions()
|
||||
|
||||
|
||||
@@ -67,6 +100,11 @@ app.include_router(knowledge_bases.router)
|
||||
app.include_router(mcp_servers.router)
|
||||
app.include_router(model_registry.router)
|
||||
app.include_router(node_types.router)
|
||||
app.include_router(openai_realtime.router)
|
||||
app.include_router(realtime_api_keys.router)
|
||||
app.include_router(site_settings.router)
|
||||
app.include_router(test_cases.router)
|
||||
app.include_router(test_runs.router)
|
||||
app.include_router(tools.router)
|
||||
app.include_router(voice_webrtc.router)
|
||||
app.include_router(voice_ws.router)
|
||||
|
||||
@@ -171,8 +171,9 @@ class Assistant(Base):
|
||||
)
|
||||
|
||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||
prompt: Mapped[str] = mapped_column(Text, 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)
|
||||
|
||||
|
||||
@@ -368,3 +374,189 @@ class ConversationArtifact(Base):
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
extra: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
|
||||
|
||||
class SiteSetting(Base):
|
||||
"""Deployment-wide settings, split into public values and write-only secrets."""
|
||||
|
||||
__tablename__ = "site_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class RealtimeApiKey(Base):
|
||||
"""Hashed deployment-level credential for the public Realtime API."""
|
||||
|
||||
__tablename__ = "realtime_api_keys"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
key_prefix: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(64))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WebhookDelivery(Base):
|
||||
"""Immutable event snapshot plus its asynchronous delivery state."""
|
||||
|
||||
__tablename__ = "webhook_deliveries"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
event_id: Mapped[str] = mapped_column(String(40), unique=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
conversation_id: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
ForeignKey("conversation_sessions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict] = mapped_column(JSONB)
|
||||
url: Mapped[str] = mapped_column(String(2048))
|
||||
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
status: Mapped[str] = mapped_column(String(16), index=True, default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
last_status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_error: Mapped[str] = mapped_column(String(2048), default="")
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class TestSuite(Base):
|
||||
"""A persisted group of text-based assistant test cases."""
|
||||
|
||||
__tablename__ = "test_suites"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class TestCase(Base):
|
||||
"""One editable test definition; nested turns remain one JSON document."""
|
||||
|
||||
__tablename__ = "test_cases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("suite_id", "sort_order", name="uq_test_case_suite_order"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
suite_id: Mapped[str] = mapped_column(
|
||||
String(40),
|
||||
ForeignKey("test_suites.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||
input_mode: Mapped[str] = mapped_column(
|
||||
String(40), default="fixed_script_text", index=True
|
||||
)
|
||||
definition: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_result: Mapped[str] = mapped_column(String(16), default="not_run")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class TestRun(Base):
|
||||
"""One immutable batch-run request plus its aggregate lifecycle state."""
|
||||
|
||||
__tablename__ = "test_runs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
assistant_id: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
ForeignKey("assistants.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
assistant_name: Mapped[str] = mapped_column(String(128), default="")
|
||||
title: Mapped[str] = mapped_column(String(256), default="批量测试")
|
||||
status: Mapped[str] = mapped_column(String(16), index=True, default="queued")
|
||||
config: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
stop_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class TestRunCase(Base):
|
||||
"""Run-local test case snapshot and detailed execution result."""
|
||||
|
||||
__tablename__ = "test_run_cases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "test_case_id", name="uq_test_run_case"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
run_id: Mapped[str] = mapped_column(
|
||||
String(40),
|
||||
ForeignKey("test_runs.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
test_case_id: Mapped[str | None] = mapped_column(
|
||||
String(40),
|
||||
ForeignKey("test_cases.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
test_case_name: Mapped[str] = mapped_column(String(128))
|
||||
suite_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
case_snapshot: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
status: Mapped[str] = mapped_column(String(16), index=True, default="waiting")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, default=1)
|
||||
result: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
execution_error: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""change assistants.prompt from varchar(8192) to text
|
||||
|
||||
Revision ID: 20260807_0014
|
||||
Revises: 20260807_0013
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260807_0014"
|
||||
down_revision: str | Sequence[str] | None = "20260807_0013"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"assistants",
|
||||
"prompt",
|
||||
existing_type=sa.String(length=8192),
|
||||
type_=sa.Text(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("''"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"assistants",
|
||||
"prompt",
|
||||
existing_type=sa.Text(),
|
||||
type_=sa.String(length=8192),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("''"),
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add global analysis webhook settings and delivery queue
|
||||
|
||||
Revision ID: 20260807_0015
|
||||
Revises: 20260807_0014
|
||||
"""
|
||||
|
||||
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_0015"
|
||||
down_revision: str | Sequence[str] | None = "20260807_0014"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"site_settings",
|
||||
sa.Column("key", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"value",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"secrets",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("key"),
|
||||
)
|
||||
op.create_table(
|
||||
"webhook_deliveries",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=40), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||
sa.Column("conversation_id", sa.String(length=40), nullable=True),
|
||||
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("url", sa.String(length=2048), nullable=False),
|
||||
sa.Column(
|
||||
"secrets",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_status_code", sa.Integer(), nullable=True),
|
||||
sa.Column("last_error", sa.String(length=2048), server_default="", nullable=False),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["conversation_id"], ["conversation_sessions.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("event_id"),
|
||||
)
|
||||
op.create_index("ix_webhook_deliveries_event_type", "webhook_deliveries", ["event_type"])
|
||||
op.create_index("ix_webhook_deliveries_conversation_id", "webhook_deliveries", ["conversation_id"])
|
||||
op.create_index("ix_webhook_deliveries_status", "webhook_deliveries", ["status"])
|
||||
op.create_index("ix_webhook_deliveries_next_attempt_at", "webhook_deliveries", ["next_attempt_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_webhook_deliveries_next_attempt_at", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_status", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_conversation_id", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_event_type", table_name="webhook_deliveries")
|
||||
op.drop_table("webhook_deliveries")
|
||||
op.drop_table("site_settings")
|
||||
112
backend/migrations/versions/20260810_0016_add_test_runs.py
Normal file
112
backend/migrations/versions/20260810_0016_add_test_runs.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""add persisted test suites, cases, and batch runs
|
||||
|
||||
Revision ID: 20260810_0016
|
||||
Revises: 20260807_0015
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260810_0016"
|
||||
down_revision: str | Sequence[str] | None = "20260807_0015"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"test_suites",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
|
||||
sa.Column("assistant_id", sa.String(length=40), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_test_suites_assistant_id", "test_suites", ["assistant_id"])
|
||||
|
||||
op.create_table(
|
||||
"test_cases",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("suite_id", sa.String(length=40), nullable=False),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("description", sa.String(length=2048), server_default="", nullable=False),
|
||||
sa.Column("input_mode", sa.String(length=40), server_default="fixed_script_text", nullable=False),
|
||||
sa.Column("definition", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("last_result", sa.String(length=16), server_default="not_run", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["suite_id"], ["test_suites.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("suite_id", "sort_order", name="uq_test_case_suite_order"),
|
||||
)
|
||||
op.create_index("ix_test_cases_suite_id", "test_cases", ["suite_id"])
|
||||
op.create_index("ix_test_cases_input_mode", "test_cases", ["input_mode"])
|
||||
|
||||
op.create_table(
|
||||
"test_runs",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("assistant_id", sa.String(length=40), nullable=True),
|
||||
sa.Column("assistant_name", sa.String(length=128), server_default="", nullable=False),
|
||||
sa.Column("title", sa.String(length=256), server_default="批量测试", nullable=False),
|
||||
sa.Column("status", sa.String(length=16), server_default="queued", nullable=False),
|
||||
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||
sa.Column("stop_reason", sa.String(length=32), nullable=True),
|
||||
sa.Column("cancel_requested", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_test_runs_assistant_id", "test_runs", ["assistant_id"])
|
||||
op.create_index("ix_test_runs_status", "test_runs", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"test_run_cases",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=40), nullable=False),
|
||||
sa.Column("test_case_id", sa.String(length=40), nullable=True),
|
||||
sa.Column("test_case_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("suite_id", sa.String(length=40), nullable=True),
|
||||
sa.Column("position", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("case_snapshot", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), server_default="waiting", nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("max_attempts", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||
sa.Column("execution_error", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["test_runs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["test_case_id"], ["test_cases.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("run_id", "test_case_id", name="uq_test_run_case"),
|
||||
)
|
||||
op.create_index("ix_test_run_cases_run_id", "test_run_cases", ["run_id"])
|
||||
op.create_index("ix_test_run_cases_test_case_id", "test_run_cases", ["test_case_id"])
|
||||
op.create_index("ix_test_run_cases_status", "test_run_cases", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_test_run_cases_status", table_name="test_run_cases")
|
||||
op.drop_index("ix_test_run_cases_test_case_id", table_name="test_run_cases")
|
||||
op.drop_index("ix_test_run_cases_run_id", table_name="test_run_cases")
|
||||
op.drop_table("test_run_cases")
|
||||
op.drop_index("ix_test_runs_status", table_name="test_runs")
|
||||
op.drop_index("ix_test_runs_assistant_id", table_name="test_runs")
|
||||
op.drop_table("test_runs")
|
||||
op.drop_index("ix_test_cases_input_mode", table_name="test_cases")
|
||||
op.drop_index("ix_test_cases_suite_id", table_name="test_cases")
|
||||
op.drop_table("test_cases")
|
||||
op.drop_index("ix_test_suites_assistant_id", table_name="test_suites")
|
||||
op.drop_table("test_suites")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""remove obsolete assistant binding from test suites
|
||||
|
||||
Revision ID: 20260810_0017
|
||||
Revises: 20260810_0016
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260810_0017"
|
||||
down_revision: str | Sequence[str] | None = "20260810_0016"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index("ix_test_suites_assistant_id", table_name="test_suites")
|
||||
op.drop_column("test_suites", "assistant_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"test_suites",
|
||||
sa.Column("assistant_id", sa.String(length=40), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_test_suites_assistant_id_assistants",
|
||||
"test_suites",
|
||||
"assistants",
|
||||
["assistant_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_test_suites_assistant_id",
|
||||
"test_suites",
|
||||
["assistant_id"],
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add public Realtime API keys
|
||||
|
||||
Revision ID: 20260810_0018
|
||||
Revises: 20260810_0017
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260810_0018"
|
||||
down_revision: str | Sequence[str] | None = "20260810_0017"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"realtime_api_keys",
|
||||
sa.Column("id", sa.String(length=40), nullable=False),
|
||||
sa.Column("name", sa.String(length=128), nullable=False),
|
||||
sa.Column("key_prefix", sa.String(length=32), nullable=False),
|
||||
sa.Column("key_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_realtime_api_keys_key_prefix",
|
||||
"realtime_api_keys",
|
||||
["key_prefix"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_realtime_api_keys_key_prefix",
|
||||
table_name="realtime_api_keys",
|
||||
)
|
||||
op.drop_table("realtime_api_keys")
|
||||
@@ -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,
|
||||
|
||||
341
backend/routes/openai_realtime.py
Normal file
341
backend/routes/openai_realtime.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Public OpenAI-compatible Realtime HTTP, WebRTC, and WebSocket entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from loguru import logger
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from services.openai_realtime.auth import (
|
||||
RealtimeAuthError,
|
||||
RealtimeCredential,
|
||||
authenticate_bearer,
|
||||
bearer_from_authorization,
|
||||
create_client_secret,
|
||||
hash_safety_identifier,
|
||||
)
|
||||
from services.openai_realtime.bridge import OpenAIRealtimeBridge
|
||||
from services.openai_realtime.events import (
|
||||
RealtimeEventError,
|
||||
assistant_id_from_model,
|
||||
)
|
||||
from services.openai_realtime.session import OpenAIRealtimeSession
|
||||
from services.openai_realtime.webrtc import OpenAIRealtimeWebRTCConnection
|
||||
from services.openai_realtime.websocket import build_openai_websocket_transport
|
||||
from services.pipecat.pipeline import run_pipeline
|
||||
from services.pipecat.transports import build_webrtc_transport
|
||||
from services.realtime import lifecycle
|
||||
from services.realtime.launcher import (
|
||||
resolve_assistant_config,
|
||||
validate_runtime_requirements,
|
||||
validate_visual_runtime,
|
||||
)
|
||||
from services.webrtc_ice import aiortc_ice_servers
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/realtime", tags=["openai-realtime"])
|
||||
_webrtc_peers: dict[str, OpenAIRealtimeWebRTCConnection] = {}
|
||||
|
||||
|
||||
def _api_error(message: str, *, code: str, status_code: int) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": code,
|
||||
"message": message,
|
||||
"param": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _credential(request: Request) -> RealtimeCredential:
|
||||
token = bearer_from_authorization(request.headers.get("authorization"))
|
||||
async with SessionLocal() as db:
|
||||
return await authenticate_bearer(db, token)
|
||||
|
||||
|
||||
def _session_payload(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise RealtimeEventError("session must be an object", param="session")
|
||||
session = value.get("session", value)
|
||||
if not isinstance(session, dict):
|
||||
raise RealtimeEventError("session must be an object", param="session")
|
||||
return session
|
||||
|
||||
|
||||
def _reject_locked_creation_fields(session: dict[str, Any]) -> None:
|
||||
locked = {"instructions", "voice", "tools", "tool_choice"}.intersection(session)
|
||||
audio = session.get("audio")
|
||||
if isinstance(audio, dict) and isinstance(audio.get("output"), dict):
|
||||
if "voice" in audio["output"]:
|
||||
locked.add("audio.output.voice")
|
||||
if locked:
|
||||
field = sorted(locked)[0]
|
||||
raise RealtimeEventError(
|
||||
f"{field} is owned by the selected assistant",
|
||||
code="immutable_session_field",
|
||||
param=f"session.{field}",
|
||||
)
|
||||
modalities = session.get("output_modalities")
|
||||
if modalities is not None and modalities not in (["audio"], ["text"]):
|
||||
raise RealtimeEventError(
|
||||
'output_modalities must be ["audio"] or ["text"]',
|
||||
param="session.output_modalities",
|
||||
)
|
||||
|
||||
|
||||
async def _build_session(
|
||||
credential: RealtimeCredential,
|
||||
requested: dict[str, Any] | None,
|
||||
*,
|
||||
safety_identifier_hash: str | None,
|
||||
) -> OpenAIRealtimeSession:
|
||||
session_value = dict(credential.session or requested or {})
|
||||
_reject_locked_creation_fields(session_value)
|
||||
requested_assistant_id = assistant_id_from_model(session_value.get("model"))
|
||||
if credential.assistant_id and requested_assistant_id != credential.assistant_id:
|
||||
raise RealtimeEventError(
|
||||
"Client secret is bound to another assistant",
|
||||
code="invalid_model",
|
||||
param="session.model",
|
||||
)
|
||||
config = await resolve_assistant_config(requested_assistant_id)
|
||||
validate_runtime_requirements(config)
|
||||
vision_enabled = validate_visual_runtime(config)
|
||||
state = OpenAIRealtimeSession(
|
||||
assistant_id=requested_assistant_id,
|
||||
config=config,
|
||||
vision_enabled=vision_enabled,
|
||||
safety_identifier_hash=(
|
||||
credential.safety_identifier_hash or safety_identifier_hash
|
||||
),
|
||||
)
|
||||
state.apply_initial_options(session_value)
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/client_secrets")
|
||||
async def create_realtime_client_secret(request: Request):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
if credential.ephemeral:
|
||||
raise RealtimeAuthError("A long-lived API key is required")
|
||||
body = await request.json()
|
||||
session = _session_payload(body)
|
||||
_reject_locked_creation_fields(session)
|
||||
assistant_id = assistant_id_from_model(session.get("model"))
|
||||
# Resolve now so invalid models/configurations fail before a browser gets a token.
|
||||
config = await resolve_assistant_config(assistant_id)
|
||||
validate_runtime_requirements(config)
|
||||
validate_visual_runtime(config)
|
||||
safety_hash = hash_safety_identifier(
|
||||
request.headers.get("openai-safety-identifier")
|
||||
)
|
||||
value, expires_at = create_client_secret(
|
||||
api_key_id=credential.api_key_id,
|
||||
assistant_id=assistant_id,
|
||||
session=session,
|
||||
safety_identifier_hash=safety_hash,
|
||||
)
|
||||
return {
|
||||
"value": value,
|
||||
"expires_at": expires_at,
|
||||
"session": session,
|
||||
}
|
||||
except RealtimeAuthError as exc:
|
||||
return _api_error(str(exc), code="invalid_api_key", status_code=401)
|
||||
except RealtimeEventError as exc:
|
||||
return _api_error(str(exc), code=exc.code, status_code=400)
|
||||
except ValueError as exc:
|
||||
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
|
||||
return _api_error(str(exc), code=code, status_code=400)
|
||||
|
||||
|
||||
@router.post("/calls")
|
||||
async def create_realtime_call(request: Request):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
content_type = request.headers.get("content-type", "").lower()
|
||||
if content_type.startswith("multipart/form-data"):
|
||||
if credential.ephemeral:
|
||||
raise RealtimeAuthError(
|
||||
"Client secrets must send an application/sdp offer"
|
||||
)
|
||||
form = await request.form()
|
||||
sdp = str(form.get("sdp") or "")
|
||||
raw_session = str(form.get("session") or "{}")
|
||||
requested = _session_payload(json.loads(raw_session))
|
||||
elif content_type.startswith("application/sdp"):
|
||||
if not credential.ephemeral:
|
||||
raise RealtimeAuthError(
|
||||
"application/sdp requires a short-lived client secret"
|
||||
)
|
||||
sdp = (await request.body()).decode("utf-8")
|
||||
requested = None
|
||||
else:
|
||||
raise RealtimeEventError(
|
||||
"Use multipart/form-data or application/sdp",
|
||||
code="unsupported_content_type",
|
||||
)
|
||||
if not sdp.strip():
|
||||
raise RealtimeEventError("SDP offer is empty", param="sdp")
|
||||
state = await _build_session(
|
||||
credential,
|
||||
requested,
|
||||
safety_identifier_hash=hash_safety_identifier(
|
||||
request.headers.get("openai-safety-identifier")
|
||||
),
|
||||
)
|
||||
answer = await _start_webrtc(sdp, state)
|
||||
return Response(
|
||||
content=answer,
|
||||
media_type="application/sdp",
|
||||
headers={"Location": f"/v1/realtime/calls/{state.id}"},
|
||||
)
|
||||
except RealtimeAuthError as exc:
|
||||
return _api_error(str(exc), code="invalid_api_key", status_code=401)
|
||||
except (RealtimeEventError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
code = exc.code if isinstance(exc, RealtimeEventError) else "invalid_request_error"
|
||||
return _api_error(str(exc), code=code, status_code=400)
|
||||
except ValueError as exc:
|
||||
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
|
||||
return _api_error(str(exc), code=code, status_code=400)
|
||||
except Exception as exc: # noqa: BLE001 - no internal details in public response
|
||||
logger.exception(f"OpenAI Realtime WebRTC 启动失败: {exc}")
|
||||
return _api_error(
|
||||
"Realtime connection could not be established",
|
||||
code="connection_error",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def _start_webrtc(sdp: str, state: OpenAIRealtimeSession) -> str:
|
||||
connection = OpenAIRealtimeWebRTCConnection(
|
||||
ice_servers=aiortc_ice_servers()
|
||||
)
|
||||
await connection.initialize(sdp=sdp, type="offer")
|
||||
_webrtc_peers[connection.pc_id] = connection
|
||||
bridge = OpenAIRealtimeBridge(state, channel="webrtc")
|
||||
transport = build_webrtc_transport(
|
||||
connection,
|
||||
video_in_enabled=state.vision_enabled,
|
||||
)
|
||||
task = lifecycle.start_pipeline_task(
|
||||
connection,
|
||||
run_pipeline(
|
||||
transport,
|
||||
state.config,
|
||||
vision_enabled=state.vision_enabled,
|
||||
assistant_id=state.assistant_id,
|
||||
channel="openai-webrtc",
|
||||
protocol_adapter=bridge,
|
||||
),
|
||||
protocol="openai-webrtc",
|
||||
)
|
||||
|
||||
@connection.event_handler("closed")
|
||||
async def on_closed(conn: OpenAIRealtimeWebRTCConnection):
|
||||
_webrtc_peers.pop(conn.pc_id, None)
|
||||
lifecycle.active_connections.discard(conn)
|
||||
await lifecycle.wait_for_pipeline_close(
|
||||
task,
|
||||
connection_id=conn.pc_id,
|
||||
)
|
||||
|
||||
answer = connection.get_answer()
|
||||
if not answer:
|
||||
raise RuntimeError("WebRTC answer was not created")
|
||||
return str(answer["sdp"])
|
||||
|
||||
|
||||
def _websocket_token(websocket: WebSocket) -> tuple[str, str | None]:
|
||||
authorization = websocket.headers.get("authorization")
|
||||
if authorization:
|
||||
return bearer_from_authorization(authorization), None
|
||||
protocols = [
|
||||
item.strip()
|
||||
for item in websocket.headers.get("sec-websocket-protocol", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
for protocol in protocols:
|
||||
prefix = "openai-insecure-api-key."
|
||||
if protocol.startswith(prefix):
|
||||
return protocol.removeprefix(prefix), "realtime" if "realtime" in protocols else None
|
||||
raise RealtimeAuthError("Missing Bearer credential")
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class _ManagedWebSocket:
|
||||
websocket: WebSocket
|
||||
pc_id: str = field(default_factory=lambda: f"ws_{uuid4().hex}")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self.websocket.application_state == WebSocketState.CONNECTED:
|
||||
await self.websocket.close(code=1001)
|
||||
|
||||
|
||||
@router.websocket("")
|
||||
async def realtime_websocket(websocket: WebSocket):
|
||||
managed: _ManagedWebSocket | None = None
|
||||
task = None
|
||||
try:
|
||||
token, accepted_subprotocol = _websocket_token(websocket)
|
||||
async with SessionLocal() as db:
|
||||
credential = await authenticate_bearer(db, token)
|
||||
requested = (
|
||||
credential.session
|
||||
if credential.ephemeral
|
||||
else {"model": websocket.query_params.get("model")}
|
||||
)
|
||||
state = await _build_session(
|
||||
credential,
|
||||
requested,
|
||||
safety_identifier_hash=hash_safety_identifier(
|
||||
websocket.headers.get("openai-safety-identifier")
|
||||
),
|
||||
)
|
||||
await websocket.accept(subprotocol=accepted_subprotocol)
|
||||
managed = _ManagedWebSocket(websocket)
|
||||
transport = build_openai_websocket_transport(websocket)
|
||||
bridge = OpenAIRealtimeBridge(state, channel="websocket")
|
||||
task = lifecycle.start_pipeline_task(
|
||||
managed,
|
||||
run_pipeline(
|
||||
transport,
|
||||
state.config,
|
||||
vision_enabled=False,
|
||||
assistant_id=state.assistant_id,
|
||||
channel="openai-websocket",
|
||||
protocol_adapter=bridge,
|
||||
),
|
||||
protocol="openai-websocket",
|
||||
)
|
||||
await task
|
||||
except (RealtimeAuthError, RealtimeEventError, ValueError) as exc:
|
||||
logger.warning(f"拒绝 OpenAI Realtime WebSocket: {exc}")
|
||||
if websocket.application_state == WebSocketState.CONNECTED:
|
||||
await websocket.close(code=1008, reason=str(exc)[:120])
|
||||
else:
|
||||
await websocket.close(code=1008)
|
||||
except Exception as exc: # noqa: BLE001 - pipeline callback logs full exception
|
||||
logger.warning(f"OpenAI Realtime WebSocket 已关闭: {type(exc).__name__}")
|
||||
if websocket.application_state == WebSocketState.CONNECTED:
|
||||
await websocket.close(code=1011)
|
||||
finally:
|
||||
if managed:
|
||||
lifecycle.active_connections.discard(managed)
|
||||
if task and not task.done():
|
||||
await lifecycle.wait_for_pipeline_close(
|
||||
task,
|
||||
connection_id=managed.pc_id if managed else "websocket",
|
||||
)
|
||||
85
backend/routes/realtime_api_keys.py
Normal file
85
backend/routes/realtime_api_keys.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Admin-only management of public Realtime API credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from db.models import RealtimeApiKey
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from schemas import RealtimeApiKeyCreate, RealtimeApiKeyCreated, RealtimeApiKeyOut
|
||||
from services.auth import require_admin
|
||||
from services.openai_realtime.auth import create_api_key_value
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/realtime/api-keys",
|
||||
tags=["realtime-api-keys"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _status(row: RealtimeApiKey) -> str:
|
||||
if row.revoked_at is not None:
|
||||
return "revoked"
|
||||
if row.expires_at is not None and row.expires_at <= datetime.now(UTC):
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def _out(row: RealtimeApiKey) -> RealtimeApiKeyOut:
|
||||
return RealtimeApiKeyOut(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
key_prefix=row.key_prefix,
|
||||
status=_status(row),
|
||||
expires_at=row.expires_at,
|
||||
last_used_at=row.last_used_at,
|
||||
revoked_at=row.revoked_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RealtimeApiKeyCreated, status_code=status.HTTP_201_CREATED)
|
||||
async def create_realtime_api_key(
|
||||
body: RealtimeApiKeyCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
key_id, key, prefix, digest = create_api_key_value()
|
||||
row = RealtimeApiKey(
|
||||
id=key_id,
|
||||
name=body.name,
|
||||
key_prefix=prefix,
|
||||
key_hash=digest,
|
||||
expires_at=body.expires_at,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return RealtimeApiKeyCreated(**_out(row).model_dump(), key=key)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RealtimeApiKeyOut])
|
||||
async def list_realtime_api_keys(session: AsyncSession = Depends(get_session)):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(RealtimeApiKey).order_by(RealtimeApiKey.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_out(row) for row in rows]
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_realtime_api_key(
|
||||
key_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(RealtimeApiKey, key_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "Realtime API Key 不存在")
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
126
backend/routes/site_settings.py
Normal file
126
backend/routes/site_settings.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Administration API for deployment-wide settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
|
||||
from db.models import SiteSetting
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from schemas import (
|
||||
AnalysisWebhookSettingOut,
|
||||
AnalysisWebhookSettingUpdate,
|
||||
AnalysisWebhookTestIn,
|
||||
AnalysisWebhookTestOut,
|
||||
)
|
||||
from services.auth import require_admin
|
||||
from services.webhooks.delivery import deliver_webhook
|
||||
from services.webhooks.events import ANALYSIS_WEBHOOK_KEY
|
||||
from services.webhooks.security import UnsafeWebhookUrl, validate_webhook_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/settings",
|
||||
tags=["site-settings"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _setting_out(row: SiteSetting | None) -> AnalysisWebhookSettingOut:
|
||||
values = dict(row.value or {}) if row else {}
|
||||
secrets = dict(row.secrets or {}) if row else {}
|
||||
return AnalysisWebhookSettingOut(
|
||||
enabled=bool(values.get("enabled")),
|
||||
url=str(values.get("url") or ""),
|
||||
secret_configured=bool(secrets.get("secret")),
|
||||
updated_at=row.updated_at if row else None,
|
||||
)
|
||||
|
||||
|
||||
async def _safe_url_or_422(url: str) -> str:
|
||||
try:
|
||||
return await validate_webhook_url(url)
|
||||
except UnsafeWebhookUrl as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/analysis-webhook", response_model=AnalysisWebhookSettingOut)
|
||||
async def get_analysis_webhook(session: AsyncSession = Depends(get_session)):
|
||||
return _setting_out(await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY))
|
||||
|
||||
|
||||
@router.put("/analysis-webhook", response_model=AnalysisWebhookSettingOut)
|
||||
async def update_analysis_webhook(
|
||||
body: AnalysisWebhookSettingUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
|
||||
stored_secrets = dict(row.secrets or {}) if row else {}
|
||||
incoming_secret = (body.secret or "").strip()
|
||||
if body.clear_secret:
|
||||
stored_secrets.pop("secret", None)
|
||||
if incoming_secret:
|
||||
stored_secrets["secret"] = incoming_secret
|
||||
|
||||
if body.url:
|
||||
await _safe_url_or_422(body.url)
|
||||
if body.enabled and not stored_secrets.get("secret"):
|
||||
raise HTTPException(422, "启用 Webhook 时必须配置签名密钥")
|
||||
|
||||
if row is None:
|
||||
row = SiteSetting(key=ANALYSIS_WEBHOOK_KEY)
|
||||
session.add(row)
|
||||
row.value = {"enabled": body.enabled, "url": body.url}
|
||||
row.secrets = stored_secrets
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _setting_out(row)
|
||||
|
||||
|
||||
@router.post("/analysis-webhook/test", response_model=AnalysisWebhookTestOut)
|
||||
async def test_analysis_webhook(
|
||||
body: AnalysisWebhookTestIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
url = await _safe_url_or_422(body.url)
|
||||
row = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
|
||||
stored_secret = str((row.secrets or {}).get("secret") or "") if row else ""
|
||||
secret = (body.secret or "").strip() or stored_secret
|
||||
if not secret:
|
||||
raise HTTPException(422, "请先填写或保存签名密钥")
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex}"
|
||||
payload = {
|
||||
"id": event_id,
|
||||
"event": "call.analysis.completed",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": {
|
||||
"conversationId": "conv_test_001",
|
||||
"assistantId": "asst_test_001",
|
||||
"assistantName": "Webhook 测试助手",
|
||||
"analysis": {
|
||||
"customer_intent": "high",
|
||||
"need_follow_up": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = await deliver_webhook(
|
||||
url=url,
|
||||
secret=secret,
|
||||
event_id=event_id,
|
||||
payload=payload,
|
||||
)
|
||||
message = (
|
||||
f"测试事件已送达,目标返回 HTTP {result.status_code}。"
|
||||
if result.ok
|
||||
else result.error or "测试事件发送失败。"
|
||||
)
|
||||
return AnalysisWebhookTestOut(
|
||||
ok=result.ok,
|
||||
status_code=result.status_code,
|
||||
latency_ms=result.latency_ms,
|
||||
message=message,
|
||||
payload=payload,
|
||||
)
|
||||
369
backend/routes/test_cases.py
Normal file
369
backend/routes/test_cases.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""Persistent test suite and fixed-text test case CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from db.models import TestCase, TestRunCase, TestSuite
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from services.auth import require_admin
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from test_schemas import (
|
||||
TestCaseBulkDeleteIn,
|
||||
TestCaseDefinition,
|
||||
TestCaseOrderIn,
|
||||
TestCaseOut,
|
||||
TestCaseWrite,
|
||||
TestSuiteCreate,
|
||||
TestSuiteOut,
|
||||
TestSuiteUpdate,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
tags=["tests"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid4().hex}"
|
||||
|
||||
|
||||
async def _touch_suite(session: AsyncSession, suite_id: str) -> None:
|
||||
suite = await session.get(TestSuite, suite_id)
|
||||
if suite is not None:
|
||||
suite.updated_at = datetime.now(UTC)
|
||||
|
||||
|
||||
async def _suite_out(session: AsyncSession, row: TestSuite) -> TestSuiteOut:
|
||||
total = await session.scalar(
|
||||
select(func.count()).select_from(TestCase).where(TestCase.suite_id == row.id)
|
||||
)
|
||||
run = await session.scalar(
|
||||
select(func.count(func.distinct(TestRunCase.run_id))).where(
|
||||
TestRunCase.suite_id == row.id
|
||||
)
|
||||
)
|
||||
passed = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(TestCase)
|
||||
.where(TestCase.suite_id == row.id, TestCase.last_result == "pass")
|
||||
)
|
||||
return TestSuiteOut(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
case_count=int(total or 0),
|
||||
passed_count=int(passed or 0),
|
||||
run_count=int(run or 0),
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _case_out(row: TestCase) -> TestCaseOut:
|
||||
definition = TestCaseDefinition.model_validate(row.definition or {})
|
||||
return TestCaseOut(
|
||||
id=row.id,
|
||||
suite_id=row.suite_id,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
input_mode=row.input_mode,
|
||||
last_result=row.last_result,
|
||||
sort_order=row.sort_order,
|
||||
updated_at=row.updated_at,
|
||||
**definition.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def _apply_case_write(row: TestCase, body: TestCaseWrite) -> None:
|
||||
row.name = body.name.strip()
|
||||
row.description = body.description.strip()
|
||||
row.input_mode = body.input_mode
|
||||
row.definition = body.definition().model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
async def _renumber_cases(session: AsyncSession, suite_id: str) -> None:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(TestCase)
|
||||
.where(TestCase.suite_id == suite_id)
|
||||
.order_by(TestCase.sort_order, TestCase.created_at, TestCase.id)
|
||||
)
|
||||
).scalars().all()
|
||||
offset = len(rows) + 1
|
||||
for index, row in enumerate(rows):
|
||||
row.sort_order = offset + index
|
||||
await session.flush()
|
||||
for index, row in enumerate(rows):
|
||||
row.sort_order = index
|
||||
|
||||
|
||||
@router.get("/api/test-suites", response_model=list[TestSuiteOut])
|
||||
async def list_test_suites(session: AsyncSession = Depends(get_session)):
|
||||
rows = (
|
||||
await session.execute(select(TestSuite).order_by(TestSuite.updated_at.desc()))
|
||||
).scalars().all()
|
||||
return [await _suite_out(session, row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/api/test-suites", response_model=TestSuiteOut)
|
||||
async def create_test_suite(
|
||||
body: TestSuiteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = TestSuite(
|
||||
id=_new_id("suite"),
|
||||
name=body.name.strip(),
|
||||
description=body.description.strip(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return await _suite_out(session, row)
|
||||
|
||||
|
||||
@router.get("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
|
||||
async def get_test_suite(
|
||||
suite_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(TestSuite, suite_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "测试集不存在")
|
||||
return await _suite_out(session, row)
|
||||
|
||||
|
||||
@router.put("/api/test-suites/{suite_id}", response_model=TestSuiteOut)
|
||||
async def update_test_suite(
|
||||
suite_id: str,
|
||||
body: TestSuiteUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(TestSuite, suite_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "测试集不存在")
|
||||
row.name = body.name.strip()
|
||||
row.description = body.description.strip()
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return await _suite_out(session, row)
|
||||
|
||||
|
||||
@router.delete("/api/test-suites/{suite_id}")
|
||||
async def delete_test_suite(
|
||||
suite_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(TestSuite, suite_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "测试集不存在")
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/test-suites/{suite_id}/duplicate", response_model=TestSuiteOut)
|
||||
async def duplicate_test_suite(
|
||||
suite_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
source = await session.get(TestSuite, suite_id)
|
||||
if source is None:
|
||||
raise HTTPException(404, "测试集不存在")
|
||||
copied = TestSuite(
|
||||
id=_new_id("suite"),
|
||||
name=f"{source.name}(副本)",
|
||||
description=source.description,
|
||||
)
|
||||
session.add(copied)
|
||||
source_cases = (
|
||||
await session.execute(
|
||||
select(TestCase)
|
||||
.where(TestCase.suite_id == suite_id)
|
||||
.order_by(TestCase.sort_order)
|
||||
)
|
||||
).scalars().all()
|
||||
for index, source_case in enumerate(source_cases):
|
||||
session.add(
|
||||
TestCase(
|
||||
id=_new_id("tc"),
|
||||
suite_id=copied.id,
|
||||
name=source_case.name,
|
||||
description=source_case.description,
|
||||
input_mode=source_case.input_mode,
|
||||
definition=dict(source_case.definition or {}),
|
||||
sort_order=index,
|
||||
last_result="not_run",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(copied)
|
||||
return await _suite_out(session, copied)
|
||||
|
||||
|
||||
@router.get("/api/test-cases", response_model=list[TestCaseOut])
|
||||
async def list_test_cases(
|
||||
suite_id: str | None = Query(default=None, alias="suiteId"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
statement = select(TestCase)
|
||||
if suite_id:
|
||||
statement = statement.where(TestCase.suite_id == suite_id)
|
||||
rows = (
|
||||
await session.execute(
|
||||
statement.order_by(TestCase.suite_id, TestCase.sort_order, TestCase.name)
|
||||
)
|
||||
).scalars().all()
|
||||
return [_case_out(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/api/test-suites/{suite_id}/cases", response_model=TestCaseOut)
|
||||
async def create_test_case(
|
||||
suite_id: str,
|
||||
body: TestCaseWrite,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
if await session.get(TestSuite, suite_id) is None:
|
||||
raise HTTPException(404, "测试集不存在")
|
||||
max_order = await session.scalar(
|
||||
select(func.max(TestCase.sort_order)).where(TestCase.suite_id == suite_id)
|
||||
)
|
||||
row = TestCase(
|
||||
id=_new_id("tc"),
|
||||
suite_id=suite_id,
|
||||
name=body.name.strip(),
|
||||
description=body.description.strip(),
|
||||
input_mode=body.input_mode,
|
||||
definition=body.definition().model_dump(mode="json", by_alias=True),
|
||||
sort_order=int(max_order if max_order is not None else -1) + 1,
|
||||
last_result="not_run",
|
||||
)
|
||||
session.add(row)
|
||||
await _touch_suite(session, suite_id)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _case_out(row)
|
||||
|
||||
|
||||
@router.post("/api/test-cases/bulk-delete")
|
||||
async def bulk_delete_test_cases(
|
||||
body: TestCaseBulkDeleteIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
|
||||
).scalars().all()
|
||||
suite_ids = {row.suite_id for row in rows}
|
||||
for row in rows:
|
||||
await session.delete(row)
|
||||
await session.flush()
|
||||
for suite_id in suite_ids:
|
||||
await _renumber_cases(session, suite_id)
|
||||
await _touch_suite(session, suite_id)
|
||||
await session.commit()
|
||||
return {"ok": True, "deleted": len(rows)}
|
||||
|
||||
|
||||
@router.put("/api/test-suites/{suite_id}/case-order")
|
||||
async def reorder_test_cases(
|
||||
suite_id: str,
|
||||
body: TestCaseOrderIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(TestCase)
|
||||
.where(TestCase.suite_id == suite_id)
|
||||
.order_by(TestCase.sort_order)
|
||||
)
|
||||
).scalars().all()
|
||||
if {row.id for row in rows} != set(body.case_ids) or len(rows) != len(body.case_ids):
|
||||
raise HTTPException(422, "排序列表必须包含测试集内全部用例且不能重复")
|
||||
by_id = {row.id: row for row in rows}
|
||||
offset = len(rows) + 1
|
||||
for index, row in enumerate(rows):
|
||||
row.sort_order = offset + index
|
||||
await session.flush()
|
||||
for index, case_id in enumerate(body.case_ids):
|
||||
by_id[case_id].sort_order = index
|
||||
await _touch_suite(session, suite_id)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/api/test-cases/{case_id}", response_model=TestCaseOut)
|
||||
async def get_test_case(
|
||||
case_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(TestCase, case_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "测试用例不存在")
|
||||
return _case_out(row)
|
||||
|
||||
|
||||
@router.put("/api/test-cases/{case_id}", response_model=TestCaseOut)
|
||||
async def update_test_case(
|
||||
case_id: str,
|
||||
body: TestCaseWrite,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(TestCase, case_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "测试用例不存在")
|
||||
_apply_case_write(row, body)
|
||||
await _touch_suite(session, row.suite_id)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _case_out(row)
|
||||
|
||||
|
||||
@router.delete("/api/test-cases/{case_id}")
|
||||
async def delete_test_case(
|
||||
case_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
row = await session.get(TestCase, case_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "测试用例不存在")
|
||||
suite_id = row.suite_id
|
||||
await session.delete(row)
|
||||
await session.flush()
|
||||
await _renumber_cases(session, suite_id)
|
||||
await _touch_suite(session, suite_id)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/test-cases/{case_id}/duplicate", response_model=TestCaseOut)
|
||||
async def duplicate_test_case(
|
||||
case_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
source = await session.get(TestCase, case_id)
|
||||
if source is None:
|
||||
raise HTTPException(404, "测试用例不存在")
|
||||
max_order = await session.scalar(
|
||||
select(func.max(TestCase.sort_order)).where(
|
||||
TestCase.suite_id == source.suite_id
|
||||
)
|
||||
)
|
||||
copied = TestCase(
|
||||
id=_new_id("tc"),
|
||||
suite_id=source.suite_id,
|
||||
name=f"{source.name}(副本)",
|
||||
description=source.description,
|
||||
input_mode=source.input_mode,
|
||||
definition=dict(source.definition or {}),
|
||||
sort_order=int(max_order if max_order is not None else -1) + 1,
|
||||
last_result="not_run",
|
||||
)
|
||||
session.add(copied)
|
||||
await _touch_suite(session, source.suite_id)
|
||||
await session.commit()
|
||||
await session.refresh(copied)
|
||||
return _case_out(copied)
|
||||
240
backend/routes/test_runs.py
Normal file
240
backend/routes/test_runs.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Start, inspect, and cancel persisted batch text test runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from db.models import (
|
||||
Assistant,
|
||||
ModelResource,
|
||||
TestCase,
|
||||
TestRun,
|
||||
TestRunCase,
|
||||
TestSuite,
|
||||
)
|
||||
from db.session import get_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from services.auth import require_admin
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.test_runs.orchestrator import test_run_orchestrator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from test_schemas import (
|
||||
BatchExecutionError,
|
||||
BatchRunCaseOut,
|
||||
BatchRunCreate,
|
||||
BatchRunSnapshotOut,
|
||||
BatchTurnResult,
|
||||
BatchEvaluationResult,
|
||||
TestCaseDefinition,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/test-runs",
|
||||
tags=["tests"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid4().hex}"
|
||||
|
||||
|
||||
async def _run_out(
|
||||
session: AsyncSession,
|
||||
run: TestRun,
|
||||
) -> BatchRunSnapshotOut:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(TestRunCase)
|
||||
.where(TestRunCase.run_id == run.id)
|
||||
.order_by(TestRunCase.position)
|
||||
)
|
||||
).scalars().all()
|
||||
cases: list[BatchRunCaseOut] = []
|
||||
for row in rows:
|
||||
result = dict(row.result or {})
|
||||
cases.append(
|
||||
BatchRunCaseOut(
|
||||
id=str(row.test_case_id or row.case_snapshot.get("id") or row.id),
|
||||
name=row.test_case_name,
|
||||
status=row.status,
|
||||
turns=[
|
||||
BatchTurnResult.model_validate(item)
|
||||
for item in result.get("turns") or []
|
||||
],
|
||||
overall_criteria=[
|
||||
BatchEvaluationResult.model_validate(item)
|
||||
for item in result.get("overallCriteria")
|
||||
or result.get("overall_criteria")
|
||||
or []
|
||||
],
|
||||
attempt_count=row.attempt_count,
|
||||
max_attempts=row.max_attempts,
|
||||
execution_error=(
|
||||
BatchExecutionError.model_validate(row.execution_error)
|
||||
if row.execution_error
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return BatchRunSnapshotOut(
|
||||
id=run.id,
|
||||
status=run.status,
|
||||
title=run.title,
|
||||
assistant_name=run.assistant_name,
|
||||
config=dict(run.config or {}),
|
||||
cases=cases,
|
||||
started_at=run.started_at or run.created_at,
|
||||
finished_at=run.finished_at,
|
||||
stop_reason=run.stop_reason,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=BatchRunSnapshotOut)
|
||||
async def create_test_run(
|
||||
body: BatchRunCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
assistant = await session.get(Assistant, body.assistant_id)
|
||||
if assistant is None:
|
||||
raise HTTPException(404, "被测助手不存在")
|
||||
if assistant.runtime_mode != "pipeline":
|
||||
raise HTTPException(422, "第一版批量测试只支持 Pipeline 运行模式")
|
||||
if assistant.type not in {"prompt", "workflow"}:
|
||||
raise HTTPException(422, f"第一版批量测试暂不支持 {assistant.type} 类型助手")
|
||||
|
||||
evaluator_resource = await session.get(
|
||||
ModelResource,
|
||||
body.evaluator_model_resource_id,
|
||||
)
|
||||
if (
|
||||
evaluator_resource is None
|
||||
or evaluator_resource.capability != "LLM"
|
||||
or not evaluator_resource.enabled
|
||||
):
|
||||
raise HTTPException(422, "评估模型不存在、已停用或不是 LLM 资源")
|
||||
try:
|
||||
runtime_config = await resolve_runtime_config(session, assistant.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
|
||||
rows = (
|
||||
await session.execute(select(TestCase).where(TestCase.id.in_(body.case_ids)))
|
||||
).scalars().all()
|
||||
by_id = {row.id: row for row in rows}
|
||||
missing = [case_id for case_id in body.case_ids if case_id not in by_id]
|
||||
if missing:
|
||||
raise HTTPException(422, "测试用例不存在: " + "、".join(missing))
|
||||
ordered_cases = [by_id[case_id] for case_id in body.case_ids]
|
||||
tools_by_id = {tool.id: tool for tool in runtime_config.tools}
|
||||
for row in ordered_cases:
|
||||
if row.input_mode != "fixed_script_text":
|
||||
raise HTTPException(422, f"用例“{row.name}”不是可运行的固定文字模式")
|
||||
try:
|
||||
definition = TestCaseDefinition.model_validate(row.definition or {})
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, f"用例“{row.name}”校验失败: {exc}") from exc
|
||||
for turn in definition.turns:
|
||||
for behavior in turn.behaviors:
|
||||
if behavior.type != "tool_call":
|
||||
continue
|
||||
tool = tools_by_id.get(behavior.tool_id)
|
||||
if tool is None or tool.function_name != behavior.function_name:
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"用例“{row.name}”引用的工具 {behavior.function_name} "
|
||||
"不属于当前被测助手",
|
||||
)
|
||||
|
||||
suite_ids = list(dict.fromkeys(row.suite_id for row in ordered_cases))
|
||||
suites = (
|
||||
await session.execute(select(TestSuite).where(TestSuite.id.in_(suite_ids)))
|
||||
).scalars().all()
|
||||
suite_by_id = {row.id: row for row in suites}
|
||||
if body.title:
|
||||
title = body.title.strip()
|
||||
elif len(suite_ids) == 1 and suite_ids[0] in suite_by_id:
|
||||
title = f"{suite_by_id[suite_ids[0]].name} · {len(ordered_cases)} 个用例"
|
||||
else:
|
||||
title = f"批量测试 · {len(ordered_cases)} 个用例"
|
||||
|
||||
now = datetime.now(UTC)
|
||||
run = TestRun(
|
||||
id=_new_id("run"),
|
||||
assistant_id=assistant.id,
|
||||
assistant_name=assistant.name,
|
||||
title=title,
|
||||
status="queued",
|
||||
config={
|
||||
"suiteCount": len(suite_ids),
|
||||
"evaluatorModelResourceId": evaluator_resource.id,
|
||||
"evaluatorModelResourceName": evaluator_resource.name,
|
||||
"evaluatorModel": str(
|
||||
(evaluator_resource.values or {}).get("modelId") or ""
|
||||
),
|
||||
**body.config.model_dump(mode="json", by_alias=True),
|
||||
},
|
||||
started_at=now,
|
||||
)
|
||||
session.add(run)
|
||||
for position, row in enumerate(ordered_cases):
|
||||
definition = TestCaseDefinition.model_validate(row.definition or {})
|
||||
snapshot = {
|
||||
"id": row.id,
|
||||
"suiteId": row.suite_id,
|
||||
"name": row.name,
|
||||
"description": row.description,
|
||||
"inputMode": row.input_mode,
|
||||
**definition.model_dump(mode="json", by_alias=True),
|
||||
}
|
||||
session.add(
|
||||
TestRunCase(
|
||||
id=_new_id("rc"),
|
||||
run_id=run.id,
|
||||
test_case_id=row.id,
|
||||
test_case_name=row.name,
|
||||
suite_id=row.suite_id,
|
||||
position=position,
|
||||
case_snapshot=snapshot,
|
||||
status="waiting",
|
||||
attempt_count=0,
|
||||
max_attempts=body.config.error_retry_count + 1,
|
||||
result={"turns": [], "overallCriteria": []},
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(run)
|
||||
test_run_orchestrator.start(run.id)
|
||||
return await _run_out(session, run)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=BatchRunSnapshotOut)
|
||||
async def get_test_run(
|
||||
run_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(404, "批量测试运行不存在")
|
||||
return await _run_out(session, run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel", response_model=BatchRunSnapshotOut)
|
||||
async def cancel_test_run(
|
||||
run_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(404, "批量测试运行不存在")
|
||||
if run.status in {"completed", "cancelled"}:
|
||||
return await _run_out(session, run)
|
||||
run.cancel_requested = True
|
||||
run.stop_reason = "manual"
|
||||
await session.commit()
|
||||
await test_run_orchestrator.cancel(run_id)
|
||||
await session.refresh(run)
|
||||
return await _run_out(session, run)
|
||||
@@ -8,18 +8,20 @@
|
||||
server → {type:"error", payload:{message}}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, Body, Depends, Request, WebSocket
|
||||
from loguru import logger
|
||||
from models import AssistantConfig, SignalingOffer
|
||||
from services.auth import require_admin, require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.realtime import lifecycle as realtime_lifecycle
|
||||
from services.realtime.launcher import (
|
||||
resolve_assistant_config,
|
||||
validate_visual_runtime,
|
||||
)
|
||||
from services.runtime_variables import DynamicVariableError, prepare_dynamic_config
|
||||
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
||||
|
||||
@@ -29,116 +31,42 @@ from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
|
||||
|
||||
router = APIRouter(tags=["voice"])
|
||||
_http_peers: dict[str, object] = {}
|
||||
_active_connections: set[object] = set()
|
||||
_pipeline_tasks: set[asyncio.Task[None]] = set()
|
||||
_connection_tasks: dict[object, asyncio.Task[None]] = {}
|
||||
_active_connections = realtime_lifecycle.active_connections
|
||||
_pipeline_tasks = realtime_lifecycle.pipeline_tasks
|
||||
_connection_tasks = realtime_lifecycle.connection_tasks
|
||||
PIPELINE_CLOSE_GRACE_SECONDS = 10.0
|
||||
|
||||
|
||||
def _consume_pipeline_result(task: asyncio.Task[None], connection: object) -> None:
|
||||
"""Retrieve background exceptions and release the task's strong reference."""
|
||||
_pipeline_tasks.discard(task)
|
||||
if _connection_tasks.get(connection) is task:
|
||||
_connection_tasks.pop(connection, None)
|
||||
try:
|
||||
error = task.exception()
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"WebRTC pipeline 已取消: task={task.get_name()}")
|
||||
return
|
||||
if error is not None:
|
||||
logger.opt(exception=error).error(
|
||||
f"WebRTC pipeline 异常结束: task={task.get_name()}"
|
||||
)
|
||||
|
||||
|
||||
def _start_pipeline_task(
|
||||
connection: object,
|
||||
coroutine: Coroutine[Any, Any, None],
|
||||
) -> asyncio.Task[None]:
|
||||
"""Start one strongly referenced pipeline task for a WebRTC connection."""
|
||||
connection_id = str(getattr(connection, "pc_id", "unknown"))
|
||||
task = asyncio.create_task(
|
||||
) -> object:
|
||||
"""Compatibility proxy for existing RTVI callers and tests."""
|
||||
return realtime_lifecycle.start_pipeline_task(
|
||||
connection,
|
||||
coroutine,
|
||||
name=f"webrtc-pipeline:{connection_id}",
|
||||
protocol="webrtc",
|
||||
)
|
||||
_active_connections.add(connection)
|
||||
_pipeline_tasks.add(task)
|
||||
_connection_tasks[connection] = task
|
||||
task.add_done_callback(
|
||||
lambda completed, connection=connection: _consume_pipeline_result(
|
||||
completed,
|
||||
connection,
|
||||
)
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def _wait_for_pipeline_close(
|
||||
task: asyncio.Task[None] | None,
|
||||
task,
|
||||
*,
|
||||
connection_id: str,
|
||||
) -> None:
|
||||
"""Let transport disconnect finish normally, then cancel a stuck pipeline."""
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(task),
|
||||
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
|
||||
)
|
||||
return
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"WebRTC pipeline 关闭超过 {PIPELINE_CLOSE_GRACE_SECONDS:g} 秒,"
|
||||
f"执行取消: pc_id={connection_id}"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# The done callback owns exception reporting and retrieval.
|
||||
return
|
||||
|
||||
task.cancel()
|
||||
done, _pending = await asyncio.wait(
|
||||
{task},
|
||||
await realtime_lifecycle.wait_for_pipeline_close(
|
||||
task,
|
||||
connection_id=connection_id,
|
||||
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
|
||||
)
|
||||
if not done:
|
||||
logger.error(f"WebRTC pipeline 取消后仍未退出: pc_id={connection_id}")
|
||||
|
||||
|
||||
async def shutdown_active_sessions() -> None:
|
||||
"""Close active peers and drain every managed pipeline during app shutdown."""
|
||||
connections = list(_active_connections)
|
||||
if connections:
|
||||
await asyncio.gather(
|
||||
*(connection.disconnect() for connection in connections),
|
||||
return_exceptions=True,
|
||||
)
|
||||
_active_connections.difference_update(connections)
|
||||
_http_peers.clear()
|
||||
|
||||
tasks = list(_pipeline_tasks)
|
||||
if not tasks:
|
||||
return
|
||||
done, pending = await asyncio.wait(
|
||||
tasks,
|
||||
"""Compatibility proxy; now drains RTVI and OpenAI Realtime sessions."""
|
||||
_http_peers.clear()
|
||||
await realtime_lifecycle.shutdown_active_sessions(
|
||||
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
cancelled, stuck = await asyncio.wait(
|
||||
pending,
|
||||
timeout=PIPELINE_CLOSE_GRACE_SECONDS,
|
||||
)
|
||||
if stuck:
|
||||
logger.error(f"应用关闭时仍有 {len(stuck)} 个 WebRTC pipeline 未退出")
|
||||
else:
|
||||
cancelled = set()
|
||||
logger.info(
|
||||
f"WebRTC 会话清理完成: normal={len(done)} cancelled={len(cancelled)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/webrtc/ice-servers", dependencies=[Depends(require_admin)])
|
||||
@@ -229,12 +157,9 @@ async def voice_signaling(websocket: WebSocket):
|
||||
async def _resolve_config(offer: SignalingOffer) -> AssistantConfig:
|
||||
"""优先用 assistant_id 从 DB 解析(含真 key);否则用调试内联配置。"""
|
||||
if offer.assistant_id:
|
||||
async with SessionLocal() as session:
|
||||
cfg = await resolve_runtime_config(session, offer.assistant_id)
|
||||
return prepare_dynamic_config(
|
||||
cfg,
|
||||
offer.dynamic_variables,
|
||||
assistant_id=offer.assistant_id,
|
||||
return await resolve_assistant_config(
|
||||
offer.assistant_id,
|
||||
dynamic_variables=offer.dynamic_variables,
|
||||
)
|
||||
if offer.inline_config:
|
||||
return prepare_dynamic_config(
|
||||
@@ -280,24 +205,7 @@ async def _handle_offer_payload(payload, peers):
|
||||
else:
|
||||
cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连
|
||||
# 服务端助手配置是视觉理解的唯一授权来源;客户端 offer 只负责携带媒体轨。
|
||||
if cfg.type == "workflow":
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
vision_enabled = WorkflowEngine(cfg.graph).uses_vision()
|
||||
else:
|
||||
vision_enabled = cfg.vision_enabled
|
||||
if vision_enabled and cfg.type != "workflow":
|
||||
has_native_vision = (
|
||||
not cfg.vision_model_resource_id and cfg.llm_support_image_input
|
||||
)
|
||||
has_aux_vision_model = (
|
||||
bool(cfg.vision_model_resource_id)
|
||||
and cfg.vision_llm_support_image_input
|
||||
)
|
||||
if not (has_native_vision or has_aux_vision_model):
|
||||
raise ValueError(
|
||||
"当前模型不支持图片输入,请在模型资源中选择支持图片输入的视觉模型"
|
||||
)
|
||||
vision_enabled = validate_visual_runtime(cfg)
|
||||
pc = SmallWebRTCConnection(ice_servers=aiortc_ice_servers())
|
||||
if pc_id:
|
||||
pc._pc_id = pc_id
|
||||
|
||||
@@ -7,7 +7,7 @@ JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_c
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
|
||||
@@ -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):
|
||||
@@ -510,3 +566,74 @@ class ConversationListOut(CamelModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ---------- 站点级 Webhook ----------
|
||||
class AnalysisWebhookSettingOut(CamelModel):
|
||||
enabled: bool = False
|
||||
url: str = ""
|
||||
secret_configured: bool = False
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class AnalysisWebhookSettingUpdate(CamelModel):
|
||||
enabled: bool = False
|
||||
url: str = Field(default="", max_length=2048)
|
||||
secret: str | None = Field(default=None, max_length=512)
|
||||
clear_secret: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled_url(self):
|
||||
self.url = self.url.strip()
|
||||
if self.enabled and not self.url:
|
||||
raise ValueError("启用 Webhook 时必须填写 URL")
|
||||
return self
|
||||
|
||||
|
||||
class AnalysisWebhookTestIn(CamelModel):
|
||||
url: str = Field(min_length=1, max_length=2048)
|
||||
secret: str | None = Field(default=None, max_length=512)
|
||||
|
||||
|
||||
class AnalysisWebhookTestOut(CamelModel):
|
||||
ok: bool
|
||||
status_code: int | None = None
|
||||
latency_ms: int | None = None
|
||||
message: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
# ---------- Realtime API keys ----------
|
||||
class RealtimeApiKeyCreate(CamelModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: object) -> object:
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
@field_validator("expires_at")
|
||||
@classmethod
|
||||
def validate_expiry(cls, value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
if normalized <= datetime.now(UTC):
|
||||
raise ValueError("expires_at 必须晚于当前时间")
|
||||
return normalized
|
||||
|
||||
|
||||
class RealtimeApiKeyOut(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
key_prefix: str
|
||||
status: Literal["active", "expired", "revoked"]
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RealtimeApiKeyCreated(RealtimeApiKeyOut):
|
||||
key: str
|
||||
|
||||
@@ -99,6 +99,9 @@ class BrainRuntime:
|
||||
Callable[[bool, dict[str, Any]], Awaitable[None]] | None
|
||||
) = None
|
||||
flow_global_functions: list[Any] = field(default_factory=list)
|
||||
# Tests replace external side effects without changing Pipecat or stored
|
||||
# tool resources. Production leaves this unset and uses ToolExecutor.
|
||||
tool_executor_factory: Callable[[Any], Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -43,6 +43,7 @@ from services.message_stage import (
|
||||
MessageDisplaySpec,
|
||||
MessageStageRunner,
|
||||
MessageStageSpec,
|
||||
confirmation_context_message,
|
||||
)
|
||||
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
|
||||
from services.system_tools import state_update_properties, system_tool_kind
|
||||
@@ -103,6 +104,8 @@ class PromptBrain(BaseBrain):
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
if runtime.tool_executor_factory is not None:
|
||||
self._tools = runtime.tool_executor_factory(self._store)
|
||||
self._tools.set_client_tools(runtime.client_tools)
|
||||
self._actions = ActionRunner(
|
||||
self._tools,
|
||||
@@ -224,8 +227,12 @@ class PromptBrain(BaseBrain):
|
||||
self.prepare_greeting_context(speech, runtime.context)
|
||||
try:
|
||||
if opening_message is not None:
|
||||
message_spec = self._opening_message_stage_spec(
|
||||
speech,
|
||||
opening_message,
|
||||
)
|
||||
message_result = await self._message_stages.run(
|
||||
self._opening_message_stage_spec(speech, opening_message),
|
||||
message_spec,
|
||||
speak=self._speak_opening,
|
||||
set_input_enabled=runtime.set_input_enabled,
|
||||
input_already_blocked=self._opening_input_blocked,
|
||||
@@ -239,6 +246,12 @@ class PromptBrain(BaseBrain):
|
||||
message_result.error or "开场消息显示失败"
|
||||
)
|
||||
return
|
||||
context_message = confirmation_context_message(
|
||||
message_result,
|
||||
source="prompt-opening",
|
||||
)
|
||||
if context_message is not None:
|
||||
runtime.context.add_message(context_message)
|
||||
|
||||
if opening_actions:
|
||||
result = await self._action_stages.run(
|
||||
@@ -651,14 +664,27 @@ class PromptBrain(BaseBrain):
|
||||
if not kind:
|
||||
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
|
||||
if kind == "end_conversation":
|
||||
return self._make_end_call_tool(tool, runtime)
|
||||
if kind == "update_state":
|
||||
return self._make_update_state_tool(tool, runtime)
|
||||
if kind == "skip_turn":
|
||||
return self._make_skip_turn_tool(tool)
|
||||
if kind == "request_human_handoff":
|
||||
return self._make_handoff_tool(tool, runtime)
|
||||
raise ValueError(f"未知系统工具: {kind}")
|
||||
schema, handler = self._make_end_call_tool(tool, runtime)
|
||||
elif kind == "update_state":
|
||||
schema, handler = self._make_update_state_tool(tool, runtime)
|
||||
elif kind == "skip_turn":
|
||||
schema, handler = self._make_skip_turn_tool(tool)
|
||||
elif kind == "request_human_handoff":
|
||||
schema, handler = self._make_handoff_tool(tool, runtime)
|
||||
else:
|
||||
raise ValueError(f"未知系统工具: {kind}")
|
||||
|
||||
if runtime.tool_executor_factory is None:
|
||||
return schema, handler
|
||||
|
||||
async def mock_system_tool(params: FunctionCallParams) -> None:
|
||||
result = await self._tools.execute(
|
||||
tool,
|
||||
dict(params.arguments or {}),
|
||||
)
|
||||
await params.result_callback(result)
|
||||
|
||||
return schema, mock_system_tool
|
||||
|
||||
def _make_update_state_tool(self, tool, runtime: BrainRuntime):
|
||||
"""更新已声明的动态变量(会话状态),并让模型继续当前回答。"""
|
||||
|
||||
@@ -58,6 +58,7 @@ from services.message_stage import (
|
||||
MessageStageResult,
|
||||
MessageStageRunner,
|
||||
MessageStageSpec,
|
||||
confirmation_context_message,
|
||||
)
|
||||
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
|
||||
from services.system_tools import state_update_properties, system_tool_kind
|
||||
@@ -214,7 +215,12 @@ class WorkflowBrain(BaseBrain):
|
||||
self._cfg = cfg
|
||||
self._runtime = runtime
|
||||
self._store = DynamicVariableStore.from_config(cfg)
|
||||
self._tools = ToolExecutor(self._store, client_tools=runtime.client_tools)
|
||||
self._tools = (
|
||||
runtime.tool_executor_factory(self._store)
|
||||
if runtime.tool_executor_factory is not None
|
||||
else ToolExecutor(self._store, client_tools=runtime.client_tools)
|
||||
)
|
||||
self._tools.set_client_tools(runtime.client_tools)
|
||||
self._actions = ActionRunner(
|
||||
self._tools,
|
||||
is_session_ending=lambda: runtime.call_end.ending,
|
||||
@@ -934,18 +940,28 @@ class WorkflowBrain(BaseBrain):
|
||||
"""Build one platform-owned tool scoped to the active Agent node."""
|
||||
kind = system_tool_kind(tool.definition or {})
|
||||
if kind == "update_state":
|
||||
return self._workflow_update_state_tool(
|
||||
schema = self._workflow_update_state_tool(
|
||||
tool,
|
||||
node_id,
|
||||
state_variable_names=state_variable_names,
|
||||
)
|
||||
if kind == "skip_turn":
|
||||
return self._workflow_skip_turn_tool(tool)
|
||||
if kind == "request_human_handoff":
|
||||
return self._workflow_handoff_tool(tool, node_id)
|
||||
if kind == "end_conversation":
|
||||
return self._workflow_end_conversation_tool(tool, node_id)
|
||||
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
|
||||
elif kind == "skip_turn":
|
||||
schema = self._workflow_skip_turn_tool(tool)
|
||||
elif kind == "request_human_handoff":
|
||||
schema = self._workflow_handoff_tool(tool, node_id)
|
||||
elif kind == "end_conversation":
|
||||
schema = self._workflow_end_conversation_tool(tool, node_id)
|
||||
else:
|
||||
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
|
||||
|
||||
runtime = self._require_runtime()
|
||||
if runtime.tool_executor_factory is None:
|
||||
return schema
|
||||
|
||||
async def mock_system_tool(args, _flow_manager):
|
||||
return await self._tools.execute(tool, dict(args or {}))
|
||||
|
||||
return replace(schema, handler=mock_system_tool)
|
||||
|
||||
def _workflow_update_state_tool(
|
||||
self,
|
||||
@@ -1406,6 +1422,12 @@ class WorkflowBrain(BaseBrain):
|
||||
context_message = fixed_speech_context_message(result.speech)
|
||||
if context_message is not None:
|
||||
context_messages.append(context_message)
|
||||
confirmation_message = confirmation_context_message(
|
||||
result,
|
||||
source=f"workflow-message:{continuation.node_id}",
|
||||
)
|
||||
if confirmation_message is not None:
|
||||
context_messages.append(confirmation_message)
|
||||
|
||||
if not self._engine.has_outgoing(continuation.node_id):
|
||||
self._state.enter(
|
||||
|
||||
@@ -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
|
||||
@@ -235,6 +243,9 @@ class ConversationRecorder:
|
||||
},
|
||||
)
|
||||
)
|
||||
# Flush before artifact insert: db.get() below autoflushes and
|
||||
# can otherwise write conversation_artifacts first (FK violation).
|
||||
await db.flush()
|
||||
db.add(
|
||||
ConversationArtifact(
|
||||
id=artifact_id,
|
||||
@@ -286,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}")
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BrainRuntime
|
||||
from services.pipecat.call_lifecycle import playback_marker_for
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.brains.base import BrainRuntime
|
||||
|
||||
|
||||
FIXED_SPEECH_CONTEXT_MARKER = "[会话事实:以下固定消息已向用户播报]"
|
||||
|
||||
@@ -79,6 +82,10 @@ class FixedSpeechOutput:
|
||||
await self._runtime.queue_frame(
|
||||
TTSSpeakFrame(content, append_to_context=False)
|
||||
)
|
||||
playback_marker = playback_marker_for(playback_completion)
|
||||
if playback_marker is not None:
|
||||
await self._runtime.queue_frame(playback_marker)
|
||||
playback_marker.completion.mark_queued()
|
||||
return playback_completion
|
||||
|
||||
async def emit(self, message: dict[str, Any]) -> None:
|
||||
|
||||
@@ -17,6 +17,7 @@ from services.message_policy import (
|
||||
|
||||
|
||||
BUILTIN_SHOW_MESSAGE = "show_message"
|
||||
MESSAGE_CONFIRMATION_CONTEXT_MARKER = "[客户端交互事件,不是语音转写]"
|
||||
SpeechCompletion = Awaitable[None] | None
|
||||
Speak = Callable[[str], Awaitable[SpeechCompletion]]
|
||||
StartedHook = Callable[[], Awaitable[None]]
|
||||
@@ -40,15 +41,54 @@ class MessageStageSpec:
|
||||
completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageConfirmation:
|
||||
"""The exact dialog and user action that completed a confirmation stage."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
confirm_label: str
|
||||
action: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageStageResult:
|
||||
"""Result used by Workflow routing and Prompt opening failure handling."""
|
||||
|
||||
succeeded: bool
|
||||
speech: str = ""
|
||||
action: str | None = None
|
||||
confirmation: MessageConfirmation | None = None
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def action(self) -> str | None:
|
||||
"""Keep existing trace and routing callers independent of result shape."""
|
||||
return self.confirmation.action if self.confirmation is not None else None
|
||||
|
||||
|
||||
def confirmation_context_message(
|
||||
result: MessageStageResult,
|
||||
*,
|
||||
source: str,
|
||||
) -> dict[str, str] | None:
|
||||
"""Represent a confirmed client dialog as one provider-neutral user event."""
|
||||
confirmation = result.confirmation
|
||||
if not result.succeeded or confirmation is None:
|
||||
return None
|
||||
return {
|
||||
"role": "user",
|
||||
"content": "\n".join(
|
||||
[
|
||||
MESSAGE_CONFIRMATION_CONTEXT_MARKER,
|
||||
f"用户已阅读消息弹窗并点击“{confirmation.confirm_label}”。",
|
||||
f"弹窗标题:{confirmation.title}",
|
||||
f"弹窗内容:{confirmation.message}",
|
||||
f"操作结果:{confirmation.action}",
|
||||
f"事件来源:{source}",
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class MessageStageRunner:
|
||||
"""Run one atomic user-visible message stage.
|
||||
@@ -110,12 +150,12 @@ class MessageStageRunner:
|
||||
if speech and speak is not None:
|
||||
playback_completion = await speak(speech)
|
||||
|
||||
action: str | None = None
|
||||
confirmation: MessageConfirmation | None = None
|
||||
if spec.display is not None:
|
||||
result = await self._show_message(spec, speech=speech)
|
||||
if not result.succeeded:
|
||||
return result
|
||||
action = result.action
|
||||
confirmation = result.confirmation
|
||||
|
||||
# Confirmation is the gate. It deliberately does not wait for the
|
||||
# audio completion future, so the user can continue immediately.
|
||||
@@ -128,7 +168,7 @@ class MessageStageRunner:
|
||||
result = MessageStageResult(
|
||||
succeeded=True,
|
||||
speech=speech,
|
||||
action=action,
|
||||
confirmation=confirmation,
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
@@ -207,8 +247,23 @@ class MessageStageRunner:
|
||||
if isinstance(data, dict)
|
||||
else None
|
||||
)
|
||||
if require_confirmation and action is None:
|
||||
return MessageStageResult(
|
||||
succeeded=False,
|
||||
speech=speech,
|
||||
error="客户端未返回确认操作",
|
||||
)
|
||||
return MessageStageResult(
|
||||
succeeded=True,
|
||||
speech=speech,
|
||||
action=action,
|
||||
confirmation=(
|
||||
MessageConfirmation(
|
||||
title=display.title,
|
||||
message=display.message,
|
||||
confirm_label=display.confirm_label,
|
||||
action=action,
|
||||
)
|
||||
if require_confirmation and action is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
1
backend/services/openai_realtime/__init__.py
Normal file
1
backend/services/openai_realtime/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""OpenAI Realtime-compatible northbound protocol adapter."""
|
||||
181
backend/services/openai_realtime/auth.py
Normal file
181
backend/services/openai_realtime/auth.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Authentication for the public OpenAI-compatible Realtime API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import settings
|
||||
from db.models import RealtimeApiKey
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
API_KEY_PREFIX = "sk-rt-"
|
||||
CLIENT_SECRET_PREFIX = "ek-rt-"
|
||||
|
||||
|
||||
class RealtimeAuthError(ValueError):
|
||||
"""Raised when a public Realtime credential cannot be accepted."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeCredential:
|
||||
api_key_id: str
|
||||
assistant_id: str | None = None
|
||||
session: dict[str, Any] | None = None
|
||||
safety_identifier_hash: str | None = None
|
||||
ephemeral: bool = False
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
|
||||
|
||||
|
||||
def _peppered_digest(value: str) -> str:
|
||||
return hmac.new(
|
||||
settings.REALTIME_TOKEN_SECRET.encode("utf-8"),
|
||||
value.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _token_signature(encoded_payload: str) -> str:
|
||||
digest = hmac.new(
|
||||
settings.REALTIME_TOKEN_SECRET.encode("utf-8"),
|
||||
encoded_payload.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return _b64encode(digest)
|
||||
|
||||
|
||||
def hash_safety_identifier(value: str | None) -> str | None:
|
||||
normalized = (value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_api_key_value() -> tuple[str, str, str, str]:
|
||||
key_id = f"rtkey_{uuid4().hex}"
|
||||
random_secret = secrets.token_urlsafe(32)
|
||||
value = f"{API_KEY_PREFIX}{key_id[6:18]}.{random_secret}"
|
||||
return key_id, value, value[:24], _peppered_digest(value)
|
||||
|
||||
|
||||
def create_client_secret(
|
||||
*,
|
||||
api_key_id: str,
|
||||
assistant_id: str,
|
||||
session: dict[str, Any],
|
||||
safety_identifier_hash: str | None,
|
||||
) -> tuple[str, int]:
|
||||
now = int(time.time())
|
||||
expires_at = now + settings.REALTIME_CLIENT_SECRET_TTL_SECONDS
|
||||
payload = {
|
||||
"sub": api_key_id,
|
||||
"assistant_id": assistant_id,
|
||||
"session": session,
|
||||
"safety_identifier_hash": safety_identifier_hash,
|
||||
"iat": now,
|
||||
"exp": expires_at,
|
||||
"jti": f"rtcs_{uuid4().hex}",
|
||||
}
|
||||
encoded = _b64encode(
|
||||
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
return f"{CLIENT_SECRET_PREFIX}{encoded}.{_token_signature(encoded)}", expires_at
|
||||
|
||||
|
||||
async def _active_api_key(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
key_id: str | None = None,
|
||||
key_prefix: str | None = None,
|
||||
) -> RealtimeApiKey | None:
|
||||
if key_id:
|
||||
row = await session.get(RealtimeApiKey, key_id)
|
||||
elif key_prefix:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(RealtimeApiKey).where(
|
||||
RealtimeApiKey.key_prefix == key_prefix
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
else:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
if row is None or row.revoked_at is not None:
|
||||
return None
|
||||
if row.expires_at is not None and row.expires_at <= now:
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
async def authenticate_bearer(
|
||||
session: AsyncSession,
|
||||
token: str,
|
||||
) -> RealtimeCredential:
|
||||
if token.startswith(API_KEY_PREFIX):
|
||||
prefix = token[:24]
|
||||
row = await _active_api_key(session, key_prefix=prefix)
|
||||
if row is None or not hmac.compare_digest(
|
||||
row.key_hash,
|
||||
_peppered_digest(token),
|
||||
):
|
||||
raise RealtimeAuthError("Invalid or expired API key")
|
||||
row.last_used_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return RealtimeCredential(api_key_id=row.id)
|
||||
|
||||
if not token.startswith(CLIENT_SECRET_PREFIX):
|
||||
raise RealtimeAuthError("Unsupported Realtime credential")
|
||||
compact = token[len(CLIENT_SECRET_PREFIX) :]
|
||||
try:
|
||||
encoded, signature = compact.rsplit(".", 1)
|
||||
except ValueError as exc:
|
||||
raise RealtimeAuthError("Invalid client secret") from exc
|
||||
if not hmac.compare_digest(_token_signature(encoded), signature):
|
||||
raise RealtimeAuthError("Invalid client secret")
|
||||
try:
|
||||
payload = json.loads(_b64decode(encoded))
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise RealtimeAuthError("Invalid client secret") from exc
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
raise RealtimeAuthError("Client secret expired")
|
||||
api_key_id = str(payload.get("sub") or "")
|
||||
row = await _active_api_key(session, key_id=api_key_id)
|
||||
if row is None:
|
||||
raise RealtimeAuthError("Parent API key is no longer active")
|
||||
row.last_used_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return RealtimeCredential(
|
||||
api_key_id=api_key_id,
|
||||
assistant_id=str(payload.get("assistant_id") or "") or None,
|
||||
session=(payload.get("session") if isinstance(payload.get("session"), dict) else None),
|
||||
safety_identifier_hash=str(payload.get("safety_identifier_hash") or "") or None,
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
def bearer_from_authorization(value: str | None) -> str:
|
||||
scheme, _, token = (value or "").partition(" ")
|
||||
if scheme.lower() != "bearer" or not token.strip():
|
||||
raise RealtimeAuthError("Missing Bearer credential")
|
||||
return token.strip()
|
||||
916
backend/services/openai_realtime/bridge.py
Normal file
916
backend/services/openai_realtime/bridge.py
Normal file
@@ -0,0 +1,916 @@
|
||||
"""Translate OpenAI Realtime events to the project's neutral pipeline messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pipecat.audio.utils import create_stream_resampler
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
InputAudioRawFrame,
|
||||
InputTransportMessageFrame,
|
||||
LLMContextFrame,
|
||||
OutputAudioRawFrame,
|
||||
OutputTransportMessageFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
StartFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADParamsUpdateFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
|
||||
from services.input_assets import store_input_image
|
||||
from services.openai_realtime.events import (
|
||||
SUPPORTED_CAPABILITIES,
|
||||
RealtimeEventError,
|
||||
error_event,
|
||||
normalize_turn_detection,
|
||||
require_event,
|
||||
)
|
||||
from services.openai_realtime.session import OpenAIRealtimeSession
|
||||
from services.pipecat.turn_config import create_vad_params
|
||||
from services.realtime.protocol import PipelineProtocolRuntime
|
||||
|
||||
|
||||
MAX_BUFFERED_AUDIO_BYTES = 24000 * 2 * 120
|
||||
|
||||
|
||||
def _event_id() -> str:
|
||||
return f"event_{uuid4().hex}"
|
||||
|
||||
|
||||
def _server_event(event_type: str, **payload: Any) -> dict[str, Any]:
|
||||
return {"type": event_type, "event_id": _event_id(), **payload}
|
||||
|
||||
|
||||
def _decode_base64(value: object, *, param: str) -> bytes:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise RealtimeEventError(f"{param} must be non-empty Base64", param=param)
|
||||
try:
|
||||
return base64.b64decode(value, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise RealtimeEventError(f"{param} is not valid Base64", param=param) from exc
|
||||
|
||||
|
||||
def _decode_data_url(value: object) -> bytes:
|
||||
if not isinstance(value, str) or not value.startswith("data:image/"):
|
||||
raise RealtimeEventError(
|
||||
"input_image only supports image Data URLs",
|
||||
code="unsupported_content_type",
|
||||
param="item.content",
|
||||
)
|
||||
header, separator, encoded = value.partition(",")
|
||||
if not separator or ";base64" not in header:
|
||||
raise RealtimeEventError(
|
||||
"input_image Data URL must use Base64 encoding",
|
||||
param="item.content",
|
||||
)
|
||||
return _decode_base64(encoded, param="item.content")
|
||||
|
||||
|
||||
class OpenAIRealtimeInputProcessor(FrameProcessor):
|
||||
def __init__(self, bridge: "OpenAIRealtimeBridge") -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
self._started = False
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, StartFrame) and not self._started:
|
||||
self._started = True
|
||||
# Initialize every downstream processor/serializer before the first
|
||||
# business event is sent to the client.
|
||||
await self.push_frame(frame, direction)
|
||||
await self._bridge.emit(
|
||||
_server_event(
|
||||
"session.created",
|
||||
session=self._bridge.session.public_value(),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(frame, InputAudioRawFrame):
|
||||
if (
|
||||
self._bridge.session.external_turn_control
|
||||
and frame.transport_source != "openai-committed"
|
||||
):
|
||||
try:
|
||||
self._bridge.buffer_audio(
|
||||
frame.audio,
|
||||
sample_rate=frame.sample_rate,
|
||||
num_channels=frame.num_channels,
|
||||
)
|
||||
except RealtimeEventError as exc:
|
||||
await self._bridge.emit(error_event(exc))
|
||||
return
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
if not isinstance(frame, InputTransportMessageFrame):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if isinstance(frame.message, dict) and frame.message.pop(
|
||||
"_pipeline_internal", False
|
||||
):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if not self._bridge.client_ready_sent:
|
||||
self._bridge.client_ready_sent = True
|
||||
await self.push_frame(
|
||||
InputTransportMessageFrame(
|
||||
message={"type": "client-ready"}
|
||||
),
|
||||
direction,
|
||||
)
|
||||
try:
|
||||
await self._bridge.handle_client_event(require_event(frame.message))
|
||||
except RealtimeEventError as exc:
|
||||
await self._bridge.emit(error_event(exc))
|
||||
except Exception as exc: # noqa: BLE001 - event errors must stay connection-local
|
||||
client_event_id = (
|
||||
str(frame.message.get("event_id") or "") or None
|
||||
if isinstance(frame.message, dict)
|
||||
else None
|
||||
)
|
||||
await self._bridge.emit(
|
||||
error_event(
|
||||
RealtimeEventError(
|
||||
f"Event processing failed: {exc}",
|
||||
code="event_processing_error",
|
||||
event_id=client_event_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class OpenAIRealtimeOutputProcessor(FrameProcessor):
|
||||
def __init__(self, bridge: "OpenAIRealtimeBridge") -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
self._audio_resampler = create_stream_resampler()
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, BotStartedSpeakingFrame):
|
||||
if not self._bridge.session.active_response_id:
|
||||
for event in self._bridge._start_assistant_output():
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(message=event)
|
||||
)
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if isinstance(frame, BotStoppedSpeakingFrame):
|
||||
if self._bridge.session.active_response_id:
|
||||
for event in self._bridge._end_assistant_output(False):
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(message=event)
|
||||
)
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if isinstance(frame, UserStartedSpeakingFrame):
|
||||
item_id = f"item_{uuid4().hex}"
|
||||
self._bridge.session.active_input_item_id = item_id
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message=_server_event(
|
||||
"input_audio_buffer.speech_started",
|
||||
audio_start_ms=0,
|
||||
item_id=item_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if isinstance(frame, UserStoppedSpeakingFrame):
|
||||
item_id = (
|
||||
self._bridge.session.active_input_item_id
|
||||
or f"item_{uuid4().hex}"
|
||||
)
|
||||
self._bridge.session.active_input_item_id = None
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message=_server_event(
|
||||
"input_audio_buffer.speech_stopped",
|
||||
audio_end_ms=0,
|
||||
item_id=item_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if isinstance(frame, OutputAudioRawFrame):
|
||||
if not self._bridge.session.output_is_audio:
|
||||
return
|
||||
if self._bridge.channel == "websocket":
|
||||
if not self._bridge.session.active_response_id:
|
||||
for event in self._bridge._start_assistant_output():
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(message=event)
|
||||
)
|
||||
response_id, item_id = self._bridge.session.begin_response()
|
||||
audio = frame.audio
|
||||
if frame.sample_rate != 24000:
|
||||
audio = await self._audio_resampler.resample(
|
||||
audio,
|
||||
frame.sample_rate,
|
||||
24000,
|
||||
)
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message=_server_event(
|
||||
"response.output_audio.delta",
|
||||
response_id=response_id,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
delta=base64.b64encode(audio).decode("ascii"),
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
if not isinstance(
|
||||
frame,
|
||||
(OutputTransportMessageFrame, OutputTransportMessageUrgentFrame),
|
||||
):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
message = frame.message
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
translated = self._bridge.translate_server_message(message)
|
||||
for event in translated:
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(message=event),
|
||||
direction,
|
||||
)
|
||||
|
||||
|
||||
class OpenAIResponseGateProcessor(FrameProcessor):
|
||||
"""Hold automatic inference when create_response is disabled or PTT is active."""
|
||||
|
||||
def __init__(self, bridge: "OpenAIRealtimeBridge") -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
self._held: list[tuple[Any, FrameDirection]] = []
|
||||
self._allow_next = False
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if direction != FrameDirection.DOWNSTREAM or not isinstance(
|
||||
frame, LLMContextFrame
|
||||
):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
turn = self._bridge.session.turn_detection
|
||||
auto_response = bool(turn and turn.get("create_response", True))
|
||||
if auto_response or self._allow_next:
|
||||
self._allow_next = False
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
self._held.append((frame, direction))
|
||||
|
||||
async def allow_one_response(self) -> None:
|
||||
if self._held:
|
||||
held, self._held = self._held, []
|
||||
for frame, direction in held:
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
self._allow_next = True
|
||||
|
||||
|
||||
class OpenAIRealtimeBridge:
|
||||
"""One adapter instance is owned by exactly one public Realtime session."""
|
||||
|
||||
def __init__(self, session: OpenAIRealtimeSession, *, channel: str) -> None:
|
||||
self.session = session
|
||||
self.channel = channel
|
||||
self._runtime: PipelineProtocolRuntime | None = None
|
||||
self.client_ready_sent = False
|
||||
self._input = OpenAIRealtimeInputProcessor(self)
|
||||
self._inference = OpenAIResponseGateProcessor(self)
|
||||
self._output = OpenAIRealtimeOutputProcessor(self)
|
||||
|
||||
def input_processors(self) -> list[FrameProcessor]:
|
||||
return [self._input]
|
||||
|
||||
def output_processors(self) -> list[FrameProcessor]:
|
||||
return [self._output]
|
||||
|
||||
def inference_processors(self) -> list[FrameProcessor]:
|
||||
return [self._inference]
|
||||
|
||||
async def bind(self, runtime: PipelineProtocolRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
await runtime.set_external_turn_control(self.session.external_turn_control)
|
||||
turn = self.session.turn_detection
|
||||
if turn:
|
||||
await runtime.set_response_interruption(
|
||||
bool(turn.get("interrupt_response", True))
|
||||
)
|
||||
|
||||
@property
|
||||
def runtime(self) -> PipelineProtocolRuntime:
|
||||
if self._runtime is None:
|
||||
raise RuntimeError("OpenAI Realtime bridge is not bound to a pipeline")
|
||||
return self._runtime
|
||||
|
||||
async def emit(self, event: dict[str, Any]) -> None:
|
||||
await self._input.push_frame(
|
||||
OutputTransportMessageUrgentFrame(message=event)
|
||||
)
|
||||
|
||||
async def queue_internal_message(self, message: dict[str, Any]) -> None:
|
||||
await self.runtime.queue_frame(
|
||||
InputTransportMessageFrame(
|
||||
message={**message, "_pipeline_internal": True}
|
||||
)
|
||||
)
|
||||
|
||||
def buffer_audio(
|
||||
self,
|
||||
audio: bytes,
|
||||
*,
|
||||
sample_rate: int,
|
||||
num_channels: int,
|
||||
) -> None:
|
||||
if self.session.buffered_audio_bytes + len(audio) > MAX_BUFFERED_AUDIO_BYTES:
|
||||
self.clear_buffered_audio()
|
||||
raise RealtimeEventError(
|
||||
"input_audio_buffer exceeds the 120 second MVP limit",
|
||||
code="input_audio_buffer_too_large",
|
||||
)
|
||||
self.session.audio_chunks.append((audio, sample_rate, num_channels))
|
||||
self.session.buffered_audio_bytes += len(audio)
|
||||
|
||||
def clear_buffered_audio(self) -> None:
|
||||
self.session.audio_chunks.clear()
|
||||
self.session.buffered_audio_bytes = 0
|
||||
self.session.audio_committed = False
|
||||
|
||||
async def handle_client_event(self, event: dict[str, Any]) -> None:
|
||||
event_type = str(event["type"])
|
||||
if event_type == "session.update":
|
||||
await self._update_session(event)
|
||||
elif event_type == "conversation.item.create":
|
||||
await self._create_item(event)
|
||||
elif event_type == "conversation.item.truncate":
|
||||
await self.runtime.cancel_response()
|
||||
await self.emit(
|
||||
_server_event(
|
||||
"conversation.item.truncated",
|
||||
item_id=event.get("item_id"),
|
||||
content_index=int(event.get("content_index") or 0),
|
||||
audio_end_ms=int(event.get("audio_end_ms") or 0),
|
||||
)
|
||||
)
|
||||
elif event_type == "input_audio_buffer.append":
|
||||
await self._append_audio(event)
|
||||
elif event_type == "input_audio_buffer.commit":
|
||||
await self._commit_audio()
|
||||
elif event_type == "input_audio_buffer.clear":
|
||||
self.clear_buffered_audio()
|
||||
await self.runtime.clear_audio()
|
||||
await self.emit(_server_event("input_audio_buffer.cleared"))
|
||||
elif event_type == "output_audio_buffer.clear":
|
||||
await self.runtime.cancel_response()
|
||||
await self._finish_response(status="cancelled")
|
||||
await self.emit(_server_event("output_audio_buffer.cleared"))
|
||||
elif event_type == "response.create":
|
||||
await self._create_response(event)
|
||||
elif event_type == "response.cancel":
|
||||
await self.runtime.cancel_response()
|
||||
await self._finish_response(status="cancelled")
|
||||
elif event_type == "x.interactive_media.capabilities.update":
|
||||
await self._update_capabilities(event)
|
||||
elif event_type == "x.interactive_media.session.variables.update":
|
||||
await self._update_variables(event)
|
||||
|
||||
async def _update_session(self, event: dict[str, Any]) -> None:
|
||||
update = event.get("session")
|
||||
if not isinstance(update, dict):
|
||||
raise RealtimeEventError("session.update requires session", param="session")
|
||||
locked = {"model", "instructions", "voice", "tools", "tool_choice"}
|
||||
changed_locked = sorted(locked.intersection(update))
|
||||
audio = update.get("audio")
|
||||
if isinstance(audio, dict):
|
||||
output = audio.get("output")
|
||||
if isinstance(output, dict) and "voice" in output:
|
||||
changed_locked.append("audio.output.voice")
|
||||
if changed_locked:
|
||||
raise RealtimeEventError(
|
||||
f"Assistant-owned session fields cannot be changed: {', '.join(changed_locked)}",
|
||||
code="immutable_session_field",
|
||||
param=changed_locked[0],
|
||||
event_id=str(event.get("event_id") or "") or None,
|
||||
)
|
||||
|
||||
modalities = update.get("output_modalities")
|
||||
if modalities is not None:
|
||||
if modalities not in (["audio"], ["text"]):
|
||||
raise RealtimeEventError(
|
||||
'output_modalities must be ["audio"] or ["text"]',
|
||||
param="session.output_modalities",
|
||||
)
|
||||
self.session.output_modalities = list(modalities)
|
||||
|
||||
marker = object()
|
||||
turn_detection: object = marker
|
||||
if isinstance(audio, dict) and isinstance(audio.get("input"), dict):
|
||||
turn_detection = audio["input"].get("turn_detection", marker)
|
||||
if turn_detection is marker:
|
||||
turn_detection = update.get("turn_detection", marker)
|
||||
if turn_detection is not marker:
|
||||
self.session.turn_detection = self._validate_turn_detection(turn_detection)
|
||||
await self.runtime.set_external_turn_control(
|
||||
self.session.external_turn_control
|
||||
)
|
||||
if self.session.turn_detection:
|
||||
await self.runtime.set_response_interruption(
|
||||
bool(self.session.turn_detection.get("interrupt_response", True))
|
||||
)
|
||||
if self.session.turn_detection:
|
||||
turn = self.session.turn_detection
|
||||
config = {
|
||||
"vad": {
|
||||
"confidence": turn["threshold"],
|
||||
"start_secs": max(0.05, turn["prefix_padding_ms"] / 1000),
|
||||
"stop_secs": 0.2,
|
||||
},
|
||||
"turn_detection": {
|
||||
"strategy": "silence",
|
||||
"silence_timeout_secs": turn["silence_duration_ms"] / 1000,
|
||||
},
|
||||
}
|
||||
await self.runtime.queue_frame(
|
||||
VADParamsUpdateFrame(params=create_vad_params(config))
|
||||
)
|
||||
await self.emit(
|
||||
_server_event("session.updated", session=self.session.public_value())
|
||||
)
|
||||
|
||||
def _validate_turn_detection(self, value: object) -> dict[str, Any] | None:
|
||||
return normalize_turn_detection(value)
|
||||
|
||||
async def _create_item(self, event: dict[str, Any]) -> None:
|
||||
item = event.get("item")
|
||||
if not isinstance(item, dict):
|
||||
raise RealtimeEventError("conversation.item.create requires item", param="item")
|
||||
item_type = item.get("type")
|
||||
if item_type == "function_call_output":
|
||||
call_id = str(item.get("call_id") or "")
|
||||
if not call_id:
|
||||
raise RealtimeEventError("function_call_output requires call_id", param="item.call_id")
|
||||
raw_output = item.get("output")
|
||||
try:
|
||||
data = json.loads(raw_output) if isinstance(raw_output, str) else raw_output
|
||||
except json.JSONDecodeError:
|
||||
data = raw_output
|
||||
await self.queue_internal_message(
|
||||
{
|
||||
"type": "client-tool-result",
|
||||
"tool_call_id": call_id,
|
||||
"status": "ok",
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
await self._emit_item_ack(item)
|
||||
return
|
||||
if item_type != "message" or item.get("role") != "user":
|
||||
raise RealtimeEventError(
|
||||
"Only user messages and function_call_output items are accepted",
|
||||
code="unsupported_item_type",
|
||||
param="item.type",
|
||||
)
|
||||
|
||||
wire_parts: list[dict[str, Any]] = []
|
||||
for part in item.get("content") or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") == "input_text":
|
||||
text = str(part.get("text") or "").strip()
|
||||
if text:
|
||||
wire_parts.append({"type": "input_text", "text": text})
|
||||
elif part.get("type") == "input_image":
|
||||
if self.session.config.runtimeMode == "realtime":
|
||||
raise RealtimeEventError(
|
||||
"input_image is not supported by the assistant's realtime runtime",
|
||||
code="unsupported_content_type",
|
||||
)
|
||||
if not self.session.vision_enabled:
|
||||
raise RealtimeEventError(
|
||||
"This assistant has not enabled image input",
|
||||
code="unsupported_content_type",
|
||||
)
|
||||
data = _decode_data_url(part.get("image_url"))
|
||||
stored = await asyncio.to_thread(store_input_image, data)
|
||||
wire_parts.append(
|
||||
{
|
||||
"type": "input_image",
|
||||
"source": {
|
||||
"type": "uploaded_asset",
|
||||
"asset_token": stored.token,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise RealtimeEventError(
|
||||
f"Unsupported content part: {part.get('type')}",
|
||||
code="unsupported_content_type",
|
||||
param="item.content",
|
||||
)
|
||||
if not wire_parts:
|
||||
raise RealtimeEventError("User item has no supported content", param="item.content")
|
||||
item_id = str(item.get("id") or f"item_{uuid4().hex}")
|
||||
public_item = {**item, "id": item_id, "status": "completed"}
|
||||
self.session.pending_item = {
|
||||
"type": "user-input",
|
||||
"schema_version": 1,
|
||||
"input_id": item_id,
|
||||
"parts": wire_parts,
|
||||
"options": {
|
||||
"run_immediately": self.session.config.runtimeMode != "realtime",
|
||||
"interrupt": True,
|
||||
},
|
||||
}
|
||||
await self._emit_item_ack(public_item)
|
||||
|
||||
async def _emit_item_ack(self, item: dict[str, Any]) -> None:
|
||||
await self.emit(
|
||||
_server_event(
|
||||
"conversation.item.added",
|
||||
previous_item_id=None,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
await self.emit(_server_event("conversation.item.done", item=item))
|
||||
|
||||
async def _append_audio(self, event: dict[str, Any]) -> None:
|
||||
audio = _decode_base64(event.get("audio"), param="audio")
|
||||
if len(audio) % 2:
|
||||
raise RealtimeEventError(
|
||||
"WebSocket audio must contain complete PCM16 samples",
|
||||
code="invalid_audio_format",
|
||||
param="audio",
|
||||
)
|
||||
if self.channel != "websocket":
|
||||
raise RealtimeEventError(
|
||||
"input_audio_buffer.append is only used by WebSocket audio",
|
||||
code="invalid_event",
|
||||
)
|
||||
if self.session.external_turn_control:
|
||||
self.buffer_audio(audio, sample_rate=24000, num_channels=1)
|
||||
return
|
||||
await self.runtime.queue_frame(
|
||||
InputAudioRawFrame(audio=audio, sample_rate=24000, num_channels=1)
|
||||
)
|
||||
|
||||
async def _commit_audio(self) -> None:
|
||||
if not self.session.audio_chunks and self.session.external_turn_control:
|
||||
raise RealtimeEventError(
|
||||
"input_audio_buffer is empty",
|
||||
code="input_audio_buffer_commit_empty",
|
||||
)
|
||||
self.session.audio_committed = True
|
||||
item_id = f"item_{uuid4().hex}"
|
||||
await self.emit(
|
||||
_server_event("input_audio_buffer.committed", item_id=item_id)
|
||||
)
|
||||
|
||||
async def _create_response(self, event: dict[str, Any]) -> None:
|
||||
response = event.get("response")
|
||||
if isinstance(response, dict):
|
||||
forbidden = {"instructions", "voice", "tools", "tool_choice", "model"}
|
||||
changed = forbidden.intersection(response)
|
||||
if changed:
|
||||
raise RealtimeEventError(
|
||||
"Per-response assistant configuration is locked",
|
||||
code="immutable_session_field",
|
||||
param=f"response.{sorted(changed)[0]}",
|
||||
)
|
||||
if (
|
||||
self.session.external_turn_control
|
||||
and self.session.audio_chunks
|
||||
and not self.session.audio_committed
|
||||
):
|
||||
raise RealtimeEventError(
|
||||
"Commit input_audio_buffer before response.create",
|
||||
code="input_audio_buffer_not_committed",
|
||||
)
|
||||
await self._inference.allow_one_response()
|
||||
submitted_item = bool(self.session.pending_item)
|
||||
if self.session.pending_item:
|
||||
pending = self.session.pending_item
|
||||
self.session.pending_item = None
|
||||
await self.queue_internal_message(pending)
|
||||
if self.session.external_turn_control and self.session.audio_chunks:
|
||||
await self.runtime.queue_frame(UserStartedSpeakingFrame())
|
||||
for audio, sample_rate, num_channels in self.session.audio_chunks:
|
||||
await self.runtime.queue_frame(
|
||||
InputAudioRawFrame(
|
||||
audio=audio,
|
||||
sample_rate=sample_rate,
|
||||
num_channels=num_channels,
|
||||
transport_source="openai-committed",
|
||||
)
|
||||
)
|
||||
await self.runtime.queue_frame(UserStoppedSpeakingFrame())
|
||||
await self.runtime.commit_audio()
|
||||
self.clear_buffered_audio()
|
||||
if not submitted_item or self.session.config.runtimeMode == "realtime":
|
||||
await self.runtime.request_response()
|
||||
|
||||
async def _finish_response(self, *, status: str) -> None:
|
||||
response_id, _item_id, text = self.session.finish_response()
|
||||
if not response_id:
|
||||
return
|
||||
await self.emit(
|
||||
_server_event(
|
||||
"response.done",
|
||||
response={
|
||||
"id": response_id,
|
||||
"object": "realtime.response",
|
||||
"status": status,
|
||||
"output": [],
|
||||
"output_text": text,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_capabilities(self, event: dict[str, Any]) -> None:
|
||||
requested = event.get("capabilities") or []
|
||||
if not isinstance(requested, list) or not all(isinstance(x, str) for x in requested):
|
||||
raise RealtimeEventError("capabilities must be a string array", param="capabilities")
|
||||
enabled = set(requested).intersection(SUPPORTED_CAPABILITIES)
|
||||
if "video_track" in enabled and not self.session.vision_enabled:
|
||||
enabled.remove("video_track")
|
||||
self.session.capabilities = enabled
|
||||
await self.emit(
|
||||
_server_event(
|
||||
"x.interactive_media.capabilities.updated",
|
||||
capabilities=sorted(enabled),
|
||||
rejected=sorted(set(requested) - enabled),
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_variables(self, event: dict[str, Any]) -> None:
|
||||
if "dynamic_variables" not in self.session.capabilities:
|
||||
raise RealtimeEventError(
|
||||
"dynamic_variables capability was not negotiated",
|
||||
code="extension_not_negotiated",
|
||||
)
|
||||
variables = event.get("variables")
|
||||
if not isinstance(variables, dict) or not variables:
|
||||
raise RealtimeEventError("variables must be a non-empty object", param="variables")
|
||||
await self.queue_internal_message(
|
||||
{
|
||||
"type": "session-update",
|
||||
"schema_version": 1,
|
||||
"update_id": str(event.get("event_id") or f"update_{uuid4().hex}"),
|
||||
"dynamic_variables": variables,
|
||||
}
|
||||
)
|
||||
|
||||
def translate_server_message(self, message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
message_type = str(message.get("type") or "")
|
||||
if message_type == "error":
|
||||
return [message]
|
||||
if "." in message_type:
|
||||
return [message]
|
||||
if message_type == "transcript":
|
||||
return self._translate_transcript(message)
|
||||
if message_type == "assistant-text-start":
|
||||
return self._start_assistant_output()
|
||||
if message_type == "assistant-text-delta":
|
||||
return self._assistant_delta(str(message.get("delta") or ""))
|
||||
if message_type == "assistant-text-end":
|
||||
return self._end_assistant_output(bool(message.get("interrupted")))
|
||||
if message_type == "client-tool-call":
|
||||
return self._client_tool_call(message)
|
||||
if message_type == "user-input-result" and message.get("status") == "error":
|
||||
return [
|
||||
error_event(
|
||||
RealtimeEventError(
|
||||
str(message.get("message") or "User input failed"),
|
||||
code="input_error",
|
||||
)
|
||||
)
|
||||
]
|
||||
extension = self._extension_event(message)
|
||||
return [extension] if extension else []
|
||||
|
||||
def _translate_transcript(self, message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
role = message.get("role")
|
||||
text = str(message.get("content") or "")
|
||||
item_id = f"item_{uuid4().hex}"
|
||||
if role == "user":
|
||||
return [
|
||||
_server_event(
|
||||
"conversation.item.input_audio_transcription.completed",
|
||||
item_id=item_id,
|
||||
content_index=0,
|
||||
transcript=text,
|
||||
)
|
||||
]
|
||||
response_id, output_item_id = self.session.begin_response()
|
||||
item = {
|
||||
"id": output_item_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
}
|
||||
self.session.response_text += text
|
||||
response = {
|
||||
"id": response_id,
|
||||
"object": "realtime.response",
|
||||
"status": "completed",
|
||||
"output": [item],
|
||||
}
|
||||
self.session.finish_response()
|
||||
return [
|
||||
_server_event("response.created", response={**response, "status": "in_progress", "output": []}),
|
||||
_server_event("response.output_item.added", response_id=response_id, output_index=0, item=item),
|
||||
_server_event("response.output_item.done", response_id=response_id, output_index=0, item=item),
|
||||
_server_event("response.done", response=response),
|
||||
]
|
||||
|
||||
def _start_assistant_output(self) -> list[dict[str, Any]]:
|
||||
if self.session.active_response_id:
|
||||
return []
|
||||
response_id, item_id = self.session.begin_response()
|
||||
content_type = "audio" if self.session.output_is_audio else "text"
|
||||
item = {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "in_progress",
|
||||
"content": [{"type": content_type}],
|
||||
}
|
||||
return [
|
||||
_server_event(
|
||||
"response.created",
|
||||
response={
|
||||
"id": response_id,
|
||||
"object": "realtime.response",
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
},
|
||||
),
|
||||
_server_event(
|
||||
"response.output_item.added",
|
||||
response_id=response_id,
|
||||
output_index=0,
|
||||
item=item,
|
||||
),
|
||||
_server_event(
|
||||
"response.content_part.added",
|
||||
response_id=response_id,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
part={"type": content_type},
|
||||
),
|
||||
]
|
||||
|
||||
def _assistant_delta(self, delta: str) -> list[dict[str, Any]]:
|
||||
events = self._start_assistant_output()
|
||||
response_id, item_id = self.session.begin_response()
|
||||
self.session.response_text += delta
|
||||
event_type = (
|
||||
"response.output_audio_transcript.delta"
|
||||
if self.session.output_is_audio
|
||||
else "response.output_text.delta"
|
||||
)
|
||||
events.append(
|
||||
_server_event(
|
||||
event_type,
|
||||
response_id=response_id,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
delta=delta,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def _end_assistant_output(self, interrupted: bool) -> list[dict[str, Any]]:
|
||||
response_id, item_id, text = self.session.finish_response()
|
||||
if not response_id or not item_id:
|
||||
return []
|
||||
content_type = "audio" if self.session.output_is_audio else "text"
|
||||
done_type = (
|
||||
"response.output_audio_transcript.done"
|
||||
if self.session.output_is_audio
|
||||
else "response.output_text.done"
|
||||
)
|
||||
item = {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "incomplete" if interrupted else "completed",
|
||||
"content": [{"type": content_type, "transcript" if self.session.output_is_audio else "text": text}],
|
||||
}
|
||||
status = "cancelled" if interrupted else "completed"
|
||||
events = [
|
||||
_server_event(done_type, response_id=response_id, item_id=item_id, output_index=0, content_index=0, transcript=text, text=text),
|
||||
_server_event("response.content_part.done", response_id=response_id, item_id=item_id, output_index=0, content_index=0, part=item["content"][0]),
|
||||
_server_event("response.output_item.done", response_id=response_id, output_index=0, item=item),
|
||||
_server_event(
|
||||
"response.done",
|
||||
response={
|
||||
"id": response_id,
|
||||
"object": "realtime.response",
|
||||
"status": status,
|
||||
"output": [item],
|
||||
},
|
||||
),
|
||||
]
|
||||
if self.session.output_is_audio:
|
||||
events.insert(
|
||||
1,
|
||||
_server_event(
|
||||
"response.output_audio.done",
|
||||
response_id=response_id,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
),
|
||||
)
|
||||
return events
|
||||
|
||||
def _client_tool_call(self, message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
if not self.session.active_response_id:
|
||||
response_id, _unused_item_id = self.session.begin_response()
|
||||
events.append(
|
||||
_server_event(
|
||||
"response.created",
|
||||
response={
|
||||
"id": response_id,
|
||||
"object": "realtime.response",
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
response_id = self.session.active_response_id
|
||||
item_id = f"item_{uuid4().hex}"
|
||||
call_id = str(message.get("tool_call_id") or "")
|
||||
arguments = json.dumps(message.get("arguments") or {}, ensure_ascii=False)
|
||||
item = {
|
||||
"id": item_id,
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"name": str(message.get("function_name") or ""),
|
||||
"call_id": call_id,
|
||||
"arguments": arguments,
|
||||
}
|
||||
events.extend([
|
||||
_server_event("response.output_item.added", response_id=response_id, output_index=0, item=item),
|
||||
_server_event("response.function_call_arguments.delta", response_id=response_id, item_id=item_id, output_index=0, call_id=call_id, delta=arguments),
|
||||
_server_event("response.function_call_arguments.done", response_id=response_id, item_id=item_id, output_index=0, call_id=call_id, name=item["name"], arguments=arguments),
|
||||
_server_event("response.output_item.done", response_id=response_id, output_index=0, item=item),
|
||||
])
|
||||
self.session.active_output_item_id = None
|
||||
return events
|
||||
|
||||
def _extension_event(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
||||
kind = str(message.get("type") or "")
|
||||
if kind == "session-update-result" and "dynamic_variables" in self.session.capabilities:
|
||||
return _server_event(
|
||||
"x.interactive_media.session.variables.updated",
|
||||
update_id=message.get("update_id"),
|
||||
status=message.get("status"),
|
||||
message=message.get("message"),
|
||||
)
|
||||
if kind in {"node-active", "workflow-event", "workflow-variables", "workflow-error"}:
|
||||
if "workflow_events" not in self.session.capabilities:
|
||||
return None
|
||||
names = {
|
||||
"node-active": "x.interactive_media.workflow.node_active",
|
||||
"workflow-event": "x.interactive_media.workflow.event",
|
||||
"workflow-variables": "x.interactive_media.workflow.variables.updated",
|
||||
"workflow-error": "x.interactive_media.workflow.error",
|
||||
}
|
||||
return _server_event(names[kind], **{k: v for k, v in message.items() if k != "type"})
|
||||
if kind in {"handoff-requested", "call-ended"}:
|
||||
if "handoff" not in self.session.capabilities:
|
||||
return None
|
||||
event_type = (
|
||||
"x.interactive_media.call.handoff_requested"
|
||||
if kind == "handoff-requested"
|
||||
else "x.interactive_media.call.ended"
|
||||
)
|
||||
return _server_event(event_type, **{k: v for k, v in message.items() if k != "type"})
|
||||
return None
|
||||
115
backend/services/openai_realtime/events.py
Normal file
115
backend/services/openai_realtime/events.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Small, explicit validation helpers for the supported Realtime event subset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
SUPPORTED_CLIENT_EVENTS = {
|
||||
"session.update",
|
||||
"conversation.item.create",
|
||||
"conversation.item.truncate",
|
||||
"input_audio_buffer.append",
|
||||
"input_audio_buffer.commit",
|
||||
"input_audio_buffer.clear",
|
||||
"output_audio_buffer.clear",
|
||||
"response.create",
|
||||
"response.cancel",
|
||||
"x.interactive_media.capabilities.update",
|
||||
"x.interactive_media.session.variables.update",
|
||||
}
|
||||
|
||||
SUPPORTED_CAPABILITIES = {
|
||||
"dynamic_variables",
|
||||
"workflow_events",
|
||||
"handoff",
|
||||
"video_track",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealtimeEventError(ValueError):
|
||||
message: str
|
||||
code: str = "invalid_request_error"
|
||||
param: str | None = None
|
||||
event_id: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
def require_event(message: object) -> dict[str, Any]:
|
||||
if not isinstance(message, dict):
|
||||
raise RealtimeEventError("Event must be a JSON object")
|
||||
event_type = str(message.get("type") or "")
|
||||
if event_type not in SUPPORTED_CLIENT_EVENTS:
|
||||
raise RealtimeEventError(
|
||||
f"Unsupported client event: {event_type or '<missing>'}",
|
||||
code="invalid_event",
|
||||
param="type",
|
||||
event_id=str(message.get("event_id") or "") or None,
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def assistant_id_from_model(model: object) -> str:
|
||||
value = str(model or "").strip()
|
||||
if not value.startswith("assistant:") or not value.removeprefix("assistant:"):
|
||||
raise RealtimeEventError(
|
||||
"model must use assistant:asst_xxx",
|
||||
code="invalid_model",
|
||||
param="session.model",
|
||||
)
|
||||
return value.removeprefix("assistant:")
|
||||
|
||||
|
||||
def normalize_turn_detection(value: object) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("type") != "server_vad":
|
||||
raise RealtimeEventError(
|
||||
"turn_detection must be server_vad or null",
|
||||
param="session.audio.input.turn_detection",
|
||||
)
|
||||
try:
|
||||
threshold = float(value.get("threshold", 0.7))
|
||||
prefix_ms = int(value.get("prefix_padding_ms", 200))
|
||||
silence_ms = int(value.get("silence_duration_ms", 600))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RealtimeEventError(
|
||||
"Invalid server_vad threshold, padding, or silence duration",
|
||||
param="session.audio.input.turn_detection",
|
||||
) from exc
|
||||
if (
|
||||
not 0 <= threshold <= 1
|
||||
or not 0 <= prefix_ms <= 5000
|
||||
or not 100 <= silence_ms <= 10000
|
||||
):
|
||||
raise RealtimeEventError(
|
||||
"Invalid server_vad threshold, padding, or silence duration",
|
||||
param="session.audio.input.turn_detection",
|
||||
)
|
||||
return {
|
||||
"type": "server_vad",
|
||||
"threshold": threshold,
|
||||
"prefix_padding_ms": prefix_ms,
|
||||
"silence_duration_ms": silence_ms,
|
||||
"create_response": bool(value.get("create_response", True)),
|
||||
"interrupt_response": bool(value.get("interrupt_response", True)),
|
||||
}
|
||||
|
||||
|
||||
def error_event(error: RealtimeEventError) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "error",
|
||||
"event_id": f"event_{uuid4().hex}",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
"message": error.message,
|
||||
"param": error.param,
|
||||
"event_id": error.event_id,
|
||||
},
|
||||
}
|
||||
141
backend/services/openai_realtime/session.py
Normal file
141
backend/services/openai_realtime/session.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Connection-local state for the OpenAI-compatible Realtime wire protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.openai_realtime.events import normalize_turn_detection
|
||||
|
||||
|
||||
def _id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid4().hex}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenAIRealtimeSession:
|
||||
assistant_id: str
|
||||
config: AssistantConfig
|
||||
vision_enabled: bool
|
||||
safety_identifier_hash: str | None = None
|
||||
id: str = field(default_factory=lambda: _id("sess"))
|
||||
output_modalities: list[str] = field(default_factory=lambda: ["audio"])
|
||||
turn_detection: dict[str, Any] | None = field(
|
||||
default_factory=lambda: {
|
||||
"type": "server_vad",
|
||||
"threshold": 0.7,
|
||||
"prefix_padding_ms": 200,
|
||||
"silence_duration_ms": 600,
|
||||
"create_response": True,
|
||||
"interrupt_response": True,
|
||||
}
|
||||
)
|
||||
capabilities: set[str] = field(default_factory=set)
|
||||
pending_item: dict[str, Any] | None = None
|
||||
audio_chunks: list[tuple[bytes, int, int]] = field(default_factory=list)
|
||||
buffered_audio_bytes: int = 0
|
||||
audio_committed: bool = False
|
||||
active_response_id: str | None = None
|
||||
active_output_item_id: str | None = None
|
||||
active_input_item_id: str | None = None
|
||||
response_text: str = ""
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
return f"assistant:{self.assistant_id}"
|
||||
|
||||
@property
|
||||
def external_turn_control(self) -> bool:
|
||||
return self.turn_detection is None
|
||||
|
||||
@property
|
||||
def output_is_audio(self) -> bool:
|
||||
return self.output_modalities == ["audio"]
|
||||
|
||||
def client_tools(self) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for tool in self.config.tools:
|
||||
if tool.type != "client":
|
||||
continue
|
||||
definition = tool.definition or {}
|
||||
parameters = (definition.get("config") or {}).get("parameters") or []
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
for parameter in parameters:
|
||||
name = str(parameter.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
properties[name] = {
|
||||
"type": parameter.get("type") or "string",
|
||||
**(
|
||||
{"description": parameter["description"]}
|
||||
if parameter.get("description")
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if parameter.get("required"):
|
||||
required.append(name)
|
||||
result.append(
|
||||
{
|
||||
"type": "function",
|
||||
"name": tool.function_name,
|
||||
"description": tool.description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def public_value(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": "realtime",
|
||||
"model": self.model,
|
||||
"output_modalities": self.output_modalities,
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": 24000},
|
||||
"turn_detection": self.turn_detection,
|
||||
},
|
||||
"output": {
|
||||
"format": {"type": "audio/pcm", "rate": 24000},
|
||||
},
|
||||
},
|
||||
"tools": self.client_tools(),
|
||||
"tool_choice": "auto",
|
||||
}
|
||||
|
||||
def apply_initial_options(self, value: dict[str, Any]) -> None:
|
||||
modalities = value.get("output_modalities")
|
||||
if modalities in (["audio"], ["text"]):
|
||||
self.output_modalities = list(modalities)
|
||||
audio = value.get("audio")
|
||||
if isinstance(audio, dict) and isinstance(audio.get("input"), dict):
|
||||
turn_detection = audio["input"].get("turn_detection", self.turn_detection)
|
||||
else:
|
||||
turn_detection = value.get("turn_detection", self.turn_detection)
|
||||
self.turn_detection = normalize_turn_detection(turn_detection)
|
||||
|
||||
def begin_response(self) -> tuple[str, str]:
|
||||
if not self.active_response_id:
|
||||
self.active_response_id = _id("resp")
|
||||
if not self.active_output_item_id:
|
||||
self.active_output_item_id = _id("item")
|
||||
self.response_text = ""
|
||||
return self.active_response_id, str(self.active_output_item_id)
|
||||
|
||||
def finish_response(self) -> tuple[str | None, str | None, str]:
|
||||
result = (
|
||||
self.active_response_id,
|
||||
self.active_output_item_id,
|
||||
self.response_text,
|
||||
)
|
||||
self.active_response_id = None
|
||||
self.active_output_item_id = None
|
||||
self.response_text = ""
|
||||
return result
|
||||
26
backend/services/openai_realtime/webrtc.py
Normal file
26
backend/services/openai_realtime/webrtc.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Project-local SmallWebRTC specialization for the OpenAI event channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
|
||||
|
||||
class OpenAIRealtimeWebRTCConnection(SmallWebRTCConnection):
|
||||
"""Prevent Pipecat's private signalling envelope from leaking to clients."""
|
||||
|
||||
data_channel_label = "oai-events"
|
||||
|
||||
def _setup_listeners(self):
|
||||
super()._setup_listeners()
|
||||
|
||||
@self._pc.on("datachannel")
|
||||
def require_openai_event_channel(channel):
|
||||
if channel.label != self.data_channel_label:
|
||||
channel.close()
|
||||
|
||||
def send_app_message(self, message: Any):
|
||||
if isinstance(message, dict) and message.get("type") == "signalling":
|
||||
return
|
||||
super().send_app_message(message)
|
||||
51
backend/services/openai_realtime/websocket.py
Normal file
51
backend/services/openai_realtime/websocket.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""UTF-8 JSON serializer and transport builder for public Realtime WebSockets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
InputTransportMessageFrame,
|
||||
OutputTransportMessageFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
)
|
||||
from pipecat.serializers.base_serializer import FrameSerializer
|
||||
|
||||
from services.pipecat.transports import build_serialized_ws_transport
|
||||
|
||||
|
||||
class OpenAIRealtimeJSONSerializer(FrameSerializer):
|
||||
async def serialize(self, frame: Frame) -> str | bytes | None:
|
||||
if self.should_ignore_frame(frame):
|
||||
return None
|
||||
if isinstance(
|
||||
frame,
|
||||
(OutputTransportMessageFrame, OutputTransportMessageUrgentFrame),
|
||||
) and isinstance(frame.message, dict):
|
||||
return json.dumps(frame.message, ensure_ascii=False, separators=(",", ":"))
|
||||
return None
|
||||
|
||||
async def deserialize(self, data: str | bytes) -> Frame | None:
|
||||
if isinstance(data, bytes):
|
||||
message: Any = {
|
||||
"type": "_invalid_binary_frame",
|
||||
"event_id": None,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
message = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
message = {"type": "_invalid_json", "event_id": None}
|
||||
return InputTransportMessageFrame(message=message)
|
||||
|
||||
|
||||
def build_openai_websocket_transport(websocket: WebSocket):
|
||||
return build_serialized_ws_transport(
|
||||
websocket,
|
||||
serializer=OpenAIRealtimeJSONSerializer(),
|
||||
sample_rate=24000,
|
||||
)
|
||||
|
||||
@@ -3,14 +3,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
DataFrame,
|
||||
InterruptionFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
|
||||
|
||||
class SpeechPlaybackCompletion:
|
||||
"""Awaitable completed by its exact marker at the transport output."""
|
||||
|
||||
def __init__(self, coordinator: CallEndCoordinator):
|
||||
self._coordinator = coordinator
|
||||
self._future: asyncio.Future[None] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
self._queued = False
|
||||
|
||||
def __await__(self):
|
||||
return self._future.__await__()
|
||||
|
||||
def done(self) -> bool:
|
||||
return self._future.done()
|
||||
|
||||
@property
|
||||
def queued(self) -> bool:
|
||||
return self._queued
|
||||
|
||||
def mark_queued(self) -> None:
|
||||
self._queued = True
|
||||
|
||||
async def mark_played(self) -> None:
|
||||
await self._coordinator.complete_tracked_speech(self)
|
||||
|
||||
def _resolve(self) -> None:
|
||||
if not self._future.done():
|
||||
self._future.set_result(None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FixedSpeechPlaybackMarkerFrame(DataFrame):
|
||||
"""Ordered frame that identifies one fixed utterance at transport output."""
|
||||
|
||||
completion: SpeechPlaybackCompletion
|
||||
|
||||
|
||||
def playback_marker_for(
|
||||
completion: Awaitable[None] | None,
|
||||
) -> FixedSpeechPlaybackMarkerFrame | None:
|
||||
"""Build a marker only for the production call-end coordinator."""
|
||||
if not isinstance(completion, SpeechPlaybackCompletion):
|
||||
return None
|
||||
return FixedSpeechPlaybackMarkerFrame(completion=completion)
|
||||
|
||||
|
||||
class CallEndCoordinator:
|
||||
"""End immediately or after the currently armed closing speech finishes."""
|
||||
|
||||
@@ -22,8 +74,7 @@ class CallEndCoordinator:
|
||||
self._speech_stopped = asyncio.Event()
|
||||
self._speech_stopped.set()
|
||||
self._response_speech_started = False
|
||||
self._tracked_speeches = 0
|
||||
self._tracked_speech_completions: deque[asyncio.Future[None]] = deque()
|
||||
self._tracked_speech_completions: set[SpeechPlaybackCompletion] = set()
|
||||
self._finish_after_tracked_speech = False
|
||||
self._finished = False
|
||||
self._reason = "completed"
|
||||
@@ -53,17 +104,32 @@ class CallEndCoordinator:
|
||||
"""Wait for the next observed bot speech to finish."""
|
||||
self._armed = True
|
||||
|
||||
def track_speech(self) -> Awaitable[None]:
|
||||
def track_speech(self) -> SpeechPlaybackCompletion:
|
||||
"""Register fixed speech and return its transport completion signal."""
|
||||
completion = asyncio.get_running_loop().create_future()
|
||||
self._tracked_speech_completions.append(completion)
|
||||
self._tracked_speeches += 1
|
||||
completion = SpeechPlaybackCompletion(self)
|
||||
self._tracked_speech_completions.add(completion)
|
||||
return completion
|
||||
|
||||
async def complete_tracked_speech(
|
||||
self,
|
||||
completion: SpeechPlaybackCompletion,
|
||||
) -> None:
|
||||
"""Complete one fixed utterance when its marker reaches output."""
|
||||
if completion not in self._tracked_speech_completions:
|
||||
return
|
||||
self._tracked_speech_completions.remove(completion)
|
||||
completion._resolve()
|
||||
if (
|
||||
self._finish_after_tracked_speech
|
||||
and not self._tracked_speech_completions
|
||||
):
|
||||
logger.info("所有工作流结束语播报完毕,挂断通话")
|
||||
await self.finish()
|
||||
|
||||
async def arm_after_tracked_speech(self) -> None:
|
||||
"""Finish after every already queued fixed utterance has played."""
|
||||
self._finish_after_tracked_speech = True
|
||||
if self._tracked_speeches == 0:
|
||||
if not self._tracked_speech_completions:
|
||||
await self.finish()
|
||||
|
||||
async def finish_after_current_speech(self, *, has_text: bool) -> None:
|
||||
@@ -83,25 +149,25 @@ class CallEndCoordinator:
|
||||
await self._queue_end(self._reason)
|
||||
|
||||
async def observe(self, frame) -> None:
|
||||
if isinstance(frame, BotStartedSpeakingFrame):
|
||||
if isinstance(frame, InterruptionFrame):
|
||||
# Pipecat discards queued data frames on interruption, including
|
||||
# playback markers. Treat already queued fixed speech as stopped so
|
||||
# an interrupted Message cannot block a later EndNode forever.
|
||||
interrupted = tuple(
|
||||
completion
|
||||
for completion in self._tracked_speech_completions
|
||||
if completion.queued
|
||||
)
|
||||
for completion in interrupted:
|
||||
await self.complete_tracked_speech(completion)
|
||||
elif isinstance(frame, BotStartedSpeakingFrame):
|
||||
self._speaking = True
|
||||
self._speech_stopped.clear()
|
||||
self._response_speech_started = True
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
|
||||
self._speaking = False
|
||||
self._speech_stopped.set()
|
||||
if self._tracked_speeches > 0:
|
||||
self._tracked_speeches -= 1
|
||||
completion = self._tracked_speech_completions.popleft()
|
||||
if not completion.done():
|
||||
completion.set_result(None)
|
||||
if (
|
||||
self._finish_after_tracked_speech
|
||||
and self._tracked_speeches == 0
|
||||
):
|
||||
logger.info("所有工作流结束语播报完毕,挂断通话")
|
||||
await self.finish()
|
||||
elif self._armed:
|
||||
if self._armed:
|
||||
logger.info("结束语播报完毕,挂断通话")
|
||||
await self.finish()
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.flows import FlowsFunctionSchema
|
||||
from pipecat.frames.frames import (
|
||||
EndFrame,
|
||||
InterruptionFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
UserImageRawFrame,
|
||||
@@ -94,6 +95,11 @@ from services.pipecat.pipeline_events import (
|
||||
bind_cascade_pipeline_events,
|
||||
bind_realtime_pipeline_events,
|
||||
)
|
||||
from services.realtime.protocol import (
|
||||
PipelineProtocolAdapter,
|
||||
PipelineProtocolRuntime,
|
||||
RealtimeProviderControlFrame,
|
||||
)
|
||||
from pipecat.workers.runner import WorkerRunner
|
||||
|
||||
|
||||
@@ -201,6 +207,7 @@ async def run_pipeline(
|
||||
vision_enabled: bool = False,
|
||||
assistant_id: str | None = None,
|
||||
channel: str = "webrtc",
|
||||
protocol_adapter: PipelineProtocolAdapter | None = None,
|
||||
) -> None:
|
||||
"""在给定 transport 上构建并运行管线,直到连接结束。
|
||||
|
||||
@@ -234,6 +241,7 @@ async def run_pipeline(
|
||||
vision_enabled=vision_enabled,
|
||||
assistant_id=assistant_id,
|
||||
channel=channel,
|
||||
protocol_adapter=protocol_adapter,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -623,17 +631,25 @@ 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),
|
||||
)
|
||||
protocol_inputs = protocol_adapter.input_processors() if protocol_adapter else []
|
||||
protocol_inference = (
|
||||
protocol_adapter.inference_processors() if protocol_adapter else []
|
||||
)
|
||||
protocol_outputs = protocol_adapter.output_processors() if protocol_adapter else []
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
*protocol_inputs,
|
||||
client_tools,
|
||||
session_update,
|
||||
vision_capture,
|
||||
user_input,
|
||||
stt_processor,
|
||||
user_aggregator,
|
||||
*protocol_inference,
|
||||
user_turn_router,
|
||||
knowledge_retrieval,
|
||||
llm,
|
||||
@@ -644,6 +660,7 @@ async def run_pipeline(
|
||||
tts_processor,
|
||||
EndCallAfterSpeechProcessor(call_end),
|
||||
ConversationHistoryProcessor(recorder),
|
||||
*protocol_outputs,
|
||||
transport.output(),
|
||||
]
|
||||
)
|
||||
@@ -656,6 +673,47 @@ async def run_pipeline(
|
||||
enable_rtvi=False,
|
||||
)
|
||||
worker_holder["worker"] = worker
|
||||
protocol_turn_state: dict[str, bool | None] = {
|
||||
"external": False,
|
||||
"interrupt_response": None,
|
||||
}
|
||||
if protocol_adapter:
|
||||
|
||||
async def set_external_turn_control(enabled: bool) -> None:
|
||||
protocol_turn_state["external"] = enabled
|
||||
await user_aggregator.apply_external_turn_control(enabled)
|
||||
|
||||
async def request_response() -> None:
|
||||
# Cascade inference is triggered when the adapter submits user-input.
|
||||
return None
|
||||
|
||||
async def commit_audio() -> None:
|
||||
return None
|
||||
|
||||
async def clear_audio() -> None:
|
||||
return None
|
||||
|
||||
async def set_response_interruption(enabled: bool) -> None:
|
||||
protocol_turn_state["interrupt_response"] = enabled
|
||||
await user_aggregator.apply_turn_strategies(
|
||||
cfg.turnConfig,
|
||||
enable_interruptions=enabled,
|
||||
)
|
||||
|
||||
async def cancel_response() -> None:
|
||||
await worker.queue_frame(InterruptionFrame())
|
||||
|
||||
await protocol_adapter.bind(
|
||||
PipelineProtocolRuntime(
|
||||
queue_frame=worker.queue_frame,
|
||||
set_external_turn_control=set_external_turn_control,
|
||||
set_response_interruption=set_response_interruption,
|
||||
commit_audio=commit_audio,
|
||||
clear_audio=clear_audio,
|
||||
request_response=request_response,
|
||||
cancel_response=cancel_response,
|
||||
)
|
||||
)
|
||||
service_controller = WorkflowServiceController(
|
||||
worker=worker,
|
||||
llm_services=llm_services,
|
||||
@@ -684,6 +742,15 @@ async def run_pipeline(
|
||||
normalized,
|
||||
enable_interruptions=enable_interrupt,
|
||||
)
|
||||
if protocol_turn_state["external"]:
|
||||
await user_aggregator.apply_external_turn_control(True)
|
||||
elif protocol_turn_state["interrupt_response"] is not None:
|
||||
await user_aggregator.apply_turn_strategies(
|
||||
normalized,
|
||||
enable_interruptions=bool(
|
||||
protocol_turn_state["interrupt_response"]
|
||||
),
|
||||
)
|
||||
await worker.queue_frame(
|
||||
VADParamsUpdateFrame(params=create_vad_params(normalized))
|
||||
)
|
||||
@@ -871,6 +938,7 @@ async def run_realtime_pipeline(
|
||||
vision_enabled: bool = False,
|
||||
assistant_id: str | None = None,
|
||||
channel: str = "webrtc",
|
||||
protocol_adapter: PipelineProtocolAdapter | None = None,
|
||||
) -> None:
|
||||
"""Run a speech-to-speech model that owns ASR, reasoning, and synthesis."""
|
||||
realtime = create_realtime_service(
|
||||
@@ -952,24 +1020,33 @@ 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"
|
||||
else None
|
||||
),
|
||||
)
|
||||
protocol_inputs = protocol_adapter.input_processors() if protocol_adapter else []
|
||||
protocol_inference = (
|
||||
protocol_adapter.inference_processors() if protocol_adapter else []
|
||||
)
|
||||
protocol_outputs = protocol_adapter.output_processors() if protocol_adapter else []
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
*protocol_inputs,
|
||||
vision_capture,
|
||||
client_tools,
|
||||
session_update,
|
||||
user_input,
|
||||
input_gate,
|
||||
*protocol_inference,
|
||||
realtime,
|
||||
dynamic_variables,
|
||||
EndCallAfterSpeechProcessor(call_end),
|
||||
ConversationHistoryProcessor(recorder),
|
||||
*protocol_outputs,
|
||||
transport.output(),
|
||||
]
|
||||
)
|
||||
@@ -983,6 +1060,46 @@ async def run_realtime_pipeline(
|
||||
enable_rtvi=False,
|
||||
)
|
||||
worker_holder["worker"] = worker
|
||||
if protocol_adapter:
|
||||
|
||||
async def set_external_turn_control(enabled: bool) -> None:
|
||||
update = getattr(realtime, "update_turn_detection", None)
|
||||
if callable(update):
|
||||
await update(None if enabled else cfg.turnConfig)
|
||||
|
||||
async def request_response() -> None:
|
||||
await worker.queue_frame(
|
||||
RealtimeProviderControlFrame(action="request_response")
|
||||
)
|
||||
|
||||
async def commit_audio() -> None:
|
||||
await worker.queue_frame(
|
||||
RealtimeProviderControlFrame(action="commit_audio")
|
||||
)
|
||||
|
||||
async def clear_audio() -> None:
|
||||
await worker.queue_frame(
|
||||
RealtimeProviderControlFrame(action="clear_audio")
|
||||
)
|
||||
|
||||
async def cancel_response() -> None:
|
||||
await realtime.interrupt()
|
||||
|
||||
async def set_response_interruption(_enabled: bool) -> None:
|
||||
# Provider-side settings vary; explicit response.cancel remains portable.
|
||||
return None
|
||||
|
||||
await protocol_adapter.bind(
|
||||
PipelineProtocolRuntime(
|
||||
queue_frame=worker.queue_frame,
|
||||
set_external_turn_control=set_external_turn_control,
|
||||
set_response_interruption=set_response_interruption,
|
||||
commit_audio=commit_audio,
|
||||
clear_audio=clear_audio,
|
||||
request_response=request_response,
|
||||
cancel_response=cancel_response,
|
||||
)
|
||||
)
|
||||
|
||||
def set_input_enabled(enabled: bool) -> None:
|
||||
input_state["enabled"] = enabled
|
||||
|
||||
@@ -257,8 +257,12 @@ class UserInputProcessor(FrameProcessor):
|
||||
return
|
||||
|
||||
if self._should_ignore_input():
|
||||
logger.debug("通话正在结束,忽略后续文字输入")
|
||||
await self._emit_result(user_input.input_id, "error", "当前不能接收新的用户输入")
|
||||
logger.debug("当前会话输入已暂停,忽略用户输入")
|
||||
await self._emit_result(
|
||||
user_input.input_id,
|
||||
"error",
|
||||
"当前暂不能接收新的用户输入",
|
||||
)
|
||||
return
|
||||
|
||||
await self._call_event_handler("on_user_input", user_input)
|
||||
@@ -438,8 +442,22 @@ class ToolInterruptionUserMuteStrategy(BaseUserMuteStrategy):
|
||||
elif isinstance(frame, FunctionCallResultFrame):
|
||||
call = self._calls.get(frame.tool_call_id)
|
||||
if call is not None:
|
||||
call.tool_finished = True
|
||||
self._release_completed()
|
||||
properties = frame.properties
|
||||
is_final = properties.is_final if properties else True
|
||||
if is_final:
|
||||
call.tool_finished = True
|
||||
run_llm = (
|
||||
properties.run_llm
|
||||
if properties and properties.run_llm is not None
|
||||
else frame.run_llm
|
||||
)
|
||||
if run_llm is False and not call.response_started:
|
||||
# A tool-only result has no following bot response whose
|
||||
# speech boundary could release the mute. If a preamble
|
||||
# is already playing, BotStoppedSpeakingFrame still owns
|
||||
# the release so allow_interruptions=False is respected.
|
||||
call.response_finished = True
|
||||
self._release_completed()
|
||||
elif isinstance(frame, FunctionCallCancelFrame):
|
||||
# A canceled call cannot reliably produce a follow-up response.
|
||||
self._calls.pop(frame.tool_call_id, None)
|
||||
|
||||
@@ -41,13 +41,14 @@ from services.pipecat.realtime_tools import (
|
||||
RealtimeToolDispatcher,
|
||||
RealtimeToolSession,
|
||||
)
|
||||
from services.realtime.protocol import RealtimeProviderControlFrame
|
||||
|
||||
|
||||
DEFAULT_QWEN_AUDIO_REALTIME_MODEL = "qwen-audio-3.0-realtime-flash"
|
||||
DEFAULT_QWEN_AUDIO_REALTIME_VOICE = "longanqian"
|
||||
QWEN_INPUT_SAMPLE_RATE = 16_000
|
||||
QWEN_OUTPUT_SAMPLE_RATE = 24_000
|
||||
SUPPORTED_TURN_DETECTION_MODES = frozenset({"server_vad", "smart_turn"})
|
||||
SUPPORTED_TURN_DETECTION_MODES = frozenset({"none", "server_vad", "smart_turn"})
|
||||
|
||||
ExtraEventHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
|
||||
SpeechStartedHandler = Callable[[], Awaitable[None]]
|
||||
@@ -161,6 +162,15 @@ class QwenAudioRealtimeService(AIService):
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if isinstance(frame, RealtimeProviderControlFrame):
|
||||
if frame.action == "commit_audio":
|
||||
await self.commit_audio_buffer()
|
||||
elif frame.action == "clear_audio":
|
||||
await self.clear_audio_buffer()
|
||||
else:
|
||||
await self.request_response()
|
||||
return
|
||||
|
||||
if isinstance(frame, InputAudioRawFrame):
|
||||
if (
|
||||
frame.sample_rate != self._input_sample_rate
|
||||
@@ -234,6 +244,20 @@ class QwenAudioRealtimeService(AIService):
|
||||
)
|
||||
await self._send_event({"type": "response.create"})
|
||||
|
||||
async def commit_audio_buffer(self) -> None:
|
||||
await self._send_event({"type": "input_audio_buffer.commit"})
|
||||
|
||||
async def clear_audio_buffer(self) -> None:
|
||||
await self._send_event({"type": "input_audio_buffer.clear"})
|
||||
|
||||
async def update_turn_detection(self, config: dict[str, Any] | None) -> None:
|
||||
"""Apply OpenAI PTT before provider startup; reject unsafe live changes."""
|
||||
|
||||
mode = "none" if config is None else "server_vad"
|
||||
if self._session_ready.is_set():
|
||||
raise ValueError("Qwen Realtime 不支持会话建立后切换轮次检测")
|
||||
self._turn_detection_mode = mode
|
||||
|
||||
async def wait_for_response_boundary(self) -> None:
|
||||
"""Wait until Qwen has finished or cancelled the current response."""
|
||||
await self._response_done.wait()
|
||||
@@ -355,7 +379,9 @@ class QwenAudioRealtimeService(AIService):
|
||||
"tool_choice": "auto",
|
||||
}
|
||||
|
||||
def _turn_detection_config(self) -> dict[str, Any]:
|
||||
def _turn_detection_config(self) -> dict[str, Any] | None:
|
||||
if self._turn_detection_mode == "none":
|
||||
return None
|
||||
if self._turn_detection_mode == "smart_turn":
|
||||
return {"type": "smart_turn"}
|
||||
return {
|
||||
|
||||
@@ -117,7 +117,7 @@ def create_llm(cfg: AssistantConfig):
|
||||
raise ValueError(f"不支持的 LLM 接口类型: {cfg.llm_interface_type}")
|
||||
extra_body = cfg.llm_values.get("extraBody")
|
||||
extra = {"extra_body": extra_body} if isinstance(extra_body, dict) else {}
|
||||
return OpenAILLMService(
|
||||
service = OpenAILLMService(
|
||||
api_key=_require(cfg.llm_api_key, "LLM apiKey"),
|
||||
base_url=_require(cfg.llm_base_url, "LLM apiUrl"),
|
||||
settings=OpenAILLMService.Settings(
|
||||
@@ -125,6 +125,15 @@ def create_llm(cfg: AssistantConfig):
|
||||
extra=extra,
|
||||
),
|
||||
)
|
||||
# Pipecat represents late async-tool results with the newer `developer`
|
||||
# role. Most OpenAI-compatible providers (including DeepSeek) only accept
|
||||
# system/assistant/user/tool. This flag uses Pipecat's built-in adapter to
|
||||
# downgrade developer messages before sending the request. Official or
|
||||
# otherwise compatible endpoints can opt in through the resource values.
|
||||
service.supports_developer_role = (
|
||||
cfg.llm_values.get("supportsDeveloperRole") is True
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def create_tts(cfg: AssistantConfig):
|
||||
|
||||
@@ -35,6 +35,7 @@ from services.pipecat.realtime_tools import (
|
||||
RealtimeToolDispatcher,
|
||||
RealtimeToolSession,
|
||||
)
|
||||
from services.realtime.protocol import RealtimeProviderControlFrame
|
||||
|
||||
DEFAULT_STEPFUN_REALTIME_URL = "wss://api.stepfun.com/v1/realtime"
|
||||
SpeechStartedHandler = Callable[[], Awaitable[None]]
|
||||
@@ -69,6 +70,7 @@ class StepFunRealtimeService(AIService):
|
||||
self._prefix_padding_ms = prefix_padding_ms
|
||||
self._silence_duration_ms = silence_duration_ms
|
||||
self._energy_awakeness_threshold = energy_awakeness_threshold
|
||||
self._turn_detection_enabled = True
|
||||
self._warned_input_sample_rate = False
|
||||
self._websocket = None
|
||||
self._receive_task: asyncio.Task | None = None
|
||||
@@ -115,6 +117,15 @@ class StepFunRealtimeService(AIService):
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if isinstance(frame, RealtimeProviderControlFrame):
|
||||
if frame.action == "commit_audio":
|
||||
await self.commit_audio_buffer()
|
||||
elif frame.action == "clear_audio":
|
||||
await self.clear_audio_buffer()
|
||||
else:
|
||||
await self.request_response()
|
||||
return
|
||||
|
||||
if isinstance(frame, InputAudioRawFrame):
|
||||
if (
|
||||
frame.sample_rate != self._input_sample_rate
|
||||
@@ -183,6 +194,23 @@ class StepFunRealtimeService(AIService):
|
||||
)
|
||||
await self._send_event({"type": "response.create"})
|
||||
|
||||
async def commit_audio_buffer(self) -> None:
|
||||
await self._send_event({"type": "input_audio_buffer.commit"})
|
||||
|
||||
async def clear_audio_buffer(self) -> None:
|
||||
await self._send_event({"type": "input_audio_buffer.clear"})
|
||||
|
||||
async def update_turn_detection(self, config: dict[str, Any] | None) -> None:
|
||||
self._turn_detection_enabled = config is not None
|
||||
if self._session_ready.is_set():
|
||||
await self._send_event(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {"turn_detection": self._turn_detection_config()},
|
||||
},
|
||||
wait_until_ready=False,
|
||||
)
|
||||
|
||||
async def wait_for_response_boundary(self) -> None:
|
||||
"""Wait until StepFun has finished or cancelled the current response."""
|
||||
await self._response_done.wait()
|
||||
@@ -386,12 +414,7 @@ class StepFunRealtimeService(AIService):
|
||||
"voice": self._voice,
|
||||
"input_audio_format": "pcm16",
|
||||
"output_audio_format": "pcm16",
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"prefix_padding_ms": self._prefix_padding_ms,
|
||||
"silence_duration_ms": self._silence_duration_ms,
|
||||
"energy_awakeness_threshold": self._energy_awakeness_threshold,
|
||||
},
|
||||
"turn_detection": self._turn_detection_config(),
|
||||
"tools": [tool.provider_schema() for tool in self._tools],
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
@@ -399,6 +422,16 @@ class StepFunRealtimeService(AIService):
|
||||
wait_until_ready=False,
|
||||
)
|
||||
|
||||
def _turn_detection_config(self) -> dict[str, Any] | None:
|
||||
if not self._turn_detection_enabled:
|
||||
return None
|
||||
return {
|
||||
"type": "server_vad",
|
||||
"prefix_padding_ms": self._prefix_padding_ms,
|
||||
"silence_duration_ms": self._silence_duration_ms,
|
||||
"energy_awakeness_threshold": self._energy_awakeness_threshold,
|
||||
}
|
||||
|
||||
async def update_instructions(self, instructions: str) -> None:
|
||||
"""Refresh model instructions without rebuilding the realtime session."""
|
||||
self._instructions = instructions
|
||||
|
||||
@@ -14,15 +14,68 @@ from pipecat.transports.base_transport import TransportParams
|
||||
|
||||
# WebRTC
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
|
||||
from pipecat.transports.smallwebrtc.transport import (
|
||||
SmallWebRTCOutputTransport,
|
||||
SmallWebRTCTransport,
|
||||
)
|
||||
|
||||
# 裸 WS 音频流
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketOutputTransport,
|
||||
FastAPIWebsocketTransport,
|
||||
FastAPIWebsocketParams,
|
||||
)
|
||||
from pipecat.serializers.base_serializer import FrameSerializer
|
||||
from pipecat.serializers.protobuf import ProtobufFrameSerializer
|
||||
|
||||
from services.pipecat.call_lifecycle import FixedSpeechPlaybackMarkerFrame
|
||||
|
||||
|
||||
class _PlaybackMarkerOutputMixin:
|
||||
"""Resolve fixed-speech markers after preceding audio has been sent."""
|
||||
|
||||
async def write_transport_frame(self, frame):
|
||||
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
|
||||
await frame.completion.mark_played()
|
||||
return
|
||||
await super().write_transport_frame(frame)
|
||||
|
||||
|
||||
class _WebRTCOutputTransport(
|
||||
_PlaybackMarkerOutputMixin,
|
||||
SmallWebRTCOutputTransport,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class _WebRTCTransport(SmallWebRTCTransport):
|
||||
def output(self) -> SmallWebRTCOutputTransport:
|
||||
if not self._output:
|
||||
self._output = _WebRTCOutputTransport(
|
||||
self._client,
|
||||
self._params,
|
||||
name=self._input_name,
|
||||
)
|
||||
return self._output
|
||||
|
||||
|
||||
class _WebsocketOutputTransport(
|
||||
_PlaybackMarkerOutputMixin,
|
||||
FastAPIWebsocketOutputTransport,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class _WebsocketTransport(FastAPIWebsocketTransport):
|
||||
def __init__(self, websocket: WebSocket, params: FastAPIWebsocketParams):
|
||||
super().__init__(websocket=websocket, params=params)
|
||||
self._output = _WebsocketOutputTransport(
|
||||
self,
|
||||
self._client,
|
||||
self._params,
|
||||
name=self._output_name,
|
||||
)
|
||||
|
||||
|
||||
def _base_params(*, video_in_enabled: bool = False) -> dict:
|
||||
"""两种 transport 共享的音频参数。"""
|
||||
@@ -41,7 +94,7 @@ def build_webrtc_transport(
|
||||
*,
|
||||
video_in_enabled: bool = False,
|
||||
) -> SmallWebRTCTransport:
|
||||
return SmallWebRTCTransport(
|
||||
return _WebRTCTransport(
|
||||
webrtc_connection=connection,
|
||||
params=TransportParams(**_base_params(video_in_enabled=video_in_enabled)),
|
||||
)
|
||||
@@ -51,10 +104,33 @@ def build_ws_transport(websocket: WebSocket) -> FastAPIWebsocketTransport:
|
||||
"""裸 WS 输出。序列化用 protobuf(自定义客户端用同款解码);
|
||||
若对接电话商,把 serializer 换成对应的 TwilioFrameSerializer 等即可。
|
||||
"""
|
||||
return FastAPIWebsocketTransport(
|
||||
return build_serialized_ws_transport(
|
||||
websocket,
|
||||
serializer=ProtobufFrameSerializer(),
|
||||
)
|
||||
|
||||
|
||||
def build_serialized_ws_transport(
|
||||
websocket: WebSocket,
|
||||
*,
|
||||
serializer: FrameSerializer,
|
||||
sample_rate: int | None = None,
|
||||
) -> FastAPIWebsocketTransport:
|
||||
"""Build a text/binary WS transport without coupling it to one protocol."""
|
||||
|
||||
sample_rates = (
|
||||
{
|
||||
"audio_in_sample_rate": sample_rate,
|
||||
"audio_out_sample_rate": sample_rate,
|
||||
}
|
||||
if sample_rate
|
||||
else {}
|
||||
)
|
||||
return _WebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
serializer=ProtobufFrameSerializer(),
|
||||
serializer=serializer,
|
||||
**_base_params(),
|
||||
**sample_rates,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -20,7 +20,10 @@ from pipecat.turns.user_stop import (
|
||||
SpeechTimeoutUserTurnStopStrategy,
|
||||
TurnAnalyzerUserTurnStopStrategy,
|
||||
)
|
||||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.turns.user_turn_strategies import (
|
||||
ExternalUserTurnStrategies,
|
||||
UserTurnStrategies,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_VAD = {
|
||||
@@ -126,3 +129,13 @@ class ConfigurableLLMUserAggregator(LLMUserAggregator):
|
||||
enable_interruptions=enable_interruptions,
|
||||
)
|
||||
await self._user_turn_controller.update_strategies(strategies)
|
||||
|
||||
async def apply_external_turn_control(self, enabled: bool) -> None:
|
||||
"""Switch between client-controlled PTT and the configured VAD."""
|
||||
|
||||
strategies = (
|
||||
ExternalUserTurnStrategies()
|
||||
if enabled
|
||||
else self._params.user_turn_strategies
|
||||
)
|
||||
await self._user_turn_controller.update_strategies(strategies)
|
||||
|
||||
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."""
|
||||
196
backend/services/post_call/analyzer.py
Normal file
196
backend/services/post_call/analyzer.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
from services.webhooks.events import enqueue_analysis_completed
|
||||
|
||||
|
||||
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 enqueue_analysis_completed(session, conversation, result)
|
||||
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)
|
||||
2
backend/services/realtime/__init__.py
Normal file
2
backend/services/realtime/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Protocol-neutral helpers for realtime transports."""
|
||||
|
||||
195
backend/services/realtime/launcher.py
Normal file
195
backend/services/realtime/launcher.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Resolve and validate an assistant before creating a media connection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from db.session import SessionLocal
|
||||
from models import AssistantConfig
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.node_specs import graph_references
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
async def resolve_assistant_config(
|
||||
assistant_id: str,
|
||||
*,
|
||||
dynamic_variables: dict[str, Any] | None = None,
|
||||
) -> AssistantConfig:
|
||||
async with SessionLocal() as session:
|
||||
config = await resolve_runtime_config(session, assistant_id)
|
||||
return prepare_dynamic_config(
|
||||
config,
|
||||
dynamic_variables or {},
|
||||
assistant_id=assistant_id,
|
||||
)
|
||||
|
||||
|
||||
def validate_visual_runtime(config: AssistantConfig) -> bool:
|
||||
"""Return the authoritative video-input permission or fail before connect."""
|
||||
|
||||
if config.type == "workflow":
|
||||
return WorkflowEngine(config.graph).uses_vision()
|
||||
|
||||
vision_enabled = config.vision_enabled
|
||||
if not vision_enabled:
|
||||
return False
|
||||
has_native_vision = (
|
||||
not config.vision_model_resource_id and config.llm_support_image_input
|
||||
)
|
||||
has_aux_vision_model = (
|
||||
bool(config.vision_model_resource_id)
|
||||
and config.vision_llm_support_image_input
|
||||
)
|
||||
if not (has_native_vision or has_aux_vision_model):
|
||||
raise ValueError(
|
||||
"当前模型不支持图片输入,请在模型资源中选择支持图片输入的视觉模型"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _require_values(labels: list[tuple[str, Any]]) -> None:
|
||||
missing = [label for label, value in labels if not value]
|
||||
if missing:
|
||||
raise ValueError(f"助手运行配置不完整: {', '.join(missing)}")
|
||||
|
||||
|
||||
def _validate_voice_resource(
|
||||
capability: str,
|
||||
*,
|
||||
interface_type: str,
|
||||
values: dict[str, Any],
|
||||
secrets: dict[str, Any],
|
||||
) -> None:
|
||||
labels: list[tuple[str, Any]] = []
|
||||
if capability == "ASR":
|
||||
if interface_type not in {"openai-asr", "dashscope-asr", "xfyun-asr"}:
|
||||
raise ValueError(f"不支持的 ASR 接口类型: {interface_type}")
|
||||
if interface_type == "xfyun-asr":
|
||||
labels.extend(
|
||||
(f"ASR {key}", secrets.get(key))
|
||||
for key in ("appId", "apiKey", "apiSecret")
|
||||
)
|
||||
else:
|
||||
labels.extend(
|
||||
[
|
||||
("ASR modelId", values.get("modelId")),
|
||||
("ASR apiUrl", values.get("apiUrl")),
|
||||
("ASR apiKey", secrets.get("apiKey")),
|
||||
]
|
||||
)
|
||||
elif capability == "TTS":
|
||||
if interface_type not in {
|
||||
"openai-tts",
|
||||
"dashscope-tts",
|
||||
"xfyun-tts",
|
||||
"xfyun-super-tts",
|
||||
}:
|
||||
raise ValueError(f"不支持的 TTS 接口类型: {interface_type}")
|
||||
labels.append(("TTS voice", values.get("voice")))
|
||||
if interface_type in {"xfyun-tts", "xfyun-super-tts"}:
|
||||
labels.extend(
|
||||
(f"TTS {key}", secrets.get(key))
|
||||
for key in ("appId", "apiKey", "apiSecret")
|
||||
)
|
||||
else:
|
||||
labels.extend(
|
||||
[
|
||||
("TTS modelId", values.get("modelId")),
|
||||
("TTS apiUrl", values.get("apiUrl")),
|
||||
("TTS apiKey", secrets.get("apiKey")),
|
||||
]
|
||||
)
|
||||
elif capability == "LLM":
|
||||
if interface_type not in {"openai-llm", "dashscope-llm"}:
|
||||
raise ValueError(f"不支持的 LLM 接口类型: {interface_type}")
|
||||
labels.extend(
|
||||
[
|
||||
("LLM modelId", values.get("modelId")),
|
||||
("LLM apiUrl", values.get("apiUrl")),
|
||||
("LLM apiKey", secrets.get("apiKey")),
|
||||
]
|
||||
)
|
||||
_require_values(labels)
|
||||
|
||||
|
||||
def validate_runtime_requirements(config: AssistantConfig) -> None:
|
||||
"""Reject structurally incomplete assistants before media negotiation."""
|
||||
|
||||
if config.type not in {"prompt", "workflow", "dify", "fastgpt"}:
|
||||
raise ValueError(f"当前助手类型不支持 Realtime API: {config.type}")
|
||||
if config.runtimeMode == "realtime":
|
||||
if config.type not in {"prompt", "workflow"}:
|
||||
raise ValueError(f"助手类型 {config.type} 不支持 realtime 运行模式")
|
||||
if config.realtime_interface_type not in {
|
||||
"qwen-audio-realtime",
|
||||
"stepfun-realtime",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"不支持的 Realtime 接口类型: {config.realtime_interface_type}"
|
||||
)
|
||||
_require_values(
|
||||
[
|
||||
("Realtime interfaceType", config.realtime_interface_type),
|
||||
("Realtime modelId", config.realtimeModel),
|
||||
("Realtime apiUrl", config.realtime_base_url),
|
||||
("Realtime apiKey", config.realtime_api_key),
|
||||
],
|
||||
)
|
||||
return
|
||||
|
||||
_validate_voice_resource(
|
||||
"ASR",
|
||||
interface_type=config.stt_interface_type,
|
||||
values=config.stt_values,
|
||||
secrets=config.stt_secrets,
|
||||
)
|
||||
_validate_voice_resource(
|
||||
"TTS",
|
||||
interface_type=config.tts_interface_type,
|
||||
values=config.tts_values,
|
||||
secrets=config.tts_secrets,
|
||||
)
|
||||
if config.type == "prompt":
|
||||
_validate_voice_resource(
|
||||
"LLM",
|
||||
interface_type=config.llm_interface_type,
|
||||
values=config.llm_values,
|
||||
secrets=config.llm_secrets,
|
||||
)
|
||||
elif config.type == "dify":
|
||||
_require_values(
|
||||
[
|
||||
("Dify apiUrl", config.dify_api_url),
|
||||
("Dify apiKey", config.dify_api_key),
|
||||
],
|
||||
)
|
||||
elif config.type == "fastgpt":
|
||||
_require_values(
|
||||
[
|
||||
("FastGPT apiUrl", config.fastgpt_api_url),
|
||||
("FastGPT apiKey", config.fastgpt_api_key),
|
||||
],
|
||||
)
|
||||
|
||||
if config.type != "workflow":
|
||||
return
|
||||
references = graph_references(config.graph)
|
||||
missing_models = references["model_resources"] - set(
|
||||
config.workflow_model_resources
|
||||
)
|
||||
missing_knowledge = references["knowledge_bases"] - set(
|
||||
config.workflow_knowledge_bases
|
||||
)
|
||||
if missing_models or missing_knowledge:
|
||||
missing = sorted([*missing_models, *missing_knowledge])
|
||||
raise ValueError(f"Workflow 引用了不可用资源: {', '.join(missing)}")
|
||||
for resource in config.workflow_model_resources.values():
|
||||
if resource.capability in {"ASR", "TTS", "LLM"}:
|
||||
_validate_voice_resource(
|
||||
resource.capability,
|
||||
interface_type=resource.interface_type,
|
||||
values=resource.values,
|
||||
secrets=resource.secrets,
|
||||
)
|
||||
109
backend/services/realtime/lifecycle.py
Normal file
109
backend/services/realtime/lifecycle.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Shared ownership of peer connections and their pipeline tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
active_connections: set[object] = set()
|
||||
pipeline_tasks: set[asyncio.Task[None]] = set()
|
||||
connection_tasks: dict[object, asyncio.Task[None]] = {}
|
||||
DEFAULT_CLOSE_GRACE_SECONDS = 10.0
|
||||
|
||||
|
||||
def _consume_pipeline_result(task: asyncio.Task[None], connection: object) -> None:
|
||||
pipeline_tasks.discard(task)
|
||||
if connection_tasks.get(connection) is task:
|
||||
connection_tasks.pop(connection, None)
|
||||
try:
|
||||
error = task.exception()
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Realtime pipeline 已取消: task={task.get_name()}")
|
||||
return
|
||||
if error is not None:
|
||||
logger.opt(exception=error).error(
|
||||
f"Realtime pipeline 异常结束: task={task.get_name()}"
|
||||
)
|
||||
|
||||
|
||||
def start_pipeline_task(
|
||||
connection: object,
|
||||
coroutine: Coroutine[Any, Any, None],
|
||||
*,
|
||||
protocol: str,
|
||||
) -> asyncio.Task[None]:
|
||||
connection_id = str(getattr(connection, "pc_id", id(connection)))
|
||||
task = asyncio.create_task(
|
||||
coroutine,
|
||||
name=f"{protocol}-pipeline:{connection_id}",
|
||||
)
|
||||
active_connections.add(connection)
|
||||
pipeline_tasks.add(task)
|
||||
connection_tasks[connection] = task
|
||||
task.add_done_callback(
|
||||
lambda completed, connection=connection: _consume_pipeline_result(
|
||||
completed,
|
||||
connection,
|
||||
)
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def wait_for_pipeline_close(
|
||||
task: asyncio.Task[None] | None,
|
||||
*,
|
||||
connection_id: str,
|
||||
timeout: float = DEFAULT_CLOSE_GRACE_SECONDS,
|
||||
) -> None:
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
|
||||
return
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Realtime pipeline 关闭超过 {timeout:g} 秒,执行取消: "
|
||||
f"connection_id={connection_id}"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
task.cancel()
|
||||
done, _pending = await asyncio.wait({task}, timeout=timeout)
|
||||
if not done:
|
||||
logger.error(f"Realtime pipeline 取消后仍未退出: connection_id={connection_id}")
|
||||
|
||||
|
||||
async def shutdown_active_sessions(
|
||||
*,
|
||||
timeout: float = DEFAULT_CLOSE_GRACE_SECONDS,
|
||||
) -> None:
|
||||
connections = list(active_connections)
|
||||
if connections:
|
||||
await asyncio.gather(
|
||||
*(connection.disconnect() for connection in connections),
|
||||
return_exceptions=True,
|
||||
)
|
||||
active_connections.difference_update(connections)
|
||||
|
||||
tasks = list(pipeline_tasks)
|
||||
if not tasks:
|
||||
return
|
||||
done, pending = await asyncio.wait(tasks, timeout=timeout)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
cancelled: set[asyncio.Task[None]] = set()
|
||||
if pending:
|
||||
cancelled, stuck = await asyncio.wait(pending, timeout=timeout)
|
||||
if stuck:
|
||||
logger.error(f"应用关闭时仍有 {len(stuck)} 个 Realtime pipeline 未退出")
|
||||
logger.info(
|
||||
f"Realtime 会话清理完成: normal={len(done)} cancelled={len(cancelled)}"
|
||||
)
|
||||
|
||||
42
backend/services/realtime/protocol.py
Normal file
42
backend/services/realtime/protocol.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Neutral extension point between a transport protocol and the voice pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from pipecat.frames.frames import Frame, SystemFrame
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipelineProtocolRuntime:
|
||||
"""Operations a wire-protocol adapter may request from a running pipeline."""
|
||||
|
||||
queue_frame: Callable[[Frame], Awaitable[None]]
|
||||
set_external_turn_control: Callable[[bool], Awaitable[None]]
|
||||
set_response_interruption: Callable[[bool], Awaitable[None]]
|
||||
commit_audio: Callable[[], Awaitable[None]]
|
||||
clear_audio: Callable[[], Awaitable[None]]
|
||||
request_response: Callable[[], Awaitable[None]]
|
||||
cancel_response: Callable[[], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealtimeProviderControlFrame(SystemFrame):
|
||||
"""Ordered control sent to a speech-to-speech provider after prior input."""
|
||||
|
||||
action: Literal["commit_audio", "clear_audio", "request_response"]
|
||||
|
||||
|
||||
class PipelineProtocolAdapter(Protocol):
|
||||
"""A protocol adapter only translates frames; it does not own the assistant."""
|
||||
|
||||
def input_processors(self) -> list[FrameProcessor]: ...
|
||||
|
||||
def inference_processors(self) -> list[FrameProcessor]: ...
|
||||
|
||||
def output_processors(self) -> list[FrameProcessor]: ...
|
||||
|
||||
async def bind(self, runtime: PipelineProtocolRuntime) -> None: ...
|
||||
1
backend/services/test_runs/__init__.py
Normal file
1
backend/services/test_runs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Fixed-text test execution domain."""
|
||||
24
backend/services/test_runs/errors.py
Normal file
24
backend/services/test_runs/errors.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Structured failures shared by the text runner and batch orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestExecutionError(RuntimeError):
|
||||
code: str
|
||||
message: str
|
||||
stage: str
|
||||
retryable: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
RuntimeError.__init__(self, self.message)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"stage": self.stage,
|
||||
"retryable": self.retryable,
|
||||
}
|
||||
374
backend/services/test_runs/evaluator.py
Normal file
374
backend/services/test_runs/evaluator.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Deterministic and LLM-backed assertions for text test results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from models import RuntimeModelResource
|
||||
|
||||
from services.test_runs.errors import TestExecutionError
|
||||
from services.test_runs.text_runner import RawToolCall, RawTurnResult, TextCaseResult
|
||||
from test_schemas import (
|
||||
BatchEvaluationResult,
|
||||
BatchTurnResult,
|
||||
FixedInputTurn,
|
||||
OverallCriterion,
|
||||
ReplyExpectedBehavior,
|
||||
TestCaseDefinition,
|
||||
ToolCallExpectedBehavior,
|
||||
ToolParamAssertion,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvaluatedCase:
|
||||
turns: list[BatchTurnResult]
|
||||
overall_criteria: list[BatchEvaluationResult]
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
evaluations = [
|
||||
item
|
||||
for turn in self.turns
|
||||
for item in turn.evaluations
|
||||
] + self.overall_criteria
|
||||
return all(item.status == "pass" for item in evaluations)
|
||||
|
||||
|
||||
class LLMJudge:
|
||||
"""Small OpenAI-compatible JSON judge using a dedicated model resource."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: RuntimeModelResource,
|
||||
*,
|
||||
timeout_seconds: float = 60,
|
||||
):
|
||||
self._resource = resource
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
*,
|
||||
criteria: str,
|
||||
subject: str,
|
||||
context: str,
|
||||
) -> tuple[bool, str, str]:
|
||||
values = self._resource.values or {}
|
||||
secrets = self._resource.secrets or {}
|
||||
base_url = str(values.get("apiUrl") or "").rstrip("/")
|
||||
api_key = str(secrets.get("apiKey") or "")
|
||||
model = str(values.get("modelId") or "")
|
||||
if not base_url or not api_key or not model:
|
||||
raise TestExecutionError(
|
||||
code="EVALUATOR_NOT_CONFIGURED",
|
||||
message="LLM 评估所需的模型资源未完整配置",
|
||||
stage="evaluation",
|
||||
)
|
||||
prompt = (
|
||||
"你是严格的自动化测试评估器。只根据提供的内容判断标准是否满足。\n"
|
||||
"必须只输出一个 JSON 对象,不要输出 Markdown:\n"
|
||||
'{"passed":true或false,"reason":"简短中文理由","actual":"实际表现摘要"}\n\n'
|
||||
f"评估对象:{subject}\n"
|
||||
f"评估标准:{criteria}\n\n"
|
||||
f"待评估内容:\n{context}"
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0,
|
||||
}
|
||||
extra_body = values.get("extraBody")
|
||||
if isinstance(extra_body, dict):
|
||||
payload.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in extra_body.items()
|
||||
if key not in {"model", "messages"}
|
||||
}
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
|
||||
response = await client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
if isinstance(content, list):
|
||||
content = "".join(
|
||||
str(part.get("text") or "")
|
||||
for part in content
|
||||
if isinstance(part, dict)
|
||||
)
|
||||
parsed = self._parse_json(str(content or ""))
|
||||
return (
|
||||
bool(parsed.get("passed")),
|
||||
str(parsed.get("reason") or "LLM 未提供判断理由"),
|
||||
str(parsed.get("actual") or ""),
|
||||
)
|
||||
except TestExecutionError:
|
||||
raise
|
||||
except (httpx.HTTPError, KeyError, IndexError, TypeError, ValueError) as exc:
|
||||
raise TestExecutionError(
|
||||
code="EVALUATOR_REQUEST_FAILED",
|
||||
message=f"LLM 评估失败: {exc}",
|
||||
stage="evaluation",
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(value: str) -> dict[str, Any]:
|
||||
text = value.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start < 0 or end < start:
|
||||
raise ValueError("评估模型没有返回 JSON")
|
||||
parsed = json.loads(text[start : end + 1])
|
||||
if (
|
||||
not isinstance(parsed, dict)
|
||||
or not isinstance(parsed.get("passed"), bool)
|
||||
):
|
||||
raise ValueError("评估模型返回格式不正确")
|
||||
return parsed
|
||||
|
||||
|
||||
class EvaluationEngine:
|
||||
def __init__(self, judge_resource: RuntimeModelResource):
|
||||
self._judge = LLMJudge(judge_resource)
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
definition: TestCaseDefinition,
|
||||
execution: TextCaseResult,
|
||||
) -> EvaluatedCase:
|
||||
raw_by_id = {turn.id: turn for turn in execution.turns}
|
||||
turns: list[BatchTurnResult] = []
|
||||
for index, expected_turn in enumerate(definition.turns):
|
||||
raw = raw_by_id.get(expected_turn.id) or RawTurnResult(
|
||||
id=expected_turn.id,
|
||||
index=index,
|
||||
user_input=expected_turn.user_input,
|
||||
assistant_reply="",
|
||||
tool_calls=[],
|
||||
)
|
||||
evaluations = [
|
||||
await self._evaluate_behavior(behavior, raw)
|
||||
for behavior in expected_turn.behaviors
|
||||
]
|
||||
turns.append(
|
||||
BatchTurnResult(
|
||||
id=raw.id,
|
||||
index=index,
|
||||
user_input=raw.user_input,
|
||||
assistant_reply=raw.assistant_reply,
|
||||
tool_calls=[item.as_result() for item in raw.tool_calls],
|
||||
evaluations=evaluations,
|
||||
)
|
||||
)
|
||||
|
||||
transcript = self._format_transcript(execution, definition)
|
||||
overall = [
|
||||
await self._evaluate_overall(criterion, transcript)
|
||||
for criterion in definition.overall_criteria
|
||||
]
|
||||
return EvaluatedCase(turns=turns, overall_criteria=overall)
|
||||
|
||||
async def _evaluate_behavior(
|
||||
self,
|
||||
behavior: ReplyExpectedBehavior | ToolCallExpectedBehavior,
|
||||
turn: RawTurnResult,
|
||||
) -> BatchEvaluationResult:
|
||||
if behavior.type == "reply":
|
||||
return await self._evaluate_reply(behavior, turn.assistant_reply)
|
||||
return await self._evaluate_tool(behavior, turn.tool_calls)
|
||||
|
||||
async def _evaluate_reply(
|
||||
self,
|
||||
behavior: ReplyExpectedBehavior,
|
||||
reply: str,
|
||||
) -> BatchEvaluationResult:
|
||||
if behavior.assertion_type == "llm":
|
||||
passed, reason, actual = await self._judge.evaluate(
|
||||
criteria=behavior.llm_criteria,
|
||||
subject="当前轮助手回复",
|
||||
context=reply or "(助手没有生成文字回复)",
|
||||
)
|
||||
return BatchEvaluationResult(
|
||||
id=behavior.id,
|
||||
label="回复 · LLM 判断",
|
||||
kind="reply",
|
||||
status="pass" if passed else "fail",
|
||||
expected=behavior.llm_criteria,
|
||||
actual=actual or reply,
|
||||
reason="" if passed else reason,
|
||||
)
|
||||
|
||||
normalized_reply = reply.casefold()
|
||||
matches = [keyword.casefold() in normalized_reply for keyword in behavior.keywords]
|
||||
matched = all(matches) if behavior.keyword_match_mode == "all" else any(matches)
|
||||
passed = not matched if behavior.negate_keywords else matched
|
||||
relation = (
|
||||
"不应包含"
|
||||
if behavior.negate_keywords
|
||||
else "应包含全部"
|
||||
if behavior.keyword_match_mode == "all"
|
||||
else "应至少包含其一"
|
||||
)
|
||||
return BatchEvaluationResult(
|
||||
id=behavior.id,
|
||||
label="回复 · 关键词",
|
||||
kind="reply",
|
||||
status="pass" if passed else "fail",
|
||||
expected=f"{relation}:{'、'.join(behavior.keywords)}",
|
||||
actual=reply or "(无文字回复)",
|
||||
reason="" if passed else "实际回复未满足关键词规则。",
|
||||
)
|
||||
|
||||
async def _evaluate_tool(
|
||||
self,
|
||||
behavior: ToolCallExpectedBehavior,
|
||||
calls: list[RawToolCall],
|
||||
) -> BatchEvaluationResult:
|
||||
matching_calls = [
|
||||
call for call in calls if call.function_name == behavior.function_name
|
||||
]
|
||||
count = len(matching_calls)
|
||||
if behavior.expectation == "not_called":
|
||||
passed = count == 0
|
||||
return BatchEvaluationResult(
|
||||
id=behavior.id,
|
||||
label=f"工具 · {behavior.function_name}",
|
||||
kind="tool_call",
|
||||
status="pass" if passed else "fail",
|
||||
expected=f"不应调用 {behavior.function_name}",
|
||||
actual=f"实际调用 {count} 次 {behavior.function_name}",
|
||||
reason="" if passed else "检测到本轮不应发生的工具调用。",
|
||||
)
|
||||
|
||||
count_passed = count >= behavior.min_calls and (
|
||||
behavior.max_calls is None or count <= behavior.max_calls
|
||||
)
|
||||
params_passed = True
|
||||
parameter_reason = ""
|
||||
if count_passed and behavior.param_assertions:
|
||||
params_passed = False
|
||||
reasons: list[str] = []
|
||||
for call in matching_calls:
|
||||
call_passed, call_reason = await self._call_matches_parameters(
|
||||
call, behavior.param_assertions
|
||||
)
|
||||
if call_passed:
|
||||
params_passed = True
|
||||
break
|
||||
reasons.append(call_reason)
|
||||
parameter_reason = ";".join(item for item in reasons if item)
|
||||
|
||||
passed = count_passed and params_passed
|
||||
count_text = (
|
||||
f"{behavior.min_calls} 次以上"
|
||||
if behavior.max_calls is None
|
||||
else f"{behavior.min_calls} 次"
|
||||
if behavior.min_calls == behavior.max_calls
|
||||
else f"{behavior.min_calls}–{behavior.max_calls} 次"
|
||||
)
|
||||
reason = ""
|
||||
if not count_passed:
|
||||
reason = "工具调用次数不符合要求。"
|
||||
elif not params_passed:
|
||||
reason = parameter_reason or "没有一次工具调用满足全部参数断言。"
|
||||
return BatchEvaluationResult(
|
||||
id=behavior.id,
|
||||
label=f"工具 · {behavior.function_name}",
|
||||
kind="tool_call",
|
||||
status="pass" if passed else "fail",
|
||||
expected=f"应调用 {behavior.function_name} {count_text}",
|
||||
actual=f"实际调用 {count} 次 {behavior.function_name}",
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
async def _call_matches_parameters(
|
||||
self,
|
||||
call: RawToolCall,
|
||||
assertions: list[ToolParamAssertion],
|
||||
) -> tuple[bool, str]:
|
||||
arguments = call.arguments if isinstance(call.arguments, dict) else {}
|
||||
for assertion in assertions:
|
||||
if assertion.name not in arguments:
|
||||
return False, f"缺少参数 {assertion.name}"
|
||||
actual = arguments[assertion.name]
|
||||
if assertion.match_mode == "exact":
|
||||
try:
|
||||
expected: Any = json.loads(assertion.value)
|
||||
except json.JSONDecodeError:
|
||||
expected = assertion.value
|
||||
if actual != expected and str(actual) != assertion.value:
|
||||
return False, f"参数 {assertion.name} 与期望值不一致"
|
||||
elif assertion.match_mode == "regex":
|
||||
actual_text = (
|
||||
actual
|
||||
if isinstance(actual, str)
|
||||
else json.dumps(actual, ensure_ascii=False, default=str)
|
||||
)
|
||||
if re.search(assertion.value, actual_text) is None:
|
||||
return False, f"参数 {assertion.name} 未匹配正则表达式"
|
||||
else:
|
||||
passed, reason, _actual = await self._judge.evaluate(
|
||||
criteria=assertion.value,
|
||||
subject=f"工具参数 {assertion.name}",
|
||||
context=json.dumps(actual, ensure_ascii=False, default=str),
|
||||
)
|
||||
if not passed:
|
||||
return False, reason
|
||||
return True, ""
|
||||
|
||||
async def _evaluate_overall(
|
||||
self,
|
||||
criterion: OverallCriterion,
|
||||
transcript: str,
|
||||
) -> BatchEvaluationResult:
|
||||
passed, reason, actual = await self._judge.evaluate(
|
||||
criteria=criterion.criteria,
|
||||
subject="完整测试对话",
|
||||
context=transcript,
|
||||
)
|
||||
return BatchEvaluationResult(
|
||||
id=criterion.id,
|
||||
label=criterion.name,
|
||||
kind="overall",
|
||||
status="pass" if passed else "fail",
|
||||
expected=criterion.criteria,
|
||||
actual=actual or "已评估完整对话",
|
||||
reason="" if passed else reason,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_transcript(
|
||||
execution: TextCaseResult,
|
||||
definition: TestCaseDefinition,
|
||||
) -> str:
|
||||
if execution.transcript:
|
||||
labels = {"user": "User", "assistant": "Agent"}
|
||||
return "\n".join(
|
||||
f"{labels.get(item['role'], item['role'])}: {item['content']}"
|
||||
for item in execution.transcript
|
||||
)
|
||||
raw_by_id = {turn.id: turn for turn in execution.turns}
|
||||
lines: list[str] = []
|
||||
for turn in definition.turns:
|
||||
raw = raw_by_id.get(turn.id)
|
||||
lines.append(f"User: {turn.user_input}")
|
||||
lines.append(f"Agent: {raw.assistant_reply if raw else ''}")
|
||||
return "\n".join(lines)
|
||||
85
backend/services/test_runs/mock_tools.py
Normal file
85
backend/services/test_runs/mock_tools.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Test-only tool executor that never reaches external side effects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from models import RuntimeTool
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||
from test_schemas import ToolCallExpectedBehavior
|
||||
|
||||
|
||||
class MockToolExecutor(ToolExecutor):
|
||||
"""Resolve the active turn's configured mocks and reject everything else."""
|
||||
|
||||
def __init__(self, store: DynamicVariableStore):
|
||||
super().__init__(store)
|
||||
self._mocks_by_tool_id: dict[str, ToolCallExpectedBehavior] = {}
|
||||
self._mocks_by_function: dict[str, ToolCallExpectedBehavior] = {}
|
||||
self.unmocked_calls: list[str] = []
|
||||
|
||||
def set_turn_behaviors(
|
||||
self,
|
||||
behaviors: list[ToolCallExpectedBehavior],
|
||||
) -> None:
|
||||
self._mocks_by_tool_id = {item.tool_id: item for item in behaviors}
|
||||
self._mocks_by_function = {item.function_name: item for item in behaviors}
|
||||
self.unmocked_calls = []
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: RuntimeTool,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
result_assignments: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.register_secrets(tool)
|
||||
behavior = self._mocks_by_tool_id.get(tool.id) or self._mocks_by_function.get(
|
||||
tool.function_name
|
||||
)
|
||||
if behavior is None:
|
||||
self.unmocked_calls.append(tool.function_name)
|
||||
raise ToolExecutionError(
|
||||
f"UNMOCKED_TOOL_CALL: 工具 {tool.function_name} 未配置 Mock 返回值"
|
||||
)
|
||||
|
||||
delay_seconds = behavior.mock_response.delay_ms / 1000
|
||||
if delay_seconds:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
payload = json.loads(behavior.mock_response.body)
|
||||
status = "ok" if behavior.mock_response.outcome == "success" else "error"
|
||||
if isinstance(payload, dict):
|
||||
result: dict[str, Any] = dict(payload)
|
||||
result["status"] = status
|
||||
else:
|
||||
result = {"status": status, "data": payload}
|
||||
if result.get("status") != "ok":
|
||||
return {**result, "updated_variables": []}
|
||||
return self._apply_result_assignments(
|
||||
tool,
|
||||
result,
|
||||
result_assignments=result_assignments,
|
||||
)
|
||||
|
||||
|
||||
class TextClientToolPort:
|
||||
"""Automatically acknowledge built-in message stages in text tests."""
|
||||
|
||||
async def call(
|
||||
self,
|
||||
function_name: str,
|
||||
arguments: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
if function_name == "show_message":
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {"action": "confirmed", "arguments": arguments},
|
||||
}
|
||||
raise ToolExecutionError(
|
||||
f"UNMOCKED_CLIENT_TOOL: 客户端工具 {function_name} 不能脱离 Mock 执行"
|
||||
)
|
||||
410
backend/services/test_runs/orchestrator.py
Normal file
410
backend/services/test_runs/orchestrator.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""In-process MVP scheduler for persisted batch test runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.models import ModelResource, TestCase, TestRun, TestRunCase
|
||||
from db.session import SessionLocal
|
||||
from models import RuntimeModelResource
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.test_runs.errors import TestExecutionError
|
||||
from services.test_runs.evaluator import EvaluationEngine
|
||||
from services.test_runs.text_runner import TextPipelineRunner
|
||||
from test_schemas import TestCaseDefinition
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RunControl:
|
||||
stop_event: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
reason: str | None = None
|
||||
case_tasks: set[asyncio.Task[None]] = field(default_factory=set)
|
||||
|
||||
|
||||
class TestRunOrchestrator:
|
||||
"""Run cases concurrently while keeping PostgreSQL as the source of truth."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._run_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._controls: dict[str, _RunControl] = {}
|
||||
|
||||
def start(self, run_id: str) -> None:
|
||||
current = self._run_tasks.get(run_id)
|
||||
if current is not None and not current.done():
|
||||
return
|
||||
control = _RunControl()
|
||||
self._controls[run_id] = control
|
||||
task = asyncio.create_task(self._execute_run(run_id, control), name=f"test-run-{run_id}")
|
||||
self._run_tasks[run_id] = task
|
||||
task.add_done_callback(lambda _task: self._forget(run_id))
|
||||
|
||||
async def cancel(self, run_id: str) -> None:
|
||||
control = self._controls.get(run_id)
|
||||
if control is None:
|
||||
return
|
||||
control.reason = "manual"
|
||||
control.stop_event.set()
|
||||
for task in tuple(control.case_tasks):
|
||||
task.cancel()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
tasks = [task for task in self._run_tasks.values() if not task.done()]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._run_tasks.clear()
|
||||
self._controls.clear()
|
||||
|
||||
def _forget(self, run_id: str) -> None:
|
||||
self._run_tasks.pop(run_id, None)
|
||||
self._controls.pop(run_id, None)
|
||||
|
||||
async def _execute_run(self, run_id: str, control: _RunControl) -> None:
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is None:
|
||||
return
|
||||
run.status = "running"
|
||||
run.started_at = run.started_at or datetime.now(UTC)
|
||||
await session.commit()
|
||||
try:
|
||||
cfg = await resolve_runtime_config(session, str(run.assistant_id or ""))
|
||||
except Exception as exc:
|
||||
await self._fail_run_setup(run_id, str(exc))
|
||||
return
|
||||
config = dict(run.config or {})
|
||||
evaluator_resource_id = str(
|
||||
config.get("evaluatorModelResourceId") or ""
|
||||
)
|
||||
evaluator_row = await session.get(
|
||||
ModelResource,
|
||||
evaluator_resource_id,
|
||||
)
|
||||
if (
|
||||
evaluator_row is None
|
||||
or evaluator_row.capability != "LLM"
|
||||
or not evaluator_row.enabled
|
||||
):
|
||||
await self._fail_run_setup(
|
||||
run_id,
|
||||
"评估模型不存在、已停用或不是 LLM 资源",
|
||||
)
|
||||
return
|
||||
evaluator_resource = RuntimeModelResource(
|
||||
id=evaluator_row.id,
|
||||
name=evaluator_row.name,
|
||||
capability=evaluator_row.capability,
|
||||
interface_type=evaluator_row.interface_type,
|
||||
values=evaluator_row.values or {},
|
||||
secrets=evaluator_row.secrets or {},
|
||||
support_image_input=bool(evaluator_row.support_image_input),
|
||||
)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(TestRunCase)
|
||||
.where(TestRunCase.run_id == run_id)
|
||||
.order_by(TestRunCase.position)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
semaphore = asyncio.Semaphore(int(config.get("concurrency") or 3))
|
||||
tasks = {
|
||||
asyncio.create_task(
|
||||
self._execute_case(
|
||||
run_id,
|
||||
row.id,
|
||||
cfg,
|
||||
evaluator_resource,
|
||||
config,
|
||||
semaphore,
|
||||
control,
|
||||
),
|
||||
name=f"test-run-case-{row.id}",
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
control.case_tasks = tasks
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await self._finish_run(run_id, control)
|
||||
except asyncio.CancelledError:
|
||||
await self._interrupt_run(run_id)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - background task must persist failure
|
||||
logger.exception(f"批量测试运行失败: run_id={run_id}: {exc}")
|
||||
await self._fail_run_setup(run_id, str(exc))
|
||||
|
||||
async def _execute_case(
|
||||
self,
|
||||
run_id: str,
|
||||
run_case_id: str,
|
||||
cfg,
|
||||
evaluator_resource: RuntimeModelResource,
|
||||
config: dict,
|
||||
semaphore: asyncio.Semaphore,
|
||||
control: _RunControl,
|
||||
) -> None:
|
||||
async with semaphore:
|
||||
if control.stop_event.is_set():
|
||||
await self._mark_skipped(run_case_id)
|
||||
return
|
||||
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(TestRunCase, run_case_id)
|
||||
if row is None:
|
||||
return
|
||||
snapshot = dict(row.case_snapshot or {})
|
||||
definition = TestCaseDefinition.model_validate(snapshot)
|
||||
max_attempts = row.max_attempts
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
if control.stop_event.is_set():
|
||||
await self._mark_skipped(run_case_id)
|
||||
return
|
||||
await self._mark_running(run_case_id, attempt)
|
||||
try:
|
||||
timeout_seconds = int(
|
||||
config.get("timeoutSecs")
|
||||
or config.get("timeout_secs")
|
||||
or 60
|
||||
)
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
execution = await TextPipelineRunner().run(
|
||||
cfg.model_copy(deep=True),
|
||||
definition,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
evaluated = await EvaluationEngine(evaluator_resource).evaluate(
|
||||
definition,
|
||||
execution,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_skipped(run_case_id)
|
||||
raise
|
||||
except TimeoutError:
|
||||
error = TestExecutionError(
|
||||
code="CASE_TIMEOUT",
|
||||
message=f"测试用例执行与评估超过 {timeout_seconds} 秒",
|
||||
stage="pipeline",
|
||||
retryable=True,
|
||||
)
|
||||
if attempt < max_attempts and not control.stop_event.is_set():
|
||||
continue
|
||||
await self._mark_error(run_case_id, error)
|
||||
if str(
|
||||
config.get("errorStrategy")
|
||||
or config.get("error_strategy")
|
||||
) == "stop_on_error":
|
||||
await self._request_stop(run_id, control, "execution_error")
|
||||
return
|
||||
except TestExecutionError as exc:
|
||||
if exc.retryable and attempt < max_attempts and not control.stop_event.is_set():
|
||||
continue
|
||||
await self._mark_error(run_case_id, exc)
|
||||
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
|
||||
await self._request_stop(run_id, control, "execution_error")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - normalize unknown runtime errors
|
||||
error = TestExecutionError(
|
||||
code="UNEXPECTED_EXECUTION_ERROR",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
stage="pipeline",
|
||||
retryable=False,
|
||||
)
|
||||
await self._mark_error(run_case_id, error)
|
||||
if str(config.get("errorStrategy") or config.get("error_strategy")) == "stop_on_error":
|
||||
await self._request_stop(run_id, control, "execution_error")
|
||||
return
|
||||
|
||||
status = "pass" if evaluated.passed else "fail"
|
||||
await self._mark_evaluated(run_case_id, status, evaluated)
|
||||
if status == "fail" and str(
|
||||
config.get("failureStrategy") or config.get("failure_strategy")
|
||||
) == "stop_on_fail":
|
||||
await self._request_stop(run_id, control, "assertion_failure")
|
||||
return
|
||||
|
||||
async def _request_stop(
|
||||
self,
|
||||
run_id: str,
|
||||
control: _RunControl,
|
||||
reason: str,
|
||||
) -> None:
|
||||
if control.reason is None:
|
||||
control.reason = reason
|
||||
control.stop_event.set()
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is not None and run.stop_reason is None:
|
||||
run.stop_reason = reason
|
||||
await session.commit()
|
||||
|
||||
async def _mark_running(self, run_case_id: str, attempt: int) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(TestRunCase, run_case_id)
|
||||
if row is None:
|
||||
return
|
||||
row.status = "running"
|
||||
row.attempt_count = attempt
|
||||
row.started_at = row.started_at or datetime.now(UTC)
|
||||
row.finished_at = None
|
||||
row.execution_error = None
|
||||
await session.commit()
|
||||
|
||||
async def _mark_evaluated(self, run_case_id: str, status: str, evaluated) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(TestRunCase, run_case_id)
|
||||
if row is None:
|
||||
return
|
||||
row.status = status
|
||||
row.result = {
|
||||
"turns": [
|
||||
item.model_dump(mode="json", by_alias=True)
|
||||
for item in evaluated.turns
|
||||
],
|
||||
"overallCriteria": [
|
||||
item.model_dump(mode="json", by_alias=True)
|
||||
for item in evaluated.overall_criteria
|
||||
],
|
||||
}
|
||||
row.execution_error = None
|
||||
row.finished_at = datetime.now(UTC)
|
||||
if row.test_case_id:
|
||||
test_case = await session.get(TestCase, row.test_case_id)
|
||||
if test_case is not None:
|
||||
test_case.last_result = status
|
||||
await session.commit()
|
||||
|
||||
async def _mark_error(
|
||||
self,
|
||||
run_case_id: str,
|
||||
error: TestExecutionError,
|
||||
) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(TestRunCase, run_case_id)
|
||||
if row is None:
|
||||
return
|
||||
row.status = "error"
|
||||
row.execution_error = error.as_dict()
|
||||
row.finished_at = datetime.now(UTC)
|
||||
if row.test_case_id:
|
||||
test_case = await session.get(TestCase, row.test_case_id)
|
||||
if test_case is not None:
|
||||
test_case.last_result = "fail"
|
||||
await session.commit()
|
||||
|
||||
async def _mark_skipped(self, run_case_id: str) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(TestRunCase, run_case_id)
|
||||
if row is None or row.status in {"pass", "fail", "error", "skipped"}:
|
||||
return
|
||||
row.status = "skipped"
|
||||
row.finished_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
async def _finish_run(self, run_id: str, control: _RunControl) -> None:
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is None:
|
||||
return
|
||||
waiting = (
|
||||
await session.execute(
|
||||
select(TestRunCase).where(
|
||||
TestRunCase.run_id == run_id,
|
||||
TestRunCase.status.in_(["waiting", "running"]),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for row in waiting:
|
||||
row.status = "skipped"
|
||||
row.finished_at = datetime.now(UTC)
|
||||
manual = bool(run.cancel_requested or control.reason == "manual")
|
||||
run.status = "cancelled" if manual else "completed"
|
||||
run.stop_reason = "manual" if manual else control.reason or run.stop_reason
|
||||
run.finished_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
async def _fail_run_setup(self, run_id: str, message: str) -> None:
|
||||
error = TestExecutionError(
|
||||
code="RUN_SETUP_FAILED",
|
||||
message=message or "批量测试初始化失败",
|
||||
stage="pipeline",
|
||||
retryable=False,
|
||||
)
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is None:
|
||||
return
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(TestRunCase).where(
|
||||
TestRunCase.run_id == run_id,
|
||||
TestRunCase.status.in_(["waiting", "running"]),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
now = datetime.now(UTC)
|
||||
for row in rows:
|
||||
row.status = "error"
|
||||
row.execution_error = error.as_dict()
|
||||
row.finished_at = now
|
||||
run.status = "completed"
|
||||
run.stop_reason = "execution_error"
|
||||
run.finished_at = now
|
||||
await session.commit()
|
||||
|
||||
async def _interrupt_run(self, run_id: str) -> None:
|
||||
await _mark_run_interrupted(run_id, "后端服务停止,运行已中断")
|
||||
|
||||
|
||||
async def _mark_run_interrupted(run_id: str, message: str) -> None:
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(TestRun, run_id)
|
||||
if run is None or run.status not in {"queued", "running"}:
|
||||
return
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(TestRunCase).where(
|
||||
TestRunCase.run_id == run_id,
|
||||
TestRunCase.status.in_(["waiting", "running"]),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
now = datetime.now(UTC)
|
||||
for row in rows:
|
||||
if row.status == "running":
|
||||
row.status = "error"
|
||||
row.execution_error = {
|
||||
"code": "SERVER_RESTARTED",
|
||||
"message": message,
|
||||
"stage": "pipeline",
|
||||
"retryable": True,
|
||||
}
|
||||
else:
|
||||
row.status = "skipped"
|
||||
row.finished_at = now
|
||||
run.status = "completed"
|
||||
run.stop_reason = "execution_error"
|
||||
run.finished_at = now
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def recover_interrupted_test_runs() -> None:
|
||||
async with SessionLocal() as session:
|
||||
run_ids = (
|
||||
await session.execute(
|
||||
select(TestRun.id).where(TestRun.status.in_(["queued", "running"]))
|
||||
)
|
||||
).scalars().all()
|
||||
for run_id in run_ids:
|
||||
await _mark_run_interrupted(str(run_id), "后端服务重启,运行已中断")
|
||||
|
||||
|
||||
test_run_orchestrator = TestRunOrchestrator()
|
||||
687
backend/services/test_runs/text_runner.py
Normal file
687
backend/services/test_runs/text_runner.py
Normal file
@@ -0,0 +1,687 @@
|
||||
"""Transportless Pipecat runner for one persisted fixed-text test case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.frames.frames import (
|
||||
BotStoppedSpeakingFrame,
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
ErrorFrame,
|
||||
FunctionCallInProgressFrame,
|
||||
FunctionCallResultFrame,
|
||||
LLMContextFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMMessagesAppendFrame,
|
||||
ManuallySwitchServiceFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.services.llm_service import FunctionCallParams
|
||||
from pipecat.workers.runner import WorkerRunner
|
||||
|
||||
from db.session import SessionLocal
|
||||
from services.brains import BrainRuntime, build_brain
|
||||
from services.knowledge import search as search_knowledge
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
FixedSpeechPlaybackMarkerFrame,
|
||||
)
|
||||
from services.pipecat.processors import (
|
||||
KnowledgeRetrievalProcessor,
|
||||
UserTurnRoutingProcessor,
|
||||
)
|
||||
from services.pipecat.service_factory import config_with_resource
|
||||
from services.pipecat.workflow_services import build_workflow_llm_switcher
|
||||
from services.test_runs.errors import TestExecutionError
|
||||
from services.test_runs.mock_tools import MockToolExecutor, TextClientToolPort
|
||||
from test_schemas import ContextTurn, FixedInputTurn, TestCaseDefinition
|
||||
|
||||
|
||||
IDLE_SETTLE_SECONDS = 0.2
|
||||
WORKER_START_TIMEOUT_SECONDS = 5.0
|
||||
INTERNAL_CANCEL_REASON = "text_test_case_complete"
|
||||
AUTOMATIC_KNOWLEDGE_HINT = (
|
||||
"你已连接内部知识库。系统会在每轮用户问题前自动提供相关资料;"
|
||||
"回答资料事实时只根据检索内容,资料不足要明确说明。"
|
||||
)
|
||||
ON_DEMAND_KNOWLEDGE_HINT = (
|
||||
"你已连接内部知识库。当用户问题涉及可能存在于业务知识库中的事实时,"
|
||||
"先调用 search_knowledge_base 检索;回答资料事实时只根据检索内容,"
|
||||
"资料不足要明确说明。"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawToolCall:
|
||||
id: str
|
||||
function_name: str
|
||||
arguments: Any
|
||||
result: Any = None
|
||||
outcome: str = "error"
|
||||
duration_ms: int = 0
|
||||
_started_at: float = field(default_factory=time.monotonic, repr=False)
|
||||
|
||||
def as_result(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"functionName": self.function_name,
|
||||
"argumentsJson": json.dumps(
|
||||
self.arguments, ensure_ascii=False, indent=2, default=str
|
||||
),
|
||||
"resultJson": json.dumps(
|
||||
self.result, ensure_ascii=False, indent=2, default=str
|
||||
),
|
||||
"outcome": self.outcome,
|
||||
"durationMs": self.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawTurnResult:
|
||||
id: str
|
||||
index: int
|
||||
user_input: str
|
||||
assistant_reply: str
|
||||
tool_calls: list[RawToolCall]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextCaseResult:
|
||||
turns: list[RawTurnResult]
|
||||
transcript: list[dict[str, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ResponseWindow:
|
||||
active_llm: int = 0
|
||||
active_assistant: int = 0
|
||||
pending_tools: set[str] = field(default_factory=set)
|
||||
activity_count: int = 0
|
||||
last_activity_at: float = field(default_factory=time.monotonic)
|
||||
|
||||
def touch(self) -> None:
|
||||
self.activity_count += 1
|
||||
self.last_activity_at = time.monotonic()
|
||||
|
||||
@property
|
||||
def idle(self) -> bool:
|
||||
return self.active_llm == 0 and self.active_assistant == 0 and not self.pending_tools
|
||||
|
||||
|
||||
class _TextCaptureProcessor(FrameProcessor):
|
||||
"""Capture assistant/tool output and expose a reliable response boundary."""
|
||||
|
||||
def __init__(self, context: LLMContext):
|
||||
super().__init__()
|
||||
self.context = context
|
||||
self.window = _ResponseWindow()
|
||||
self.outputs: list[str] = []
|
||||
self.tool_calls: list[RawToolCall] = []
|
||||
self._tool_by_id: dict[str, RawToolCall] = {}
|
||||
self.error: TestExecutionError | None = None
|
||||
self.on_fixed_speech_complete: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if isinstance(frame, TTSSpeakFrame):
|
||||
text = frame.text.strip()
|
||||
if text:
|
||||
self.outputs.append(text)
|
||||
if frame.append_to_context:
|
||||
self.context.add_message({"role": "assistant", "content": text})
|
||||
self.window.touch()
|
||||
if self.on_fixed_speech_complete is not None:
|
||||
await self.on_fixed_speech_complete()
|
||||
return
|
||||
|
||||
if isinstance(frame, FixedSpeechPlaybackMarkerFrame):
|
||||
await frame.completion.mark_played()
|
||||
self.window.touch()
|
||||
return
|
||||
|
||||
if isinstance(frame, LLMFullResponseStartFrame):
|
||||
self.window.active_llm += 1
|
||||
self.window.touch()
|
||||
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||
self.window.active_llm = max(0, self.window.active_llm - 1)
|
||||
self.window.touch()
|
||||
await self.push_frame(frame, direction)
|
||||
# There is no TTS/output transport. This releases provider/tool
|
||||
# follow-up logic that normally waits for a speaking boundary.
|
||||
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
|
||||
return
|
||||
elif isinstance(frame, FunctionCallInProgressFrame):
|
||||
call = RawToolCall(
|
||||
id=frame.tool_call_id,
|
||||
function_name=frame.function_name,
|
||||
arguments=frame.arguments or {},
|
||||
)
|
||||
self.tool_calls.append(call)
|
||||
self._tool_by_id[call.id] = call
|
||||
self.window.pending_tools.add(call.id)
|
||||
self.window.touch()
|
||||
elif isinstance(frame, FunctionCallResultFrame):
|
||||
call = self._tool_by_id.get(frame.tool_call_id)
|
||||
if call is None:
|
||||
call = RawToolCall(
|
||||
id=frame.tool_call_id,
|
||||
function_name=frame.function_name,
|
||||
arguments=frame.arguments or {},
|
||||
)
|
||||
self.tool_calls.append(call)
|
||||
self._tool_by_id[call.id] = call
|
||||
call.result = frame.result
|
||||
call.duration_ms = max(0, round((time.monotonic() - call._started_at) * 1000))
|
||||
status = frame.result.get("status") if isinstance(frame.result, dict) else None
|
||||
call.outcome = "error" if status in {"error", "failed", "timeout"} else "success"
|
||||
self.window.pending_tools.discard(call.id)
|
||||
self.window.touch()
|
||||
elif isinstance(frame, ErrorFrame):
|
||||
self.error = TestExecutionError(
|
||||
code="PIPELINE_ERROR",
|
||||
message=frame.error or "Pipeline 执行失败",
|
||||
stage="model" if frame.processor else "pipeline",
|
||||
retryable=True,
|
||||
)
|
||||
self.window.touch()
|
||||
elif isinstance(frame, (EndFrame, CancelFrame)):
|
||||
self.window.touch()
|
||||
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
def _context_messages(context_turns: list[ContextTurn]) -> list[dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
latest_call_by_name: dict[str, str] = {}
|
||||
for index, turn in enumerate(context_turns):
|
||||
if turn.role in {"agent", "user"}:
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant" if turn.role == "agent" else "user",
|
||||
"content": turn.content,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
call_id = turn.tool_call_id or latest_call_by_name.get(turn.tool_name or "")
|
||||
call_id = call_id or f"context_tool_{index}_{uuid4().hex[:8]}"
|
||||
if turn.role == "tool_call":
|
||||
latest_call_by_name[turn.tool_name or ""] = call_id
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": turn.tool_name,
|
||||
"arguments": json.dumps(
|
||||
json.loads(turn.content), ensure_ascii=False
|
||||
),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
else:
|
||||
result_payload = json.loads(turn.content)
|
||||
if turn.is_error:
|
||||
result_payload = (
|
||||
{**result_payload, "status": "error"}
|
||||
if isinstance(result_payload, dict)
|
||||
else {"status": "error", "data": result_payload}
|
||||
)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"content": json.dumps(result_payload, ensure_ascii=False),
|
||||
}
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
async def _wait_for_quiescence(
|
||||
capture: _TextCaptureProcessor,
|
||||
runner_task: asyncio.Task[None],
|
||||
activity_marker: int,
|
||||
*,
|
||||
require_activity: bool = True,
|
||||
) -> None:
|
||||
while True:
|
||||
if capture.error is not None:
|
||||
raise capture.error
|
||||
if runner_task.done():
|
||||
await runner_task
|
||||
return
|
||||
has_activity = capture.window.activity_count > activity_marker
|
||||
settled = time.monotonic() - capture.window.last_activity_at >= IDLE_SETTLE_SECONDS
|
||||
if capture.window.idle and settled and (has_activity or not require_activity):
|
||||
return
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
class TextPipelineRunner:
|
||||
"""Execute all fixed input turns in one isolated, transportless pipeline."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
cfg: AssistantConfig,
|
||||
definition: TestCaseDefinition,
|
||||
*,
|
||||
timeout_seconds: int,
|
||||
) -> TextCaseResult:
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
return await self._run(cfg, definition)
|
||||
except TimeoutError as exc:
|
||||
raise TestExecutionError(
|
||||
code="PIPELINE_TIMEOUT",
|
||||
message=f"测试用例执行超过 {timeout_seconds} 秒",
|
||||
stage="pipeline",
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
cfg: AssistantConfig,
|
||||
definition: TestCaseDefinition,
|
||||
) -> TextCaseResult:
|
||||
if cfg.runtimeMode != "pipeline":
|
||||
raise TestExecutionError(
|
||||
code="UNSUPPORTED_RUNTIME_MODE",
|
||||
message="第一版文本测试只支持 Pipeline 运行模式",
|
||||
stage="pipeline",
|
||||
)
|
||||
if cfg.type not in {"prompt", "workflow"}:
|
||||
raise TestExecutionError(
|
||||
code="UNSUPPORTED_ASSISTANT_TYPE",
|
||||
message=f"第一版文本测试暂不支持 {cfg.type} 类型助手",
|
||||
stage="pipeline",
|
||||
)
|
||||
|
||||
brain = build_brain(cfg)
|
||||
seed_messages = _context_messages(definition.context_turns)
|
||||
knowledge_config = cfg.knowledge_retrieval_config or {}
|
||||
knowledge_mode = str(knowledge_config.get("mode") or "automatic")
|
||||
|
||||
def with_knowledge_hint(text: str) -> str:
|
||||
if cfg.type != "prompt" or not cfg.knowledge_base_id:
|
||||
return text
|
||||
hint = (
|
||||
AUTOMATIC_KNOWLEDGE_HINT
|
||||
if knowledge_mode == "automatic"
|
||||
else ON_DEMAND_KNOWLEDGE_HINT
|
||||
)
|
||||
return "\n\n".join(part for part in (text, hint) if part)
|
||||
|
||||
system_prompt = with_knowledge_hint(brain.system_prompt(cfg))
|
||||
context = LLMContext(
|
||||
messages=(
|
||||
seed_messages
|
||||
if cfg.type == "workflow"
|
||||
else [{"role": "system", "content": system_prompt}, *seed_messages]
|
||||
)
|
||||
)
|
||||
|
||||
graph_settings = cfg.graph.get("settings") or {}
|
||||
default_resource = cfg.workflow_model_resources.get(
|
||||
str(graph_settings.get("defaultLlmResourceId") or "")
|
||||
)
|
||||
llm_cfg = (
|
||||
config_with_resource(cfg, default_resource)
|
||||
if cfg.type == "workflow" and default_resource is not None
|
||||
else cfg
|
||||
)
|
||||
llm = brain.build_llm(llm_cfg, context)
|
||||
llm_services: dict[str, FrameProcessor] = {}
|
||||
current_llm_service: FrameProcessor = llm
|
||||
if cfg.type == "workflow":
|
||||
llm, llm_services, current_llm_service = build_workflow_llm_switcher(cfg, llm)
|
||||
|
||||
aggregators = LLMContextAggregatorPair(context)
|
||||
user_aggregator = aggregators.user()
|
||||
assistant_aggregator = aggregators.assistant()
|
||||
capture = _TextCaptureProcessor(context)
|
||||
knowledge = KnowledgeRetrievalProcessor(
|
||||
cfg.knowledge_base_id if knowledge_mode == "automatic" else None,
|
||||
top_n=int(knowledge_config.get("top_n", knowledge_config.get("topN", 5))),
|
||||
score_threshold=float(
|
||||
knowledge_config.get(
|
||||
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
|
||||
)
|
||||
),
|
||||
)
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
user_aggregator,
|
||||
UserTurnRoutingProcessor(brain),
|
||||
knowledge,
|
||||
llm,
|
||||
capture,
|
||||
assistant_aggregator,
|
||||
]
|
||||
)
|
||||
worker = PipelineWorker(
|
||||
pipeline,
|
||||
params=PipelineParams(enable_metrics=False),
|
||||
enable_rtvi=False,
|
||||
enable_turn_tracking=False,
|
||||
idle_timeout_secs=None,
|
||||
)
|
||||
runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
|
||||
worker_started = asyncio.Event()
|
||||
|
||||
@worker.event_handler("on_pipeline_started")
|
||||
async def on_pipeline_started(_worker, _frame):
|
||||
worker_started.set()
|
||||
|
||||
executor_holder: dict[str, MockToolExecutor] = {}
|
||||
|
||||
def create_test_executor(store) -> MockToolExecutor:
|
||||
executor = MockToolExecutor(store)
|
||||
executor_holder["executor"] = executor
|
||||
return executor
|
||||
|
||||
async def queue_call_end(reason: str) -> None:
|
||||
logger.debug(f"文本测试收到会话结束请求: {reason}")
|
||||
await worker.queue_frame(EndFrame(reason=reason))
|
||||
|
||||
call_end = CallEndCoordinator(queue_call_end)
|
||||
|
||||
async def finish_fixed_speech_if_ending() -> None:
|
||||
if call_end.ending:
|
||||
await call_end.finish()
|
||||
|
||||
capture.on_fixed_speech_complete = finish_fixed_speech_if_ending
|
||||
current_service = current_llm_service
|
||||
|
||||
async def switch_services(
|
||||
llm_resource_id: str | None,
|
||||
_asr_resource_id: str | None,
|
||||
_tts_resource_id: str | None,
|
||||
) -> None:
|
||||
nonlocal current_service
|
||||
target = (
|
||||
llm_services.get(llm_resource_id)
|
||||
if llm_resource_id
|
||||
else current_llm_service
|
||||
)
|
||||
if target is None:
|
||||
raise ValueError(f"Workflow LLM 资源未加载:{llm_resource_id}")
|
||||
if target is current_service:
|
||||
return
|
||||
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
|
||||
current_service = target
|
||||
|
||||
def set_system_prompt(text: str) -> None:
|
||||
messages = context.get_messages()
|
||||
text = with_knowledge_hint(text)
|
||||
if messages and messages[0].get("role") == "system":
|
||||
messages[0] = {"role": "system", "content": text}
|
||||
else:
|
||||
messages.insert(0, {"role": "system", "content": text})
|
||||
|
||||
knowledge_schema: FunctionSchema | None = None
|
||||
if (
|
||||
cfg.type == "prompt"
|
||||
and cfg.knowledge_base_id
|
||||
and knowledge_mode == "on_demand"
|
||||
):
|
||||
knowledge_schema = FunctionSchema(
|
||||
name="search_knowledge_base",
|
||||
description=(
|
||||
"在当前助手绑定的知识库中检索资料。"
|
||||
f"知识库:{cfg.knowledge_base_name}。"
|
||||
f"{cfg.knowledge_base_description}"
|
||||
),
|
||||
properties={
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "完整问题或检索关键词",
|
||||
}
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
|
||||
async def search_bound_knowledge(params: FunctionCallParams) -> None:
|
||||
query = str(params.arguments.get("query") or "").strip()
|
||||
if not query:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": "检索问题为空"}
|
||||
)
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
results = await search_knowledge(
|
||||
session,
|
||||
cfg.knowledge_base_id or "",
|
||||
query,
|
||||
top_k=int(
|
||||
knowledge_config.get(
|
||||
"top_n",
|
||||
knowledge_config.get("topN", 5),
|
||||
)
|
||||
),
|
||||
score_threshold=float(
|
||||
knowledge_config.get(
|
||||
"score_threshold",
|
||||
knowledge_config.get("scoreThreshold", 0.0),
|
||||
)
|
||||
),
|
||||
)
|
||||
await params.result_callback(
|
||||
{"status": "ok", "results": results}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - return tool errors to LLM
|
||||
logger.warning(f"文本测试知识库检索失败: {exc}")
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": "知识库检索暂时不可用"}
|
||||
)
|
||||
|
||||
llm.register_function("search_knowledge_base", search_bound_knowledge)
|
||||
|
||||
def set_tools(schemas=None) -> None:
|
||||
visible = list(schemas or [])
|
||||
if knowledge_schema is not None:
|
||||
visible.append(knowledge_schema)
|
||||
if visible:
|
||||
context.set_tools(ToolsSchema(standard_tools=visible))
|
||||
else:
|
||||
context.set_tools()
|
||||
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=context,
|
||||
llm=llm,
|
||||
queue_frame=worker.queue_frame,
|
||||
set_system_prompt=set_system_prompt,
|
||||
set_tools=set_tools,
|
||||
call_end=call_end,
|
||||
session_id=f"test_{uuid4().hex}",
|
||||
client_tools=TextClientToolPort(),
|
||||
worker=worker,
|
||||
context_aggregator=aggregators,
|
||||
transport=None,
|
||||
switch_services=switch_services,
|
||||
set_knowledge_scope=knowledge.set_scope,
|
||||
set_vision_scope=lambda _scope: None,
|
||||
vision_function=None,
|
||||
set_input_enabled=lambda _enabled: None,
|
||||
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
|
||||
flow_global_functions=[],
|
||||
tool_executor_factory=create_test_executor,
|
||||
),
|
||||
)
|
||||
executor = executor_holder["executor"]
|
||||
|
||||
@assistant_aggregator.event_handler("on_assistant_turn_started")
|
||||
async def on_assistant_turn_started(_aggregator):
|
||||
capture.window.active_assistant += 1
|
||||
capture.window.touch()
|
||||
await brain.on_assistant_text_start(uuid4().hex)
|
||||
|
||||
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
|
||||
async def on_assistant_turn_stopped(_aggregator, message):
|
||||
content = str(message.content or "").strip()
|
||||
capture.window.active_assistant = max(
|
||||
0, capture.window.active_assistant - 1
|
||||
)
|
||||
if content:
|
||||
capture.outputs.append(content)
|
||||
capture.window.touch()
|
||||
await brain.on_assistant_text_end(
|
||||
uuid4().hex,
|
||||
content,
|
||||
bool(getattr(message, "interrupted", False)),
|
||||
)
|
||||
if call_end.ending:
|
||||
# Text tests have no audio transport. A fully aggregated
|
||||
# response is therefore the equivalent of playback ending.
|
||||
await call_end.finish()
|
||||
|
||||
all_tool_behaviors = [
|
||||
behavior
|
||||
for turn in definition.turns
|
||||
for behavior in turn.behaviors
|
||||
if behavior.type == "tool_call"
|
||||
]
|
||||
executor.set_turn_behaviors(all_tool_behaviors)
|
||||
|
||||
runner_task: asyncio.Task[None] | None = None
|
||||
try:
|
||||
await brain.run_preflight()
|
||||
if executor.unmocked_calls:
|
||||
raise TestExecutionError(
|
||||
code="UNMOCKED_TOOL_CALL",
|
||||
message="启动阶段调用了未配置 Mock 的工具: "
|
||||
+ "、".join(executor.unmocked_calls),
|
||||
stage="tool",
|
||||
)
|
||||
|
||||
await runner.add_workers(worker)
|
||||
runner_task = asyncio.create_task(
|
||||
runner.run(), name=f"text-test-{uuid4().hex[:12]}"
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
worker_started.wait(), timeout=WORKER_START_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
marker = capture.window.activity_count
|
||||
await brain.on_connected(greeting_pending=False)
|
||||
await brain.on_client_ready()
|
||||
await _wait_for_quiescence(
|
||||
capture,
|
||||
runner_task,
|
||||
marker,
|
||||
require_activity=False,
|
||||
)
|
||||
# Startup messages initialize the session but are not a scripted
|
||||
# user turn result in the fixed-input editor contract.
|
||||
capture.outputs.clear()
|
||||
capture.tool_calls.clear()
|
||||
|
||||
results: list[RawTurnResult] = []
|
||||
for index, turn in enumerate(definition.turns):
|
||||
if worker.has_finished():
|
||||
raise TestExecutionError(
|
||||
code="PIPELINE_ENDED_EARLY",
|
||||
message=f"Pipeline 在第 {index + 1} 轮输入前已经结束",
|
||||
stage="pipeline",
|
||||
retryable=False,
|
||||
)
|
||||
result = await self._run_turn(
|
||||
worker,
|
||||
runner_task,
|
||||
capture,
|
||||
executor,
|
||||
turn,
|
||||
index,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
transcript = [
|
||||
{
|
||||
"role": str(message.get("role") or ""),
|
||||
"content": str(message.get("content") or ""),
|
||||
}
|
||||
for message in context.get_messages()
|
||||
if message.get("role") in {"user", "assistant"}
|
||||
and str(message.get("content") or "").strip()
|
||||
]
|
||||
return TextCaseResult(turns=results, transcript=transcript)
|
||||
except TestExecutionError:
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TestExecutionError(
|
||||
code="PIPELINE_EXECUTION_FAILED",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
stage="model",
|
||||
retryable=True,
|
||||
) from exc
|
||||
finally:
|
||||
if not worker.has_finished():
|
||||
await worker.cancel(reason=INTERNAL_CANCEL_REASON)
|
||||
if runner_task is not None:
|
||||
await asyncio.gather(runner_task, return_exceptions=True)
|
||||
|
||||
async def _run_turn(
|
||||
self,
|
||||
worker: PipelineWorker,
|
||||
runner_task: asyncio.Task[None],
|
||||
capture: _TextCaptureProcessor,
|
||||
executor: MockToolExecutor,
|
||||
turn: FixedInputTurn,
|
||||
index: int,
|
||||
) -> RawTurnResult:
|
||||
tool_behaviors = [
|
||||
behavior for behavior in turn.behaviors if behavior.type == "tool_call"
|
||||
]
|
||||
executor.set_turn_behaviors(tool_behaviors)
|
||||
activity_marker = capture.window.activity_count
|
||||
output_marker = len(capture.outputs)
|
||||
tool_marker = len(capture.tool_calls)
|
||||
await worker.queue_frame(
|
||||
LLMMessagesAppendFrame(
|
||||
messages=[{"role": "user", "content": turn.user_input}],
|
||||
run_llm=True,
|
||||
)
|
||||
)
|
||||
await _wait_for_quiescence(capture, runner_task, activity_marker)
|
||||
if executor.unmocked_calls:
|
||||
raise TestExecutionError(
|
||||
code="UNMOCKED_TOOL_CALL",
|
||||
message="本轮调用了未配置 Mock 的工具: "
|
||||
+ "、".join(executor.unmocked_calls),
|
||||
stage="tool",
|
||||
)
|
||||
return RawTurnResult(
|
||||
id=turn.id,
|
||||
index=index,
|
||||
user_input=turn.user_input,
|
||||
assistant_reply="\n\n".join(capture.outputs[output_marker:]).strip(),
|
||||
tool_calls=list(capture.tool_calls[tool_marker:]),
|
||||
)
|
||||
1
backend/services/webhooks/__init__.py
Normal file
1
backend/services/webhooks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Global webhook delivery helpers."""
|
||||
86
backend/services/webhooks/delivery.py
Normal file
86
backend/services/webhooks/delivery.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Serialize, sign and send one webhook event."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from services.webhooks.security import validate_webhook_url
|
||||
|
||||
|
||||
WEBHOOK_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
ok: bool
|
||||
status_code: int | None
|
||||
latency_ms: int
|
||||
error: str = ""
|
||||
|
||||
|
||||
def canonical_json(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def webhook_signature(secret: str, timestamp: str, body: bytes) -> str:
|
||||
signed = timestamp.encode("ascii") + b"." + body
|
||||
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
||||
return f"v1={digest}"
|
||||
|
||||
|
||||
def should_retry(status_code: int | None) -> bool:
|
||||
return status_code is None or status_code in {408, 429} or status_code >= 500
|
||||
|
||||
|
||||
async def deliver_webhook(
|
||||
*,
|
||||
url: str,
|
||||
secret: str,
|
||||
event_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> DeliveryResult:
|
||||
"""Deliver without following redirects; callers decide whether to retry."""
|
||||
safe_url = await validate_webhook_url(url)
|
||||
body = canonical_json(payload)
|
||||
timestamp = str(int(time.time()))
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-AIVideo-Event-Id": event_id,
|
||||
"X-AIVideo-Timestamp": timestamp,
|
||||
"X-AIVideo-Signature": webhook_signature(secret, timestamp, body),
|
||||
}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=WEBHOOK_TIMEOUT_SECONDS,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = await client.post(safe_url, content=body, headers=headers)
|
||||
latency_ms = round((time.monotonic() - started) * 1000)
|
||||
if 200 <= response.status_code < 300:
|
||||
return DeliveryResult(True, response.status_code, latency_ms)
|
||||
return DeliveryResult(
|
||||
False,
|
||||
response.status_code,
|
||||
latency_ms,
|
||||
f"目标服务返回 HTTP {response.status_code}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(
|
||||
False,
|
||||
None,
|
||||
round((time.monotonic() - started) * 1000),
|
||||
str(exc)[:2048],
|
||||
)
|
||||
69
backend/services/webhooks/events.py
Normal file
69
backend/services/webhooks/events.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Build durable webhook events in the same transaction as domain updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from db.models import ConversationSession, SiteSetting, WebhookDelivery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
ANALYSIS_WEBHOOK_KEY = "analysis_webhook"
|
||||
ANALYSIS_COMPLETED_EVENT = "call.analysis.completed"
|
||||
|
||||
|
||||
def analysis_completed_payload(
|
||||
conversation: ConversationSession,
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
event_id: str,
|
||||
timestamp: datetime,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": event_id,
|
||||
"event": ANALYSIS_COMPLETED_EVENT,
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"data": {
|
||||
"conversationId": conversation.id,
|
||||
"assistantId": conversation.assistant_id,
|
||||
"assistantName": conversation.assistant_name,
|
||||
"analysis": result,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def enqueue_analysis_completed(
|
||||
session: AsyncSession,
|
||||
conversation: ConversationSession,
|
||||
result: dict[str, Any],
|
||||
) -> WebhookDelivery | None:
|
||||
setting = await session.get(SiteSetting, ANALYSIS_WEBHOOK_KEY)
|
||||
values = dict(setting.value or {}) if setting else {}
|
||||
secrets = dict(setting.secrets or {}) if setting else {}
|
||||
url = str(values.get("url") or "")
|
||||
secret = str(secrets.get("secret") or "")
|
||||
if not values.get("enabled") or not url or not secret:
|
||||
return None
|
||||
|
||||
event_id = f"evt_{uuid.uuid4().hex}"
|
||||
now = datetime.now(UTC)
|
||||
delivery = WebhookDelivery(
|
||||
id=f"dlv_{uuid.uuid4().hex}",
|
||||
event_id=event_id,
|
||||
event_type=ANALYSIS_COMPLETED_EVENT,
|
||||
conversation_id=conversation.id,
|
||||
payload=analysis_completed_payload(
|
||||
conversation,
|
||||
result,
|
||||
event_id=event_id,
|
||||
timestamp=now,
|
||||
),
|
||||
url=url,
|
||||
secrets={"secret": secret},
|
||||
status="pending",
|
||||
next_attempt_at=now,
|
||||
)
|
||||
session.add(delivery)
|
||||
return delivery
|
||||
61
backend/services/webhooks/security.py
Normal file
61
backend/services/webhooks/security.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Validate webhook destinations before every outbound request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafeWebhookUrl(ValueError):
|
||||
"""Raised when a URL could reach local or otherwise non-public infrastructure."""
|
||||
|
||||
def __init__(self, message: str, *, retryable: bool = False):
|
||||
super().__init__(message)
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
def _public_ip(address: str) -> bool:
|
||||
try:
|
||||
return ipaddress.ip_address(address).is_global
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
async def validate_webhook_url(url: str) -> str:
|
||||
"""Require HTTPS and ensure every currently resolved address is public."""
|
||||
value = url.strip()
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise UnsafeWebhookUrl("Webhook URL 必须是完整的 HTTPS 地址")
|
||||
if parsed.username or parsed.password:
|
||||
raise UnsafeWebhookUrl("Webhook URL 不能包含用户名或密码")
|
||||
|
||||
try:
|
||||
port = parsed.port or 443
|
||||
except ValueError as exc:
|
||||
raise UnsafeWebhookUrl("Webhook URL 端口无效") from exc
|
||||
|
||||
try:
|
||||
direct_ip = ipaddress.ip_address(parsed.hostname)
|
||||
except ValueError:
|
||||
direct_ip = None
|
||||
if direct_ip is not None:
|
||||
if not direct_ip.is_global:
|
||||
raise UnsafeWebhookUrl("Webhook URL 不能指向内网或本机地址")
|
||||
return value
|
||||
|
||||
try:
|
||||
records = await asyncio.to_thread(
|
||||
socket.getaddrinfo,
|
||||
parsed.hostname,
|
||||
port,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise UnsafeWebhookUrl("Webhook 域名暂时无法解析", retryable=True) from exc
|
||||
addresses = {str(record[4][0]) for record in records}
|
||||
if not addresses or any(not _public_ip(address) for address in addresses):
|
||||
raise UnsafeWebhookUrl("Webhook 域名解析到了内网或非公网地址")
|
||||
return value
|
||||
120
backend/services/webhooks/worker.py
Normal file
120
backend/services/webhooks/worker.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""PostgreSQL-backed webhook delivery worker with bounded retries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from db.models import WebhookDelivery
|
||||
from db.session import SessionLocal
|
||||
from loguru import logger
|
||||
from services.webhooks.delivery import DeliveryResult, deliver_webhook, should_retry
|
||||
from services.webhooks.security import UnsafeWebhookUrl
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
|
||||
POLL_INTERVAL_SECONDS = 2.0
|
||||
MAX_ATTEMPTS = 5
|
||||
RETRY_DELAYS_SECONDS = (30, 120, 600, 3600)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedDelivery:
|
||||
id: str
|
||||
event_id: str
|
||||
url: str
|
||||
secret: str
|
||||
payload: dict
|
||||
|
||||
|
||||
async def recover_interrupted_webhooks() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
update(WebhookDelivery)
|
||||
.where(WebhookDelivery.status == "processing")
|
||||
.values(status="pending", next_attempt_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def claim_pending_webhook() -> ClaimedDelivery | None:
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(WebhookDelivery)
|
||||
.where(
|
||||
WebhookDelivery.status == "pending",
|
||||
or_(
|
||||
WebhookDelivery.next_attempt_at.is_(None),
|
||||
WebhookDelivery.next_attempt_at <= now,
|
||||
),
|
||||
)
|
||||
.order_by(WebhookDelivery.next_attempt_at, WebhookDelivery.created_at)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
row.status = "processing"
|
||||
await session.commit()
|
||||
return ClaimedDelivery(
|
||||
id=row.id,
|
||||
event_id=row.event_id,
|
||||
url=row.url,
|
||||
secret=str((row.secrets or {}).get("secret") or ""),
|
||||
payload=dict(row.payload or {}),
|
||||
)
|
||||
|
||||
|
||||
async def finish_delivery(delivery_id: str, result: DeliveryResult) -> None:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(WebhookDelivery, delivery_id)
|
||||
if not row or row.status != "processing":
|
||||
return
|
||||
row.attempt_count += 1
|
||||
row.last_status_code = result.status_code
|
||||
row.last_error = result.error[:2048]
|
||||
if result.ok:
|
||||
row.status = "delivered"
|
||||
row.delivered_at = datetime.now(UTC)
|
||||
row.next_attempt_at = None
|
||||
elif should_retry(result.status_code) and row.attempt_count < MAX_ATTEMPTS:
|
||||
delay_index = min(row.attempt_count - 1, len(RETRY_DELAYS_SECONDS) - 1)
|
||||
row.status = "pending"
|
||||
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||
seconds=RETRY_DELAYS_SECONDS[delay_index]
|
||||
)
|
||||
else:
|
||||
row.status = "failed"
|
||||
row.next_attempt_at = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def run_webhook_worker() -> None:
|
||||
logger.info("通话分析 Webhook worker 已启动")
|
||||
while True:
|
||||
try:
|
||||
delivery = await claim_pending_webhook()
|
||||
if delivery:
|
||||
try:
|
||||
result = await deliver_webhook(
|
||||
url=delivery.url,
|
||||
secret=delivery.secret,
|
||||
event_id=delivery.event_id,
|
||||
payload=delivery.payload,
|
||||
)
|
||||
except UnsafeWebhookUrl as exc:
|
||||
# DNS outages are transient; policy violations are permanent.
|
||||
result = DeliveryResult(
|
||||
False, None if exc.retryable else 400, 0, str(exc)
|
||||
)
|
||||
await finish_delivery(delivery.id, result)
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Webhook worker 暂时不可用:{exc}")
|
||||
await asyncio.sleep(POLL_INTERVAL_SECONDS)
|
||||
@@ -22,7 +22,9 @@ from services.message_stage import (
|
||||
MessageDisplaySpec,
|
||||
MessageStageRunner,
|
||||
MessageStageSpec,
|
||||
confirmation_context_message,
|
||||
)
|
||||
from services.pipecat.call_lifecycle import playback_marker_for
|
||||
from services.pipecat.realtime_tools import RealtimeTool, RealtimeToolResult
|
||||
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
|
||||
from services.system_tools import state_update_properties, system_tool_kind
|
||||
@@ -99,6 +101,18 @@ class RealtimeWorkflowOutput(WorkflowOutput):
|
||||
content,
|
||||
suppress_transcript=True,
|
||||
)
|
||||
playback_marker = playback_marker_for(playback_completion)
|
||||
if playback_marker is not None:
|
||||
async def finish_playback_tracking() -> None:
|
||||
# The provider boundary is emitted after its final audio frame.
|
||||
# Queueing the marker then places it behind that audio at output.
|
||||
if provider_completion is not None:
|
||||
await provider_completion
|
||||
await self._runtime.queue_frame(playback_marker)
|
||||
playback_marker.completion.mark_queued()
|
||||
await playback_completion
|
||||
|
||||
return asyncio.create_task(finish_playback_tracking())
|
||||
# Message playback policy and deterministic continuation must use the
|
||||
# transport boundary. Provider response.done only means generation
|
||||
# has finished; audio may still be buffered at the output transport.
|
||||
@@ -880,7 +894,17 @@ class WorkflowRealtimeController:
|
||||
node_id=node_id,
|
||||
code="workflow_message_error",
|
||||
)
|
||||
return result.succeeded
|
||||
return False
|
||||
context_message = confirmation_context_message(
|
||||
result,
|
||||
source=f"workflow-message:{node_id}",
|
||||
)
|
||||
if context_message is not None:
|
||||
await self._runtime.realtime.send_text(
|
||||
context_message["content"],
|
||||
run_immediately=False,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _enter_action(self, node_id: str) -> bool:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||
@@ -983,12 +1007,20 @@ class WorkflowRealtimeController:
|
||||
return
|
||||
self._runtime.call_end.begin("workflow_completed")
|
||||
if message:
|
||||
self._runtime.call_end.arm_after_speech()
|
||||
completion = await self._output.speak(
|
||||
message,
|
||||
source="workflow-end-speech",
|
||||
node_id=node_id,
|
||||
)
|
||||
arm_tracked = getattr(
|
||||
self._runtime.call_end,
|
||||
"arm_after_tracked_speech",
|
||||
None,
|
||||
)
|
||||
if callable(arm_tracked):
|
||||
await arm_tracked()
|
||||
else:
|
||||
self._runtime.call_end.arm_after_speech()
|
||||
if completion:
|
||||
await completion
|
||||
else:
|
||||
|
||||
@@ -38,6 +38,14 @@ AUTH_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "1440"))
|
||||
AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true"
|
||||
AUTH_COOKIE_SAMESITE = os.getenv("AUTH_COOKIE_SAMESITE", "lax")
|
||||
|
||||
# ---- Public Realtime API auth ----
|
||||
# Keep public integration credentials separate from the admin login cookie.
|
||||
# Production deployments should set a dedicated random secret.
|
||||
REALTIME_TOKEN_SECRET = os.getenv("REALTIME_TOKEN_SECRET", AUTH_SECRET_KEY)
|
||||
REALTIME_CLIENT_SECRET_TTL_SECONDS = int(
|
||||
os.getenv("REALTIME_CLIENT_SECRET_TTL_SECONDS", "60")
|
||||
)
|
||||
|
||||
# ---- WebRTC STUN / TURN ----
|
||||
# Override STUN_URL in remote deployments to use the colocated coturn server
|
||||
# instead of waiting for a public STUN service that may be unreachable.
|
||||
@@ -52,7 +60,7 @@ TURN_PASSWORD = os.getenv("TURN_PASSWORD", "")
|
||||
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
|
||||
|
||||
# ---- S3-compatible object storage (RustFS in local compose) ----
|
||||
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL", "http://127.0.0.1:9000")
|
||||
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "rustfsadmin")
|
||||
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "rustfsadmin")
|
||||
S3_BUCKET = os.getenv("S3_BUCKET", "ai-video")
|
||||
|
||||
386
backend/test_schemas.py
Normal file
386
backend/test_schemas.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""Contracts for persisted text test cases and batch execution results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from schemas import CamelModel
|
||||
|
||||
|
||||
TestCaseInputMode = Literal[
|
||||
"fixed_script_text",
|
||||
"fixed_script_turn_voice",
|
||||
"fixed_script_continuous_voice",
|
||||
"user_sim_text",
|
||||
"user_sim_voice",
|
||||
]
|
||||
ContextRole = Literal["agent", "user", "tool_call", "tool_result"]
|
||||
EvaluationKind = Literal["reply", "tool_call", "overall"]
|
||||
EvaluationStatus = Literal["pass", "fail"]
|
||||
CaseStatus = Literal["waiting", "running", "pass", "fail", "error", "skipped"]
|
||||
RunStatus = Literal["queued", "running", "completed", "cancelled"]
|
||||
ErrorStage = Literal["pipeline", "model", "tool", "evaluation"]
|
||||
StopReason = Literal["manual", "assertion_failure", "execution_error"]
|
||||
|
||||
|
||||
class ContextTurn(CamelModel):
|
||||
role: ContextRole
|
||||
content: str = Field(max_length=20_000)
|
||||
tool_name: str | None = Field(default=None, max_length=128)
|
||||
tool_call_id: str | None = Field(default=None, max_length=128)
|
||||
is_error: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_content(self):
|
||||
self.content = self.content.strip()
|
||||
self.tool_call_id = (self.tool_call_id or "").strip() or None
|
||||
if not self.content:
|
||||
raise ValueError("上下文内容不能为空")
|
||||
if self.role in {"tool_call", "tool_result"}:
|
||||
self.tool_name = (self.tool_name or "").strip()
|
||||
if not self.tool_name:
|
||||
raise ValueError("工具上下文必须填写工具名称")
|
||||
try:
|
||||
payload = json.loads(self.content)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("工具上下文必须是有效 JSON") from exc
|
||||
if self.role == "tool_call" and not isinstance(payload, dict):
|
||||
raise ValueError("Tool Call 参数必须是 JSON 对象")
|
||||
return self
|
||||
|
||||
|
||||
class ToolParamAssertion(CamelModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
match_mode: Literal["exact", "regex", "llm"]
|
||||
value: str = Field(min_length=1, max_length=4_000)
|
||||
|
||||
@field_validator("name", "value")
|
||||
@classmethod
|
||||
def strip_required_text(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("参数名称和值不能为空")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_regex(self):
|
||||
if self.match_mode == "regex":
|
||||
try:
|
||||
re.compile(self.value)
|
||||
except re.error as exc:
|
||||
raise ValueError(f"正则表达式无效: {exc}") from exc
|
||||
return self
|
||||
|
||||
|
||||
class ToolMockResponse(CamelModel):
|
||||
outcome: Literal["success", "error"] = "success"
|
||||
body: str = Field(default='{"status":"ok"}', max_length=100_000)
|
||||
delay_ms: int = Field(default=0, ge=0, le=60_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_json_body(self):
|
||||
if not self.body.strip():
|
||||
raise ValueError("Mock 工具返回值不能为空")
|
||||
try:
|
||||
json.loads(self.body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Mock 工具返回值必须是有效 JSON") from exc
|
||||
return self
|
||||
|
||||
|
||||
class ReplyExpectedBehavior(CamelModel):
|
||||
id: str = Field(min_length=1, max_length=128)
|
||||
type: Literal["reply"] = "reply"
|
||||
assertion_type: Literal["keyword", "llm"]
|
||||
keywords: list[str] = Field(default_factory=list, max_length=50)
|
||||
keyword_match_mode: Literal["any", "all"] = "any"
|
||||
negate_keywords: bool = False
|
||||
llm_criteria: str = Field(default="", max_length=4_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_assertion(self):
|
||||
self.keywords = [item.strip() for item in self.keywords if item.strip()]
|
||||
self.llm_criteria = self.llm_criteria.strip()
|
||||
if self.assertion_type == "keyword" and not self.keywords:
|
||||
raise ValueError("关键词断言至少需要一个关键词")
|
||||
if self.assertion_type == "llm" and not self.llm_criteria:
|
||||
raise ValueError("LLM 判断要求不能为空")
|
||||
return self
|
||||
|
||||
|
||||
class ToolCallExpectedBehavior(CamelModel):
|
||||
id: str = Field(min_length=1, max_length=128)
|
||||
type: Literal["tool_call"] = "tool_call"
|
||||
tool_id: str = Field(min_length=1, max_length=40)
|
||||
function_name: str = Field(min_length=1, max_length=128)
|
||||
expectation: Literal["called", "not_called"]
|
||||
min_calls: int = Field(default=1, ge=0, le=100)
|
||||
max_calls: int | None = Field(default=None, ge=0, le=100)
|
||||
param_assertions: list[ToolParamAssertion] = Field(default_factory=list, max_length=50)
|
||||
mock_response: ToolMockResponse = Field(default_factory=ToolMockResponse)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_call_expectation(self):
|
||||
self.tool_id = self.tool_id.strip()
|
||||
self.function_name = self.function_name.strip()
|
||||
if not self.tool_id or not self.function_name:
|
||||
raise ValueError("必须选择有效的工具")
|
||||
if self.expectation == "called" and self.min_calls < 1:
|
||||
raise ValueError("应调用工具时最少调用次数必须大于 0")
|
||||
if self.max_calls is not None and self.max_calls < self.min_calls:
|
||||
raise ValueError("最多调用次数不能小于最少调用次数")
|
||||
return self
|
||||
|
||||
|
||||
ExpectedBehavior = Annotated[
|
||||
Union[ReplyExpectedBehavior, ToolCallExpectedBehavior],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class FixedInputTurn(CamelModel):
|
||||
id: str = Field(min_length=1, max_length=128)
|
||||
user_input: str = Field(min_length=1, max_length=20_000)
|
||||
behaviors: list[ExpectedBehavior] = Field(default_factory=list, max_length=100)
|
||||
|
||||
@field_validator("user_input")
|
||||
@classmethod
|
||||
def strip_user_input(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("用户输入不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class OverallCriterion(CamelModel):
|
||||
id: str = Field(min_length=1, max_length=128)
|
||||
type: Literal["llm"] = "llm"
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
criteria: str = Field(min_length=1, max_length=2_000)
|
||||
|
||||
@field_validator("name", "criteria")
|
||||
@classmethod
|
||||
def strip_criterion(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("整体评估标准不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class TestCaseDefinition(CamelModel):
|
||||
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
|
||||
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
|
||||
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_has_expectation(self):
|
||||
if not any(turn.behaviors for turn in self.turns) and not self.overall_criteria:
|
||||
raise ValueError("至少配置一项有效的预期行为或整体评估标准")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tool_context_pairs(self):
|
||||
pending: list[tuple[str | None, str]] = []
|
||||
seen_ids: set[str] = set()
|
||||
for index, turn in enumerate(self.context_turns):
|
||||
if turn.role == "tool_call":
|
||||
call_id = (turn.tool_call_id or "").strip() or None
|
||||
if call_id and call_id in seen_ids:
|
||||
raise ValueError(f"上下文第 {index + 1} 条 Tool Call ID 重复")
|
||||
if call_id:
|
||||
seen_ids.add(call_id)
|
||||
pending.append((call_id, turn.tool_name or ""))
|
||||
continue
|
||||
if turn.role != "tool_result":
|
||||
continue
|
||||
|
||||
result_id = (turn.tool_call_id or "").strip() or None
|
||||
matched_index = -1
|
||||
for pending_index in range(len(pending) - 1, -1, -1):
|
||||
call_id, tool_name = pending[pending_index]
|
||||
if result_id:
|
||||
matches = call_id == result_id
|
||||
else:
|
||||
matches = tool_name == turn.tool_name
|
||||
if matches:
|
||||
matched_index = pending_index
|
||||
break
|
||||
if matched_index < 0:
|
||||
raise ValueError(
|
||||
f"上下文第 {index + 1} 条 Tool Result 没有匹配的 Tool Call"
|
||||
)
|
||||
_call_id, tool_name = pending.pop(matched_index)
|
||||
if tool_name != turn.tool_name:
|
||||
raise ValueError(
|
||||
f"上下文第 {index + 1} 条 Tool Result 的工具名称不匹配"
|
||||
)
|
||||
|
||||
if pending:
|
||||
names = "、".join(tool_name for _call_id, tool_name in pending)
|
||||
raise ValueError(f"上下文 Tool Call 缺少对应的 Tool Result:{names}")
|
||||
return self
|
||||
|
||||
|
||||
class TestSuiteCreate(CamelModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str = Field(default="", max_length=2_048)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def strip_name(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("测试集名称不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class TestSuiteUpdate(TestSuiteCreate):
|
||||
pass
|
||||
|
||||
|
||||
class TestSuiteOut(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
case_count: int = 0
|
||||
passed_count: int = 0
|
||||
run_count: int = 0
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TestCaseWrite(CamelModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str = Field(default="", max_length=2_048)
|
||||
input_mode: TestCaseInputMode = "fixed_script_text"
|
||||
context_turns: list[ContextTurn] = Field(default_factory=list, max_length=100)
|
||||
turns: list[FixedInputTurn] = Field(min_length=1, max_length=100)
|
||||
overall_criteria: list[OverallCriterion] = Field(default_factory=list, max_length=50)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def strip_name(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("测试用例名称不能为空")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mvp_definition(self):
|
||||
if self.input_mode != "fixed_script_text":
|
||||
raise ValueError("第一版只支持固定脚本 · 文字")
|
||||
TestCaseDefinition(
|
||||
context_turns=self.context_turns,
|
||||
turns=self.turns,
|
||||
overall_criteria=self.overall_criteria,
|
||||
)
|
||||
return self
|
||||
|
||||
def definition(self) -> TestCaseDefinition:
|
||||
return TestCaseDefinition(
|
||||
context_turns=self.context_turns,
|
||||
turns=self.turns,
|
||||
overall_criteria=self.overall_criteria,
|
||||
)
|
||||
|
||||
|
||||
class TestCaseOut(TestCaseWrite):
|
||||
id: str
|
||||
suite_id: str
|
||||
last_result: Literal["pass", "fail", "not_run"]
|
||||
sort_order: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TestCaseOrderIn(CamelModel):
|
||||
case_ids: list[str] = Field(min_length=1, max_length=1_000)
|
||||
|
||||
|
||||
class TestCaseBulkDeleteIn(CamelModel):
|
||||
case_ids: list[str] = Field(min_length=1, max_length=1_000)
|
||||
|
||||
|
||||
class BatchRunConfig(CamelModel):
|
||||
concurrency: int = Field(default=3, ge=1, le=20)
|
||||
timeout_secs: int = Field(default=60, ge=1, le=600)
|
||||
failure_strategy: Literal["continue", "stop_on_fail"] = "continue"
|
||||
error_retry_count: int = Field(default=0, ge=0, le=3)
|
||||
error_strategy: Literal["continue", "stop_on_error"] = "continue"
|
||||
|
||||
|
||||
class BatchRunCreate(CamelModel):
|
||||
assistant_id: str = Field(min_length=1, max_length=40)
|
||||
evaluator_model_resource_id: str = Field(min_length=1, max_length=40)
|
||||
case_ids: list[str] = Field(min_length=1, max_length=1_000)
|
||||
config: BatchRunConfig = Field(default_factory=BatchRunConfig)
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
|
||||
@field_validator("case_ids")
|
||||
@classmethod
|
||||
def unique_case_ids(cls, values: list[str]) -> list[str]:
|
||||
normalized = list(dict.fromkeys(value.strip() for value in values if value.strip()))
|
||||
if not normalized:
|
||||
raise ValueError("至少选择一个测试用例")
|
||||
return normalized
|
||||
|
||||
|
||||
class BatchExecutionError(CamelModel):
|
||||
code: str
|
||||
message: str
|
||||
stage: ErrorStage
|
||||
retryable: bool
|
||||
|
||||
|
||||
class BatchEvaluationResult(CamelModel):
|
||||
id: str
|
||||
label: str
|
||||
kind: EvaluationKind
|
||||
status: EvaluationStatus
|
||||
expected: str
|
||||
actual: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class BatchToolCallRecord(CamelModel):
|
||||
id: str
|
||||
function_name: str
|
||||
arguments_json: str
|
||||
result_json: str
|
||||
outcome: Literal["success", "error"]
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
class BatchTurnResult(CamelModel):
|
||||
id: str
|
||||
index: int
|
||||
user_input: str
|
||||
assistant_reply: str
|
||||
tool_calls: list[BatchToolCallRecord] = Field(default_factory=list)
|
||||
evaluations: list[BatchEvaluationResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BatchRunCaseOut(CamelModel):
|
||||
id: str
|
||||
name: str
|
||||
status: CaseStatus
|
||||
turns: list[BatchTurnResult] = Field(default_factory=list)
|
||||
overall_criteria: list[BatchEvaluationResult] = Field(default_factory=list)
|
||||
attempt_count: int
|
||||
max_attempts: int
|
||||
execution_error: BatchExecutionError | None = None
|
||||
|
||||
|
||||
class BatchRunSnapshotOut(CamelModel):
|
||||
id: str
|
||||
status: RunStatus
|
||||
title: str
|
||||
assistant_name: str
|
||||
config: dict[str, Any]
|
||||
cases: list[BatchRunCaseOut]
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
stop_reason: StopReason | None
|
||||
@@ -31,6 +31,7 @@ from services.brains.dify_llm import (
|
||||
)
|
||||
from services.brains.workflow_brain import ConfiguredFlowManager, WorkflowBrain
|
||||
from services.fixed_speech import FIXED_SPEECH_CONTEXT_MARKER
|
||||
from services.message_stage import MESSAGE_CONFIRMATION_CONTEXT_MARKER
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
from services.action_runtime import ActionOutcome, ActionStatus
|
||||
from services.workflow.models import (
|
||||
@@ -418,6 +419,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
confirmation_started = asyncio.Event()
|
||||
user_confirmed = asyncio.Event()
|
||||
client_calls = []
|
||||
context = LLMContext(messages=[])
|
||||
|
||||
class FakeClientTools:
|
||||
async def call(self, function_name, arguments, **options):
|
||||
@@ -429,7 +431,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
context=context,
|
||||
llm=FakeLLM(),
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
@@ -458,6 +460,13 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(input_states, [False])
|
||||
self.assertEqual(called_tool_ids, [])
|
||||
self.assertFalse(
|
||||
any(
|
||||
MESSAGE_CONFIRMATION_CONTEXT_MARKER
|
||||
in str(message.get("content") or "")
|
||||
for message in context.get_messages()
|
||||
)
|
||||
)
|
||||
self.assertEqual(client_calls[0][0], "show_message")
|
||||
self.assertFalse(client_calls[0][1]["dismissible"])
|
||||
self.assertTrue(client_calls[0][2]["interrupt_on_result"])
|
||||
@@ -481,6 +490,17 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
await opening_task
|
||||
self.assertEqual(input_states, [False, True])
|
||||
self.assertEqual(called_tool_ids, ["opening_data"])
|
||||
confirmation_messages = [
|
||||
message
|
||||
for message in context.get_messages()
|
||||
if MESSAGE_CONFIRMATION_CONTEXT_MARKER
|
||||
in str(message.get("content") or "")
|
||||
]
|
||||
self.assertEqual(len(confirmation_messages), 1)
|
||||
self.assertEqual(confirmation_messages[0]["role"], "user")
|
||||
self.assertIn("点击“确认”", confirmation_messages[0]["content"])
|
||||
self.assertIn("弹窗标题:重要提示", confirmation_messages[0]["content"])
|
||||
self.assertIn("弹窗内容:请确认已阅读。", confirmation_messages[0]["content"])
|
||||
self.assertEqual(
|
||||
sum(isinstance(frame, LLMRunFrame) for frame in queued),
|
||||
1,
|
||||
@@ -500,6 +520,14 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
# Replayed client-ready must not execute startup actions twice.
|
||||
await brain.on_client_ready()
|
||||
self.assertEqual(brain._actions.execute.await_count, 1)
|
||||
self.assertEqual(
|
||||
sum(
|
||||
MESSAGE_CONFIRMATION_CONTEXT_MARKER
|
||||
in str(message.get("content") or "")
|
||||
for message in context.get_messages()
|
||||
),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
sum(isinstance(frame, LLMRunFrame) for frame in queued),
|
||||
1,
|
||||
@@ -2277,10 +2305,106 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
result = await message_task
|
||||
self.assertTrue(result.succeeded)
|
||||
self.assertEqual(result.action, "confirmed")
|
||||
self.assertIsNotNone(result.confirmation)
|
||||
self.assertEqual(result.confirmation.title, "重要提示")
|
||||
self.assertEqual(result.confirmation.message, "请核对客户信息。")
|
||||
self.assertEqual(result.confirmation.confirm_label, "确认")
|
||||
self.assertFalse(call_end.playback_completion.done())
|
||||
self.assertEqual(input_states, [False, True])
|
||||
self.assertEqual(events[-1], "message_completed")
|
||||
|
||||
async def test_confirmation_message_is_forwarded_to_next_agent_context(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "message",
|
||||
"type": "message",
|
||||
"data": {
|
||||
"title": "办理提示",
|
||||
"message": "确认后开始办理。",
|
||||
"confirmLabel": "继续办理",
|
||||
"completionPolicy": "confirmation",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"type": "agent",
|
||||
"data": {"prompt": "收集事故信息"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-message",
|
||||
"source": "start",
|
||||
"target": "message",
|
||||
"data": {"mode": "always"},
|
||||
},
|
||||
{
|
||||
"id": "message-agent",
|
||||
"source": "message",
|
||||
"target": "agent",
|
||||
"data": {"mode": "always"},
|
||||
},
|
||||
],
|
||||
}
|
||||
brain = WorkflowBrain(graph)
|
||||
|
||||
class FakeManager:
|
||||
def __init__(self):
|
||||
self.current_node = None
|
||||
self.configs = []
|
||||
|
||||
async def initialize(self, config):
|
||||
self.current_node = config["name"]
|
||||
self.configs.append(config)
|
||||
|
||||
async def set_node_from_config(self, config):
|
||||
self.current_node = config["name"]
|
||||
self.configs.append(config)
|
||||
|
||||
class FakeClientTools:
|
||||
async def call(self, *_args, **_kwargs):
|
||||
return {"status": "ok", "data": {"action": "confirmed"}}
|
||||
|
||||
manager = FakeManager()
|
||||
client_tools = FakeClientTools()
|
||||
brain._runtime = BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=noop_queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=FakeCallEnd(),
|
||||
client_tools=client_tools,
|
||||
set_input_enabled=lambda _enabled: None,
|
||||
)
|
||||
brain._manager = manager
|
||||
brain._message_stages.set_client_tools(client_tools)
|
||||
|
||||
await brain.on_connected()
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if manager.current_node == "agent":
|
||||
break
|
||||
|
||||
self.assertEqual(manager.current_node, "agent")
|
||||
confirmation_messages = [
|
||||
message
|
||||
for message in manager.configs[-1]["task_messages"]
|
||||
if MESSAGE_CONFIRMATION_CONTEXT_MARKER
|
||||
in str(message.get("content") or "")
|
||||
]
|
||||
self.assertEqual(len(confirmation_messages), 1)
|
||||
self.assertEqual(confirmation_messages[0]["role"], "user")
|
||||
self.assertIn("点击“继续办理”", confirmation_messages[0]["content"])
|
||||
self.assertIn(
|
||||
"事件来源:workflow-message:message",
|
||||
confirmation_messages[0]["content"],
|
||||
)
|
||||
|
||||
async def test_speech_only_message_waits_for_transport_playback(self):
|
||||
brain = WorkflowBrain(
|
||||
{
|
||||
|
||||
@@ -3,8 +3,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
|
||||
from services.pipecat.call_lifecycle import CallEndCoordinator
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
InterruptionFrame,
|
||||
)
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
playback_marker_for,
|
||||
)
|
||||
|
||||
|
||||
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -65,17 +72,68 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
first_marker = playback_marker_for(first_completion)
|
||||
second_marker = playback_marker_for(second_completion)
|
||||
self.assertIsNotNone(first_marker)
|
||||
self.assertIsNotNone(second_marker)
|
||||
|
||||
await first_marker.completion.mark_played()
|
||||
self.assertTrue(first_completion.done())
|
||||
self.assertFalse(second_completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
await second_marker.completion.mark_played()
|
||||
self.assertTrue(second_completion.done())
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
async def test_previous_speech_stop_does_not_complete_fixed_speech(self):
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
|
||||
self.assertFalse(completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
await marker.completion.mark_played()
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
async def test_delayed_previous_speech_boundary_cannot_claim_marker(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
|
||||
self.assertFalse(completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
await marker.completion.mark_played()
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
async def test_interruption_completes_marker_already_in_output_queue(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
marker.completion.mark_queued()
|
||||
|
||||
await self.coordinator.observe(InterruptionFrame())
|
||||
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
async def test_interruption_does_not_complete_marker_not_yet_queued(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
|
||||
await self.coordinator.observe(InterruptionFrame())
|
||||
|
||||
self.assertFalse(completion.done())
|
||||
marker.completion.mark_queued()
|
||||
await marker.completion.mark_played()
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
@@ -122,6 +161,9 @@ class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
||||
def add(self, value):
|
||||
self.added.append(value)
|
||||
|
||||
async def flush(self):
|
||||
return None
|
||||
|
||||
async def get(self, _model, _session_id):
|
||||
return conversation
|
||||
|
||||
|
||||
203
backend/tests/test_fixed_speech_playback.py
Normal file
203
backend/tests/test_fixed_speech_playback.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
import services.brains # Initialize the brain registry before realtime imports.
|
||||
from services.fixed_speech import FixedSpeechOutput
|
||||
from services.message_stage import MESSAGE_CONFIRMATION_CONTEXT_MARKER
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
FixedSpeechPlaybackMarkerFrame,
|
||||
)
|
||||
from services.pipecat.transports import build_ws_transport
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.workflow.realtime import WorkflowRealtimeController
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
class FixedSpeechPlaybackTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_pipeline_speech_queues_marker_immediately_after_tts(self):
|
||||
queued = []
|
||||
|
||||
async def queue_end(_reason: str) -> None:
|
||||
pass
|
||||
|
||||
async def queue_frame(frame) -> None:
|
||||
queued.append(frame)
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
output = FixedSpeechOutput(
|
||||
DynamicVariableStore({}),
|
||||
SimpleNamespace(call_end=call_end, queue_frame=queue_frame),
|
||||
)
|
||||
|
||||
completion = await output.speak(
|
||||
"固定结束语",
|
||||
source="test",
|
||||
record_history=False,
|
||||
)
|
||||
|
||||
self.assertIsInstance(queued[0], TTSSpeakFrame)
|
||||
self.assertIsInstance(queued[1], FixedSpeechPlaybackMarkerFrame)
|
||||
self.assertIs(queued[1].completion, completion)
|
||||
|
||||
async def test_websocket_output_resolves_marker(self):
|
||||
async def queue_end(_reason: str) -> None:
|
||||
pass
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
completion = call_end.track_speech()
|
||||
marker = FixedSpeechPlaybackMarkerFrame(completion=completion)
|
||||
websocket = SimpleNamespace(headers={})
|
||||
output = build_ws_transport(websocket).output()
|
||||
|
||||
await output.write_transport_frame(marker)
|
||||
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
async def test_realtime_end_ignores_unrelated_speech_boundaries(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "end",
|
||||
"data": {"message": "感谢来电,再见。", "scope": "session"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
reasons = []
|
||||
queued = []
|
||||
|
||||
async def queue_end(reason: str) -> None:
|
||||
reasons.append(reason)
|
||||
|
||||
async def queue_frame(frame) -> None:
|
||||
queued.append(frame)
|
||||
|
||||
class FakeRealtime:
|
||||
def __init__(self):
|
||||
self.provider_completion = None
|
||||
|
||||
async def update_session(self, _instructions, _tools):
|
||||
pass
|
||||
|
||||
async def speak_fixed(self, _text, *, suppress_transcript=True):
|
||||
self.provider_completion = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
return self.provider_completion
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
realtime = FakeRealtime()
|
||||
controller = WorkflowRealtimeController(
|
||||
cfg=AssistantConfig(type="workflow", graph=graph),
|
||||
engine=WorkflowEngine(graph),
|
||||
store=DynamicVariableStore({}),
|
||||
runtime=SimpleNamespace(
|
||||
realtime=realtime,
|
||||
queue_frame=queue_frame,
|
||||
call_end=call_end,
|
||||
session_id="test-session",
|
||||
client_tools=None,
|
||||
set_input_enabled=lambda _enabled: None,
|
||||
capture_image=None,
|
||||
),
|
||||
)
|
||||
|
||||
end_task = asyncio.create_task(controller._enter_end("end"))
|
||||
while realtime.provider_completion is None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await call_end.observe(BotStartedSpeakingFrame())
|
||||
await call_end.observe(BotStoppedSpeakingFrame())
|
||||
self.assertEqual(reasons, [])
|
||||
self.assertFalse(end_task.done())
|
||||
|
||||
realtime.provider_completion.set_result(None)
|
||||
marker = None
|
||||
while marker is None:
|
||||
await asyncio.sleep(0)
|
||||
marker = next(
|
||||
(
|
||||
frame
|
||||
for frame in queued
|
||||
if isinstance(frame, FixedSpeechPlaybackMarkerFrame)
|
||||
),
|
||||
None,
|
||||
)
|
||||
await marker.completion.mark_played()
|
||||
await end_task
|
||||
|
||||
self.assertEqual(reasons, ["workflow_completed"])
|
||||
|
||||
async def test_realtime_confirmation_is_appended_without_running_model(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "message",
|
||||
"type": "message",
|
||||
"data": {
|
||||
"title": "办理提示",
|
||||
"message": "确认后开始办理。",
|
||||
"confirmLabel": "继续办理",
|
||||
"completionPolicy": "confirmation",
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
appended = []
|
||||
input_states = []
|
||||
|
||||
class FakeRealtime:
|
||||
async def send_text(self, text, *, run_immediately=True):
|
||||
appended.append((text, run_immediately))
|
||||
|
||||
class FakeClientTools:
|
||||
async def call(self, *_args, **_kwargs):
|
||||
return {"status": "ok", "data": {"action": "confirmed"}}
|
||||
|
||||
async def queue_frame(_frame):
|
||||
pass
|
||||
|
||||
controller = WorkflowRealtimeController(
|
||||
cfg=AssistantConfig(type="workflow", graph=graph),
|
||||
engine=WorkflowEngine(graph),
|
||||
store=DynamicVariableStore({}),
|
||||
runtime=SimpleNamespace(
|
||||
realtime=FakeRealtime(),
|
||||
queue_frame=queue_frame,
|
||||
call_end=SimpleNamespace(ending=False),
|
||||
session_id="test-session",
|
||||
client_tools=FakeClientTools(),
|
||||
set_input_enabled=input_states.append,
|
||||
capture_image=None,
|
||||
),
|
||||
)
|
||||
|
||||
succeeded = await controller._enter_message("message")
|
||||
|
||||
self.assertTrue(succeeded)
|
||||
self.assertEqual(input_states, [False, True])
|
||||
self.assertEqual(len(appended), 1)
|
||||
self.assertFalse(appended[0][1])
|
||||
self.assertIn(MESSAGE_CONFIRMATION_CONTEXT_MARKER, appended[0][0])
|
||||
self.assertIn("点击“继续办理”", appended[0][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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()
|
||||
@@ -4,13 +4,61 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from services.pipecat.service_factory import (
|
||||
HTTP_TTS_STOP_FRAME_TIMEOUT_S,
|
||||
WEBSOCKET_TTS_STOP_FRAME_TIMEOUT_S,
|
||||
create_llm,
|
||||
create_tts,
|
||||
)
|
||||
|
||||
|
||||
class LLMServiceFactoryTest(unittest.TestCase):
|
||||
def test_openai_compatible_llm_converts_async_tool_developer_result(self):
|
||||
service = create_llm(
|
||||
AssistantConfig(
|
||||
llm_interface_type="openai-llm",
|
||||
model="deepseek-chat",
|
||||
llm_api_key="test-key",
|
||||
llm_base_url="https://llm.example.test/v1",
|
||||
)
|
||||
)
|
||||
context = LLMContext(
|
||||
messages=[
|
||||
{"role": "system", "content": "你是助手"},
|
||||
{
|
||||
"role": "developer",
|
||||
"content": '{"type":"async_tool","status":"finished"}',
|
||||
},
|
||||
{"role": "user", "content": "ok"},
|
||||
]
|
||||
)
|
||||
|
||||
params = service.get_llm_adapter().get_llm_invocation_params(
|
||||
context,
|
||||
convert_developer_to_user=not service.supports_developer_role,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[message["role"] for message in params["messages"]],
|
||||
["system", "user", "user"],
|
||||
)
|
||||
self.assertNotIn("developer", str(params["messages"]))
|
||||
|
||||
def test_provider_can_explicitly_enable_developer_role(self):
|
||||
service = create_llm(
|
||||
AssistantConfig(
|
||||
llm_interface_type="openai-llm",
|
||||
model="gpt-compatible",
|
||||
llm_api_key="test-key",
|
||||
llm_base_url="https://llm.example.test/v1",
|
||||
llm_values={"supportsDeveloperRole": True},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(service.supports_developer_role)
|
||||
|
||||
|
||||
class TTSServiceFactoryTest(unittest.TestCase):
|
||||
def test_http_tts_keeps_wider_audio_chunk_timeout(self):
|
||||
config = AssistantConfig(
|
||||
|
||||
@@ -8,6 +8,7 @@ from pipecat.frames.frames import (
|
||||
FunctionCallResultFrame,
|
||||
FunctionCallsStartedFrame,
|
||||
)
|
||||
from pipecat.services.llm_service import FunctionCallResultProperties
|
||||
from services.pipecat.processors import ToolInterruptionUserMuteStrategy
|
||||
from services.tool_policy import policy_for_tool
|
||||
|
||||
@@ -143,6 +144,106 @@ class ToolInterruptionStrategyTests(unittest.IsolatedAsyncioTestCase):
|
||||
await strategy.process_frame(BotStoppedSpeakingFrame())
|
||||
)
|
||||
|
||||
async def test_async_tool_only_result_releases_without_followup_speech(self):
|
||||
strategy = ToolInterruptionUserMuteStrategy(
|
||||
{"set_photo_button_visible": "async"}
|
||||
)
|
||||
await strategy.process_frame(
|
||||
FunctionCallsStartedFrame(
|
||||
function_calls=[
|
||||
SimpleNamespace(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_4",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
await strategy.process_frame(
|
||||
FunctionCallResultFrame(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_4",
|
||||
arguments={"visible": True},
|
||||
result={"status": "ok"},
|
||||
properties=FunctionCallResultProperties(run_llm=False),
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertFalse(strategy.is_muted)
|
||||
|
||||
async def test_async_tool_only_result_waits_for_active_preamble(self):
|
||||
strategy = ToolInterruptionUserMuteStrategy(
|
||||
{"set_photo_button_visible": "async"}
|
||||
)
|
||||
await strategy.process_frame(BotStartedSpeakingFrame())
|
||||
await strategy.process_frame(
|
||||
FunctionCallsStartedFrame(
|
||||
function_calls=[
|
||||
SimpleNamespace(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_5",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
await strategy.process_frame(
|
||||
FunctionCallResultFrame(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_5",
|
||||
arguments={"visible": True},
|
||||
result={"status": "ok"},
|
||||
properties=FunctionCallResultProperties(run_llm=False),
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
await strategy.process_frame(BotStoppedSpeakingFrame())
|
||||
)
|
||||
|
||||
async def test_async_intermediate_result_does_not_release_mute(self):
|
||||
strategy = ToolInterruptionUserMuteStrategy(
|
||||
{"set_photo_button_visible": "async"}
|
||||
)
|
||||
await strategy.process_frame(
|
||||
FunctionCallsStartedFrame(
|
||||
function_calls=[
|
||||
SimpleNamespace(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_6",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
await strategy.process_frame(
|
||||
FunctionCallResultFrame(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_6",
|
||||
arguments={"visible": True},
|
||||
result={"status": "pending"},
|
||||
properties=FunctionCallResultProperties(
|
||||
run_llm=False,
|
||||
is_final=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
await strategy.process_frame(
|
||||
FunctionCallResultFrame(
|
||||
function_name="set_photo_button_visible",
|
||||
tool_call_id="call_6",
|
||||
arguments={"visible": True},
|
||||
result={"status": "ok"},
|
||||
properties=FunctionCallResultProperties(run_llm=False),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
82
backend/tests/test_webhooks.py
Normal file
82
backend/tests/test_webhooks.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.webhooks.delivery import (
|
||||
canonical_json,
|
||||
should_retry,
|
||||
webhook_signature,
|
||||
)
|
||||
from services.webhooks.events import analysis_completed_payload
|
||||
from services.webhooks.security import UnsafeWebhookUrl, validate_webhook_url
|
||||
|
||||
|
||||
class WebhookSecurityTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_requires_https(self):
|
||||
with self.assertRaises(UnsafeWebhookUrl):
|
||||
await validate_webhook_url("http://hooks.example.com/events")
|
||||
|
||||
async def test_rejects_loopback_ip(self):
|
||||
with self.assertRaises(UnsafeWebhookUrl):
|
||||
await validate_webhook_url("https://127.0.0.1/events")
|
||||
|
||||
async def test_rejects_hostname_resolving_to_private_ip(self):
|
||||
records = [(2, 1, 6, "", ("10.0.0.8", 443))]
|
||||
with patch("services.webhooks.security.socket.getaddrinfo", return_value=records):
|
||||
with self.assertRaises(UnsafeWebhookUrl):
|
||||
await validate_webhook_url("https://hooks.example.com/events")
|
||||
|
||||
async def test_accepts_hostname_resolving_to_public_ip(self):
|
||||
records = [(2, 1, 6, "", ("8.8.8.8", 443))]
|
||||
with patch("services.webhooks.security.socket.getaddrinfo", return_value=records):
|
||||
result = await validate_webhook_url("https://hooks.example.com/events")
|
||||
self.assertEqual(result, "https://hooks.example.com/events")
|
||||
|
||||
|
||||
class WebhookDeliveryTest(unittest.TestCase):
|
||||
def test_signature_uses_timestamp_dot_raw_body(self):
|
||||
body = canonical_json({"b": 2, "a": "中文"})
|
||||
expected = hmac.new(
|
||||
b"secret",
|
||||
b"1700000000." + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
self.assertEqual(
|
||||
webhook_signature("secret", "1700000000", body),
|
||||
f"v1={expected}",
|
||||
)
|
||||
|
||||
def test_only_transient_responses_are_retried(self):
|
||||
for status in (None, 408, 429, 500, 503):
|
||||
self.assertTrue(should_retry(status))
|
||||
for status in (200, 301, 400, 401, 404):
|
||||
self.assertFalse(should_retry(status))
|
||||
|
||||
def test_analysis_event_has_stable_envelope(self):
|
||||
conversation = SimpleNamespace(
|
||||
id="conv_001",
|
||||
assistant_id="asst_001",
|
||||
assistant_name="销售助手",
|
||||
)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
payload = analysis_completed_payload(
|
||||
conversation, # type: ignore[arg-type]
|
||||
{"customer_intent": "high"},
|
||||
event_id="evt_001",
|
||||
timestamp=datetime(2026, 8, 7, tzinfo=UTC),
|
||||
)
|
||||
self.assertEqual(payload["id"], "evt_001")
|
||||
self.assertEqual(payload["event"], "call.analysis.completed")
|
||||
self.assertEqual(payload["data"]["conversationId"], "conv_001")
|
||||
self.assertEqual(
|
||||
payload["data"]["analysis"], {"customer_intent": "high"}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"name": "AI 视频助手",
|
||||
"name": "AI 视频助手 · 开发者文档",
|
||||
"theme": "mint",
|
||||
"colors": {
|
||||
"primary": "#1b2741",
|
||||
@@ -45,12 +45,8 @@
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "开始使用",
|
||||
"pages": ["index", "quickstart"]
|
||||
},
|
||||
{
|
||||
"group": "产品与对接",
|
||||
"pages": ["features", "integrations"]
|
||||
"group": "开始与对接",
|
||||
"pages": ["index", "integrations", "realtime-protocol"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -40,6 +40,10 @@ make db-seed
|
||||
|
||||
保存后,从助手详情页打开语音预览即可测试。
|
||||
|
||||
## 4. 对接 Realtime 协议
|
||||
|
||||
- [Realtime 兼容协议设计](/realtime-protocol):了解后端提供的 OpenAI Realtime 兼容 WebRTC、WebSocket、鉴权与 Interactive Media 扩展协议。
|
||||
|
||||
<Note>
|
||||
浏览器麦克风仅在 localhost 或 HTTPS 下可用。局域网、远程环境请按仓库中的 `deploy/README.md` 配置 HTTPS 与 TURN。
|
||||
</Note>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
693
docs-developer/realtime-protocol.mdx
Normal file
693
docs-developer/realtime-protocol.mdx
Normal file
@@ -0,0 +1,693 @@
|
||||
---
|
||||
title: Realtime 兼容协议设计
|
||||
description: 面向集成方的 OpenAI Realtime 兼容 WebRTC、WebSocket 与扩展事件协议。
|
||||
icon: radio
|
||||
---
|
||||
|
||||
# Realtime 兼容协议设计
|
||||
|
||||
> 当前实现版本为 `Interactive Media Realtime Extensions v1`。OpenAI 兼容接口与现有 `/api/webrtc/offer`、`/ws/voice`、`/ws/stream`、RTVI 消息并行提供;RTVI 不是待删除的迁移接口。
|
||||
|
||||
## 设计目标
|
||||
|
||||
AI 视频助手以 OpenAI Realtime 协议作为对外接口,同时保留助手、知识库、工具、工作流、动态变量和视频输入等现有能力。
|
||||
|
||||
协议适配层只负责连接、事件和 Pipecat Frame 之间的转换,不引入第二套语音运行时:
|
||||
|
||||
```text
|
||||
OpenAI Realtime 客户端
|
||||
│
|
||||
│ WebRTC / WebSocket
|
||||
▼
|
||||
Realtime 协议适配层
|
||||
│
|
||||
│ Pipecat transport / frame
|
||||
▼
|
||||
现有 run_pipeline()
|
||||
│
|
||||
├── Prompt / Workflow / External Agent
|
||||
├── ASR / LLM / TTS / Realtime Model
|
||||
├── Knowledge / System、HTTP、Client、MCP Tools
|
||||
└── History / Handoff / End Call
|
||||
```
|
||||
|
||||
协议分为两层:
|
||||
|
||||
| 层级 | 约束 | 面向对象 |
|
||||
| --- | --- | --- |
|
||||
| OpenAI Realtime Core | 保持标准事件名称、字段含义和生命周期,不改变标准事件语义 | 任意 OpenAI Realtime 兼容客户端 |
|
||||
| `x.interactive_media.*` Extensions | 所有项目扩展均有命名空间和能力协商 | 了解 Interactive Media 扩展的客户端 |
|
||||
|
||||
普通兼容客户端不声明扩展能力时,服务端不会发送 `x.interactive_media.*` 事件。它仍可使用音频、文本、图片、转写、回复流、打断和函数调用,但不会获得工作流节点、动态变量、连续视频等项目专属状态。
|
||||
|
||||
## 接口概览
|
||||
|
||||
假设服务地址为 `https://api.example.com`:
|
||||
|
||||
| 接口 | 用途 | 推荐客户端 |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/realtime/client_secrets` | 创建短期、绑定助手的临时令牌 | 浏览器或移动端 |
|
||||
| `POST /v1/realtime/calls` | 用 SDP Offer 创建 WebRTC 会话并返回 SDP Answer | 浏览器、移动端 |
|
||||
| `wss://api.example.com/v1/realtime?model=assistant:<id>` | 创建 WebSocket Realtime 会话 | 服务端、话务网关、自定义客户端 |
|
||||
|
||||
`model` 使用 `assistant:<id>`,其中 `id` 是当前数据库中的助手 ID,例如:
|
||||
|
||||
```text
|
||||
assistant:asst_xxx
|
||||
```
|
||||
|
||||
这里的 `model` 代表一个完整助手,而不是直接选择底层模型。助手绑定的系统提示词、模型凭证、知识库、服务端工具、工作流和运行模式仍由服务端控制。
|
||||
|
||||
### 鉴权
|
||||
|
||||
- 长期 API Key 仅供可信服务端使用,通过 `Authorization: Bearer <api-key>` 发送。
|
||||
- 浏览器先由自己的业务后端创建临时令牌,再用临时令牌连接 Realtime 接口。
|
||||
- 临时令牌绑定助手和会话配置,默认有效期为 60 秒;首版不保证单次使用,令牌只用于建连,建连后到期不会中止会话。
|
||||
- 父长期 Key 被撤销或过期后,尚未使用的临时令牌立即失效。
|
||||
- 管理后台 Cookie 不属于公开兼容协议,也不应作为第三方客户端的鉴权方式。
|
||||
- 会话和事件不会返回底层模型密钥、助手系统提示词或工具密钥。
|
||||
|
||||
### 长期 API Key 管理
|
||||
|
||||
Realtime Key 存在数据库中,每个 Key 默认可访问当前及未来创建的全部助手。管理接口仅允许后台管理员 Cookie 或 Basic Auth 调用:
|
||||
|
||||
| 接口 | 行为 |
|
||||
| --- | --- |
|
||||
| `POST /api/realtime/api-keys` | 创建 Key;完整 `sk-rt-...` 只在本次响应显示一次 |
|
||||
| `GET /api/realtime/api-keys` | 查看名称、前缀、有效期、最后使用时间和状态,不返回密钥 |
|
||||
| `DELETE /api/realtime/api-keys/{id}` | 逻辑撤销并保留审计记录 |
|
||||
|
||||
轮换方式为先创建新 Key,再撤销旧 Key。服务端只保存使用部署级 pepper 计算的 HMAC-SHA256,不保存可恢复的 Key 明文。
|
||||
|
||||
## 会话配置
|
||||
|
||||
首次建连配置以及后续 `session.update` 使用标准 Realtime session 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "realtime",
|
||||
"model": "assistant:asst_xxx",
|
||||
"output_modalities": ["audio"],
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {
|
||||
"type": "audio/pcm",
|
||||
"rate": 24000
|
||||
},
|
||||
"turn_detection": {
|
||||
"type": "server_vad"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"format": {
|
||||
"type": "audio/pcm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端可修改输出模态,以及输入端 Server VAD / Push-to-Talk 参数。服务端在 `session.updated` 中返回**实际生效**的配置。
|
||||
|
||||
以下内容始终以服务端助手配置为准:
|
||||
|
||||
- 助手身份和基础系统提示词;
|
||||
- Pipeline 或 Realtime 运行模式;
|
||||
- 底层模型资源及凭证;
|
||||
- 知识库和服务端 System、HTTP、MCP 工具;
|
||||
- 工作流图、结束条件和接管策略。
|
||||
- voice、工具定义、系统提示词和每次回复的临时指令。
|
||||
|
||||
服务端不能静默接受一个实际未生效的字段。字段不支持、被锁定或没有权限时,应返回标准 `error` 事件。
|
||||
|
||||
## WebRTC
|
||||
|
||||
WebRTC 是浏览器和移动端实时音视频交互的首选通道。音频和可选视频走媒体轨道,JSON 事件走 DataChannel。
|
||||
|
||||
### 建连方式一:统一接口
|
||||
|
||||
可信业务后端向 `/v1/realtime/calls` 发送 `multipart/form-data`:
|
||||
|
||||
| 表单字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `sdp` | string | 浏览器生成的 SDP Offer |
|
||||
| `session` | JSON string | Realtime session 配置,必须选择一个已授权助手 |
|
||||
|
||||
请求使用长期 API Key 鉴权。成功响应为 `Content-Type: application/sdp` 的 SDP Answer。
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.example.com/v1/realtime/calls" \
|
||||
-H "Authorization: Bearer $REALTIME_API_KEY" \
|
||||
-F 'sdp=<offer.sdp' \
|
||||
-F 'session={"type":"realtime","model":"assistant:asst_xxx"}'
|
||||
```
|
||||
|
||||
业务浏览器不应直接持有示例中的长期 API Key。它通常把 Offer 发给自己的业务后端,由后端完成上述调用。
|
||||
|
||||
### 建连方式二:临时令牌
|
||||
|
||||
1. 可信业务后端使用长期 API Key 调用 `/v1/realtime/client_secrets`。
|
||||
2. 服务端返回绑定助手的短期令牌。
|
||||
3. 浏览器创建 `RTCPeerConnection`、音频轨道和 `oai-events` DataChannel。
|
||||
4. 浏览器以临时令牌调用 `/v1/realtime/calls`,请求体为原始 SDP Offer,类型为 `application/sdp`。
|
||||
5. 浏览器把响应中的 SDP Answer 设置为远端描述。
|
||||
|
||||
创建令牌的请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"model": "assistant:asst_xxx",
|
||||
"output_modalities": ["audio"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
浏览器侧的核心连接过程如下:
|
||||
|
||||
```js
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const remoteAudio = new Audio();
|
||||
remoteAudio.autoplay = true;
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
if (event.track.kind === "audio") {
|
||||
remoteAudio.srcObject = event.streams[0];
|
||||
}
|
||||
};
|
||||
|
||||
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
pc.addTrack(localStream.getAudioTracks()[0], localStream);
|
||||
|
||||
const events = pc.createDataChannel("oai-events");
|
||||
events.onmessage = (event) => {
|
||||
const serverEvent = JSON.parse(event.data);
|
||||
console.log(serverEvent);
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const answerSdp = await fetch("https://api.example.com/v1/realtime/calls", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ephemeralToken}`,
|
||||
"Content-Type": "application/sdp"
|
||||
},
|
||||
body: offer.sdp
|
||||
}).then((response) => response.text());
|
||||
|
||||
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
|
||||
```
|
||||
|
||||
### WebRTC 数据分工
|
||||
|
||||
| 数据 | 通道 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 用户麦克风 | WebRTC audio track | 不再通过 JSON 重复发送音频块 |
|
||||
| 助手语音 | WebRTC remote audio track | 浏览器直接播放,不依赖 `response.output_audio.delta` 拼接 |
|
||||
| 标准和扩展事件 | `oai-events` DataChannel | 使用 UTF-8 JSON 文本,保持可靠、有序传输 |
|
||||
| 用户摄像头 | 可选 WebRTC video track | 通过 `video_track` capability 协商,非 OpenAI 标准能力 |
|
||||
|
||||
摄像头轨道只在助手启用视觉、令牌允许且 SDP 协商成功时接收。普通 OpenAI 兼容客户端不添加视频轨道,仍可正常完成语音和文本会话。
|
||||
|
||||
切换摄像头、静音、音量控制和 `RTCPeerConnection.getStats()` 属于客户端 WebRTC 能力,不需要新增协议事件。切换设备时可使用 `RTCRtpSender.replaceTrack()`,无需重建会话。
|
||||
|
||||
## WebSocket
|
||||
|
||||
WebSocket 适合服务到服务、话务网关和希望自行处理音频缓冲的客户端。连接时在 query 中指定助手:
|
||||
|
||||
```text
|
||||
wss://api.example.com/v1/realtime?model=assistant%3Aasst_xxx
|
||||
```
|
||||
|
||||
可信服务端在握手请求中发送:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <api-key-or-ephemeral-token>
|
||||
```
|
||||
|
||||
浏览器无法设置 `Authorization` 握手头时,可同时发送 `realtime` 与 `openai-insecure-api-key.<token>` WebSocket 子协议;服务端选择 `realtime`。这只适用于短期令牌,不应把长期 Key 放入浏览器。
|
||||
|
||||
连接成功后,客户端和服务端都只发送 UTF-8 JSON 文本帧。浏览器无法安全保存长期 API Key,浏览器实时通话仍应优先使用 WebRTC。
|
||||
|
||||
### 文本输入
|
||||
|
||||
先创建用户消息,再请求生成回复:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_text_001",
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "请介绍办理流程"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_response_001",
|
||||
"type": "response.create"
|
||||
}
|
||||
```
|
||||
|
||||
### 音频输入和输出
|
||||
|
||||
WebSocket 音频以 Base64 编码放入标准事件:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_audio_001",
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": "<base64-pcm-bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
- 开启 Server VAD 时,服务端自动产生 `speech_started`、`speech_stopped` 并触发回复。
|
||||
- 关闭 VAD 时,客户端发送 `input_audio_buffer.commit`,然后发送 `response.create`。
|
||||
- 服务端通过 `response.output_audio.delta` 返回 Base64 音频块,并以 `response.output_audio.done` 结束。
|
||||
- 助手文本或音频转写分别通过 `response.output_text.*` 和 `response.output_audio_transcript.*` 返回。
|
||||
|
||||
WebSocket MVP 不传输连续原始视频。视觉输入使用标准 `input_image`;需要连续摄像头的客户端使用 WebRTC 视频轨道。
|
||||
|
||||
### 图片输入
|
||||
|
||||
图片和文本可放在同一个用户消息中:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "画面里有什么?"
|
||||
},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/jpeg;base64,<base64-image>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Push-to-Talk
|
||||
|
||||
Push-to-Talk(按住说话)属于 OpenAI Realtime Core 兼容能力,不需要新增 `x.interactive_media.push_to_talk.*` 扩展事件。按钮是否按下、快捷键和麦克风 UI 状态由客户端本地管理,服务端只处理标准音频缓冲与回复事件。
|
||||
|
||||
### 关闭自动 VAD
|
||||
|
||||
客户端建立会话后先通过 `session.update` 关闭自动轮次检测:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_ptt_config_001",
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"audio": {
|
||||
"input": {
|
||||
"turn_detection": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关闭 VAD 后,服务端不会根据静音自动提交用户语音或创建回复。客户端必须在用户松开按钮时发送 `input_audio_buffer.commit`,然后发送 `response.create`。
|
||||
|
||||
### WebSocket 时序
|
||||
|
||||
WebSocket 的音频和控制事件在同一条有序连接上传输:
|
||||
|
||||
| 阶段 | 客户端行为 |
|
||||
| --- | --- |
|
||||
| 按下 | 开始在客户端录音;如果已有活动回复,发送 `response.cancel` |
|
||||
| 打断播放 | 立即停止本地音频播放,并发送 `conversation.item.truncate`,用 `audio_end_ms` 删除用户未听到的内容 |
|
||||
| 松开 | 通过一个或多个 `input_audio_buffer.append` 发送本次录音 |
|
||||
| 提交 | 依次发送 `input_audio_buffer.commit` 和 `response.create` |
|
||||
|
||||
完整的松开事件序列如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_ptt_audio_001",
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": "<base64-pcm-bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_ptt_commit_001",
|
||||
"type": "input_audio_buffer.commit"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_ptt_response_001",
|
||||
"type": "response.create"
|
||||
}
|
||||
```
|
||||
|
||||
如果用户在录音完成前取消本次输入,客户端应丢弃本地录音;已经发送到服务端的未提交音频则使用 `input_audio_buffer.clear` 清除。
|
||||
|
||||
### WebRTC 时序
|
||||
|
||||
WebRTC 的音频媒体轨道和 `oai-events` 控制事件属于不同通道,因此开始新一轮输入前必须显式清理旧缓冲:
|
||||
|
||||
| 阶段 | 客户端行为 |
|
||||
| --- | --- |
|
||||
| 按下 | 先发送 `input_audio_buffer.clear`,再开始或放开本地麦克风输入门控 |
|
||||
| 打断生成 | 如果已有活动回复,发送 `response.cancel` |
|
||||
| 打断播放 | 如果助手音频仍在播放,发送 `output_audio_buffer.clear`;服务端同时截断未播放的对话内容 |
|
||||
| 松开 | 关闭本地麦克风输入门控,依次发送 `input_audio_buffer.commit` 和 `response.create` |
|
||||
|
||||
```js
|
||||
function sendEvent(event) {
|
||||
dataChannel.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
function onPushDown() {
|
||||
sendEvent({ type: "input_audio_buffer.clear" });
|
||||
|
||||
if (hasActiveResponse) {
|
||||
sendEvent({ type: "response.cancel" });
|
||||
}
|
||||
|
||||
if (isAssistantAudioPlaying) {
|
||||
sendEvent({ type: "output_audio_buffer.clear" });
|
||||
}
|
||||
|
||||
microphoneTrack.enabled = true;
|
||||
}
|
||||
|
||||
function onPushUp() {
|
||||
microphoneTrack.enabled = false;
|
||||
sendEvent({ type: "input_audio_buffer.commit" });
|
||||
sendEvent({ type: "response.create" });
|
||||
}
|
||||
```
|
||||
|
||||
客户端应处理指针移出、窗口失焦和权限撤销等情况,确保一次按下只产生一次提交。没有有效音频时不要发送 `input_audio_buffer.commit`。
|
||||
|
||||
### 保留 VAD、手动触发回复
|
||||
|
||||
如果只想由客户端决定何时生成回复,但仍希望服务端判断说话开始和结束,可以保留 VAD,并关闭自动回复及自动打断:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"audio": {
|
||||
"input": {
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"interrupt_response": false,
|
||||
"create_response": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这种模式适合在提交前进行审核、输入校验或知识检索,不等同于严格的按住说话。
|
||||
|
||||
## 标准事件范围
|
||||
|
||||
首版兼容层至少支持以下 OpenAI Realtime 事件。
|
||||
|
||||
### 客户端发送
|
||||
|
||||
| 事件 | 用途 |
|
||||
| --- | --- |
|
||||
| `session.update` | 更新允许覆盖的会话配置 |
|
||||
| `conversation.item.create` | 发送文本、整段音频、图片或函数结果 |
|
||||
| `conversation.item.truncate` | 打断后删除未播放的助手内容 |
|
||||
| `input_audio_buffer.append` | WebSocket 追加音频 |
|
||||
| `input_audio_buffer.commit` | 无 VAD 时提交音频 |
|
||||
| `input_audio_buffer.clear` | 清除未提交音频 |
|
||||
| `output_audio_buffer.clear` | WebRTC/SIP 清除未播放音频并截断上下文 |
|
||||
| `response.create` | 请求助手生成回复 |
|
||||
| `response.cancel` | 取消正在生成的回复 |
|
||||
|
||||
### 服务端发送
|
||||
|
||||
| 事件组 | 事件 |
|
||||
| --- | --- |
|
||||
| 会话 | `session.created`、`session.updated`、`error` |
|
||||
| 用户语音 | `input_audio_buffer.speech_started`、`input_audio_buffer.speech_stopped`、`input_audio_buffer.committed` |
|
||||
| 用户转写 | `conversation.item.input_audio_transcription.completed` |
|
||||
| 对话项 | `conversation.item.added`、`conversation.item.done` |
|
||||
| 回复生命周期 | `response.created`、`response.output_item.added`、`response.output_item.done`、`response.done` |
|
||||
| 文本 | `response.output_text.delta`、`response.output_text.done` |
|
||||
| 音频 | `response.output_audio.delta`、`response.output_audio.done` |
|
||||
| 音频转写 | `response.output_audio_transcript.delta`、`response.output_audio_transcript.done` |
|
||||
| 函数调用 | `response.function_call_arguments.delta`、`response.function_call_arguments.done` |
|
||||
|
||||
WebRTC 中的助手音频本体走媒体轨道,因此客户端不应依赖 `response.output_audio.delta` 播放声音;音频生命周期和转写事件仍通过 `oai-events` 发送。
|
||||
|
||||
## 首版兼容矩阵
|
||||
|
||||
| 分类 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| WebRTC 音频、`oai-events` DataChannel | 支持 | 复用 SmallWebRTC 和现有 Pipeline |
|
||||
| WebSocket 文本与 24 kHz PCM16 mono Base64 音频 | 支持 | 只接受 UTF-8 JSON 文本帧 |
|
||||
| 文本轮次、Server VAD、PTT、取消和清空输出 | 支持 | `turn_detection: null` 时由 `commit` + `response.create` 结束轮次 |
|
||||
| `input_image` Data URL | 条件支持 | 仅 Pipeline 模式且助手已授权视觉输入;Realtime 模式明确报错 |
|
||||
| Client Tool 标准 function call | 支持 | System、HTTP、MCP 工具仍在服务端内部执行 |
|
||||
| `x.interactive_media.*` | 协商后支持 | 未协商的客户端不会收到扩展事件 |
|
||||
| `model`、instructions、voice、工具和系统提示词 | 锁定 | 由助手配置决定,覆盖请求返回标准 `error` |
|
||||
| WebSocket 其他采样率、压缩音频、连续视频 | 暂不支持 | WebRTC 音频由 SDP 协商;连续视频仅走 WebRTC |
|
||||
| 公开助手别名、断线续传、并发回复、SIP | 暂不支持 | `model` 必须使用实际的 `assistant:asst_xxx` |
|
||||
|
||||
Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或自动轮次检测;建立连接后再次切换会返回明确错误。Pipeline 与支持动态更新的 Realtime provider 可在会话内切换。
|
||||
|
||||
## 工具调用
|
||||
|
||||
服务端 System、HTTP 和 MCP 工具在现有运行时内部执行。Client 工具使用标准函数调用协议,不再建立一套平行的 `client-tool-call` 公开协议。
|
||||
|
||||
服务端完成函数参数后发送标准函数调用事件;客户端执行函数并回传:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_abc123",
|
||||
"output": "{\"ok\":true,\"result\":{\"ticket_id\":\"T-1001\"}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
随后发送 `response.create` 让助手基于工具结果继续当前轮次。`call_id` 在整个会话内唯一,客户端应原样返回。
|
||||
|
||||
## Interactive Media 扩展
|
||||
|
||||
### 能力协商
|
||||
|
||||
客户端收到 `session.created` 后主动声明所需扩展:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_capabilities_001",
|
||||
"type": "x.interactive_media.capabilities.update",
|
||||
"capabilities": [
|
||||
"workflow_events",
|
||||
"dynamic_variables",
|
||||
"handoff",
|
||||
"video_track"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
服务端返回实际接受的能力:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_server_001",
|
||||
"type": "x.interactive_media.capabilities.updated",
|
||||
"capabilities": [
|
||||
"workflow_events",
|
||||
"dynamic_variables",
|
||||
"handoff",
|
||||
"video_track"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
```
|
||||
|
||||
服务端只发送已接受能力对应的扩展事件。扩展版本与 OpenAI Realtime Core 独立演进。
|
||||
|
||||
### 扩展事件目录
|
||||
|
||||
| 方向 | 事件 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| Client → Server | `x.interactive_media.capabilities.update` | 请求扩展能力 |
|
||||
| Server → Client | `x.interactive_media.capabilities.updated` | 返回允许的能力 |
|
||||
| Client → Server | `x.interactive_media.session.variables.update` | 静默更新动态变量,不创建用户消息 |
|
||||
| Server → Client | `x.interactive_media.session.variables.updated` | 确认变量更新状态 |
|
||||
| Server → Client | `x.interactive_media.workflow.node_active` | 当前工作流节点变化 |
|
||||
| Server → Client | `x.interactive_media.workflow.variables.updated` | 工作流变量变化 |
|
||||
| Server → Client | `x.interactive_media.workflow.event` | 工作流业务事件 |
|
||||
| Server → Client | `x.interactive_media.workflow.error` | 工作流执行失败 |
|
||||
| Server → Client | `x.interactive_media.call.handoff_requested` | 助手请求人工接管 |
|
||||
| Server → Client | `x.interactive_media.call.ended` | 会话结束及原因 |
|
||||
|
||||
动态变量更新示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_variables_001",
|
||||
"type": "x.interactive_media.session.variables.update",
|
||||
"update_id": "update_001",
|
||||
"variables": {
|
||||
"user_name": "王先生",
|
||||
"region": "上海"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_server_002",
|
||||
"type": "x.interactive_media.session.variables.updated",
|
||||
"update_id": "update_001",
|
||||
"status": "accepted"
|
||||
}
|
||||
```
|
||||
|
||||
工作流节点事件示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_server_003",
|
||||
"type": "x.interactive_media.workflow.node_active",
|
||||
"nodeId": "collect_materials"
|
||||
}
|
||||
```
|
||||
|
||||
会话结束示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_server_004",
|
||||
"type": "x.interactive_media.call.ended",
|
||||
"reason": "assistant_completed"
|
||||
}
|
||||
```
|
||||
|
||||
`x.interactive_media.call.ended` 发出后,服务端停止产生新回复并正常关闭媒体与信令通道。未启用扩展的客户端只观察到标准回复结束和连接关闭。
|
||||
|
||||
## 打断和回复并发
|
||||
|
||||
- 默认同一会话只允许一个活动回复。
|
||||
- 用户开始说话且助手允许打断时,服务端取消当前生成并停止后续音频输出。
|
||||
- 客户端可显式发送 `response.cancel`。
|
||||
- WebSocket 客户端自行管理音频播放,因此停止播放后应发送 `conversation.item.truncate`,用 `audio_end_ms` 告诉服务端用户实际听到的位置。
|
||||
- WebRTC/SIP 的输出音频由服务端缓冲;VAD 打断时由服务端自动截断,显式 Push-to-Talk 打断使用 `output_audio_buffer.clear`。
|
||||
- 助手配置禁止打断时,服务端继续当前回复,并对不允许的显式取消返回 `error`。
|
||||
|
||||
## 错误格式
|
||||
|
||||
协议错误使用标准 `error` 事件,不使用只存在于某个传输的自定义错误结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_server_error_001",
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": "session_field_locked",
|
||||
"message": "The assistant does not allow overriding instructions.",
|
||||
"param": "session.instructions",
|
||||
"event_id": "evt_update_001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常见错误码包括:
|
||||
|
||||
| code | 含义 |
|
||||
| --- | --- |
|
||||
| `authentication_failed` | API Key 或临时令牌无效 |
|
||||
| `assistant_not_found` | 助手不存在、未发布或不可访问 |
|
||||
| `session_field_locked` | 客户端尝试覆盖服务端锁定配置 |
|
||||
| `unsupported_event` | 当前协议版本不支持该事件 |
|
||||
| `capability_not_enabled` | 未协商或无权使用某项扩展 |
|
||||
| `response_in_progress` | 已有活动回复且当前请求不能并发执行 |
|
||||
| `tool_result_timeout` | Client 工具未在规定时间内返回 |
|
||||
| `internal_error` | 运行时出现不可恢复错误 |
|
||||
|
||||
错误只终止相关事件;只有鉴权失败、协议严重错误或运行时不可恢复时才关闭整个连接。
|
||||
|
||||
## 事件顺序和重连
|
||||
|
||||
- WebSocket 文本帧和 `oai-events` DataChannel 都按连接内的发送顺序处理。
|
||||
- 客户端事件建议携带唯一 `event_id`;服务端错误通过 `error.event_id` 指回原事件。
|
||||
- `session.created` 是连接建立后的第一个业务事件。
|
||||
- 每个回复由 `response.created` 开始,以 `response.done` 结束;Delta 必须携带所属的 response、item 和 content 索引。
|
||||
- `Interactive Media Realtime Extensions v1` 不支持会话断线续传。重连会创建新会话;服务端历史记录仍按平台策略持久化。
|
||||
- 客户端不得把收到的旧连接事件写入新连接。
|
||||
|
||||
## 现有能力映射
|
||||
|
||||
| 现有能力或旧事件 | 新协议 |
|
||||
| --- | --- |
|
||||
| WebRTC 麦克风与助手音频 | WebRTC audio track |
|
||||
| 摄像头连续输入 | 可选 WebRTC video track + `video_track` capability |
|
||||
| `user-input` 文本或图片 | `conversation.item.create` + `response.create` |
|
||||
| `session-update` | `x.interactive_media.session.variables.update` |
|
||||
| `transcript` 用户转写 | `conversation.item.input_audio_transcription.*` |
|
||||
| `assistant-text-start/delta/end` | `response.output_text.*` 或 `response.output_audio_transcript.*` |
|
||||
| `client-tool-call/result` | 标准 function call + `function_call_output` |
|
||||
| `node-active` | `x.interactive_media.workflow.node_active` |
|
||||
| `workflow-variables` | `x.interactive_media.workflow.variables.updated` |
|
||||
| `workflow-event` | `x.interactive_media.workflow.event` |
|
||||
| `call-ended` | `x.interactive_media.call.ended` |
|
||||
| 知识库、服务端工具、工作流执行 | 继续在现有运行时内部完成 |
|
||||
|
||||
Pipeline 与 Realtime 两种助手运行模式对客户端使用同一套北向事件。协议适配层负责把不同内部模型事件归一化,客户端不需要根据运行模式切换协议。
|
||||
|
||||
原有 RTVI 入口和消息格式继续并行保留,当前前端不需要迁移;新兼容接口面向新的第三方集成。
|
||||
|
||||
## 传输选择
|
||||
|
||||
| 场景 | 推荐传输 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 浏览器实时语音、打断和摄像头 | WebRTC | 媒体自适应、播放简单、支持视频轨道 |
|
||||
| 移动端实时语音 | WebRTC | 更适合不稳定网络和双向媒体 |
|
||||
| 服务端 Agent 或批处理式实时流 | WebSocket | JSON 与音频缓冲可完全由调用方控制 |
|
||||
| 电话网关 | WebSocket | 易于接入现有 PCM/PCMU 音频桥接 |
|
||||
| 纯文本 Realtime | WebSocket | 无需建立媒体轨道 |
|
||||
|
||||
## 兼容性原则
|
||||
|
||||
1. 不修改标准事件的名称和含义。
|
||||
2. 所有项目扩展使用 `x.interactive_media.*` 命名空间。
|
||||
3. 扩展必须先协商再发送,普通客户端只看到标准事件。
|
||||
4. 未支持字段明确报错,不静默伪装为成功。
|
||||
5. OpenAI Realtime Core 和 Interactive Media 扩展分别进行版本管理。
|
||||
6. 新协议只替换北向连接层,不改变现有 Pipecat 运行时和助手配置模型。
|
||||
|
||||
## 参考
|
||||
|
||||
- [OpenAI Realtime API with WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc)
|
||||
- [OpenAI Realtime API with WebSocket](https://developers.openai.com/api/docs/guides/realtime-websocket)
|
||||
- [OpenAI Realtime conversations](https://developers.openai.com/api/docs/guides/realtime-conversations)
|
||||
- [OpenAI Realtime Push-to-Talk](https://developers.openai.com/api/docs/guides/realtime-conversations#push-to-talk)
|
||||
53
docs-user/docs.json
Normal file
53
docs-user/docs.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"name": "AI 视频助手 · 用户文档",
|
||||
"theme": "mint",
|
||||
"colors": {
|
||||
"primary": "#1b2741",
|
||||
"light": "#3a4a6b",
|
||||
"dark": "#0c1426"
|
||||
},
|
||||
"logo": {
|
||||
"light": "/logo-light.svg",
|
||||
"dark": "/logo-dark.svg",
|
||||
"href": "/"
|
||||
},
|
||||
"appearance": {
|
||||
"default": "dark"
|
||||
},
|
||||
"fonts": {
|
||||
"family": "Inter",
|
||||
"weight": 400,
|
||||
"heading": {
|
||||
"family": "Cormorant Garamond",
|
||||
"weight": 300
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"library": "lucide"
|
||||
},
|
||||
"background": {
|
||||
"decoration": "gradient",
|
||||
"color": {
|
||||
"light": "#f3f5fb",
|
||||
"dark": "#070b16"
|
||||
}
|
||||
},
|
||||
"styling": {
|
||||
"eyebrows": "breadcrumbs",
|
||||
"codeblocks": {
|
||||
"theme": {
|
||||
"light": "github-light",
|
||||
"dark": "github-dark"
|
||||
}
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "产品",
|
||||
"pages": ["index", "features"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
---
|
||||
title: AI 视频助手
|
||||
title: 产品概览
|
||||
description: 面向实时语音交互的助手管理与集成平台。
|
||||
icon: video
|
||||
---
|
||||
|
||||
# AI 视频助手
|
||||
# 产品概览
|
||||
|
||||
在一个界面中配置模型、知识库、工具和助手,并通过 WebRTC 或 WebSocket 接入实时语音对话。
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="快速开始" icon="rocket" color="#3a4a6b" href="/quickstart">
|
||||
本地启动服务并完成首次助手配置。
|
||||
</Card>
|
||||
<Card title="对接说明" icon="plug" color="#3a4a6b" href="/integrations">
|
||||
了解模型、外部 Agent 与语音通道的接入方式。
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 平台组成
|
||||
|
||||
- **助手**:定义提示词、运行模式、模型绑定及可选工作流。
|
||||
@@ -24,3 +15,8 @@ icon: video
|
||||
- **语音引擎**:Pipecat 管线支持浏览器 WebRTC 与裸 WebSocket 音频流。
|
||||
|
||||
> 凭证只保存在模型资源或助手的服务端配置中;接口返回会自动打码。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [核心功能](/features):了解助手类型、模型、知识库与工具。
|
||||
- 本地启动与对接见仓库中的 `docs-developer/`(独立开发者文档站点)。
|
||||
18
docs-user/logo-dark.svg
Normal file
18
docs-user/logo-dark.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 196 48" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="sky" cx="0" cy="0" r="1" gradientTransform="matrix(31 0 0 31 14 10)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5F86B8" stop-opacity=".75"/>
|
||||
<stop offset="1" stop-color="#5F86B8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="lavender" cx="0" cy="0" r="1" gradientTransform="matrix(28 0 0 28 38 39)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8A78AD" stop-opacity=".68"/>
|
||||
<stop offset="1" stop-color="#8A78AD" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="24" r="24" fill="#E8EDF9"/>
|
||||
<circle cx="24" cy="24" r="24" fill="url(#sky)"/>
|
||||
<circle cx="24" cy="24" r="24" fill="url(#lavender)"/>
|
||||
<rect x="14" y="17.5" width="14" height="13" rx="2.25" stroke="#0C1426" stroke-width="2.5"/>
|
||||
<path d="m28 21.25 7-4v13.5l-7-4z" stroke="#0C1426" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<text x="61" y="31" fill="#E8EDF9" font-family="Cormorant Garamond, serif" font-size="24" font-weight="400">AI视频助手</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
18
docs-user/logo-light.svg
Normal file
18
docs-user/logo-light.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 196 48" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="sky" cx="0" cy="0" r="1" gradientTransform="matrix(31 0 0 31 14 10)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A8C8E8" stop-opacity=".8"/>
|
||||
<stop offset="1" stop-color="#A8C8E8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="lavender" cx="0" cy="0" r="1" gradientTransform="matrix(28 0 0 28 38 39)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#C8B8E0" stop-opacity=".72"/>
|
||||
<stop offset="1" stop-color="#C8B8E0" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="24" r="24" fill="#E8EDF9"/>
|
||||
<circle cx="24" cy="24" r="24" fill="url(#sky)"/>
|
||||
<circle cx="24" cy="24" r="24" fill="url(#lavender)"/>
|
||||
<rect x="14" y="17.5" width="14" height="13" rx="2.25" stroke="#0C1426" stroke-width="2.5"/>
|
||||
<path d="m28 21.25 7-4v13.5l-7-4z" stroke="#0C1426" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<text x="61" y="31" fill="#0F1B33" font-family="Cormorant Garamond, serif" font-size="24" font-weight="400">AI视频助手</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
56
frontend/package-lock.json
generated
56
frontend/package-lock.json
generated
@@ -9,6 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@daily-co/daily-js": "^0.90.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@pipecat-ai/client-js": "^1.12.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
@@ -477,6 +480,59 @@
|
||||
"node": ">=22.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.71.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@daily-co/daily-js": "^0.90.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@pipecat-ai/client-js": "^1.12.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.5",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
import { CreateAssistantPage } from "@/components/pages/CreateAssistantPage";
|
||||
|
||||
export default function Page() {
|
||||
return <AssistantPage mode="choose" />;
|
||||
return <CreateAssistantPage />;
|
||||
}
|
||||
|
||||
10
frontend/src/app/assistants/templates/[id]/page.tsx
Normal file
10
frontend/src/app/assistants/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
redirect(`/assistants/templates?template=${encodeURIComponent(id)}`);
|
||||
}
|
||||
10
frontend/src/app/assistants/templates/page.tsx
Normal file
10
frontend/src/app/assistants/templates/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { TemplateLibraryPage } from "@/components/pages/TemplateLibraryPage";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ template?: string }>;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const { template } = await searchParams;
|
||||
return <TemplateLibraryPage initialTemplateId={template} />;
|
||||
}
|
||||
@@ -180,7 +180,44 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
/* Theme-aware scrollbars (light/dark navy tokens) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 48%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 42%,
|
||||
transparent
|
||||
);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: 0.01em;
|
||||
@@ -261,42 +298,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit opt-in alias; base layer already styles all scrollbars. */
|
||||
.scrollbar-subtle {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 48%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 42%,
|
||||
transparent
|
||||
);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* 手机通话页「开始通话」引导光晕:柔和向外扩散的涟漪环 */
|
||||
|
||||
5
frontend/src/app/test/batch/page.tsx
Normal file
5
frontend/src/app/test/batch/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BatchTestPage } from "@/components/pages/BatchTestPage";
|
||||
|
||||
export default function Page() {
|
||||
return <BatchTestPage />;
|
||||
}
|
||||
19
frontend/src/app/test/cases/[id]/page.tsx
Normal file
19
frontend/src/app/test/cases/[id]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ case?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { case: initialCaseId } = await searchParams;
|
||||
return (
|
||||
<TestCasesPage
|
||||
mode="detail"
|
||||
suiteId={id}
|
||||
initialCaseId={initialCaseId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/test/cases/new/page.tsx
Normal file
5
frontend/src/app/test/cases/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default function Page() {
|
||||
return <TestCasesPage mode="create" />;
|
||||
}
|
||||
5
frontend/src/app/test/cases/page.tsx
Normal file
5
frontend/src/app/test/cases/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TestCasesPage } from "@/components/pages/TestCasesPage";
|
||||
|
||||
export default function Page() {
|
||||
return <TestCasesPage mode="list" />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestPage } from "@/components/pages/TestPage";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
return <TestPage />;
|
||||
redirect("/test/cases");
|
||||
}
|
||||
|
||||
212
frontend/src/components/assistant-editor/analysis-config.tsx
Normal file
212
frontend/src/components/assistant-editor/analysis-config.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type {
|
||||
AnalysisConfig,
|
||||
AnalysisField,
|
||||
AnalysisFieldType,
|
||||
} from "@/lib/api";
|
||||
|
||||
export type { AnalysisConfig, AnalysisField, AnalysisFieldType } from "@/lib/api";
|
||||
|
||||
const FIELD_TYPE_OPTIONS: Array<{
|
||||
value: AnalysisFieldType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "string", label: "string" },
|
||||
{ value: "boolean", label: "boolean" },
|
||||
{ value: "integer", label: "integer" },
|
||||
{ value: "number", label: "number" },
|
||||
{ value: "enum", label: "enum" },
|
||||
];
|
||||
|
||||
function createField(): AnalysisField {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
type: "string",
|
||||
description: "",
|
||||
enumValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
type AnalysisConfigEditorProps = {
|
||||
config: AnalysisConfig;
|
||||
onChange: (config: AnalysisConfig) => void;
|
||||
modelOptions: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export function AnalysisConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
modelOptions,
|
||||
}: AnalysisConfigEditorProps) {
|
||||
function patch(partial: Partial<AnalysisConfig>) {
|
||||
onChange({ ...config, ...partial });
|
||||
}
|
||||
|
||||
function updateField(id: string, partial: Partial<AnalysisField>) {
|
||||
patch({
|
||||
fields: config.fields.map((field) =>
|
||||
field.id === id ? { ...field, ...partial } : field,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function removeField(id: string) {
|
||||
patch({ fields: config.fields.filter((field) => field.id !== id) });
|
||||
}
|
||||
|
||||
function addField() {
|
||||
patch({ fields: [...config.fields, createField()] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
title="通话后分析"
|
||||
hint="通话结束后,使用所选模型从对话中提取关键信息。"
|
||||
checked={config.enabled}
|
||||
onChange={(enabled) => patch({ enabled })}
|
||||
/>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<ResourceSelectField
|
||||
label="分析模型"
|
||||
value={config.modelResourceId}
|
||||
onChange={(modelResourceId) => patch({ modelResourceId })}
|
||||
options={modelOptions}
|
||||
noneLabel="请选择"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
关键信息字段
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
定义通话结束后需要从对话中提取的结构化字段。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={addField}
|
||||
aria-label="添加字段"
|
||||
title="添加字段"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.fields.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
还没有关键信息字段
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
点击右上角加号添加字段,例如 customer_intent、booked。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{config.fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_112px_minmax(0,1.2fr)_32px] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(event) =>
|
||||
updateField(field.id, { name: event.target.value })
|
||||
}
|
||||
placeholder="字段名,如 customer_intent"
|
||||
aria-label={`字段 ${index + 1} 名称`}
|
||||
className="h-9 border-hairline-strong bg-background"
|
||||
/>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(type: AnalysisFieldType) =>
|
||||
updateField(field.id, {
|
||||
type,
|
||||
enumValues: type === "enum" ? field.enumValues : [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`字段 ${index + 1} 类型`}
|
||||
className="h-9 w-full border-hairline-strong bg-background"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{field.type === "enum" ? (
|
||||
<Input
|
||||
value={field.enumValues.join(", ")}
|
||||
onChange={(event) =>
|
||||
updateField(field.id, {
|
||||
enumValues: event.target.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="枚举值,逗号分隔"
|
||||
aria-label={`字段 ${index + 1} 枚举值`}
|
||||
className="h-9 border-hairline-strong bg-background"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={field.description}
|
||||
onChange={(event) =>
|
||||
updateField(field.id, {
|
||||
description: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="说明(可选)"
|
||||
aria-label={`字段 ${index + 1} 说明`}
|
||||
className="h-9 border-hairline-strong bg-background"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-muted-soft hover:text-destructive"
|
||||
onClick={() => removeField(field.id)}
|
||||
aria-label={`删除字段 ${index + 1}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1166
frontend/src/components/assistant-editor/debug-auto-test.tsx
Normal file
1166
frontend/src/components/assistant-editor/debug-auto-test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AudioLines,
|
||||
Braces,
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AutoTestLivePanel,
|
||||
AutoTestRunBar,
|
||||
DebugModeTabs,
|
||||
useAutoTestRunner,
|
||||
} from "@/components/assistant-editor/debug-auto-test";
|
||||
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
|
||||
import { ClientMessageDialog } from "@/components/client-message-dialog";
|
||||
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
|
||||
@@ -49,6 +55,7 @@ import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
|
||||
import { useCameraPreview, type CameraPreview } from "@/hooks/use-camera-preview";
|
||||
import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
|
||||
import {
|
||||
createUserInputId,
|
||||
useVoicePreview,
|
||||
type ChatMessage,
|
||||
type ClientToolDefinition,
|
||||
@@ -66,6 +73,7 @@ type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
||||
// 调试面板顶部主视图:聊天记录 / 视频流
|
||||
type DebugView = "chat" | "video";
|
||||
type DebugInputMode = "mic" | "text";
|
||||
type DebugMode = "manual" | "auto";
|
||||
type PendingDebugImage = {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
@@ -174,6 +182,9 @@ export function DebugDrawer({
|
||||
}) {
|
||||
const preview = useVoicePreview(assistantId, onNodeActive);
|
||||
const camera = useCameraPreview();
|
||||
const autoTest = useAutoTestRunner();
|
||||
const stopAutoTest = autoTest.stop;
|
||||
const [debugMode, setDebugMode] = useState<DebugMode>("manual");
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
||||
const [view, setView] = useState<DebugView>("chat");
|
||||
@@ -217,6 +228,20 @@ export function DebugDrawer({
|
||||
[camera, preview],
|
||||
);
|
||||
|
||||
// 切到自动测试时停掉手动会话,避免两边抢占麦克风/对话区
|
||||
const handleModeChange = useCallback(
|
||||
(mode: DebugMode) => {
|
||||
setDebugMode(mode);
|
||||
if (mode === "auto" && recording) {
|
||||
preview.disconnect();
|
||||
}
|
||||
if (mode === "manual") {
|
||||
stopAutoTest();
|
||||
}
|
||||
},
|
||||
[preview, recording, stopAutoTest],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={overlay
|
||||
@@ -239,14 +264,18 @@ export function DebugDrawer({
|
||||
<div className="shrink-0 text-sm font-medium text-foreground">
|
||||
调试与预览
|
||||
</div>
|
||||
<NetworkQualityIndicator
|
||||
quality={preview.networkQuality}
|
||||
status={preview.status}
|
||||
/>
|
||||
<DebugConnectionStatus
|
||||
status={preview.status}
|
||||
micWarning={preview.micWarning}
|
||||
/>
|
||||
{debugMode === "manual" && (
|
||||
<>
|
||||
<NetworkQualityIndicator
|
||||
quality={preview.networkQuality}
|
||||
status={preview.status}
|
||||
/>
|
||||
<DebugConnectionStatus
|
||||
status={preview.status}
|
||||
micWarning={preview.micWarning}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ClientToolsPopover tools={preview.clientTools} />
|
||||
@@ -260,7 +289,7 @@ export function DebugDrawer({
|
||||
onChange={setDynamicVariableValues}
|
||||
/>
|
||||
)}
|
||||
{SHOW_VOICE_VIZ && view === "chat" && (
|
||||
{debugMode === "manual" && SHOW_VOICE_VIZ && view === "chat" && (
|
||||
<>
|
||||
{!showTranscript && (
|
||||
<SegmentedIconGroup label="可视化样式">
|
||||
@@ -296,28 +325,69 @@ export function DebugDrawer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
|
||||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||||
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2.5 border-b border-hairline px-5 py-2.5">
|
||||
<DebugModeTabs mode={debugMode} onChange={handleModeChange} />
|
||||
{debugMode === "manual" && (
|
||||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||||
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DebugVoicePanel
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
showTranscript={showTranscript}
|
||||
vizStyle={vizStyle}
|
||||
assistantId={assistantId}
|
||||
preview={preview}
|
||||
camera={camera}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
vision={vision}
|
||||
dynamicVariables={resolvedDynamicVariables}
|
||||
dynamicVariablesError={dynamicVariablesError}
|
||||
/>
|
||||
{debugMode === "auto" ? (
|
||||
<AutoTestPanel autoTest={autoTest} />
|
||||
) : (
|
||||
<DebugVoicePanel
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
showTranscript={showTranscript}
|
||||
vizStyle={vizStyle}
|
||||
assistantId={assistantId}
|
||||
preview={preview}
|
||||
camera={camera}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
vision={vision}
|
||||
dynamicVariables={resolvedDynamicVariables}
|
||||
dynamicVariablesError={dynamicVariablesError}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoTestPanel({
|
||||
autoTest,
|
||||
}: {
|
||||
autoTest: ReturnType<typeof useAutoTestRunner>;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
selectedCase,
|
||||
selectCase,
|
||||
clearCase,
|
||||
start,
|
||||
stop,
|
||||
rerun,
|
||||
} = autoTest;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<AutoTestLivePanel testCase={selectedCase} state={state} />
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
||||
<AutoTestRunBar
|
||||
state={state}
|
||||
onSelectCase={selectCase}
|
||||
onClearCase={clearCase}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
onRerun={rerun}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientToolsPopover({ tools }: { tools: ClientToolDefinition[] }) {
|
||||
const [copiedToolName, setCopiedToolName] = useState<string | null>(null);
|
||||
|
||||
@@ -799,6 +869,7 @@ function DebugVoicePanel({
|
||||
sendText,
|
||||
sendUserInput,
|
||||
appendUserImage,
|
||||
removeUserImage,
|
||||
connect,
|
||||
disconnect,
|
||||
audioRef,
|
||||
@@ -866,6 +937,7 @@ function DebugVoicePanel({
|
||||
setSendingInput(true);
|
||||
setInputError("");
|
||||
let assetToken = "";
|
||||
let inputId = "";
|
||||
try {
|
||||
const imageUrl = await fileToDataUrl(pendingImage.file);
|
||||
const asset = await inputAssetsApi.uploadImage(pendingImage.file);
|
||||
@@ -878,11 +950,13 @@ function DebugVoicePanel({
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const result = await sendUserInput(parts);
|
||||
appendUserImage(result.inputId, imageUrl, timestamp, text);
|
||||
inputId = createUserInputId();
|
||||
appendUserImage(inputId, imageUrl, timestamp, text);
|
||||
await sendUserInput(parts, { inputId });
|
||||
setTextDraft("");
|
||||
setPendingImage(null);
|
||||
} catch (sendError) {
|
||||
if (inputId) removeUserImage(inputId);
|
||||
if (assetToken) {
|
||||
void inputAssetsApi.remove(assetToken).catch(() => {});
|
||||
}
|
||||
@@ -1636,12 +1710,26 @@ function DebugTranscriptPanel({
|
||||
recording?: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 新消息时滚到底部
|
||||
useEffect(() => {
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
// Scroll when message list changes (before paint).
|
||||
useLayoutEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Images load after the message row mounts; re-scroll when content height grows.
|
||||
useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content) return;
|
||||
const observer = new ResizeObserver(() => scrollToBottom());
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
@@ -1671,7 +1759,7 @@ function DebugTranscriptPanel({
|
||||
ref={scrollRef}
|
||||
className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div ref={contentRef} className="flex flex-col gap-4">
|
||||
{messages.map((message) => {
|
||||
const time = formatMessageTime(message.timestamp);
|
||||
return message.role === "assistant" ? (
|
||||
@@ -1707,6 +1795,7 @@ function DebugTranscriptPanel({
|
||||
src={attachment.url}
|
||||
alt={attachment.alt}
|
||||
className="max-h-72 w-full rounded-[0.8rem] object-cover"
|
||||
onLoad={scrollToBottom}
|
||||
/>
|
||||
))}
|
||||
{message.content && (
|
||||
|
||||
@@ -70,7 +70,13 @@ export function EditorBackButton({
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
|
||||
export function AssistantIdentity({
|
||||
assistantId,
|
||||
entityLabel = "助手",
|
||||
}: {
|
||||
assistantId: string | null;
|
||||
entityLabel?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copyId() {
|
||||
@@ -90,7 +96,9 @@ export function AssistantIdentity({ assistantId }: { assistantId: string | null
|
||||
type="button"
|
||||
onClick={() => void copyId()}
|
||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
|
||||
aria-label={
|
||||
copied ? `${entityLabel} ID 已复制` : `复制${entityLabel} ID`
|
||||
}
|
||||
title={copied ? "已复制" : "复制 ID"}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AnalysisConfigEditor,
|
||||
} from "@/components/assistant-editor/analysis-config";
|
||||
import {
|
||||
Braces,
|
||||
Bot,
|
||||
Brain,
|
||||
Bug,
|
||||
ChartLine,
|
||||
Database,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
MoreHorizontal,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -35,6 +41,12 @@ import { SectionCard } from "@/components/editor/section-card";
|
||||
import { VisionConfigSection } from "@/components/editor/vision-config-section";
|
||||
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { TopbarPortal } from "@/components/layout/topbar-portal";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -86,6 +98,7 @@ const promptSections = [
|
||||
{ id: "capabilities", label: "知识与工具" },
|
||||
{ id: "interaction", label: "交互策略" },
|
||||
{ id: "variables", label: "动态变量" },
|
||||
{ id: "analysis", label: "分析" },
|
||||
] as const;
|
||||
|
||||
type PromptSectionId = (typeof promptSections)[number]["id"];
|
||||
@@ -106,6 +119,7 @@ type PromptEditorProps = {
|
||||
tools: Tool[];
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
updateForm: <K extends keyof AssistantForm>(
|
||||
key: K,
|
||||
value: AssistantForm[K],
|
||||
@@ -130,6 +144,7 @@ export function PromptEditor({
|
||||
tools,
|
||||
onBack,
|
||||
onSave,
|
||||
onDelete,
|
||||
updateForm,
|
||||
handlePromptVisionEnabledChange,
|
||||
handlePromptModelChange,
|
||||
@@ -146,11 +161,13 @@ export function PromptEditor({
|
||||
capabilities: null,
|
||||
interaction: null,
|
||||
variables: null,
|
||||
analysis: null,
|
||||
});
|
||||
const selectedAnchorRef = useRef<PromptSectionId | null>(null);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PromptSectionId>("conversation");
|
||||
const [debugOpen, setDebugOpen] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -263,6 +280,16 @@ export function PromptEditor({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteSelect() {
|
||||
if (!onDelete || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopbarPortal>
|
||||
@@ -274,6 +301,42 @@ export function PromptEditor({
|
||||
onChange={(value) => updateForm("name", value)}
|
||||
/>
|
||||
<AssistantIdentity assistantId={assistantId} />
|
||||
{onDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={!assistantId || deleting}
|
||||
aria-label="更多操作"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal size={15} />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
disabled={deleting}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
window.setTimeout(() => void handleDeleteSelect(), 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
@@ -691,6 +754,27 @@ export function PromptEditor({
|
||||
/>
|
||||
</SectionCard>
|
||||
</section>
|
||||
|
||||
<section
|
||||
ref={(element) => {
|
||||
sectionRefs.current.analysis = element;
|
||||
}}
|
||||
className="scroll-mt-3 space-y-3"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<ChartLine size={15} />}
|
||||
title="分析"
|
||||
description="通话结束后自动提取关键信息"
|
||||
>
|
||||
<AnalysisConfigEditor
|
||||
config={form.analysisConfig}
|
||||
onChange={(analysisConfig) =>
|
||||
updateForm("analysisConfig", analysisConfig)
|
||||
}
|
||||
modelOptions={llmOptions}
|
||||
/>
|
||||
</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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user