feat(agent): align prompts and runtime with workflow@20261020

- archive workflow/20261020 export (diff vs 20261016: collection prompt
  only - state-decides-body rule, route when info complete)
- prompts: collection.md updated; 6 photo/confirm prompts archived with
  source nodeIds; render_prompt replaces {{$vars}} (Asia/Shanghai clock,
  person fields, explicit 'not provided' for missing prefills)
- llm: message history passed as user/assistant roles (chatNode history=50);
  optional LLM_TEMPERATURE; FakeLLMService supports history-aware responders
- collect: context via system supplement, raw user input as user message
  (Text Editor#2 parity), shared validated_reply (one call, one repair)
- welcome: Assigned Reply#3 original text, stored in dialogue history
- transitions: 2004 plate-confirm entries (2003->2004/2005, 2004->2005)
- verified: prompt files byte-identical to workflow JSON (parametrized test)
This commit is contained in:
Eric Wang
2026-10-21 15:10:51 +08:00
parent 1c6b1a513d
commit a5407e03b2
14 changed files with 7881 additions and 98 deletions
+86 -10
View File
@@ -29,8 +29,11 @@ class LLMAPIError(LLMError):
class LLMService(Protocol):
"""节点依赖的最小 LLM 接口。"""
async def complete(self, system_prompt: str, user_message: str) -> str:
"""返回模型文本输出(含 <state> 前缀,由调用方解析校验)。"""
async def complete(
self, system_prompt: str, user_message: str, *,
history: list[dict[str, str]] | None = None,
) -> str:
"""返回模型文本;history 保留 user/assistant 角色,与工作流对齐。"""
...
@@ -44,15 +47,29 @@ class FakeLLMService:
def __init__(self, responder: Responder):
self._responder = responder
self.calls: list[dict[str, str]] = []
self.calls: list[dict] = []
# 兼容两参 responder;三参 responder 可读取对话历史。
import inspect
async def complete(self, system_prompt: str, user_message: str) -> str:
self._accepts_history = (
len(inspect.signature(responder).parameters) >= 3
)
async def complete(
self, system_prompt: str, user_message: str, *,
history: list[dict[str, str]] | None = None,
) -> str:
self.calls.append(
{"system_prompt": system_prompt, "user_message": user_message}
{"system_prompt": system_prompt, "user_message": user_message,
"history": [dict(message) for message in (history or [])]}
)
import asyncio
result = self._responder(system_prompt, user_message)
result = (
self._responder(system_prompt, user_message, history or [])
if self._accepts_history
else self._responder(system_prompt, user_message)
)
if asyncio.iscoroutine(result):
result = await result
return result
@@ -74,12 +91,14 @@ class OpenAILLMService:
base_url: str = "",
timeout_seconds: float = 30.0,
max_retries: int = 2,
temperature: float | None = None,
):
self._model = model
self._api_key = api_key
self._base_url = base_url
self._timeout_seconds = timeout_seconds
self._max_retries = max_retries
self._temperature = temperature
self._client = None
def _get_client(self):
@@ -94,7 +113,10 @@ class OpenAILLMService:
self._client = AsyncOpenAI(**kwargs)
return self._client
async def complete(self, system_prompt: str, user_message: str) -> str:
async def complete(
self, system_prompt: str, user_message: str, *,
history: list[dict[str, str]] | None = None,
) -> str:
import asyncio
from openai import APIConnectionError, APITimeoutError, RateLimitError
@@ -102,15 +124,17 @@ class OpenAILLMService:
client = self._get_client()
messages = [
{"role": "system", "content": system_prompt},
*[dict(message) for message in (history or [])],
{"role": "user", "content": user_message},
]
kwargs: dict = {"model": self._model, "messages": messages}
if self._temperature is not None:
kwargs["temperature"] = self._temperature
retries = 0
while True:
try:
response = await asyncio.wait_for(
client.chat.completions.create(
model=self._model, messages=messages
),
client.chat.completions.create(**kwargs),
timeout=self._timeout_seconds,
)
except (APITimeoutError, asyncio.TimeoutError) as exc:
@@ -145,6 +169,57 @@ def _default_fake_responder(system_prompt: str, user_message: str) -> str:
return f"<state>1002</state>已收到:{user_message}"
def workflow_fake_responder(system_prompt: str, user_message: str, history=None) -> str:
"""模拟工作流提示词行为的假 LLM(离线测试/冒烟用)。
从注入的运行时上下文(system 末尾的 context_prompt)读取当前
阶段与 clientMode;按工作流提示词语义响应客户端事件:
会话标记 1001→1002、【拍摄完成】沿链推进、失败一次→0005、
第二次无回复→0004;普通输入保持当前阶段。正文字面回显输入。
"""
import re
from .events import ClientEvent, parse_client_event
stage_match = re.search(r'"stage_code":\s*"(\d{4})"', system_prompt)
current = stage_match.group(1) if stage_match else "1001"
mode = "browser_addon" if '"client_mode": "browser_addon"' in system_prompt else "direct"
event = parse_client_event(user_message)
if event in (ClientEvent.SESSION_START, ClientEvent.CONTINUE) and current == "1001":
return f"<state>1002</state>已收到:{user_message}"
if event is ClientEvent.PHOTO_DONE:
chain = {
"2000": "2001", "2001": "2002", "2002": "2003", "2003": "2005",
"2010": "2011", "2011": "2012", "2012": "2013",
"2013": "2014", "2014": "2015",
}
target = chain.get(current)
if target is None:
target = "0000" if mode == "browser_addon" else ("3001" if current == "2005" else "3002")
return f"<state>{target}</state>已收到:{user_message}"
if event is ClientEvent.PHOTO_FAILED_3X:
return "<state>0005</state>多次拍摄识别仍未成功,正在为您转接人工处理。"
if event is ClientEvent.NO_REPLY:
trailing = 0
for message in reversed(history or []):
if message.get("role") != "user":
continue
if parse_client_event(str(message.get("content", ""))) is ClientEvent.NO_REPLY:
trailing += 1
else:
break
if trailing >= 1:
return "<state>0004</state>长时间未收到回应,为您转接人工。"
return f"<state>{current}</state>请问您还在吗?"
return f"<state>{current}</state>已收到:{user_message}"
def workflow_fake_llm() -> "FakeLLMService":
"""离线假 LLM:行为对齐工作流提示词,供冒烟与无模型测试。"""
return FakeLLMService(workflow_fake_responder)
def build_llm_service(provider: str | None = None) -> LLMService:
"""按配置构造 LLM 服务;provider 缺省读 Config.AGENT_LLM_PROVIDER。"""
from ..core.config import Config
@@ -157,6 +232,7 @@ def build_llm_service(provider: str | None = None) -> LLMService:
base_url=Config.LLM_BASE_URL,
timeout_seconds=Config.LLM_TIMEOUT_SECONDS,
max_retries=Config.LLM_MAX_RETRIES,
temperature=Config.LLM_TEMPERATURE,
)
if provider == "fake":
return FakeLLMService(_default_fake_responder)