Refactor assistant greeting logic to conditionally use system prompt for generated openers. Update related tests to verify new behavior and ensure correct metadata handling in API responses. Enhance UI to reflect changes in opener management based on generated opener settings.
This commit is contained in:
@@ -182,11 +182,13 @@ 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]]:
|
def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[str, Any], List[str]]:
|
||||||
warnings: List[str] = []
|
warnings: List[str] = []
|
||||||
|
generated_opener_enabled = bool(assistant.generated_opener_enabled)
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"systemPrompt": _compose_runtime_system_prompt(assistant.prompt),
|
"systemPrompt": _compose_runtime_system_prompt(assistant.prompt),
|
||||||
"firstTurnMode": assistant.first_turn_mode or "bot_first",
|
"firstTurnMode": assistant.first_turn_mode or "bot_first",
|
||||||
"greeting": assistant.opener or "",
|
# Generated opener should rely on systemPrompt instead of fixed opener text.
|
||||||
"generatedOpenerEnabled": bool(assistant.generated_opener_enabled),
|
"greeting": "" if generated_opener_enabled else (assistant.opener or ""),
|
||||||
|
"generatedOpenerEnabled": generated_opener_enabled,
|
||||||
"output": {"mode": "audio" if assistant.voice_output_enabled else "text"},
|
"output": {"mode": "audio" if assistant.voice_output_enabled else "text"},
|
||||||
"bargeIn": {
|
"bargeIn": {
|
||||||
"enabled": not bool(assistant.bot_cannot_be_interrupted),
|
"enabled": not bool(assistant.bot_cannot_be_interrupted),
|
||||||
|
|||||||
@@ -331,5 +331,6 @@ class TestAssistantAPI:
|
|||||||
metadata = runtime_resp.json()["sessionStartMetadata"]
|
metadata = runtime_resp.json()["sessionStartMetadata"]
|
||||||
assert metadata["firstTurnMode"] == "user_first"
|
assert metadata["firstTurnMode"] == "user_first"
|
||||||
assert metadata["generatedOpenerEnabled"] is True
|
assert metadata["generatedOpenerEnabled"] is True
|
||||||
|
assert metadata["greeting"] == ""
|
||||||
assert metadata["bargeIn"]["enabled"] is False
|
assert metadata["bargeIn"]["enabled"] is False
|
||||||
assert metadata["bargeIn"]["minDurationMs"] == 900
|
assert metadata["bargeIn"]["minDurationMs"] == 900
|
||||||
|
|||||||
@@ -780,7 +780,6 @@ class DuplexPipeline:
|
|||||||
if not self.llm_service:
|
if not self.llm_service:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
prompt_hint = (self._runtime_greeting or "").strip()
|
|
||||||
system_context = (self.conversation.system_prompt or self._runtime_system_prompt or "").strip()
|
system_context = (self.conversation.system_prompt or self._runtime_system_prompt or "").strip()
|
||||||
# Keep context concise to avoid overloading greeting generation.
|
# Keep context concise to avoid overloading greeting generation.
|
||||||
if len(system_context) > 1200:
|
if len(system_context) > 1200:
|
||||||
@@ -793,8 +792,6 @@ class DuplexPipeline:
|
|||||||
user_prompt = "请生成一句中文开场白(不超过25个汉字)。"
|
user_prompt = "请生成一句中文开场白(不超过25个汉字)。"
|
||||||
if system_context:
|
if system_context:
|
||||||
user_prompt += f"\n\n以下是该助手的系统提示词,请据此决定语气、角色和边界:\n{system_context}"
|
user_prompt += f"\n\n以下是该助手的系统提示词,请据此决定语气、角色和边界:\n{system_context}"
|
||||||
if prompt_hint:
|
|
||||||
user_prompt += f"\n\n额外风格提示:{prompt_hint}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
generated = await self.llm_service.generate(
|
generated = await self.llm_service.generate(
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class _FakeLLM:
|
|||||||
self._rounds = rounds
|
self._rounds = rounds
|
||||||
self._call_index = 0
|
self._call_index = 0
|
||||||
|
|
||||||
|
async def generate(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
return ""
|
||||||
|
|
||||||
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
|
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
idx = self._call_index
|
idx = self._call_index
|
||||||
self._call_index += 1
|
self._call_index += 1
|
||||||
@@ -69,6 +72,19 @@ class _FakeLLM:
|
|||||||
yield event
|
yield event
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureGenerateLLM:
|
||||||
|
def __init__(self, response: str):
|
||||||
|
self.response = response
|
||||||
|
self.messages: List[Any] = []
|
||||||
|
|
||||||
|
async def generate(self, messages, temperature=0.7, max_tokens=None):
|
||||||
|
self.messages = list(messages)
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
|
||||||
def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
|
def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
|
||||||
monkeypatch.setattr("core.duplex_pipeline.SileroVAD", _DummySileroVAD)
|
monkeypatch.setattr("core.duplex_pipeline.SileroVAD", _DummySileroVAD)
|
||||||
monkeypatch.setattr("core.duplex_pipeline.VADProcessor", _DummyVADProcessor)
|
monkeypatch.setattr("core.duplex_pipeline.VADProcessor", _DummyVADProcessor)
|
||||||
@@ -203,6 +219,33 @@ async def test_pipeline_applies_default_args_to_tool_call(monkeypatch):
|
|||||||
assert args.get("unit") == "c"
|
assert args.get("unit") == "c"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generated_opener_prompt_uses_system_prompt_only(monkeypatch):
|
||||||
|
monkeypatch.setattr("core.duplex_pipeline.SileroVAD", _DummySileroVAD)
|
||||||
|
monkeypatch.setattr("core.duplex_pipeline.VADProcessor", _DummyVADProcessor)
|
||||||
|
monkeypatch.setattr("core.duplex_pipeline.EouDetector", _DummyEouDetector)
|
||||||
|
|
||||||
|
llm = _CaptureGenerateLLM("你好")
|
||||||
|
pipeline = DuplexPipeline(
|
||||||
|
transport=_FakeTransport(),
|
||||||
|
session_id="s_generated_opener",
|
||||||
|
llm_service=llm,
|
||||||
|
tts_service=_FakeTTS(),
|
||||||
|
asr_service=_FakeASR(),
|
||||||
|
)
|
||||||
|
pipeline.conversation.system_prompt = "SYSTEM_PROMPT_ONLY"
|
||||||
|
pipeline._runtime_greeting = "DEV_HINT_SHOULD_NOT_BE_USED"
|
||||||
|
|
||||||
|
generated = await pipeline._generate_runtime_greeting()
|
||||||
|
|
||||||
|
assert generated == "你好"
|
||||||
|
assert len(llm.messages) == 2
|
||||||
|
user_prompt = llm.messages[1].content
|
||||||
|
assert "SYSTEM_PROMPT_ONLY" in user_prompt
|
||||||
|
assert "DEV_HINT_SHOULD_NOT_BE_USED" not in user_prompt
|
||||||
|
assert "额外风格提示" not in user_prompt
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ws_message_parses_tool_call_results():
|
async def test_ws_message_parses_tool_call_results():
|
||||||
msg = parse_client_message(
|
msg = parse_client_message(
|
||||||
|
|||||||
@@ -889,25 +889,26 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
{selectedAssistant.generatedOpenerEnabled === true ? (
|
||||||
<Input
|
<div className="rounded-md border border-dashed border-white/15 bg-white/5 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
自动生成模式下不使用固定开场白文本,仅依据系统提示词生成首句。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
value={selectedAssistant.opener}
|
value={selectedAssistant.opener}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
updateAssistant('opener', next);
|
updateAssistant('opener', next);
|
||||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
|
||||||
updateTemplateSuggestionState('opener', next, e.currentTarget.selectionStart, e.currentTarget);
|
updateTemplateSuggestionState('opener', next, e.currentTarget.selectionStart, e.currentTarget);
|
||||||
}}
|
}}
|
||||||
onKeyUp={(e) => {
|
onKeyUp={(e) => {
|
||||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
|
||||||
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
|
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
|
||||||
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
|
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
|
||||||
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
|
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -915,40 +916,39 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
setTemplateSuggestion((prev) => (prev?.field === 'opener' ? null : prev));
|
setTemplateSuggestion((prev) => (prev?.field === 'opener' ? null : prev));
|
||||||
}, 120);
|
}, 120);
|
||||||
}}
|
}}
|
||||||
placeholder={selectedAssistant.generatedOpenerEnabled === true ? '将基于提示词自动生成开场白' : '例如:您好,我是您的专属AI助手...'}
|
placeholder="例如:您好,我是您的专属AI助手..."
|
||||||
disabled={selectedAssistant.generatedOpenerEnabled === true}
|
className="bg-white/5 border-white/10 focus:border-primary/50"
|
||||||
className="bg-white/5 border-white/10 focus:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed"
|
/>
|
||||||
/>
|
{templateSuggestion?.field === 'opener' &&
|
||||||
{templateSuggestion?.field === 'opener' &&
|
filteredSystemTemplateVariables.length > 0 &&
|
||||||
filteredSystemTemplateVariables.length > 0 &&
|
typeof document !== 'undefined' &&
|
||||||
selectedAssistant.generatedOpenerEnabled !== true &&
|
createPortal(
|
||||||
typeof document !== 'undefined' &&
|
<div
|
||||||
createPortal(
|
className="fixed z-[100] w-[320px] max-w-[calc(100vw-1rem)] rounded-md border border-white/15 bg-black/95 shadow-xl backdrop-blur-md max-h-48 overflow-auto"
|
||||||
<div
|
style={{
|
||||||
className="fixed z-[100] w-[320px] max-w-[calc(100vw-1rem)] rounded-md border border-white/15 bg-black/95 shadow-xl backdrop-blur-md max-h-48 overflow-auto"
|
left: templateSuggestion.anchorLeft,
|
||||||
style={{
|
top: templateSuggestion.anchorTop,
|
||||||
left: templateSuggestion.anchorLeft,
|
}}
|
||||||
top: templateSuggestion.anchorTop,
|
>
|
||||||
}}
|
{filteredSystemTemplateVariables.map((item) => (
|
||||||
>
|
<button
|
||||||
{filteredSystemTemplateVariables.map((item) => (
|
key={item.key}
|
||||||
<button
|
type="button"
|
||||||
key={item.key}
|
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors"
|
||||||
type="button"
|
onMouseDown={(e) => {
|
||||||
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors"
|
e.preventDefault();
|
||||||
onMouseDown={(e) => {
|
applySystemTemplateVariable('opener', item.key);
|
||||||
e.preventDefault();
|
}}
|
||||||
applySystemTemplateVariable('opener', item.key);
|
>
|
||||||
}}
|
<div className="text-xs text-cyan-100">{`{{${item.key}}}`}</div>
|
||||||
>
|
<div className="text-[10px] text-muted-foreground mt-0.5">{item.description}</div>
|
||||||
<div className="text-xs text-cyan-100">{`{{${item.key}}}`}</div>
|
</button>
|
||||||
<div className="text-[10px] text-muted-foreground mt-0.5">{item.description}</div>
|
))}
|
||||||
</button>
|
</div>,
|
||||||
))}
|
document.body
|
||||||
</div>,
|
)}
|
||||||
document.body
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{selectedAssistant.generatedOpenerEnabled === true
|
{selectedAssistant.generatedOpenerEnabled === true
|
||||||
? '通话接通后将根据提示词自动生成开场白。'
|
? '通话接通后将根据提示词自动生成开场白。'
|
||||||
@@ -2139,10 +2139,13 @@ export const DebugDrawer: React.FC<{
|
|||||||
|| textSessionStarted;
|
|| textSessionStarted;
|
||||||
const requiredTemplateVariableKeys = useMemo(() => {
|
const requiredTemplateVariableKeys = useMemo(() => {
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
|
const includeOpenerTemplate = assistant.generatedOpenerEnabled !== true;
|
||||||
extractDynamicTemplateKeys(String(assistant.prompt || '')).forEach((key) => keys.add(key));
|
extractDynamicTemplateKeys(String(assistant.prompt || '')).forEach((key) => keys.add(key));
|
||||||
extractDynamicTemplateKeys(String(assistant.opener || '')).forEach((key) => keys.add(key));
|
if (includeOpenerTemplate) {
|
||||||
|
extractDynamicTemplateKeys(String(assistant.opener || '')).forEach((key) => keys.add(key));
|
||||||
|
}
|
||||||
return Array.from(keys).sort();
|
return Array.from(keys).sort();
|
||||||
}, [assistant.opener, assistant.prompt]);
|
}, [assistant.generatedOpenerEnabled, assistant.opener, assistant.prompt]);
|
||||||
const missingRequiredDynamicVariableKeys = useMemo(() => {
|
const missingRequiredDynamicVariableKeys = useMemo(() => {
|
||||||
const valuesByKey = new Map<string, string>();
|
const valuesByKey = new Map<string, string>();
|
||||||
for (const row of dynamicVariables) {
|
for (const row of dynamicVariables) {
|
||||||
@@ -3142,6 +3145,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
const buildLocalResolvedRuntime = () => {
|
const buildLocalResolvedRuntime = () => {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const ttsEnabled = Boolean(textTtsEnabled);
|
const ttsEnabled = Boolean(textTtsEnabled);
|
||||||
|
const generatedOpenerEnabled = assistant.generatedOpenerEnabled === true;
|
||||||
const knowledgeBaseId = String(assistant.knowledgeBaseId || '').trim();
|
const knowledgeBaseId = String(assistant.knowledgeBaseId || '').trim();
|
||||||
const knowledge = knowledgeBaseId
|
const knowledge = knowledgeBaseId
|
||||||
? { enabled: true, kbId: knowledgeBaseId, nResults: 5 }
|
? { enabled: true, kbId: knowledgeBaseId, nResults: 5 }
|
||||||
@@ -3156,8 +3160,8 @@ export const DebugDrawer: React.FC<{
|
|||||||
},
|
},
|
||||||
systemPrompt: assistant.prompt || '',
|
systemPrompt: assistant.prompt || '',
|
||||||
firstTurnMode: assistant.firstTurnMode || 'bot_first',
|
firstTurnMode: assistant.firstTurnMode || 'bot_first',
|
||||||
greeting: assistant.opener || '',
|
greeting: generatedOpenerEnabled ? '' : (assistant.opener || ''),
|
||||||
generatedOpenerEnabled: assistant.generatedOpenerEnabled === true,
|
generatedOpenerEnabled,
|
||||||
bargeIn: {
|
bargeIn: {
|
||||||
enabled: assistant.botCannotBeInterrupted !== true,
|
enabled: assistant.botCannotBeInterrupted !== true,
|
||||||
minDurationMs: Math.max(0, Number(assistant.interruptionSensitivity ?? 180)),
|
minDurationMs: Math.max(0, Number(assistant.interruptionSensitivity ?? 180)),
|
||||||
@@ -3965,7 +3969,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
className="h-6 px-2 text-[10px]"
|
className="h-6 px-2 text-[10px]"
|
||||||
onClick={importDynamicVariablesFromPlaceholders}
|
onClick={importDynamicVariablesFromPlaceholders}
|
||||||
disabled={isDynamicVariablesLocked || requiredTemplateVariableKeys.length === 0 || dynamicVariables.length >= DYNAMIC_VARIABLE_MAX_ITEMS}
|
disabled={isDynamicVariablesLocked || requiredTemplateVariableKeys.length === 0 || dynamicVariables.length >= DYNAMIC_VARIABLE_MAX_ITEMS}
|
||||||
title="Import keys from {{placeholder}} in prompt/opener"
|
title={`Import keys from {{placeholder}} in prompt${assistant.generatedOpenerEnabled === true ? '' : '/opener'}`}
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
@@ -3982,7 +3986,7 @@ export const DebugDrawer: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Use placeholders like {'{{customer_name}}'} in prompt/opener.
|
Use placeholders like {'{{customer_name}}'} in prompt{assistant.generatedOpenerEnabled === true ? '' : '/opener'}.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Built-in system vars: {'{{system__time}}'}, {'{{system_utc}}'}, {'{{system_timezone}}'}.
|
Built-in system vars: {'{{system__time}}'}, {'{{system_utc}}'}, {'{{system_timezone}}'}.
|
||||||
|
|||||||
Reference in New Issue
Block a user