Add presence probe configuration to Assistant model and API. Introduce new fields for enabling presence probes, idle and cooldown durations, maximum prompts, context inclusion, and custom questions. Update schemas, routers, and frontend components to support these features, along with corresponding tests to ensure functionality.

This commit is contained in:
Xin Wang
2026-02-28 15:47:53 +08:00
parent 0821d73e7c
commit 8f1317860f
11 changed files with 1006 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from typing import Any, Dict, List, Optional
import uuid
from datetime import datetime
@@ -35,6 +36,14 @@ router = APIRouter(prefix="/assistants", tags=["Assistants"])
OPENAI_COMPATIBLE_DEFAULT_MODEL = "FunAudioLLM/CosyVoice2-0.5B"
OPENAI_COMPATIBLE_DEFAULT_BASE_URL = "https://api.siliconflow.cn/v1"
OPENER_AUDIO_DIR = Path(__file__).resolve().parents[2] / "data" / "opener_audio"
PRESENCE_PROBE_MIN_IDLE_SECONDS = 5.0
PRESENCE_PROBE_MAX_IDLE_SECONDS = 3600.0
PRESENCE_PROBE_DEFAULT_IDLE_SECONDS = 20.0
PRESENCE_PROBE_MIN_COOLDOWN_SECONDS = 5.0
PRESENCE_PROBE_MAX_COOLDOWN_SECONDS = 7200.0
PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS = 45.0
PRESENCE_PROBE_MAX_PROMPTS_CAP = 10
PRESENCE_PROBE_DEFAULT_MAX_PROMPTS = 2
OPENAI_COMPATIBLE_KNOWN_VOICES = {
"alex",
"anna",
@@ -85,6 +94,125 @@ def _config_version_id(assistant: Assistant) -> str:
return f"asst_{assistant.id}_{updated.strftime('%Y%m%d%H%M%S')}"
def _ensure_assistant_schema(db: Session) -> None:
"""Apply lightweight SQLite migrations for newly added assistant columns."""
bind = db.get_bind()
inspector = inspect(bind)
try:
columns = {col["name"] for col in inspector.get_columns("assistants")}
except Exception:
return
altered = False
if "presence_probe_enabled" not in columns:
db.execute(text("ALTER TABLE assistants ADD COLUMN presence_probe_enabled BOOLEAN DEFAULT 0"))
altered = True
if "presence_probe_idle_seconds" not in columns:
db.execute(
text(
"ALTER TABLE assistants ADD COLUMN presence_probe_idle_seconds FLOAT DEFAULT 20.0"
)
)
altered = True
if "presence_probe_cooldown_seconds" not in columns:
db.execute(
text(
"ALTER TABLE assistants ADD COLUMN presence_probe_cooldown_seconds FLOAT DEFAULT 45.0"
)
)
altered = True
if "presence_probe_max_prompts" not in columns:
db.execute(text("ALTER TABLE assistants ADD COLUMN presence_probe_max_prompts INTEGER DEFAULT 2"))
altered = True
if "presence_probe_include_context" not in columns:
db.execute(
text("ALTER TABLE assistants ADD COLUMN presence_probe_include_context BOOLEAN DEFAULT 1")
)
altered = True
if "presence_probe_question" not in columns:
db.execute(text("ALTER TABLE assistants ADD COLUMN presence_probe_question TEXT DEFAULT ''"))
altered = True
if altered:
db.commit()
def _coerce_bounded_float(
raw_value: Any,
*,
default_value: float,
min_value: float,
max_value: float,
) -> float:
if isinstance(raw_value, (int, float)):
parsed = float(raw_value)
elif isinstance(raw_value, str):
try:
parsed = float(raw_value.strip())
except ValueError:
parsed = default_value
else:
parsed = default_value
if parsed < min_value:
return min_value
if parsed > max_value:
return max_value
return parsed
def _coerce_bounded_int(
raw_value: Any,
*,
default_value: int,
min_value: int,
max_value: int,
) -> int:
if isinstance(raw_value, (int, float)):
parsed = int(raw_value)
elif isinstance(raw_value, str):
try:
parsed = int(raw_value.strip())
except ValueError:
parsed = default_value
else:
parsed = default_value
if parsed < min_value:
return min_value
if parsed > max_value:
return max_value
return parsed
def _resolve_presence_probe_config_from_assistant(assistant: Assistant) -> Dict[str, Any]:
question = str(assistant.presence_probe_question or "").strip()
if len(question) > 160:
question = question[:160]
include_context_raw = getattr(assistant, "presence_probe_include_context", True)
include_context = True if include_context_raw is None else bool(include_context_raw)
return {
"enabled": bool(assistant.presence_probe_enabled),
"idleSeconds": _coerce_bounded_float(
assistant.presence_probe_idle_seconds,
default_value=PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
min_value=PRESENCE_PROBE_MIN_IDLE_SECONDS,
max_value=PRESENCE_PROBE_MAX_IDLE_SECONDS,
),
"cooldownSeconds": _coerce_bounded_float(
assistant.presence_probe_cooldown_seconds,
default_value=PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
min_value=PRESENCE_PROBE_MIN_COOLDOWN_SECONDS,
max_value=PRESENCE_PROBE_MAX_COOLDOWN_SECONDS,
),
"maxPrompts": _coerce_bounded_int(
assistant.presence_probe_max_prompts,
default_value=PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=PRESENCE_PROBE_MAX_PROMPTS_CAP,
),
"includeContext": include_context,
"question": question,
}
def _normalize_runtime_tool_schema(tool_id: str, raw_schema: Any) -> Dict[str, Any]:
schema = dict(raw_schema) if isinstance(raw_schema, dict) else {}
if not schema:
@@ -182,6 +310,7 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings:
def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[str, Any], List[str]]:
warnings: List[str] = []
presence_probe_cfg = _resolve_presence_probe_config_from_assistant(assistant)
metadata: Dict[str, Any] = {
"systemPrompt": _compose_runtime_system_prompt(assistant.prompt),
"firstTurnMode": assistant.first_turn_mode or "bot_first",
@@ -199,6 +328,20 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s
"userId": int(assistant.user_id or 1),
"source": "debug",
},
"presenceProbe": {
"enabled": bool(presence_probe_cfg.get("enabled")),
"idleSeconds": float(presence_probe_cfg.get("idleSeconds") or PRESENCE_PROBE_DEFAULT_IDLE_SECONDS),
"cooldownSeconds": float(
presence_probe_cfg.get("cooldownSeconds") or PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS
),
"maxPrompts": int(presence_probe_cfg.get("maxPrompts") or PRESENCE_PROBE_DEFAULT_MAX_PROMPTS),
"includeContext": bool(presence_probe_cfg.get("includeContext", True)),
**(
{"question": str(presence_probe_cfg.get("question") or "")}
if str(presence_probe_cfg.get("question") or "").strip()
else {}
),
},
}
config_mode = str(assistant.config_mode or "platform").strip().lower()
@@ -321,6 +464,7 @@ def _build_engine_assistant_config(db: Session, assistant: Assistant) -> Dict[st
def assistant_to_dict(assistant: Assistant) -> dict:
opener_audio = assistant.opener_audio
opener_audio_ready = bool(opener_audio and opener_audio.file_path and Path(opener_audio.file_path).exists())
presence_probe_cfg = _resolve_presence_probe_config_from_assistant(assistant)
return {
"id": assistant.id,
"name": assistant.name,
@@ -342,6 +486,18 @@ def assistant_to_dict(assistant: Assistant) -> dict:
"tools": assistant.tools or [],
"botCannotBeInterrupted": bool(assistant.bot_cannot_be_interrupted),
"interruptionSensitivity": assistant.interruption_sensitivity,
"presenceProbeEnabled": bool(presence_probe_cfg.get("enabled")),
"presenceProbeIdleSeconds": float(
presence_probe_cfg.get("idleSeconds") or PRESENCE_PROBE_DEFAULT_IDLE_SECONDS
),
"presenceProbeCooldownSeconds": float(
presence_probe_cfg.get("cooldownSeconds") or PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS
),
"presenceProbeMaxPrompts": int(
presence_probe_cfg.get("maxPrompts") or PRESENCE_PROBE_DEFAULT_MAX_PROMPTS
),
"presenceProbeIncludeContext": bool(presence_probe_cfg.get("includeContext", True)),
"presenceProbeQuestion": str(presence_probe_cfg.get("question") or ""),
"configMode": assistant.config_mode,
"apiUrl": assistant.api_url,
"apiKey": assistant.api_key,
@@ -360,6 +516,12 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None:
"firstTurnMode": "first_turn_mode",
"interruptionSensitivity": "interruption_sensitivity",
"botCannotBeInterrupted": "bot_cannot_be_interrupted",
"presenceProbeEnabled": "presence_probe_enabled",
"presenceProbeIdleSeconds": "presence_probe_idle_seconds",
"presenceProbeCooldownSeconds": "presence_probe_cooldown_seconds",
"presenceProbeMaxPrompts": "presence_probe_max_prompts",
"presenceProbeIncludeContext": "presence_probe_include_context",
"presenceProbeQuestion": "presence_probe_question",
"configMode": "config_mode",
"voiceOutputEnabled": "voice_output_enabled",
"generatedOpenerEnabled": "generated_opener_enabled",
@@ -371,7 +533,37 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None:
"rerankModelId": "rerank_model_id",
}
for field, value in update_data.items():
setattr(assistant, field_map.get(field, field), value)
target = field_map.get(field, field)
if target == "presence_probe_idle_seconds":
value = _coerce_bounded_float(
value,
default_value=PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
min_value=PRESENCE_PROBE_MIN_IDLE_SECONDS,
max_value=PRESENCE_PROBE_MAX_IDLE_SECONDS,
)
elif target == "presence_probe_cooldown_seconds":
value = _coerce_bounded_float(
value,
default_value=PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
min_value=PRESENCE_PROBE_MIN_COOLDOWN_SECONDS,
max_value=PRESENCE_PROBE_MAX_COOLDOWN_SECONDS,
)
elif target == "presence_probe_max_prompts":
value = _coerce_bounded_int(
value,
default_value=PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=PRESENCE_PROBE_MAX_PROMPTS_CAP,
)
elif target == "presence_probe_question":
value = str(value or "").strip()
if len(value) > 160:
value = value[:160]
elif target == "presence_probe_enabled":
value = bool(value)
elif target == "presence_probe_include_context":
value = bool(value)
setattr(assistant, target, value)
def _ensure_assistant_opener_audio(db: Session, assistant: Assistant) -> AssistantOpenerAudio:
@@ -490,6 +682,7 @@ def list_assistants(
db: Session = Depends(get_db)
):
"""获取助手列表"""
_ensure_assistant_schema(db)
query = db.query(Assistant)
total = query.count()
assistants = query.order_by(Assistant.created_at.desc()) \
@@ -505,6 +698,7 @@ def list_assistants(
@router.get("/{id}", response_model=AssistantOut)
def get_assistant(id: str, db: Session = Depends(get_db)):
"""获取单个助手详情"""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -514,6 +708,7 @@ def get_assistant(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/config", response_model=AssistantEngineConfigResponse)
def get_assistant_config(id: str, db: Session = Depends(get_db)):
"""Canonical engine config endpoint consumed by engine backend adapter."""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -523,6 +718,7 @@ def get_assistant_config(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/runtime-config", response_model=AssistantEngineConfigResponse)
def get_assistant_runtime_config(id: str, db: Session = Depends(get_db)):
"""Legacy alias for resolved engine runtime config."""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -532,6 +728,7 @@ def get_assistant_runtime_config(id: str, db: Session = Depends(get_db)):
@router.post("", response_model=AssistantOut)
def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
"""创建新助手"""
_ensure_assistant_schema(db)
assistant = Assistant(
id=str(uuid.uuid4())[:8],
user_id=1, # 默认用户,后续添加认证
@@ -549,6 +746,27 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
tools=data.tools,
bot_cannot_be_interrupted=data.botCannotBeInterrupted,
interruption_sensitivity=data.interruptionSensitivity,
presence_probe_enabled=bool(data.presenceProbeEnabled),
presence_probe_idle_seconds=_coerce_bounded_float(
data.presenceProbeIdleSeconds,
default_value=PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
min_value=PRESENCE_PROBE_MIN_IDLE_SECONDS,
max_value=PRESENCE_PROBE_MAX_IDLE_SECONDS,
),
presence_probe_cooldown_seconds=_coerce_bounded_float(
data.presenceProbeCooldownSeconds,
default_value=PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
min_value=PRESENCE_PROBE_MIN_COOLDOWN_SECONDS,
max_value=PRESENCE_PROBE_MAX_COOLDOWN_SECONDS,
),
presence_probe_max_prompts=_coerce_bounded_int(
data.presenceProbeMaxPrompts,
default_value=PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=PRESENCE_PROBE_MAX_PROMPTS_CAP,
),
presence_probe_include_context=bool(data.presenceProbeIncludeContext),
presence_probe_question=str(data.presenceProbeQuestion or "").strip()[:160],
config_mode=data.configMode,
api_url=data.apiUrl,
api_key=data.apiKey,
@@ -570,6 +788,7 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
@router.get("/{id}/opener-audio", response_model=AssistantOpenerAudioOut)
def get_assistant_opener_audio(id: str, db: Session = Depends(get_db)):
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -578,6 +797,7 @@ def get_assistant_opener_audio(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/opener-audio/pcm")
def get_assistant_opener_audio_pcm(id: str, db: Session = Depends(get_db)):
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -600,6 +820,7 @@ def generate_assistant_opener_audio(
data: AssistantOpenerAudioGenerateRequest,
db: Session = Depends(get_db),
):
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -689,6 +910,7 @@ def generate_assistant_opener_audio(
@router.put("/{id}")
def update_assistant(id: str, data: AssistantUpdate, db: Session = Depends(get_db)):
"""更新助手"""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")
@@ -710,6 +932,7 @@ def update_assistant(id: str, data: AssistantUpdate, db: Session = Depends(get_d
@router.delete("/{id}")
def delete_assistant(id: str, db: Session = Depends(get_db)):
"""删除助手"""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found")