Add dynamic variable support to Assistant model and related components
- Introduce dynamic variable definitions in AssistantConfig and Assistant models, allowing for flexible prompt customization. - Implement validation for dynamic variable names and types in the schema. - Update backend services and routes to handle dynamic variables in assistant configurations and runtime processing. - Enhance frontend components to support dynamic variable definitions, including a new editor for managing variables. - Add tests to ensure proper functionality and validation of dynamic variables in various scenarios.
This commit is contained in:
233
backend/services/runtime_variables.py
Normal file
233
backend/services/runtime_variables.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Conversation-scoped dynamic variables for prompt pipeline assistants.
|
||||
|
||||
The renderer is deliberately small: it only understands ``{{ name }}``
|
||||
placeholders and never evaluates expressions. A value is substituted once,
|
||||
so user input cannot introduce a second template expansion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from models import AssistantConfig
|
||||
|
||||
|
||||
Primitive = str | int | float | bool
|
||||
VARIABLE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
||||
MAX_VARIABLES = 50
|
||||
MAX_VALUE_LENGTH = 2048
|
||||
MAX_HISTORY_ENTRIES = 50
|
||||
MAX_HISTORY_JSON_LENGTH = 32_000
|
||||
|
||||
|
||||
class DynamicVariableError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _primitive(value: Any, name: str) -> Primitive:
|
||||
if not isinstance(value, (str, int, float, bool)) or value is None:
|
||||
raise DynamicVariableError(f"动态变量 {name} 仅支持字符串、数字或布尔值")
|
||||
if len(str(value)) > MAX_VALUE_LENGTH:
|
||||
raise DynamicVariableError(f"动态变量 {name} 超过 {MAX_VALUE_LENGTH} 字符")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_public(values: dict[str, Any] | None) -> dict[str, Primitive]:
|
||||
values = values or {}
|
||||
if len(values) > MAX_VARIABLES:
|
||||
raise DynamicVariableError(f"动态变量最多允许 {MAX_VARIABLES} 个")
|
||||
result: dict[str, Primitive] = {}
|
||||
for name, value in values.items():
|
||||
if not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"动态变量名称不合法: {name}")
|
||||
if name.startswith(("system__", "secret__")):
|
||||
raise DynamicVariableError(f"客户端不能设置保留变量: {name}")
|
||||
result[name] = _primitive(value, name)
|
||||
return result
|
||||
|
||||
|
||||
def _type_matches(value: Primitive, expected: str) -> bool:
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
return False
|
||||
|
||||
|
||||
def _system_values(
|
||||
*, assistant_id: str | None, conversation_id: str, timezone: str
|
||||
) -> dict[str, Primitive]:
|
||||
try:
|
||||
zone = ZoneInfo(timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise DynamicVariableError(f"无效时区: {timezone}") from exc
|
||||
now = datetime.now(zone)
|
||||
return {
|
||||
"system__agent_id": assistant_id or "",
|
||||
"system__conversation_id": conversation_id,
|
||||
"system__time": now.strftime("%A, %H:%M %d %B %Y"),
|
||||
"system__time_utc": now.astimezone(ZoneInfo("UTC")).isoformat(),
|
||||
"system__timezone": timezone,
|
||||
"system__agent_turns": 0,
|
||||
"system__conversation_history": json.dumps(
|
||||
{"entries": []}, ensure_ascii=False, separators=(",", ":")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DynamicVariableStore:
|
||||
"""Mutable state owned by exactly one conversation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
values: dict[str, Primitive],
|
||||
secrets: dict[str, str] | None = None,
|
||||
):
|
||||
self.values = dict(values)
|
||||
self.secrets = dict(secrets or {})
|
||||
self.history: list[dict[str, str]] = []
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
|
||||
return cls(cfg.dynamic_variables, cfg.secret_dynamic_variables)
|
||||
|
||||
def render(self, template: str, *, allow_secrets: bool = False) -> str:
|
||||
if not template:
|
||||
return template
|
||||
timezone = str(self.values.get("system__timezone") or "Asia/Shanghai")
|
||||
try:
|
||||
now = datetime.now(ZoneInfo(timezone))
|
||||
self.values["system__time"] = now.strftime("%A, %H:%M %d %B %Y")
|
||||
self.values["system__time_utc"] = now.astimezone(ZoneInfo("UTC")).isoformat()
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
if name.startswith("secret__"):
|
||||
if not allow_secrets:
|
||||
raise DynamicVariableError(f"密钥变量 {name} 只能用于 HTTP Header")
|
||||
if name not in self.secrets:
|
||||
raise DynamicVariableError(f"缺少密钥变量: {name}")
|
||||
return self.secrets[name]
|
||||
if name not in self.values:
|
||||
raise DynamicVariableError(f"缺少动态变量: {name}")
|
||||
value = self.values[name]
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
return PLACEHOLDER.sub(replace, template)
|
||||
|
||||
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
|
||||
if isinstance(value, str):
|
||||
return self.render(value, allow_secrets=allow_secrets)
|
||||
if isinstance(value, list):
|
||||
return [self.render_data(item, allow_secrets=allow_secrets) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: self.render_data(item, allow_secrets=allow_secrets)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return deepcopy(value)
|
||||
|
||||
def record(self, role: str, content: str, *, completed_agent_turn: bool = False) -> None:
|
||||
if content:
|
||||
self.history.append({"role": role, "message": content})
|
||||
self.history = self.history[-MAX_HISTORY_ENTRIES:]
|
||||
if completed_agent_turn:
|
||||
self.values["system__agent_turns"] = int(
|
||||
self.values.get("system__agent_turns", 0)
|
||||
) + 1
|
||||
serialized = json.dumps(
|
||||
{"entries": self.history}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
while len(serialized) > MAX_HISTORY_JSON_LENGTH and len(self.history) > 1:
|
||||
self.history.pop(0)
|
||||
serialized = json.dumps(
|
||||
{"entries": self.history},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self.values["system__conversation_history"] = serialized
|
||||
|
||||
def assign(self, name: str, value: Any) -> None:
|
||||
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"工具不能更新保留变量: {name}")
|
||||
self.values[name] = _primitive(value, name)
|
||||
|
||||
|
||||
def prepare_dynamic_config(
|
||||
cfg: AssistantConfig,
|
||||
client_values: dict[str, Any] | None,
|
||||
*,
|
||||
assistant_id: str | None,
|
||||
timezone: str = "Asia/Shanghai",
|
||||
trusted_values: dict[str, Any] | None = None,
|
||||
) -> AssistantConfig:
|
||||
"""Validate and merge one call's values without mutating stored config."""
|
||||
if cfg.type != "prompt":
|
||||
if client_values:
|
||||
raise DynamicVariableError("动态变量目前仅支持 prompt 助手")
|
||||
return cfg
|
||||
|
||||
supplied = _validate_public(client_values)
|
||||
# Server-side CRM/order/auth providers can pass trusted_values. They have
|
||||
# precedence over browser input but still cannot claim reserved prefixes.
|
||||
supplied.update(_validate_public(trusted_values))
|
||||
definitions = cfg.dynamic_variable_definitions or {}
|
||||
merged: dict[str, Primitive] = {}
|
||||
for name, definition in definitions.items():
|
||||
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"动态变量定义名称不合法: {name}")
|
||||
if name in supplied:
|
||||
value = supplied.pop(name)
|
||||
elif "default" in definition and definition.get("default") is not None:
|
||||
value = _primitive(definition["default"], name)
|
||||
elif definition.get("required", False):
|
||||
raise DynamicVariableError(f"缺少必填动态变量: {name}")
|
||||
else:
|
||||
continue
|
||||
expected = str(definition.get("type") or "string")
|
||||
if not _type_matches(value, expected):
|
||||
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
|
||||
merged[name] = value
|
||||
merged.update(supplied)
|
||||
|
||||
conversation_id = f"conv_{uuid4().hex[:20]}"
|
||||
merged.update(
|
||||
_system_values(
|
||||
assistant_id=assistant_id,
|
||||
conversation_id=conversation_id,
|
||||
timezone=timezone,
|
||||
)
|
||||
)
|
||||
prepared = cfg.model_copy(deep=True)
|
||||
prepared.dynamic_variables = merged
|
||||
prepared.conversation_id = conversation_id
|
||||
# Validate prompt and greeting before media resources are allocated.
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
store.render(prepared.prompt)
|
||||
store.render(prepared.greeting)
|
||||
return prepared
|
||||
|
||||
|
||||
def value_at_path(payload: Any, path: str) -> Any:
|
||||
current = payload
|
||||
for part in path.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
|
||||
current = current[int(part)]
|
||||
else:
|
||||
raise KeyError(path)
|
||||
return current
|
||||
Reference in New Issue
Block a user