#!/usr/bin/env python3 """ 从「知你客服8号」复制为「知你客服9号」,强化「摘要 + 检索」可用性: 1. 上下文 code-build-context:更长近期轮次、注入 conversation_summary、向量结果 + 关键词从历史中捞相关句。 2. 摘要路径 code-build-memory-value:在原有摘要分支上合并进完整 conversation_history(追加而非仅 2 条),并写回 conversation_summary。 3. cache-update-summary:显式 value 为「memory」表达式,避免整包 input_data 写入 Redis。 4. 向量写入:为每条 turn 带 metadata.user_id;引擎侧检索已按 user_id 过滤。 需平台可登录;默认源 8 号 ID 为上次创建结果,可用 ZHINI_8_AGENT_ID 覆盖。 部署后请重启 Celery/API 以加载引擎向量过滤逻辑。 """ 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_8_AGENT_ID", "d7b64bf6-c8e3-4dc7-befc-03a98d5ff741") USER = os.getenv("PLATFORM_USERNAME", "admin") PWD = os.getenv("PLATFORM_PASSWORD", "123456") NEW_NAME = "知你客服9号" NEW_DESC = ( "在知你客服8号基础上强化摘要与检索:" "远期要点写入 conversation_summary;" "当轮上下文含「近期对话 + 摘要 + 向量片段 + 关键词相关历史」;" "向量库写入带 user_id 元数据,引擎检索按用户隔离。" "仍依赖 MEMORY_PERSIST_DB_ENABLED 与固定 user_id。" ) LLM_PROMPT = """你是客服助手。根据「用户当前输入」「已知用户信息」「远期摘要」「相关历史(检索)」和「最近几轮」完成: 1)判断意图; 2)生成一句自然、有帮助的回复; 3)【强制】用户说出或暗示姓名、昵称时,必须在 user_profile.name 保存;合并已有字段勿丢失; 4)用户问「我叫什么」等时,必须依据 user_profile.name 与对话/摘要回答;已有 name 时禁止说「还不知道」; 5)「远期摘要」概括更早话题;「相关历史」可能含向量命中或关键词命中的旧轮次,请结合使用。 只输出一行合法 JSON,不要 markdown。格式示例: {"intent":"greeting","reply":"你好!","user_profile":{"name":"小明"}} 用户输入:{{user_input}} 已知用户信息:{{memory.user_profile}} 远期摘要:{{memory.conversation_summary}} 相关历史(检索到的):{{memory.relevant_from_retrieval}} 最近几轮:{{memory.recent_turns}} 要求:reply 简洁自然,200 字以内;user_profile 为对象。""" CODE_BUILD_CONTEXT = 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 '' 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, }, 'query': left.get('query') or '', 'user_id': left.get('user_id'), } """ CODE_BUILD_MEMORY_VALUE = r"""left = input_data.get('left') or {} right_out = input_data.get('right') or {} summary = '' if isinstance(right_out, dict): summary = right_out.get('output') or right_out.get('result') or '' if not isinstance(summary, str): summary = str(summary or '') summary = summary.strip() mem = left.get('memory') or {} user_input = left.get('user_input') or left.get('query') or '' reply = left.get('right') or '' if isinstance(reply, dict): reply = reply.get('right') or reply.get('content') or str(reply) profile_update = left.get('user_profile_update') or {} if not isinstance(profile_update, dict): profile_update = {} user_profile = dict(mem.get('user_profile') or {}, **profile_update) ts = datetime.now().isoformat() old_hist = mem.get('conversation_history') or [] if not isinstance(old_hist, list): old_hist = [] new_hist = old_hist + [ {'role': 'user', 'content': user_input, 'timestamp': ts}, {'role': 'assistant', 'content': str(reply or ''), 'timestamp': ts}, ] max_len = 40 if len(new_hist) > max_len: new_hist = new_hist[-max_len:] prev_sum = (mem.get('conversation_summary') or '').strip() conversation_summary = summary if summary else prev_sum memory_value = { 'conversation_summary': conversation_summary, 'conversation_history': new_hist, 'user_profile': user_profile, 'context': mem.get('context') or {}, } result = { 'memory': memory_value, 'user_id': left.get('user_id'), 'query': left.get('query'), 'user_input': user_input, 'right': reply, 'user_profile_update': profile_update, } """ CODE_BUILD_TURN_FOR_VECTOR = r"""reply = input_data.get('right') or '' if isinstance(reply, dict): reply = reply.get('right') or reply.get('content') or str(reply) query = input_data.get('query') or '' user_id = str(input_data.get('user_id') or 'default') raw = (user_id + '\n' + str(query) + '\n' + str(reply)).encode('utf-8', errors='ignore') doc_id = 'turn_' + hashlib.sha256(raw).hexdigest()[:24] text = '用户:' + str(query) + '\n助手:' + str(reply) result = { 'text': text, 'user_id': user_id, 'id': doc_id, 'metadata': {'user_id': user_id}, } """ 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 elif nid == "code-build-context": n.setdefault("data", {})["code"] = CODE_BUILD_CONTEXT elif nid == "code-build-memory-value": n.setdefault("data", {})["code"] = CODE_BUILD_MEMORY_VALUE elif nid == "code-build-turn-for-vector": n.setdefault("data", {})["code"] = CODE_BUILD_TURN_FOR_VECTOR elif nid == "cache-update-summary": d = n.setdefault("data", {}) d["value"] = "memory" elif nid == "transform-for-vector-upsert": m = n.setdefault("data", {}).setdefault("mapping", {}) m["metadata"] = "{{left.metadata}}" 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"] _patch_nodes(wf) up = requests.put( f"{BASE}/api/v1/agents/{new_id}", headers=h, json={"description": NEW_DESC, "workflow_config": wf}, timeout=60, ) if up.status_code != 200: print("更新失败:", up.status_code, up.text[:800], file=sys.stderr) return 1 print("已更新:LLM 提示、code-build-context / memory-value / vector-turn、cache-update-summary.value、upsert.metadata") 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())