270 lines
9.6 KiB
Python
270 lines
9.6 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
从「知你客服9号」复制为「知你客服10号」,改进记忆与连接策略:
|
|||
|
|
|
|||
|
|
1. 在 json-parse 之后插入 code-identity-merge:把用户指定的「助手称呼」写入 memory.context.assistant_display_name
|
|||
|
|
(与 user_profile.name 区分)。
|
|||
|
|
2. code-build-context:向 LLM 注入 assistant_display_name。
|
|||
|
|
3. llm-unified 提示词:自我介绍优先用 assistant_display_name;user_profile.name 仅表示用户。
|
|||
|
|
4. condition-need-summary:history_count >= 2 即走摘要分支(原常为 >=4,summary 易长期为空)。
|
|||
|
|
|
|||
|
|
需 Celery 已加载含 re/hashlib 注入的 workflow_engine(代码节点内勿写 import re)。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
import requests
|
|||
|
|
|
|||
|
|
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
|||
|
|
SOURCE_AGENT_ID = os.getenv("ZHINI_9_AGENT_ID", "de5932d6-3c05-4b27-ab08-f6cb403ce4b9")
|
|||
|
|
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
|||
|
|
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
|||
|
|
|
|||
|
|
NEW_NAME = "知你客服10号"
|
|||
|
|
NEW_DESC = (
|
|||
|
|
"在知你客服9号基础上:① memory.context.assistant_display_name 存助手对外称呼,与 user_profile.name(用户)分离;"
|
|||
|
|
"② 摘要分支 history_count>=2 更易生成 conversation_summary;"
|
|||
|
|
"③ 工作流在 json-parse 后增加 code-identity-merge 再进入抽取/写记忆。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
CODE_IDENTITY_MERGE = r"""mem = dict(input_data.get('memory') or {})
|
|||
|
|
ctx = dict(mem.get('context') or {})
|
|||
|
|
q = str(input_data.get('query') or input_data.get('user_input') or '').strip()
|
|||
|
|
for pat in (
|
|||
|
|
r'你的\s*名字\s*叫\s*([^\s,。!?,.!?]{1,32})',
|
|||
|
|
r'你\s*叫\s*(?!什么)([^\s,。!?,.!?]{1,32})',
|
|||
|
|
r'(?:客服|助手)\s*叫\s*([^\s,。!?,.!?]{1,32})',
|
|||
|
|
):
|
|||
|
|
m = re.search(pat, q)
|
|||
|
|
if not m:
|
|||
|
|
continue
|
|||
|
|
name = m.group(1).strip().strip(',。!?,.!?')
|
|||
|
|
if not name:
|
|||
|
|
continue
|
|||
|
|
if any(b in name for b in ('什么', '哪位', '谁', '啥')):
|
|||
|
|
continue
|
|||
|
|
ctx['assistant_display_name'] = name
|
|||
|
|
break
|
|||
|
|
mem['context'] = ctx
|
|||
|
|
out = dict(input_data)
|
|||
|
|
out['memory'] = mem
|
|||
|
|
result = out
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
CODE_BUILD_CONTEXT_V10 = r"""left = input_data.get('left') or {}
|
|||
|
|
right = input_data.get('right') or []
|
|||
|
|
if not isinstance(right, list):
|
|||
|
|
right = []
|
|||
|
|
mem = left.get('memory') or {}
|
|||
|
|
hist = mem.get('conversation_history') or []
|
|||
|
|
if not isinstance(hist, list):
|
|||
|
|
hist = []
|
|||
|
|
summary = mem.get('conversation_summary') or ''
|
|||
|
|
ctx = mem.get('context') or {}
|
|||
|
|
if not isinstance(ctx, dict):
|
|||
|
|
ctx = {}
|
|||
|
|
assistant_name = str(ctx.get('assistant_display_name') or '').strip()
|
|||
|
|
recent_n = 16
|
|||
|
|
recent = hist[-recent_n:] if len(hist) > recent_n else hist
|
|||
|
|
recent_str = '\n'.join(f"{x.get('role', '')}: {x.get('content', '')}" for x in recent)
|
|||
|
|
vec_str = '\n'.join((rec.get('text') or rec.get('content') or '') for rec in right)
|
|||
|
|
query = (left.get('user_input') or left.get('query') or '').strip()
|
|||
|
|
older = hist[:-recent_n] if len(hist) > recent_n else []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tok(s):
|
|||
|
|
s = str(s)
|
|||
|
|
ch = {c for c in s if '\u4e00' <= c <= '\u9fff'}
|
|||
|
|
wd = set(s.lower().replace('\n', ' ').split())
|
|||
|
|
return ch | wd
|
|||
|
|
|
|||
|
|
|
|||
|
|
qt = _tok(query) if query else set()
|
|||
|
|
scored = []
|
|||
|
|
for m in older:
|
|||
|
|
c = str(m.get('content', ''))
|
|||
|
|
if not c:
|
|||
|
|
continue
|
|||
|
|
sc = len(qt & _tok(c)) if qt else 0
|
|||
|
|
if sc > 0:
|
|||
|
|
scored.append((sc, str(m.get('role', '')), c[:240]))
|
|||
|
|
scored.sort(key=lambda x: -x[0])
|
|||
|
|
kw_lines = [f"{role}: {text}" for _, role, text in scored[:6]]
|
|||
|
|
kw_str = '\n'.join(kw_lines)
|
|||
|
|
relevant_str = vec_str.strip()
|
|||
|
|
if kw_str:
|
|||
|
|
if relevant_str:
|
|||
|
|
relevant_str = relevant_str + '\n---\n关键词相关历史:\n' + kw_str
|
|||
|
|
else:
|
|||
|
|
relevant_str = '关键词相关历史:\n' + kw_str
|
|||
|
|
result = {
|
|||
|
|
'user_input': left.get('user_input') or left.get('query') or '',
|
|||
|
|
'memory': {
|
|||
|
|
'user_profile': mem.get('user_profile') or {},
|
|||
|
|
'conversation_summary': summary,
|
|||
|
|
'relevant_from_retrieval': relevant_str,
|
|||
|
|
'recent_turns': recent_str,
|
|||
|
|
'assistant_display_name': assistant_name,
|
|||
|
|
},
|
|||
|
|
'query': left.get('query') or '',
|
|||
|
|
'user_id': left.get('user_id'),
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
LLM_PROMPT_V10 = """你是客服助手。根据用户输入、用户画像、助手称呼、远期摘要、检索片段与最近对话生成回复。
|
|||
|
|
|
|||
|
|
【称呼规则】
|
|||
|
|
- user_profile.name(及同类字段)仅表示「用户」的昵称/姓名。
|
|||
|
|
- memory.assistant_display_name 表示用户为你指定的「对外称呼」。若非空,用户问「你叫什么名字」「你是谁」时,须用该称呼自称(可带「客服助手」类前缀,但核心名须一致);禁止忽略已保存的 assistant_display_name 改回默认虚构名。
|
|||
|
|
- 若 assistant_display_name 为空,可自称「客服助手」等通用名。
|
|||
|
|
|
|||
|
|
【任务】
|
|||
|
|
1)判断意图;2)自然、有帮助的 reply(JSON 内一条字符串);
|
|||
|
|
3)用户自我介绍姓名时写入 user_profile(如 name),勿把用户姓名写入 assistant_display_name;
|
|||
|
|
4)用户问「我叫什么」时依据 user_profile 与历史/摘要回答。
|
|||
|
|
|
|||
|
|
只输出一行合法 JSON,不要 markdown。示例:
|
|||
|
|
{"intent":"chat","reply":"你好!","user_profile":{"name":"小明"}}
|
|||
|
|
|
|||
|
|
用户输入:{{user_input}}
|
|||
|
|
用户画像:{{memory.user_profile}}
|
|||
|
|
助手对外称呼(用户指定,可能为空):{{memory.assistant_display_name}}
|
|||
|
|
远期摘要:{{memory.conversation_summary}}
|
|||
|
|
相关历史(检索):{{memory.relevant_from_retrieval}}
|
|||
|
|
最近几轮:{{memory.recent_turns}}
|
|||
|
|
|
|||
|
|
要求:reply 200 字以内;user_profile 为对象。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _insert_identity_node_and_edges(wf: dict) -> None:
|
|||
|
|
nodes = wf.setdefault("nodes", [])
|
|||
|
|
edges = wf.setdefault("edges", [])
|
|||
|
|
if any(n.get("id") == "code-identity-merge" for n in nodes):
|
|||
|
|
return
|
|||
|
|
# 参考 json-parse 位置:在其右侧插入
|
|||
|
|
jx, jy = 2200, 400
|
|||
|
|
for n in nodes:
|
|||
|
|
if n.get("id") == "json-parse":
|
|||
|
|
pos = n.get("position") or {}
|
|||
|
|
jx = pos.get("x", jx) + 80
|
|||
|
|
jy = pos.get("y", jy)
|
|||
|
|
break
|
|||
|
|
nodes.append(
|
|||
|
|
{
|
|||
|
|
"id": "code-identity-merge",
|
|||
|
|
"type": "code",
|
|||
|
|
"position": {"x": jx, "y": jy},
|
|||
|
|
"data": {
|
|||
|
|
"label": "合并助手称呼到 context",
|
|||
|
|
"language": "python",
|
|||
|
|
"code": CODE_IDENTITY_MERGE,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
new_edges = []
|
|||
|
|
removed = False
|
|||
|
|
for e in edges:
|
|||
|
|
if e.get("source") == "json-parse" and e.get("target") == "transform-extract-reply-and-profile":
|
|||
|
|
removed = True
|
|||
|
|
continue
|
|||
|
|
new_edges.append(e)
|
|||
|
|
if not removed:
|
|||
|
|
print("警告: 未找到 json-parse -> transform-extract-reply-and-profile 的边,仍追加新边", file=sys.stderr)
|
|||
|
|
new_edges.append(
|
|||
|
|
{
|
|||
|
|
"id": "e11a-identity",
|
|||
|
|
"source": "json-parse",
|
|||
|
|
"target": "code-identity-merge",
|
|||
|
|
"sourceHandle": "right",
|
|||
|
|
"targetHandle": "left",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
new_edges.append(
|
|||
|
|
{
|
|||
|
|
"id": "e11b-identity",
|
|||
|
|
"source": "code-identity-merge",
|
|||
|
|
"target": "transform-extract-reply-and-profile",
|
|||
|
|
"sourceHandle": "right",
|
|||
|
|
"targetHandle": "left",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
wf["edges"] = new_edges
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _patch_nodes(wf: dict) -> None:
|
|||
|
|
nodes = wf.get("nodes") or []
|
|||
|
|
for n in nodes:
|
|||
|
|
nid = n.get("id")
|
|||
|
|
if nid == "llm-unified":
|
|||
|
|
n.setdefault("data", {})["prompt"] = LLM_PROMPT_V10
|
|||
|
|
elif nid == "code-build-context":
|
|||
|
|
n.setdefault("data", {})["code"] = CODE_BUILD_CONTEXT_V10
|
|||
|
|
elif nid == "condition-need-summary":
|
|||
|
|
d = n.setdefault("data", {})
|
|||
|
|
c = d.get("condition", "")
|
|||
|
|
if "history_count" in c and ">=" in c:
|
|||
|
|
d["condition"] = "{history_count} >= 2"
|
|||
|
|
else:
|
|||
|
|
d["condition"] = "{history_count} >= 2"
|
|||
|
|
elif nid == "code-identity-merge":
|
|||
|
|
n.setdefault("data", {})["code"] = CODE_IDENTITY_MERGE
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
r = requests.post(
|
|||
|
|
f"{BASE}/api/v1/auth/login",
|
|||
|
|
data={"username": USER, "password": PWD},
|
|||
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|||
|
|
timeout=15,
|
|||
|
|
)
|
|||
|
|
if r.status_code != 200:
|
|||
|
|
print("登录失败:", r.status_code, r.text[:500], file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
token = r.json().get("access_token")
|
|||
|
|
if not token:
|
|||
|
|
print("无 access_token", file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|||
|
|
|
|||
|
|
dup = requests.post(
|
|||
|
|
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
|||
|
|
headers=h,
|
|||
|
|
json={"name": NEW_NAME},
|
|||
|
|
timeout=30,
|
|||
|
|
)
|
|||
|
|
if dup.status_code != 201:
|
|||
|
|
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
new_id = dup.json()["id"]
|
|||
|
|
print("已创建副本:", new_id, NEW_NAME)
|
|||
|
|
|
|||
|
|
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
|||
|
|
if g.status_code != 200:
|
|||
|
|
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
agent = g.json()
|
|||
|
|
wf = agent["workflow_config"]
|
|||
|
|
_insert_identity_node_and_edges(wf)
|
|||
|
|
_patch_nodes(wf)
|
|||
|
|
|
|||
|
|
up = requests.put(
|
|||
|
|
f"{BASE}/api/v1/agents/{new_id}",
|
|||
|
|
headers=h,
|
|||
|
|
json={"description": NEW_DESC, "workflow_config": wf},
|
|||
|
|
timeout=120,
|
|||
|
|
)
|
|||
|
|
if up.status_code != 200:
|
|||
|
|
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
print("已更新:identity 节点与边、摘要阈值>=2、上下文与 LLM 提示")
|
|||
|
|
print("Agent ID:", new_id)
|
|||
|
|
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|