diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index a936b78a..85a2bd0d 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -266,6 +266,20 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str: "3. **建图但不执行**(done 的 run 为 false):用户要的是**流程本身**" "——「设计一个工作流」「做一个每天能跑的」这类。他要的是这张图," "不是这一次的结果,跑一遍只是替他多花一次钱。\n" + "\n" + # 以前只有用户明说"记住"才会写记忆:17 轮里只有 4 轮带记忆节点, + # 全在用户主动谈记忆的那个会话里。于是他说过一次"我叫张三", + # 换个话题再问就谁也不知道了 + "顺带判断这句话里有没有**值得长期记住**的东西。有就在主链路前面加一个\n" + "memory 节点(action=write,紧跟 input),没有就别加:\n" + "- 该记:用户是谁、怎么称呼、长期偏好;口径和约定(「我们说的活跃指" + "当月有订单」);用户的纠正(「不对,管理员要按 platform 算」);" + "稳定的环境事实(「后端跑在 8001」)\n" + "- 不该记:这一轮的问题本身;查出来的数和结果(会过期,而且能重查);" + "一次性的指令(「重来」「继续」);**用户没说过、你推测出来的东西**\n" + "- content 要写成一句脱离上下文也成立的陈述——召回时没有这轮对话," + "「他说他叫张三」这种读起来就不知道「他」是谁\n" + "- 拿不准就不记。记错了会污染以后每一次对话,而漏记一条,用户再说一遍就是了\n" ) return f"请设计一个工作流:{payload.instruction}" diff --git a/backend/app/core/events.py b/backend/app/core/events.py index 8fd621b9..f2c82f80 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -42,6 +42,9 @@ class EventType(StrEnum): # 知识检索。以前只发一条 info 日志,于是"这句结论依据的是哪份文档的哪一段" # 在轨迹里查不到——而 SQL 取数那条路早就有工件快照可以下钻 RETRIEVE_END = "retrieve.end" + # 记忆读写。以前发的是 info 日志,而解码器丢弃所有 info——于是往长期记忆里 + # 写东西这件事在界面上是隐形的。让 Copilot 主动记之后,这条不可接受 + MEMORY_END = "memory.end" SANDBOX_START = "sandbox.start" SANDBOX_END = "sandbox.end" diff --git a/backend/app/engine/nodes/knowledge.py b/backend/app/engine/nodes/knowledge.py index 47df7590..82197fb2 100644 --- a/backend/app/engine/nodes/knowledge.py +++ b/backend/app/engine/nodes/knowledge.py @@ -52,8 +52,16 @@ async def run_memory(state: GraphState, ctx: NodeContext) -> dict[str, Any]: else: raise NodeError(ctx.node.id, f"未知的记忆操作:{action}") - ctx.emit(EventType.LOG, level="info", - message=f"记忆 {action} @ {scope}:{result.get('count', result.get('cleared', 1))} 条") + # 写入必须看得见:这是往长期记忆里存东西,会影响以后每一次对话。 + # 以前发的是 info 日志,而解码层丢弃所有 info,等于系统背着用户记了东西 + ctx.emit( + EventType.MEMORY_END, + action=action, + scope=scope, + count=result.get("count", result.get("cleared", 1 if action == "write" else 0)), + # 记了什么要原样说出来,只报"写了 1 条"等于没说 + content=result.get("content", "")[:300], + ) updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} var_name = ctx.cfg("assign_to", "") diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index 8a068f2a..fb041e01 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -452,3 +452,46 @@ async def test_copilot_is_told_not_to_invent_a_memory_scope() -> None: assert memory_line, "节点参考里找不到 memory 这一行" assert "scope" not in memory_line, "scope 还列在可填字段里,模型就会去填它" assert "不要写 scope" in NODE_REFERENCE + + +# -------------------------------------------------------------------------- +# 主动记忆 +# -------------------------------------------------------------------------- + + +def test_copilot_is_told_when_to_record_a_memory() -> None: + """以前只有用户明说「记住」才会写:17 轮里只有 4 轮带记忆节点,全在用户 + 主动谈记忆的那个会话里。于是他说过一次「我叫张三」,换个话题再问就 + 谁也不知道了。 + + 但边界比指令本身更要紧——记错了会污染以后每一次对话。 + """ + from app.api.copilot import GenerateIn, _user_message + + text = _user_message(GenerateIn(instruction="随便问问", intent="answer"), patch=False) + assert "该记" in text and "不该记" in text + # 最容易被记进去的垃圾:这一轮的问题、查出来的数、一次性指令、以及推测 + for junk in ("这一轮的问题本身", "查出来的数", "一次性的指令", "推测出来的东西"): + assert junk in text, f"没有拦住「{junk}」这类" + assert "拿不准就不记" in text + + +async def test_writing_a_memory_is_visible_in_the_trace(client, monkeypatch) -> None: + """自动记忆的前提是看得见。 + + 以前 memory 节点发的是 info 日志,而解码层丢弃所有 info——等于系统背着 + 用户往长期记忆里存东西。让 Copilot 主动记之后这条不可接受。 + """ + from app.core.events import EventType + + assert EventType.MEMORY_END == "memory.end" + + # 节点确实发这条事件,而且带上了记了什么 + import inspect + + from app.engine.nodes import knowledge as node + + src = inspect.getsource(node.run_memory) + assert "MEMORY_END" in src + assert "LOG, level=\"info\"" not in src, "又退回 info 日志了,界面上会看不见" + assert 'result.get("content"' in src, "没把记了什么带出去,只报条数等于没说" diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 8238cef0..a1b9878a 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -712,6 +712,37 @@ export function decodeRun(events: RunEvent[]): Step[] { break } + case 'memory.end': { + // 写入要把**记了什么**原样显示出来。只说"写入 1 条"等于没说—— + // 用户没法判断系统记的是不是他想让它记的,而这东西会影响以后每次对话 + const action = String(d.action ?? '') + const count = num(d.count) ?? 0 + const content = String(d.content ?? '') + if (action === 'write') { + push({ + id: `mw-${seq}`, seq, kind: 'note', nodeId, status: 'done', + title: content ? `记住了:${content.slice(0, 40)}${content.length > 40 ? '…' : ''}` + : '写入了一条记忆', + detail: content || undefined, + }, nodeId) + break + } + if (action === 'clear') { + push({ + id: `mc-${seq}`, seq, kind: 'note', nodeId, status: 'done', level: 'warn', + title: `清空了记忆域「${String(d.scope ?? '')}」的 ${count} 条`, + }, nodeId) + break + } + push({ + id: `mr-${seq}`, seq, kind: 'schema', nodeId, + status: count ? 'done' : 'failed', + level: count ? undefined : 'warn', + title: count ? `想起 ${count} 条相关记忆` : '没有想起相关的记忆', + }, nodeId) + break + } + case 'retrieve.end': { // 知识检索。以前只发一条 info 日志,于是"这句结论依据的是哪份文档的 // 哪一段"在轨迹里查不到——而 SQL 取数那条路早就能下钻到工件 diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index e8b392d0..0a120934 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -71,6 +71,30 @@ console.log('\n=== 知识检索 ===') check('未知事件不被静默吞掉', flatten(unknown).some((x) => x.title === 'brand.new')) } +console.log('\n=== 长期记忆 ===') +{ + // 写记忆以前只发 info 日志,而 info 在这一层是被丢弃的——系统往长期记忆里 + // 存东西,界面上一点痕迹都没有。Copilot 会主动记之后,这条不可接受。 + const ev = (data) => [{ seq: 1, type: 'memory.end', node_id: 'mem', data }] + + const w = flatten(mod.decodeRun(ev({ + action: 'write', scope: 'default', count: 1, + content: '用户姓名:张三;工作单位:示例科技', + })))[0] + check('写记忆看得见', !!w) + check('把记了什么说出来,不是只报条数', + !!w?.title?.includes('张三'), w?.title) + check('全文留在详情里', !!w?.detail?.includes('示例科技')) + + const r = flatten(mod.decodeRun(ev({ action: 'recall', scope: 'default', count: 3 })))[0] + check('召回说人话', !!r?.title?.includes('想起 3 条'), r?.title) + const none = flatten(mod.decodeRun(ev({ action: 'recall', scope: 'default', count: 0 })))[0] + check('没想起来要标出来,不是静悄悄地过去', none?.status === 'failed') + + const c = flatten(mod.decodeRun(ev({ action: 'clear', scope: 'default', count: 7 })))[0] + check('清空是破坏性的,标成警告', c?.level === 'warn' && c.title.includes('7')) +} + console.log('\n=== 被截断的结果集 ===') { // 后端按字符数硬切预览,切点落在 JSON 中间是常态。严格 JSON.parse 一律失败,