diff --git a/api/app/routers/assistants.py b/api/app/routers/assistants.py index 6ce52de..d3a28e2 100644 --- a/api/app/routers/assistants.py +++ b/api/app/routers/assistants.py @@ -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]]: warnings: List[str] = [] + generated_opener_enabled = bool(assistant.generated_opener_enabled) metadata: Dict[str, Any] = { "systemPrompt": _compose_runtime_system_prompt(assistant.prompt), "firstTurnMode": assistant.first_turn_mode or "bot_first", - "greeting": assistant.opener or "", - "generatedOpenerEnabled": bool(assistant.generated_opener_enabled), + # Generated opener should rely on systemPrompt instead of fixed opener text. + "greeting": "" if generated_opener_enabled else (assistant.opener or ""), + "generatedOpenerEnabled": generated_opener_enabled, "output": {"mode": "audio" if assistant.voice_output_enabled else "text"}, "bargeIn": { "enabled": not bool(assistant.bot_cannot_be_interrupted), diff --git a/api/tests/test_assistants.py b/api/tests/test_assistants.py index d1fc52d..adb7d7c 100644 --- a/api/tests/test_assistants.py +++ b/api/tests/test_assistants.py @@ -331,5 +331,6 @@ class TestAssistantAPI: metadata = runtime_resp.json()["sessionStartMetadata"] assert metadata["firstTurnMode"] == "user_first" assert metadata["generatedOpenerEnabled"] is True + assert metadata["greeting"] == "" assert metadata["bargeIn"]["enabled"] is False assert metadata["bargeIn"]["minDurationMs"] == 900 diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py index 037274a..6ee5747 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/core/duplex_pipeline.py @@ -780,7 +780,6 @@ class DuplexPipeline: if not self.llm_service: return None - prompt_hint = (self._runtime_greeting or "").strip() system_context = (self.conversation.system_prompt or self._runtime_system_prompt or "").strip() # Keep context concise to avoid overloading greeting generation. if len(system_context) > 1200: @@ -793,8 +792,6 @@ class DuplexPipeline: user_prompt = "请生成一句中文开场白(不超过25个汉字)。" if system_context: user_prompt += f"\n\n以下是该助手的系统提示词,请据此决定语气、角色和边界:\n{system_context}" - if prompt_hint: - user_prompt += f"\n\n额外风格提示:{prompt_hint}" try: generated = await self.llm_service.generate( diff --git a/engine/tests/test_tool_call_flow.py b/engine/tests/test_tool_call_flow.py index ac60de9..3a34193 100644 --- a/engine/tests/test_tool_call_flow.py +++ b/engine/tests/test_tool_call_flow.py @@ -61,6 +61,9 @@ class _FakeLLM: self._rounds = rounds 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): idx = self._call_index self._call_index += 1 @@ -69,6 +72,19 @@ class _FakeLLM: 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]]]: monkeypatch.setattr("core.duplex_pipeline.SileroVAD", _DummySileroVAD) 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" +@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 async def test_ws_message_parses_tool_call_results(): msg = parse_client_message( diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index d83e7eb..c85ada5 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -889,25 +889,26 @@ export const AssistantsPage: React.FC = () => { -
- + 自动生成模式下不使用固定开场白文本,仅依据系统提示词生成首句。 +
+ ) : ( +
+ { const next = e.target.value; updateAssistant('opener', next); - if (selectedAssistant.generatedOpenerEnabled === true) return; updateTemplateSuggestionState('opener', next, e.currentTarget.selectionStart, e.currentTarget); }} onKeyUp={(e) => { - if (selectedAssistant.generatedOpenerEnabled === true) return; updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget); }} onClick={(e) => { - if (selectedAssistant.generatedOpenerEnabled === true) return; updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget); }} onFocus={(e) => { - if (selectedAssistant.generatedOpenerEnabled === true) return; updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart, e.currentTarget); }} onBlur={() => { @@ -915,40 +916,39 @@ export const AssistantsPage: React.FC = () => { setTemplateSuggestion((prev) => (prev?.field === 'opener' ? null : prev)); }, 120); }} - placeholder={selectedAssistant.generatedOpenerEnabled === true ? '将基于提示词自动生成开场白' : '例如:您好,我是您的专属AI助手...'} - disabled={selectedAssistant.generatedOpenerEnabled === true} - className="bg-white/5 border-white/10 focus:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed" - /> - {templateSuggestion?.field === 'opener' && - filteredSystemTemplateVariables.length > 0 && - selectedAssistant.generatedOpenerEnabled !== true && - typeof document !== 'undefined' && - createPortal( -
- {filteredSystemTemplateVariables.map((item) => ( - - ))} -
, - document.body - )} -
+ placeholder="例如:您好,我是您的专属AI助手..." + className="bg-white/5 border-white/10 focus:border-primary/50" + /> + {templateSuggestion?.field === 'opener' && + filteredSystemTemplateVariables.length > 0 && + typeof document !== 'undefined' && + createPortal( +
+ {filteredSystemTemplateVariables.map((item) => ( + + ))} +
, + document.body + )} + + )}

{selectedAssistant.generatedOpenerEnabled === true ? '通话接通后将根据提示词自动生成开场白。' @@ -2139,10 +2139,13 @@ export const DebugDrawer: React.FC<{ || textSessionStarted; const requiredTemplateVariableKeys = useMemo(() => { const keys = new Set(); + const includeOpenerTemplate = assistant.generatedOpenerEnabled !== true; 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(); - }, [assistant.opener, assistant.prompt]); + }, [assistant.generatedOpenerEnabled, assistant.opener, assistant.prompt]); const missingRequiredDynamicVariableKeys = useMemo(() => { const valuesByKey = new Map(); for (const row of dynamicVariables) { @@ -3142,6 +3145,7 @@ export const DebugDrawer: React.FC<{ const buildLocalResolvedRuntime = () => { const warnings: string[] = []; const ttsEnabled = Boolean(textTtsEnabled); + const generatedOpenerEnabled = assistant.generatedOpenerEnabled === true; const knowledgeBaseId = String(assistant.knowledgeBaseId || '').trim(); const knowledge = knowledgeBaseId ? { enabled: true, kbId: knowledgeBaseId, nResults: 5 } @@ -3156,8 +3160,8 @@ export const DebugDrawer: React.FC<{ }, systemPrompt: assistant.prompt || '', firstTurnMode: assistant.firstTurnMode || 'bot_first', - greeting: assistant.opener || '', - generatedOpenerEnabled: assistant.generatedOpenerEnabled === true, + greeting: generatedOpenerEnabled ? '' : (assistant.opener || ''), + generatedOpenerEnabled, bargeIn: { enabled: assistant.botCannotBeInterrupted !== true, minDurationMs: Math.max(0, Number(assistant.interruptionSensitivity ?? 180)), @@ -3965,7 +3969,7 @@ export const DebugDrawer: React.FC<{ className="h-6 px-2 text-[10px]" onClick={importDynamicVariablesFromPlaceholders} 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 @@ -3982,7 +3986,7 @@ export const DebugDrawer: React.FC<{

- Use placeholders like {'{{customer_name}}'} in prompt/opener. + Use placeholders like {'{{customer_name}}'} in prompt{assistant.generatedOpenerEnabled === true ? '' : '/opener'}.

Built-in system vars: {'{{system__time}}'}, {'{{system_utc}}'}, {'{{system_timezone}}'}.