重建会话记录逻辑

This commit is contained in:
2026-06-04 15:26:23 +08:00
parent 0ecb2babf3
commit 0188240d62
9 changed files with 375 additions and 42 deletions
+12 -3
View File
@@ -11,7 +11,7 @@ const initializePromise = Promise.all([
export default tool({
description:
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。新增记忆前必须先查看同 scope 的现有记忆,避免写入近似重复项;如果已有相近内容,应优先 replace/remove 而不是重复 add。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
args: {
action: tool.schema
.enum(["add", "list", "replace", "remove"])
@@ -95,9 +95,18 @@ export default tool({
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.changed ? "memory stored" : "memory already existed",
decision:
result.changed
? "accepted"
: result.detail === "memory already existed"
? "deduped"
: "rejected",
detail:
result.detail === "similar memory already exists"
? "similar memory already exists; review listed memories before storing a rewritten variant"
: result.detail,
entry: result.entry,
existing_entry: result.similar,
target: scope,
});
}
+25 -20
View File
@@ -339,28 +339,33 @@ export class LearningOrchestrator {
source: "review" as const,
traceId: input.requestContext.traceId,
};
const result =
proposal.action === "add"
? await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft)
: proposal.action === "replace"
? await this.memoryStore.replace(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
draft,
)
: await this.memoryStore.remove(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
);
const accepted =
"entry" in result ? Boolean(result.entry) : Boolean(result.changed);
let accepted = false;
let detail = "memory rejected";
if (proposal.action === "add") {
const result = await this.memoryStore.upsert(proposal.scope as MemoryScope, scopeKey, draft);
accepted = Boolean(result.entry);
detail = result.detail;
} else if (proposal.action === "replace") {
const result = await this.memoryStore.replace(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
draft,
);
accepted = Boolean(result.changed);
detail = result.detail;
} else {
const result = await this.memoryStore.remove(
proposal.scope as MemoryScope,
scopeKey,
proposal.target_id ?? "",
);
accepted = Boolean(result.changed);
detail = result.detail;
}
await writeLearningAuditLog({
action: `memory-${proposal.action}`,
detail: sanitizeAuditDetail(
"detail" in result ? result.detail : result.changed ? "memory stored" : "memory deduped",
),
detail: sanitizeAuditDetail(detail),
outcome: accepted ? "accepted" : "rejected",
projectId: input.requestContext.projectId,
proposal: sanitizeMemoryProposalForAudit(proposal),
+120 -3
View File
@@ -57,13 +57,33 @@ export class MemoryStore {
return this.serializeWrite(async () => {
const content = normalizeMemoryContent(draft.content);
if (!content) {
return { changed: false, entry: null as MemoryEntry | null };
return {
changed: false,
detail: "content rejected by persistence policy",
entry: null as MemoryEntry | null,
similar: null as MemoryEntry | null,
};
}
const entries = await this.readEntries(scope, key);
const existing = entries.find((entry) => entry.content === content);
if (existing) {
return { changed: false, entry: existing };
return {
changed: false,
detail: "memory already existed",
entry: existing,
similar: existing,
};
}
const similar = findSimilarMemory(entries, content);
if (similar) {
return {
changed: false,
detail: "similar memory already exists",
entry: similar,
similar,
};
}
const entry: MemoryEntry = {
@@ -80,7 +100,12 @@ export class MemoryStore {
rootDir: this.baseDir,
},
);
return { changed: true, entry };
return {
changed: true,
detail: "memory stored",
entry,
similar: null as MemoryEntry | null,
};
});
}
@@ -105,6 +130,13 @@ export class MemoryStore {
if (duplicate) {
return { changed: false, detail: "replacement would duplicate an existing memory" };
}
const similar = findSimilarMemory(entries, content, entries[index]?.id);
if (similar) {
return {
changed: false,
detail: "replacement would overlap with a similar existing memory",
};
}
entries[index] = {
content,
id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()),
@@ -214,6 +246,91 @@ const normalizeMemoryContent = (content: string) => {
return normalized;
};
const findSimilarMemory = (
entries: MemoryEntry[],
content: string,
excludeId?: string,
) =>
entries.find(
(entry) => entry.id !== excludeId && areSimilarMemoryContents(entry.content, content),
) ?? null;
const areSimilarMemoryContents = (left: string, right: string) => {
const normalizedLeft = normalizeComparableMemory(left);
const normalizedRight = normalizeComparableMemory(right);
if (!normalizedLeft || !normalizedRight) {
return false;
}
if (normalizedLeft === normalizedRight) {
return true;
}
const [shorter, longer] =
normalizedLeft.length <= normalizedRight.length
? [normalizedLeft, normalizedRight]
: [normalizedRight, normalizedLeft];
if (shorter.length >= 12 && longer.includes(shorter)) {
return true;
}
if (shorter.length < 8) {
return false;
}
if (
longestCommonSubsequenceLength(normalizedLeft, normalizedRight) / shorter.length >= 0.5
) {
return true;
}
return (
diceCoefficient(buildCharacterBigrams(normalizedLeft), buildCharacterBigrams(normalizedRight)) >=
0.72
);
};
const normalizeComparableMemory = (content: string) =>
normalizeMemoryContent(content)
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
const buildCharacterBigrams = (content: string) => {
const grams = new Set<string>();
for (let index = 0; index < content.length - 1; index += 1) {
grams.add(content.slice(index, index + 2));
}
return grams;
};
const diceCoefficient = (left: Set<string>, right: Set<string>) => {
if (left.size === 0 || right.size === 0) {
return 0;
}
let overlap = 0;
for (const item of left) {
if (right.has(item)) {
overlap += 1;
}
}
return (2 * overlap) / (left.size + right.size);
};
const longestCommonSubsequenceLength = (left: string, right: string) => {
const previous = new Array(right.length + 1).fill(0);
const current = new Array(right.length + 1).fill(0);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
current[rightIndex] =
left[leftIndex - 1] === right[rightIndex - 1]
? previous[rightIndex - 1] + 1
: Math.max(previous[rightIndex], current[rightIndex - 1]);
}
for (let rightIndex = 0; rightIndex <= right.length; rightIndex += 1) {
previous[rightIndex] = current[rightIndex];
current[rightIndex] = 0;
}
}
return previous[right.length];
};
const parseMemoryMarkdown = (content: string): MemoryEntry[] =>
content
.split("\n")
+21 -15
View File
@@ -15,6 +15,7 @@ import { type SessionRecord } from "../sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle,
shouldGenerateSessionTitle,
} from "./chatSession.js";
@@ -205,6 +206,26 @@ export const buildChatRouter = (
messages: parsed.data.messages,
branchGroups: parsed.data.branch_groups,
});
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext: {
actorKey,
clientSessionId: nextRecord.sessionId,
projectId,
projectKey,
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
userId,
},
sessionId: nextRecord.sessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: nextRecord.sessionId },
"post-save learning failed",
);
});
}
res.json({
id: nextRecord.sessionId,
title: nextRecord.title ?? "新对话",
@@ -635,21 +656,6 @@ export const buildChatRouter = (
);
}
}
if (assistantText) {
void learningOrchestrator.onTurnCompleted({
assistantMessage: assistantText,
model: parsed.data.model,
requestContext,
sessionId: clientSessionId,
toolCallCount: streamResult.toolCallCount,
userMessage: parsed.data.message,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"post-turn learning failed",
);
});
}
}
} finally {
sessionBridge.finalizeRequest(clientSessionId);
+53 -1
View File
@@ -254,6 +254,46 @@ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
const isSyntheticAssistantError = (content: string) =>
/^⚠️\s*\*\*(请求已中断|错误[:]?)/.test(content);
export const extractLatestFrontendTurn = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return null;
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const assistant = messages[index];
if (!isObjectRecord(assistant) || assistant.role !== "assistant") {
continue;
}
const assistantMessage =
typeof assistant.content === "string"
? assistant.content.replace(/\s+/g, " ").trim()
: "";
if (!assistantMessage || isSyntheticAssistantError(assistantMessage)) {
continue;
}
const user = messages
.slice(0, index)
.reverse()
.find((message) => isObjectRecord(message) && message.role === "user");
if (!isObjectRecord(user) || typeof user.content !== "string") {
continue;
}
const userMessage = user.content.replace(/\s+/g, " ").trim();
if (!userMessage) {
continue;
}
return {
assistantMessage,
toolCallCount: estimateFrontendToolCallCount(assistant),
userMessage,
};
}
return null;
};
const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return "";
@@ -299,4 +339,16 @@ const buildRestoredConversationFromMessages = (messages: unknown[] | undefined)
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
trimmedConversation,
].join("\n");
};
};
const estimateFrontendToolCallCount = (assistant: Record<string, unknown>) => {
const progress = Array.isArray(assistant.progress) ? assistant.progress : [];
const artifacts = Array.isArray(assistant.artifacts) ? assistant.artifacts : [];
const toolProgressCount = progress.filter(
(item) =>
isObjectRecord(item) &&
(item.phase === "tool" ||
(typeof item.id === "string" && item.id.startsWith("tool-"))),
).length;
return Math.max(toolProgressCount, artifacts.length);
};
+13
View File
@@ -77,6 +77,19 @@ export class SessionTranscriptStore {
}
const timestamp = new Date().toISOString();
const lastTurn = transcript.turns.at(-1);
if (
lastTurn?.userMessage === userMessage &&
lastTurn.assistantMessage === assistantMessage
) {
lastTurn.toolCallCount = Math.max(lastTurn.toolCallCount, turn.toolCallCount);
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.sessionId = context.sessionId;
transcript.updatedAt = timestamp;
await atomicWriteJson(key, transcript);
return transcript;
}
const record: SessionTurnRecord = {
id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage),
assistantMessage,
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MemoryStore } from "../../src/memory/store.js";
describe("MemoryStore", () => {
let tempDir: string;
let backupDir: string;
let store: MemoryStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-memory-"));
backupDir = await mkdtemp(join(tmpdir(), "tjwater-memory-backup-"));
store = new MemoryStore(tempDir, backupDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
await rm(backupDir, { force: true, recursive: true });
});
it("dedupes exact duplicate memories", async () => {
const first = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
source: "tool",
});
const second = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
source: "tool",
});
expect(first.changed).toBe(true);
expect(second.changed).toBe(false);
expect(second.detail).toBe("memory already existed");
});
it("rejects rewritten memories that are too similar to an existing one", async () => {
await store.upsert("workspace", "project-1", {
content: "保存记忆前先查看当前 workspace memory,避免重复写入相同约束。",
source: "tool",
});
const result = await store.upsert("workspace", "project-1", {
content: "写入前先看一遍当前 workspace 记忆,避免把同样的约束重复保存进去。",
source: "tool",
});
expect(result.changed).toBe(false);
expect(result.detail).toBe("similar memory already exists");
expect(result.entry?.content).toBe("保存记忆前先查看当前 workspace memory,避免重复写入相同约束。");
});
it("rejects replace when the new content overlaps a similar existing memory", async () => {
const first = await store.upsert("user", "actor-1", {
content: "回答时默认使用中文,并保持结论先行。",
source: "tool",
});
const second = await store.upsert("user", "actor-1", {
content: "回答要包含必要的文件路径引用。",
source: "tool",
});
const result = await store.replace("user", "actor-1", second.entry?.id ?? "", {
content: "默认使用中文回答,结论放在最前面。",
source: "tool",
});
expect(first.changed).toBe(true);
expect(second.changed).toBe(true);
expect(result.changed).toBe(false);
expect(result.detail).toBe("replacement would overlap with a similar existing memory");
});
});
+22
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle,
shouldGenerateSessionTitle,
} from "../../src/routes/chatSession.js";
@@ -161,3 +162,24 @@ describe("buildPromptWithLearningContext", () => {
expect(prompt).toBe("基于刚才结果继续分析");
});
});
describe("extractLatestFrontendTurn", () => {
it("extracts the latest valid frontend user and assistant turn", () => {
const turn = extractLatestFrontendTurn([
{ role: "user", content: "检查 DMA-2 漏损" },
{
role: "assistant",
content: "DMA-2 夜间最小流量持续抬升。",
progress: [{ id: "tool-dma", phase: "tool" }],
},
{ role: "user", content: "继续分析相邻分区" },
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
]);
expect(turn).toEqual({
assistantMessage: "DMA-2 夜间最小流量持续抬升。",
toolCallCount: 1,
userMessage: "检查 DMA-2 漏损",
});
});
});
+33
View File
@@ -135,4 +135,37 @@ describe("SessionTranscriptStore", () => {
expect(forkRecentTurns).toHaveLength(1);
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
});
it("does not duplicate the latest turn when the frontend state is saved again", async () => {
await store.appendTurn(
{
actorKey: "actor-3",
clientSessionId: "thread-3",
projectKey: "project-3",
sessionId: "thread-3",
},
{
assistantMessage: "已完成压力波动分析。",
toolCallCount: 1,
userMessage: "分析压力波动。",
},
);
const transcript = await store.appendTurn(
{
actorKey: "actor-3",
clientSessionId: "thread-3",
projectKey: "project-3",
sessionId: "thread-3",
},
{
assistantMessage: "已完成压力波动分析。",
toolCallCount: 2,
userMessage: "分析压力波动。",
},
);
expect(transcript.turns).toHaveLength(1);
expect(transcript.turns[0]?.toolCallCount).toBe(2);
});
});