重建会话记录逻辑

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
+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,