Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb62b36cd5 | |||
| 57d2c14fca | |||
| d52aab8ab0 | |||
| c10788dfe1 | |||
| 1f9e147dea | |||
| 7b3acb6813 | |||
| aafda0cf64 |
@@ -265,6 +265,8 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
|
|||||||
# IMAGE_MAKE_SEMANTIC_REVIEW_ENABLED=1
|
# IMAGE_MAKE_SEMANTIC_REVIEW_ENABLED=1
|
||||||
# IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE=70
|
# IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE=70
|
||||||
# IMAGE_MAKE_SEMANTIC_REVIEW_MAX_ATTEMPTS=3
|
# IMAGE_MAKE_SEMANTIC_REVIEW_MAX_ATTEMPTS=3
|
||||||
|
# H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=1
|
||||||
|
# H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=0
|
||||||
# Sandbox MCP 调用 Portal 内部生图入口;容器内通常使用 host.docker.internal。
|
# Sandbox MCP 调用 Portal 内部生图入口;容器内通常使用 host.docker.internal。
|
||||||
# MINDSPACE_AGENT_API_BASE_URL=http://127.0.0.1:8081/api
|
# MINDSPACE_AGENT_API_BASE_URL=http://127.0.0.1:8081/api
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function buildMemoryPrompt(messages) {
|
|||||||
'你是 TKMind 的用户长期记忆提取器。',
|
'你是 TKMind 的用户长期记忆提取器。',
|
||||||
'请从下面的用户对话中提取可以长期复用的个人记忆。',
|
'请从下面的用户对话中提取可以长期复用的个人记忆。',
|
||||||
'只记录用户明确表达或强证据支持的信息,不要猜测,不要记录临时闲聊。',
|
'只记录用户明确表达或强证据支持的信息,不要猜测,不要记录临时闲聊。',
|
||||||
|
'不要把问题、请求、指令或待办本身当作用户事实;问句没有提供答案时不要记录。',
|
||||||
'不要记录密码、密钥、身份证、手机号、银行卡等敏感信息。',
|
'不要记录密码、密钥、身份证、手机号、银行卡等敏感信息。',
|
||||||
'只返回 JSON 对象,不要 Markdown,不要解释。',
|
'只返回 JSON 对象,不要 Markdown,不要解释。',
|
||||||
'格式:{"memories":[{"label":"preference|habit|interest|goal|fact|experience|knowledge","text":"一句完整中文记忆","confidence":0.0-1.0}]}',
|
'格式:{"memories":[{"label":"preference|habit|interest|goal|fact|experience|knowledge","text":"一句完整中文记忆","confidence":0.0-1.0}]}',
|
||||||
@@ -382,7 +383,9 @@ export function createConversationMemoryService(pool, options = {}) {
|
|||||||
warnLlmExtractionFailed(err);
|
warnLlmExtractionFailed(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!memories?.length) memories = fallbackMemoriesFromMessages(messages);
|
// An empty array is an authoritative LLM decision that the batch contains no
|
||||||
|
// durable memory. Only fall back when extraction was unavailable (`null`).
|
||||||
|
if (memories == null) memories = fallbackMemoriesFromMessages(messages);
|
||||||
const stored = await storeMemories(userId, messages, memories);
|
const stored = await storeMemories(userId, messages, memories);
|
||||||
await markAnalyzed(messages.map((message) => message.id));
|
await markAnalyzed(messages.map((message) => message.id));
|
||||||
return { ok: true, analyzed: messages.length, memories: stored };
|
return { ok: true, analyzed: messages.length, memories: stored };
|
||||||
|
|||||||
@@ -208,6 +208,35 @@ test('saveAndAnalyze marks messages analyzed when llm extraction fails and no me
|
|||||||
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('saveAndAnalyze still uses fallback when llm extraction is unavailable', async () => {
|
||||||
|
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
|
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
|
||||||
|
const pool = createPool();
|
||||||
|
const service = createConversationMemoryService(pool, {
|
||||||
|
now: () => 2250,
|
||||||
|
llmProviderService: {
|
||||||
|
async createChatCompletion() {
|
||||||
|
throw new Error('upstream unavailable');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.saveAndAnalyze('session-fallback', 'user-fallback', [
|
||||||
|
{
|
||||||
|
id: 'm-fallback',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '我喜欢简洁的回答。' }],
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.memories, 1);
|
||||||
|
assert.match(pool.state.memories[0].memory_text, /简洁/);
|
||||||
|
|
||||||
|
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
|
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
||||||
|
});
|
||||||
|
|
||||||
test('saveAndAnalyze uses admin effective env for memory extraction model', async () => {
|
test('saveAndAnalyze uses admin effective env for memory extraction model', async () => {
|
||||||
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
|
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
|
||||||
@@ -284,6 +313,40 @@ test('saveAndAnalyze extracts memories through llmProviderService', async () =>
|
|||||||
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('saveAndAnalyze respects an empty llm result instead of storing a question through fallback', async () => {
|
||||||
|
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
|
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
|
||||||
|
const pool = createPool();
|
||||||
|
const service = createConversationMemoryService(pool, {
|
||||||
|
now: () => 2750,
|
||||||
|
llmProviderService: {
|
||||||
|
async createChatCompletion({ messages }) {
|
||||||
|
assert.match(String(messages?.[0]?.content ?? ''), /问句没有提供答案时不要记录/);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reply: JSON.stringify({ memories: [] }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.saveAndAnalyze('session-question', 'user-question', [
|
||||||
|
{
|
||||||
|
id: 'm-question',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '用户记住的记忆召回灰度测试代号是什么?' }],
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.analyzed, 1);
|
||||||
|
assert.equal(result.memories, 0);
|
||||||
|
assert.equal(pool.state.memories.length, 0);
|
||||||
|
|
||||||
|
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
|
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
||||||
|
});
|
||||||
|
|
||||||
test('saveAndAnalyze throttles repeated llm extraction warnings', async () => {
|
test('saveAndAnalyze throttles repeated llm extraction warnings', async () => {
|
||||||
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
|
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
|
||||||
|
|||||||
@@ -47,6 +47,117 @@ export function filterUserVisibleConversation(messages) {
|
|||||||
return messages.filter((message) => message?.metadata?.userVisible !== false);
|
return messages.filter((message) => message?.metadata?.userVisible !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAgentRunUserMessage(row) {
|
||||||
|
const raw = row?.user_message_json ?? row?.userMessageJson;
|
||||||
|
let parsed = raw;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
const message = {
|
||||||
|
...parsed,
|
||||||
|
role: 'user',
|
||||||
|
metadata: {
|
||||||
|
...(parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : {}),
|
||||||
|
userVisible: true,
|
||||||
|
agentVisible: true,
|
||||||
|
memoryInputSource: 'agent-run-original',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return extractConversationMessageText(message) ? message : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreConversationUserMessagesFromAgentRunRows(messages, runRows) {
|
||||||
|
const conversation = Array.isArray(messages) ? messages : [];
|
||||||
|
const originals = (Array.isArray(runRows) ? runRows : [])
|
||||||
|
.map((row) => parseAgentRunUserMessage(row))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (originals.length === 0) return conversation;
|
||||||
|
|
||||||
|
const userIndexes = conversation
|
||||||
|
.map((message, index) => (message?.role === 'user' ? index : -1))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
if (userIndexes.length === 0) return conversation;
|
||||||
|
|
||||||
|
const restored = [...conversation];
|
||||||
|
let userOffset = userIndexes.length - 1;
|
||||||
|
let originalOffset = originals.length - 1;
|
||||||
|
while (userOffset >= 0 && originalOffset >= 0) {
|
||||||
|
const messageIndex = userIndexes[userOffset];
|
||||||
|
const current = restored[messageIndex] ?? {};
|
||||||
|
const original = originals[originalOffset];
|
||||||
|
restored[messageIndex] = {
|
||||||
|
...current,
|
||||||
|
role: 'user',
|
||||||
|
content: original.content,
|
||||||
|
metadata: {
|
||||||
|
...(current.metadata && typeof current.metadata === 'object' ? current.metadata : {}),
|
||||||
|
...(original.metadata && typeof original.metadata === 'object' ? original.metadata : {}),
|
||||||
|
userVisible: current?.metadata?.userVisible ?? true,
|
||||||
|
agentVisible: current?.metadata?.agentVisible ?? true,
|
||||||
|
memoryInputSource: 'agent-run-original',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
userOffset -= 1;
|
||||||
|
originalOffset -= 1;
|
||||||
|
}
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSuccessfulAgentRunUserMessageRows(
|
||||||
|
pool,
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
{ limit = 200 } = {},
|
||||||
|
) {
|
||||||
|
if (!pool || !sessionId || !userId) return [];
|
||||||
|
const resolvedLimit = Math.max(1, Math.min(200, Number(limit) || 200));
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, user_message_json, created_at
|
||||||
|
FROM h5_agent_runs
|
||||||
|
WHERE agent_session_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND status = 'succeeded'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[sessionId, userId, resolvedLimit],
|
||||||
|
);
|
||||||
|
return [...rows].reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreConversationUserMessagesFromAgentRuns(pool, messages, sessionId, userId) {
|
||||||
|
const userMessageCount = Array.isArray(messages)
|
||||||
|
? messages.filter((message) => message?.role === 'user').length
|
||||||
|
: 0;
|
||||||
|
if (userMessageCount === 0) return Array.isArray(messages) ? messages : [];
|
||||||
|
const rows = await loadSuccessfulAgentRunUserMessageRows(pool, sessionId, userId, {
|
||||||
|
limit: userMessageCount,
|
||||||
|
});
|
||||||
|
return restoreConversationUserMessagesFromAgentRunRows(messages, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreConversationUserMessagesFromAgentRunsFailOpen(
|
||||||
|
pool,
|
||||||
|
messages,
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
{ logger = console } = {},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await restoreConversationUserMessagesFromAgentRuns(pool, messages, sessionId, userId);
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.(
|
||||||
|
'[memory-v2] original agent-run transcript unavailable; using visible session transcript:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
return Array.isArray(messages) ? messages : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function countNonEmptyConversationMessages(messages) {
|
export function countNonEmptyConversationMessages(messages) {
|
||||||
if (!Array.isArray(messages)) return 0;
|
if (!Array.isArray(messages)) return 0;
|
||||||
return messages.filter((message) => {
|
return messages.filter((message) => {
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import {
|
|||||||
buildConversationFromDbRows,
|
buildConversationFromDbRows,
|
||||||
countNonEmptyConversationMessages,
|
countNonEmptyConversationMessages,
|
||||||
filterUserVisibleConversation,
|
filterUserVisibleConversation,
|
||||||
|
parseAgentRunUserMessage,
|
||||||
parseStoredConversationRow,
|
parseStoredConversationRow,
|
||||||
repairConversationFromDbRows,
|
repairConversationFromDbRows,
|
||||||
|
restoreConversationUserMessagesFromAgentRunRows,
|
||||||
|
restoreConversationUserMessagesFromAgentRuns,
|
||||||
|
restoreConversationUserMessagesFromAgentRunsFailOpen,
|
||||||
shouldRepairConversationFromDb,
|
shouldRepairConversationFromDb,
|
||||||
} from './conversation-repair.mjs';
|
} from './conversation-repair.mjs';
|
||||||
|
|
||||||
@@ -97,3 +101,78 @@ test('filterUserVisibleConversation keeps messages without explicit userVisible
|
|||||||
assert.equal(visible[0].role, 'user');
|
assert.equal(visible[0].role, 'user');
|
||||||
assert.equal(visible[1].role, 'assistant');
|
assert.equal(visible[1].role, 'assistant');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseAgentRunUserMessage keeps the original user text for memory extraction', () => {
|
||||||
|
const parsed = parseAgentRunUserMessage({
|
||||||
|
user_message_json: JSON.stringify({
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '请记住灰度代号 MEM-NEW' }],
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(parsed.content[0].text, '请记住灰度代号 MEM-NEW');
|
||||||
|
assert.equal(parsed.metadata.memoryInputSource, 'agent-run-original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreConversationUserMessagesFromAgentRunRows removes injected memory context', () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 'upstream-user-1',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '【Memind 任务编排】\n[Memory Context]\n- 旧记忆\n用户任务:\n请记住灰度代号 MEM-NEW' }],
|
||||||
|
metadata: { userVisible: true, agentVisible: true },
|
||||||
|
},
|
||||||
|
gooseMsg('assistant-1', 'assistant', '已记住'),
|
||||||
|
];
|
||||||
|
const restored = restoreConversationUserMessagesFromAgentRunRows(messages, [{
|
||||||
|
user_message_json: JSON.stringify({
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '请记住灰度代号 MEM-NEW' }],
|
||||||
|
metadata: { userVisible: true, agentVisible: true },
|
||||||
|
}),
|
||||||
|
}]);
|
||||||
|
assert.equal(restored[0].id, 'upstream-user-1');
|
||||||
|
assert.equal(restored[0].content[0].text, '请记住灰度代号 MEM-NEW');
|
||||||
|
assert.doesNotMatch(restored[0].content[0].text, /旧记忆|Memory Context/);
|
||||||
|
assert.equal(restored[1].content[0].text, '已记住');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreConversationUserMessagesFromAgentRuns scopes successful runs by session and user', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
return [[{
|
||||||
|
user_message_json: JSON.stringify({
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '原始用户消息' }],
|
||||||
|
}),
|
||||||
|
}]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const restored = await restoreConversationUserMessagesFromAgentRuns(
|
||||||
|
pool,
|
||||||
|
[gooseMsg('user-1', 'user', '编排后的消息')],
|
||||||
|
'session-1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
assert.match(calls[0].sql, /status = 'succeeded'/);
|
||||||
|
assert.match(calls[0].sql, /ORDER BY created_at DESC/);
|
||||||
|
assert.match(calls[0].sql, /LIMIT \?/);
|
||||||
|
assert.deepEqual(calls[0].params, ['session-1', 'user-1', 1]);
|
||||||
|
assert.equal(restored[0].content[0].text, '原始用户消息');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreConversationUserMessagesFromAgentRunsFailOpen preserves the visible transcript', async () => {
|
||||||
|
const warnings = [];
|
||||||
|
const messages = [gooseMsg('user-1', 'user', '可见会话原文')];
|
||||||
|
const restored = await restoreConversationUserMessagesFromAgentRunsFailOpen(
|
||||||
|
{ async query() { throw new Error('agent run query unavailable'); } },
|
||||||
|
messages,
|
||||||
|
'session-1',
|
||||||
|
'user-1',
|
||||||
|
{ logger: { warn: (...items) => warnings.push(items.join(' ')) } },
|
||||||
|
);
|
||||||
|
assert.equal(restored, messages);
|
||||||
|
assert.match(warnings[0], /agent run query unavailable/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ IMAGE_MAKE_MEMIND_CONFIG_TOKEN=<与 Portal IMAGE_MAKE_TOKEN 相同的值>
|
|||||||
配置 `H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=0` 可只关闭服务号“页面必须新缩略图”硬门,作为紧急回滚;
|
配置 `H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=0` 可只关闭服务号“页面必须新缩略图”硬门,作为紧急回滚;
|
||||||
默认开启。该开关不会修改 H5 聊天图片策略。
|
默认开启。该开关不会修改 H5 聊天图片策略。
|
||||||
|
|
||||||
|
配置 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=1` 可开启服务号缩略图确定性修复,默认关闭。修复仅在一个正式页面
|
||||||
|
与一张本轮 hero 图片的映射唯一时,把本轮 `asset.htmlSrc` 写回 `mindspace-cover.cover` 并重新执行完整校验;
|
||||||
|
多页、多图、缺少本轮新图或元数据无效时继续 fail closed。
|
||||||
|
|
||||||
## 发布前置条件
|
## 发布前置条件
|
||||||
|
|
||||||
Portal runtime 不包含独立 `image_make` 服务。正式启用前必须确保:
|
Portal runtime 不包含独立 `image_make` 服务。正式启用前必须确保:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-07-22 微信服务号页面缩略图交付失败
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
微信服务号用户要求生成带主题图片的页面。页面和候选图片已经生成,但服务号最终回复“没有完成本轮新缩略图”,未发送页面链接,并要求用户重新描述完整需求。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
这是两个连续问题叠加后的结果:
|
||||||
|
|
||||||
|
1. 首轮图片语义审核未达阈值后,Agent 更换提示词再次调用图片工具,却复用了同一个服务号页面缩略图幂等键。`image_make` 对同键不同请求返回冲突,后续被表现为图片服务不可用。
|
||||||
|
2. 后续重试已经生成并落盘合格 hero 图,但页面交付校验在页面文件与本轮工具写入快照不同步时得到 `missing_generated_cover`,形成假阴性,没有使用唯一的本轮 hero 图恢复 `mindspace-cover.cover`。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 仅对 `wechat-<消息>-page-<序号>-thumbnail` 且 `purpose=hero` 的请求,把提示词摘要加入 `image_make` 幂等键;同提示词仍可幂等,不同提示词不会冲突。其它图片调用的幂等行为不变。
|
||||||
|
- 明确要求 Agent 对同一正式页面只调用一次图片工具;语义重试由图片服务内部完成。
|
||||||
|
- 增加默认关闭的 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR`。开启后,只有“一个正式页面 + 一张本轮 hero 图”且页面已有合法 `mindspace-cover` 元数据时,才原子写回 cover 并重新执行完整校验。任何多页、多图、缺图或无效元数据情况继续 fail closed。
|
||||||
|
- 增加结构化失败诊断,记录校验原因、页面、cover 和本轮生成资产,不记录图片提示词或用户正文。
|
||||||
|
- 失败文案不再要求重述完整需求;页面内容保留,用户可回复“重试上次页面”。
|
||||||
|
- 页面生成提示增加禁止杜撰产品指标、客户数、SLA、认证、案例、排名和承诺的约束。
|
||||||
|
|
||||||
|
## 影响边界与回滚
|
||||||
|
|
||||||
|
- 正常页面生成、H5 聊天、非服务号图片、服务号普通聊天和多页/多图 fail-closed 行为不变。
|
||||||
|
- 确定性修复默认关闭;生产验证后显式设置 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=1` 才启用。
|
||||||
|
- 如需回滚修复分支,只需将该变量设为 `0` 或移除;新缩略图硬门仍保持开启。
|
||||||
@@ -13,6 +13,18 @@
|
|||||||
`updated_at=0` 游标开始做有限批次 backfill,新写入行可能长期排在批次之外。结果是
|
`updated_at=0` 游标开始做有限批次 backfill,新写入行可能长期排在批次之外。结果是
|
||||||
`agent_memory_resolved` 显示已注入,但回答只拿到旧的无关记忆。
|
`agent_memory_resolved` 显示已注入,但回答只拿到旧的无关记忆。
|
||||||
|
|
||||||
|
首次修复同步后,灰度又发现 `remember-recent` 读取的是 Agent 编排后的会话文本,其中
|
||||||
|
包含已注入的 `[Memory Context]`。提取器因此可能把旧记忆再次沉淀,而忽略用户本轮明确
|
||||||
|
要求保存的内容,形成旧记忆自我复制。
|
||||||
|
|
||||||
|
提取器返回 `{"memories":[]}` 时,旧逻辑仍会触发规则回退,可能把“……是什么”一类
|
||||||
|
问句误存为事实。空数组必须视为成功的“无需保存”判断;只有提取不可用并返回 `null`
|
||||||
|
时才允许规则回退。
|
||||||
|
|
||||||
|
当前生产使用的本地 hash embedding 只有 3 维,且连续中文可能被视为单个 token。即使正确
|
||||||
|
记忆已进入 pgvector,它也可能排在向量 Top-K 之外。因此 pgvector 读取必须合并有界的
|
||||||
|
向量候选与最近候选,再以中文字符 n-gram 查询覆盖率重排;无词法重合时保持向量排序。
|
||||||
|
|
||||||
## 必须保留的行为
|
## 必须保留的行为
|
||||||
|
|
||||||
1. `MEMORY_CANDIDATE_PERSISTENCE_ENABLED=1` 且 MySQL 可用时,Portal 必须先执行
|
1. `MEMORY_CANDIDATE_PERSISTENCE_ENABLED=1` 且 MySQL 可用时,Portal 必须先执行
|
||||||
@@ -28,11 +40,18 @@
|
|||||||
6. 用户记忆 `write/compact` 成功后,必须在返回前按 `userId + sessionId` 将本次活跃记忆
|
6. 用户记忆 `write/compact` 成功后,必须在返回前按 `userId + sessionId` 将本次活跃记忆
|
||||||
幂等 upsert 到 pgvector;候选晋升成功后也必须按实际晋升用户同步。不得依赖从零开始
|
幂等 upsert 到 pgvector;候选晋升成功后也必须按实际晋升用户同步。不得依赖从零开始
|
||||||
的全局有限批次 backfill 来保证新记忆可立即召回。
|
的全局有限批次 backfill 来保证新记忆可立即召回。
|
||||||
|
7. 显式记忆提取必须优先使用同一用户、同一会话中已成功 `h5_agent_runs.user_message_json`
|
||||||
|
保存的原始用户消息。不得把 Agent 编排提示或 `[Memory Context]` 当作用户的新记忆;
|
||||||
|
原始消息查询失败时必须 fail-open 到现有可见会话,不能阻塞保存接口。
|
||||||
|
8. pgvector 召回必须同时覆盖有界向量候选和有界最近候选,去重后只返回请求的 limit;
|
||||||
|
中文词法重排用于弥补低维本地 hash 的排序缺陷,且候选池上限不得超过 100。
|
||||||
|
9. LLM 提取明确返回空数组时不得再走规则回退;提取提示必须明确排除没有提供答案的
|
||||||
|
问题、请求、指令和待办,避免把问句本身沉淀为长期记忆。
|
||||||
|
|
||||||
## 回归检查
|
## 回归检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --test memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs \
|
node --test conversation-memory.test.mjs conversation-repair.test.mjs memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs \
|
||||||
memory-v2-pgvector-backfill.test.mjs memory-v2-runtime.test.mjs
|
memory-v2-pgvector-backfill.test.mjs memory-v2-runtime.test.mjs
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|||||||
+101
-7
@@ -24,6 +24,43 @@ function vectorLiteral(embedding) {
|
|||||||
return `[${embedding.join(',')}]`;
|
return `[${embedding.join(',')}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fff]+/gu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCharacterNgrams(value, size = 2) {
|
||||||
|
const text = normalizeSearchText(value);
|
||||||
|
if (!text) return new Set();
|
||||||
|
if (text.length <= size) return new Set([text]);
|
||||||
|
const grams = new Set();
|
||||||
|
for (let index = 0; index <= text.length - size; index += 1) {
|
||||||
|
grams.add(text.slice(index, index + size));
|
||||||
|
}
|
||||||
|
return grams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lexicalQueryCoverage(query, text) {
|
||||||
|
const queryGrams = buildCharacterNgrams(query);
|
||||||
|
if (queryGrams.size === 0) return 0;
|
||||||
|
const textGrams = buildCharacterNgrams(text);
|
||||||
|
let overlap = 0;
|
||||||
|
for (const gram of queryGrams) {
|
||||||
|
if (textGrams.has(gram)) overlap += 1;
|
||||||
|
}
|
||||||
|
return overlap / queryGrams.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampValue(value) {
|
||||||
|
if (value == null) return 0;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric)) return numeric;
|
||||||
|
const parsed = Date.parse(String(value));
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRow(row) {
|
function normalizeRow(row) {
|
||||||
const text = String(row?.content ?? row?.memory_text ?? row?.text ?? '').trim();
|
const text = String(row?.content ?? row?.memory_text ?? row?.text ?? '').trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -33,9 +70,38 @@ function normalizeRow(row) {
|
|||||||
text,
|
text,
|
||||||
score: row?.score == null ? null : Number(row.score),
|
score: row?.score == null ? null : Number(row.score),
|
||||||
createdAt: row?.created_at ?? row?.createdAt ?? null,
|
createdAt: row?.created_at ?? row?.createdAt ?? null,
|
||||||
|
updatedAt: row?.updated_at ?? row?.updatedAt ?? row?.created_at ?? row?.createdAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rankHybridCandidates(rows, query, limit) {
|
||||||
|
const byId = new Map();
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
const memory = normalizeRow(row);
|
||||||
|
if (!memory) continue;
|
||||||
|
const key = memory.id ?? `${memory.label}:${memory.text}`;
|
||||||
|
if (!byId.has(key)) byId.set(key, memory);
|
||||||
|
}
|
||||||
|
return [...byId.values()]
|
||||||
|
.map((memory) => ({
|
||||||
|
memory,
|
||||||
|
lexicalScore: lexicalQueryCoverage(query, memory.text),
|
||||||
|
vectorScore: Number.isFinite(memory.score) ? memory.score : -1,
|
||||||
|
updatedAt: timestampValue(memory.updatedAt),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.lexicalScore !== right.lexicalScore) {
|
||||||
|
return right.lexicalScore - left.lexicalScore;
|
||||||
|
}
|
||||||
|
if (left.lexicalScore > 0 && left.updatedAt !== right.updatedAt) {
|
||||||
|
return right.updatedAt - left.updatedAt;
|
||||||
|
}
|
||||||
|
return right.vectorScore - left.vectorScore;
|
||||||
|
})
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ memory }) => memory);
|
||||||
|
}
|
||||||
|
|
||||||
export function createPgvectorMemoryBackend({
|
export function createPgvectorMemoryBackend({
|
||||||
pool = null,
|
pool = null,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
@@ -75,15 +141,38 @@ export function createPgvectorMemoryBackend({
|
|||||||
const embedding = await resolveEmbedding(input);
|
const embedding = await resolveEmbedding(input);
|
||||||
if (!embedding) return { memories: [], semanticMemories: [] };
|
if (!embedding) return { memories: [], semanticMemories: [] };
|
||||||
const limit = Math.max(1, Math.min(50, Number(input.limit ?? defaultLimit) || defaultLimit));
|
const limit = Math.max(1, Math.min(50, Number(input.limit ?? defaultLimit) || defaultLimit));
|
||||||
|
const candidateLimit = Math.max(
|
||||||
|
limit,
|
||||||
|
Math.min(100, Number(input.candidateLimit ?? 50) || 50),
|
||||||
|
);
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT id, content, type, created_at, 1 - (embedding <=> $2::vector) AS score
|
WITH vector_candidates AS (
|
||||||
FROM ${resolvedTableName}
|
SELECT id, content, type, created_at, updated_at,
|
||||||
WHERE user_id = $1
|
1 - (embedding <=> $2::vector) AS score,
|
||||||
ORDER BY embedding <=> $2::vector
|
0 AS source_priority
|
||||||
LIMIT $3
|
FROM ${resolvedTableName}
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY embedding <=> $2::vector
|
||||||
|
LIMIT $3
|
||||||
|
), recent_candidates AS (
|
||||||
|
SELECT id, content, type, created_at, updated_at,
|
||||||
|
1 - (embedding <=> $2::vector) AS score,
|
||||||
|
1 AS source_priority
|
||||||
|
FROM ${resolvedTableName}
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT $3
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (id) id, content, type, created_at, updated_at, score
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM vector_candidates
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM recent_candidates
|
||||||
|
) AS candidates
|
||||||
|
ORDER BY id, source_priority
|
||||||
`;
|
`;
|
||||||
const result = await pool.query(sql, [userId, vectorLiteral(embedding), limit]);
|
const result = await pool.query(sql, [userId, vectorLiteral(embedding), candidateLimit]);
|
||||||
const memories = (result?.rows ?? []).map((row) => normalizeRow(row)).filter(Boolean);
|
const memories = rankHybridCandidates(result?.rows ?? [], input.query, limit);
|
||||||
return {
|
return {
|
||||||
semanticMemories: memories.map((item) => item.text),
|
semanticMemories: memories.map((item) => item.text),
|
||||||
memories,
|
memories,
|
||||||
@@ -91,3 +180,8 @@ export function createPgvectorMemoryBackend({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const pgvectorMemoryBackendInternals = {
|
||||||
|
lexicalQueryCoverage,
|
||||||
|
rankHybridCandidates,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createMemoryV2 } from './memory-v2.mjs';
|
import { createMemoryV2 } from './memory-v2.mjs';
|
||||||
import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs';
|
import {
|
||||||
|
createPgvectorMemoryBackend,
|
||||||
|
pgvectorMemoryBackendInternals,
|
||||||
|
} from './memory-v2-pgvector.mjs';
|
||||||
|
|
||||||
test('pgvector backend is disabled by default and does not query storage', async () => {
|
test('pgvector backend is disabled by default and does not query storage', async () => {
|
||||||
let queried = false;
|
let queried = false;
|
||||||
@@ -85,7 +88,9 @@ test('pgvector backend performs parameterized vector lookup when explicitly enab
|
|||||||
|
|
||||||
assert.equal(queries.length, 1);
|
assert.equal(queries.length, 1);
|
||||||
assert.match(queries[0].sql, /FROM memory_embeddings/);
|
assert.match(queries[0].sql, /FROM memory_embeddings/);
|
||||||
assert.deepEqual(queries[0].params, ['user-1', '[0.25,0.5,0.75]', 5]);
|
assert.match(queries[0].sql, /WITH vector_candidates/);
|
||||||
|
assert.match(queries[0].sql, /recent_candidates/);
|
||||||
|
assert.deepEqual(queries[0].params, ['user-1', '[0.25,0.5,0.75]', 50]);
|
||||||
assert.deepEqual(result.semanticMemories, ['用户关注 Memory V2 的 facade 边界']);
|
assert.deepEqual(result.semanticMemories, ['用户关注 Memory V2 的 facade 边界']);
|
||||||
assert.deepEqual(result.memories, [
|
assert.deepEqual(result.memories, [
|
||||||
{
|
{
|
||||||
@@ -94,10 +99,68 @@ test('pgvector backend performs parameterized vector lookup when explicitly enab
|
|||||||
text: '用户关注 Memory V2 的 facade 边界',
|
text: '用户关注 Memory V2 的 facade 边界',
|
||||||
score: 0.87,
|
score: 0.87,
|
||||||
createdAt: '2026-07-02T00:00:00.000Z',
|
createdAt: '2026-07-02T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-02T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pgvector hybrid ranking recovers a recent Chinese memory missed by vector top-k', async () => {
|
||||||
|
const marker = 'MEM-RECALL-NEW';
|
||||||
|
const backend = createPgvectorMemoryBackend({
|
||||||
|
enabled: true,
|
||||||
|
pool: {
|
||||||
|
async query() {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: '用户以前关注贵州旅游攻略',
|
||||||
|
type: 'interest',
|
||||||
|
score: 0.91,
|
||||||
|
created_at: '2026-06-01T00:00:00.000Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: `用户的记忆召回灰度测试代号是 ${marker}`,
|
||||||
|
type: 'fact',
|
||||||
|
score: -0.49,
|
||||||
|
created_at: '2026-07-22T00:00:00.000Z',
|
||||||
|
updated_at: '2026-07-22T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: '用户偏好简洁回答',
|
||||||
|
type: 'preference',
|
||||||
|
score: 0.3,
|
||||||
|
created_at: '2026-07-21T00:00:00.000Z',
|
||||||
|
updated_at: '2026-07-21T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
embedQuery: async () => [0.25, 0.5, 0.75],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await backend.resolve({
|
||||||
|
userId: 'user-1',
|
||||||
|
query: '我之前让你记住的记忆召回灰度测试代号是什么?请只回答完整代号。',
|
||||||
|
limit: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(result.memories[0].text, new RegExp(marker));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pgvector hybrid ranking keeps vector order when query has no lexical overlap', () => {
|
||||||
|
const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([
|
||||||
|
{ id: 1, content: 'alpha', score: 0.2 },
|
||||||
|
{ id: 2, content: 'beta', score: 0.8 },
|
||||||
|
], '完全无关的中文查询', 2);
|
||||||
|
assert.equal(ranked[0].id, '2');
|
||||||
|
assert.equal(ranked[1].id, '1');
|
||||||
|
});
|
||||||
|
|
||||||
test('pgvector backend validates table names before building SQL', () => {
|
test('pgvector backend validates table names before building SQL', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createPgvectorMemoryBackend({ tableName: 'memory_embeddings;DROP TABLE users' }),
|
() => createPgvectorMemoryBackend({ tableName: 'memory_embeddings;DROP TABLE users' }),
|
||||||
|
|||||||
@@ -385,7 +385,8 @@ test('createMemoryV2Runtime selects pgvector only when pool and embedding are co
|
|||||||
assert.equal(queries.length, 1);
|
assert.equal(queries.length, 1);
|
||||||
assert.equal(queries[0].options.connectionString, 'postgresql://local/memory');
|
assert.equal(queries[0].options.connectionString, 'postgresql://local/memory');
|
||||||
assert.equal(queries[0].options.max, 2);
|
assert.equal(queries[0].options.max, 2);
|
||||||
assert.deepEqual(queries[0].params, ['u1', '[0.1,0.2,0.3]', 8]);
|
assert.match(queries[0].sql, /recent_candidates/);
|
||||||
|
assert.deepEqual(queries[0].params, ['u1', '[0.1,0.2,0.3]', 50]);
|
||||||
|
|
||||||
await memory.close();
|
await memory.close();
|
||||||
assert.equal(poolEnded, true);
|
assert.equal(poolEnded, true);
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ function htmlSrcForWorkspaceAsset(workspaceRelativePath) {
|
|||||||
return normalized.slice('public/'.length) || null;
|
return normalized.slice('public/'.length) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
|
||||||
|
const WECHAT_PAGE_THUMBNAIL_KEY_PATTERN = /^wechat-[a-zA-Z0-9._-]+-page-\d+-thumbnail$/;
|
||||||
|
|
||||||
|
function boundedIdempotencyKey(base, suffix = '') {
|
||||||
|
const normalizedBase = String(base ?? '').trim();
|
||||||
|
const normalizedSuffix = String(suffix ?? '');
|
||||||
|
return `${normalizedBase.slice(0, Math.max(0, IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH - normalizedSuffix.length))}${normalizedSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImageMakeIdempotencyKey({ idempotencyKey, purpose, prompt, negativePrompt }) {
|
||||||
|
const normalized = String(idempotencyKey ?? '').trim();
|
||||||
|
if (!normalized) return `imgreq_${crypto.randomUUID()}`;
|
||||||
|
if (purpose !== 'hero' || !WECHAT_PAGE_THUMBNAIL_KEY_PATTERN.test(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const promptHash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update([purpose, String(prompt ?? '').trim(), String(negativePrompt ?? '').trim()].join('\0'))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12);
|
||||||
|
return boundedIdempotencyKey(normalized, `-p-${promptHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePurposeDimensions(spec, env, logger) {
|
function resolvePurposeDimensions(spec, env, logger) {
|
||||||
const widthKey = `IMAGE_MAKE_${spec.envPrefix}_WIDTH`;
|
const widthKey = `IMAGE_MAKE_${spec.envPrefix}_WIDTH`;
|
||||||
const heightKey = `IMAGE_MAKE_${spec.envPrefix}_HEIGHT`;
|
const heightKey = `IMAGE_MAKE_${spec.envPrefix}_HEIGHT`;
|
||||||
@@ -65,7 +88,12 @@ export function createMindSpaceImageGenerationService({
|
|||||||
const maxAttempts = Number.isFinite(configuredAttempts)
|
const maxAttempts = Number.isFinite(configuredAttempts)
|
||||||
? Math.max(1, Math.min(3, Math.floor(configuredAttempts)))
|
? Math.max(1, Math.min(3, Math.floor(configuredAttempts)))
|
||||||
: 3;
|
: 3;
|
||||||
const baseIdempotencyKey = idempotencyKey || `imgreq_${crypto.randomUUID()}`;
|
const baseIdempotencyKey = resolveImageMakeIdempotencyKey({
|
||||||
|
idempotencyKey,
|
||||||
|
purpose,
|
||||||
|
prompt,
|
||||||
|
negativePrompt,
|
||||||
|
});
|
||||||
let generated = null;
|
let generated = null;
|
||||||
let acceptedReview = null;
|
let acceptedReview = null;
|
||||||
let retryFeedback = '';
|
let retryFeedback = '';
|
||||||
@@ -76,7 +104,7 @@ export function createMindSpaceImageGenerationService({
|
|||||||
: prompt;
|
: prompt;
|
||||||
const attemptKey = attempt === 1
|
const attemptKey = attempt === 1
|
||||||
? baseIdempotencyKey
|
? baseIdempotencyKey
|
||||||
: `${baseIdempotencyKey.slice(0, 160)}-review-${attempt}`;
|
: boundedIdempotencyKey(baseIdempotencyKey, `-review-${attempt}`);
|
||||||
generated = await imageMakeClient.generateImage({
|
generated = await imageMakeClient.generateImage({
|
||||||
prompt: attemptPrompt,
|
prompt: attemptPrompt,
|
||||||
negativePrompt,
|
negativePrompt,
|
||||||
|
|||||||
@@ -214,6 +214,55 @@ test('MindSpace image generation retries a semantic mismatch and stores only a r
|
|||||||
assert.match(generatedRequests[1].prompt, /缺失元素:竹海/);
|
assert.match(generatedRequests[1].prompt, /缺失元素:竹海/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('WeChat page thumbnail idempotency follows the prompt while other keys stay stable', async () => {
|
||||||
|
const generatedRequests = [];
|
||||||
|
let assetCount = 0;
|
||||||
|
const service = createMindSpaceImageGenerationService({
|
||||||
|
configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } },
|
||||||
|
assetService: {
|
||||||
|
async createChatAsset() {
|
||||||
|
assetCount += 1;
|
||||||
|
return {
|
||||||
|
id: `asset-${assetCount}`,
|
||||||
|
workspaceRelativePath: `public/images/hero-${assetCount}.webp`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
imageMakeClient: {
|
||||||
|
async generateImage(input) {
|
||||||
|
generatedRequests.push(input);
|
||||||
|
return {
|
||||||
|
jobId: `job-${generatedRequests.length}`,
|
||||||
|
buffer: Buffer.from(`image-${generatedRequests.length}`),
|
||||||
|
mimeType: 'image/webp', sha256: 'abc', width: 768, height: 432,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async acknowledge() {},
|
||||||
|
},
|
||||||
|
imageReviewService: passingReviewService(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
userId: 'user-1',
|
||||||
|
purpose: 'hero',
|
||||||
|
prompt: 'TKMind 舌战群儒,突出安全与落地',
|
||||||
|
idempotencyKey: 'wechat-25550597627048917-page-1-thumbnail',
|
||||||
|
};
|
||||||
|
await service.generate(input);
|
||||||
|
await service.generate(input);
|
||||||
|
await service.generate({ ...input, prompt: 'TKMind 舌战群儒,突出场景与思维进化' });
|
||||||
|
await service.generate({ ...input, idempotencyKey: 'ordinary-request', prompt: '普通图片' });
|
||||||
|
|
||||||
|
assert.equal(generatedRequests[0].idempotencyKey, generatedRequests[1].idempotencyKey);
|
||||||
|
assert.notEqual(generatedRequests[1].idempotencyKey, generatedRequests[2].idempotencyKey);
|
||||||
|
assert.match(
|
||||||
|
generatedRequests[0].idempotencyKey,
|
||||||
|
/^wechat-25550597627048917-page-1-thumbnail-p-[a-f0-9]{12}$/,
|
||||||
|
);
|
||||||
|
assert.ok(generatedRequests[0].idempotencyKey.length <= 128);
|
||||||
|
assert.equal(generatedRequests[3].idempotencyKey, 'ordinary-request');
|
||||||
|
});
|
||||||
|
|
||||||
test('MindSpace image generation blocks storage after all semantic review attempts fail', async () => {
|
test('MindSpace image generation blocks storage after all semantic review attempts fail', async () => {
|
||||||
let generateCount = 0;
|
let generateCount = 0;
|
||||||
let storeCount = 0;
|
let storeCount = 0;
|
||||||
|
|||||||
+13
-2
@@ -199,7 +199,11 @@ import { createFeedbackService } from './user-feedback.mjs';
|
|||||||
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
||||||
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
||||||
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
|
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
|
||||||
import { filterUserVisibleConversation, repairSessionConversationFromDb } from './conversation-repair.mjs';
|
import {
|
||||||
|
filterUserVisibleConversation,
|
||||||
|
repairSessionConversationFromDb,
|
||||||
|
restoreConversationUserMessagesFromAgentRunsFailOpen,
|
||||||
|
} from './conversation-repair.mjs';
|
||||||
import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs';
|
import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs';
|
||||||
import { createSessionStreamStore } from './session-stream-store.mjs';
|
import { createSessionStreamStore } from './session-stream-store.mjs';
|
||||||
import { isSessionStreamReplayEnabled } from './session-stream.mjs';
|
import { isSessionStreamReplayEnabled } from './session-stream.mjs';
|
||||||
@@ -2275,7 +2279,14 @@ async function loadUserVisibleConversation(sessionId, userId) {
|
|||||||
if (authPool && userId) {
|
if (authPool && userId) {
|
||||||
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
|
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
|
||||||
}
|
}
|
||||||
return filterUserVisibleConversation(session?.conversation ?? []);
|
const visible = filterUserVisibleConversation(session?.conversation ?? []);
|
||||||
|
if (!authPool || !userId) return visible;
|
||||||
|
return restoreConversationUserMessagesFromAgentRunsFailOpen(
|
||||||
|
authPool,
|
||||||
|
visible,
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncUserMemoriesIntoSession(userId, sessionId) {
|
async function syncUserMemoriesIntoSession(userId, sessionId) {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function loadWechatMpConfig(env = process.env) {
|
|||||||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
||||||
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
|
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
|
||||||
requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0',
|
requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0',
|
||||||
|
repairFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR === '1',
|
||||||
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-9
@@ -6,7 +6,11 @@ import { developerToolsFromPolicy } from './capabilities.mjs';
|
|||||||
import { mergeMessageContent } from './message-stream.mjs';
|
import { mergeMessageContent } from './message-stream.mjs';
|
||||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||||
import { resolveSessionAccess } from './session-broker.mjs';
|
import { resolveSessionAccess } from './session-broker.mjs';
|
||||||
import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
|
import {
|
||||||
|
extractPublicHtmlWriteArtifacts,
|
||||||
|
isStubPublicHtmlContent,
|
||||||
|
materializeMissingPublicHtmlWrites,
|
||||||
|
} from './mindspace-public-finish-sync.mjs';
|
||||||
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
|
||||||
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +45,8 @@ import {
|
|||||||
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
|
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
|
||||||
import {
|
import {
|
||||||
collectWechatGeneratedImages,
|
collectWechatGeneratedImages,
|
||||||
|
repairUnambiguousFreshWechatPageThumbnail,
|
||||||
|
summarizeFreshWechatThumbnailVerification,
|
||||||
verifyFreshWechatPageThumbnails,
|
verifyFreshWechatPageThumbnails,
|
||||||
} from './wechat/verify/generated-thumbnail.mjs';
|
} from './wechat/verify/generated-thumbnail.mjs';
|
||||||
import { resolveBillingTokenState } from './billing-token-state.mjs';
|
import { resolveBillingTokenState } from './billing-token-state.mjs';
|
||||||
@@ -1030,6 +1036,7 @@ export function isRecoverableWechatAgentSessionError(message) {
|
|||||||
const normalized = String(message ?? '').trim();
|
const normalized = String(message ?? '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
if (/stale_session_poisoned_completion/i.test(normalized)) return true;
|
if (/stale_session_poisoned_completion/i.test(normalized)) return true;
|
||||||
|
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
||||||
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
||||||
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
||||||
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
||||||
@@ -1512,6 +1519,7 @@ export function createWechatMpService({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
|
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
|
||||||
|
const repairFreshPageThumbnail = config.repairFreshPageThumbnail === true;
|
||||||
config = {
|
config = {
|
||||||
...loadWechatMpConfig({}),
|
...loadWechatMpConfig({}),
|
||||||
...config,
|
...config,
|
||||||
@@ -1530,6 +1538,7 @@ export function createWechatMpService({
|
|||||||
? config.mediaAnalysisGrayUsers
|
? config.mediaAnalysisGrayUsers
|
||||||
: [],
|
: [],
|
||||||
requireFreshPageThumbnail,
|
requireFreshPageThumbnail,
|
||||||
|
repairFreshPageThumbnail,
|
||||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1840,10 +1849,42 @@ export function createWechatMpService({
|
|||||||
user,
|
user,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
publishDir,
|
publishDir,
|
||||||
|
notifyFailure = true,
|
||||||
}) => {
|
}) => {
|
||||||
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
||||||
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||||
const verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
let verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
||||||
|
if (!verification.ok) {
|
||||||
|
logger.warn?.(
|
||||||
|
'WeChat MP fresh thumbnail verification failed:',
|
||||||
|
summarizeFreshWechatThumbnailVerification(verification, images),
|
||||||
|
);
|
||||||
|
if (config.repairFreshPageThumbnail) {
|
||||||
|
const repair = repairUnambiguousFreshWechatPageThumbnail({
|
||||||
|
artifacts,
|
||||||
|
images,
|
||||||
|
currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(reply?.messages ?? [], {
|
||||||
|
publishDir,
|
||||||
|
}),
|
||||||
|
verificationReason: verification.reason,
|
||||||
|
});
|
||||||
|
if (repair.ok) {
|
||||||
|
verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
||||||
|
logger.info?.('WeChat MP fresh thumbnail repaired:', {
|
||||||
|
relativePath: String(repair.artifact?.relativePath ?? ''),
|
||||||
|
jobId: String(repair.image?.jobId ?? ''),
|
||||||
|
cover: repair.cover,
|
||||||
|
verified: verification.ok,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn?.('WeChat MP fresh thumbnail repair skipped:', {
|
||||||
|
reason: repair.reason,
|
||||||
|
artifactCount: repair.artifactCount,
|
||||||
|
imageCount: repair.imageCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (verification.ok) {
|
if (verification.ok) {
|
||||||
try {
|
try {
|
||||||
for (const match of verification.matches) {
|
for (const match of verification.matches) {
|
||||||
@@ -1855,19 +1896,24 @@ export function createWechatMpService({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
verification.ok = false;
|
verification = {
|
||||||
verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`;
|
...verification,
|
||||||
|
ok: false,
|
||||||
|
reason: `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
||||||
try {
|
if (notifyFailure) {
|
||||||
await sendCustomerServiceText(openid, text, user);
|
try {
|
||||||
} catch (sendErr) {
|
await sendCustomerServiceText(openid, text, user);
|
||||||
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
||||||
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
||||||
throw markWechatUserNotified(error);
|
throw notifyFailure ? markWechatUserNotified(error) : error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureSessionProvider = async (sessionId) => {
|
const ensureSessionProvider = async (sessionId) => {
|
||||||
@@ -2351,6 +2397,7 @@ export function createWechatMpService({
|
|||||||
user,
|
user,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
publishDir: workingDir,
|
publishDir: workingDir,
|
||||||
|
notifyFailure: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||||
|
|||||||
+222
-18
@@ -864,11 +864,13 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
|
|||||||
assert.equal(config.enabled, true);
|
assert.equal(config.enabled, true);
|
||||||
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
|
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
|
||||||
assert.equal(config.requireFreshPageThumbnail, true);
|
assert.equal(config.requireFreshPageThumbnail, true);
|
||||||
|
assert.equal(config.repairFreshPageThumbnail, false);
|
||||||
assert.deepEqual(config.generatedImagePublicBaseUrls, [
|
assert.deepEqual(config.generatedImagePublicBaseUrls, [
|
||||||
'https://example.com',
|
'https://example.com',
|
||||||
'https://img.example.com',
|
'https://img.example.com',
|
||||||
]);
|
]);
|
||||||
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
|
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
|
||||||
|
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR: '1' }).repairFreshPageThumbnail, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyWechatMpSignature accepts valid signature', () => {
|
test('verifyWechatMpSignature accepts valid signature', () => {
|
||||||
@@ -2723,6 +2725,12 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), true);
|
assert.equal(isRecoverableWechatAgentSessionError('stale_session_poisoned_completion'), true);
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableWechatAgentSessionError(
|
||||||
|
'wechat_page_fresh_thumbnail_required:fresh_image_not_generated',
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
assert.equal(isRecoverableWechatAgentSessionError('无权访问该会话'), true);
|
assert.equal(isRecoverableWechatAgentSessionError('无权访问该会话'), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
|
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
|
||||||
@@ -4943,7 +4951,7 @@ test('wechat mp service exposes route status and can recreate route for bound us
|
|||||||
assert.equal(calls.includes('upserted'), true);
|
assert.equal(calls.includes('upserted'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => {
|
test('wechat mp page delivery repairs one unambiguous fresh thumbnail missing from disk metadata', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
const nonce = 'nonce';
|
const nonce = 'nonce';
|
||||||
@@ -4952,6 +4960,7 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
|||||||
const wechatPayloads = [];
|
const wechatPayloads = [];
|
||||||
const generated = {
|
const generated = {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
purpose: 'hero',
|
||||||
jobId: 'job-fresh-page',
|
jobId: 'job-fresh-page',
|
||||||
source: { mimeType: 'image/webp', width: 1280, height: 720 },
|
source: { mimeType: 'image/webp', width: 1280, height: 720 },
|
||||||
asset: {
|
asset: {
|
||||||
@@ -4961,18 +4970,13 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
|||||||
workspaceRelativePath: 'public/images/fresh-page.webp',
|
workspaceRelativePath: 'public/images/fresh-page.webp',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const pageHtml = previewReadyPageHtml({
|
|
||||||
title: 'Fresh',
|
|
||||||
subtitle: '本轮新缩略图',
|
|
||||||
cover: generated.asset.htmlSrc,
|
|
||||||
});
|
|
||||||
const pageImage = await sharp({
|
const pageImage = await sharp({
|
||||||
create: { width: 64, height: 64, channels: 3, background: '#3366cc' },
|
create: { width: 64, height: 64, channels: 3, background: '#3366cc' },
|
||||||
}).webp().toBuffer();
|
}).webp().toBuffer();
|
||||||
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||||
const service = createBoundWechatService({
|
const service = createBoundWechatService({
|
||||||
token,
|
token,
|
||||||
config: { requireFreshPageThumbnail: true },
|
config: { requireFreshPageThumbnail: true, repairFreshPageThumbnail: true },
|
||||||
userAuth: {
|
userAuth: {
|
||||||
async resolveWorkingDir() {
|
async resolveWorkingDir() {
|
||||||
return workspaceRoot;
|
return workspaceRoot;
|
||||||
@@ -5025,16 +5029,6 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
|||||||
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'call-write-page',
|
|
||||||
type: 'toolRequest',
|
|
||||||
toolCall: {
|
|
||||||
value: {
|
|
||||||
name: 'sandbox-fs__write_file',
|
|
||||||
arguments: { path: 'public/fresh.html', content: pageHtml },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -5067,10 +5061,12 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
|||||||
assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto');
|
assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto');
|
||||||
assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/);
|
assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/);
|
||||||
assert.match(body.user_message.content[0].text, /本轮新生成资产/);
|
assert.match(body.user_message.content[0].text, /本轮新生成资产/);
|
||||||
|
assert.match(body.user_message.content[0].text, /禁止编造用户未提供的产品指标/);
|
||||||
|
assert.match(body.user_message.content[0].text, /Agent 层只调用一次 generate_image/);
|
||||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
htmlPath,
|
htmlPath,
|
||||||
pageHtml,
|
previewReadyPageHtml({ title: 'Fresh', subtitle: '交付前磁盘副本' }),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
|
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
|
||||||
@@ -5110,6 +5106,10 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
|||||||
assert.equal(result.status, 200);
|
assert.equal(result.status, 200);
|
||||||
await result.task;
|
await result.task;
|
||||||
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
|
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
|
||||||
|
assert.match(
|
||||||
|
fs.readFileSync(htmlPath, 'utf8'),
|
||||||
|
/"cover":"images\/fresh-page\.webp"/,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
crypto.randomUUID = originalRandomUuid;
|
crypto.randomUUID = originalRandomUuid;
|
||||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||||
@@ -5119,6 +5119,210 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
|
|||||||
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
|
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => {
|
||||||
|
const token = 'token';
|
||||||
|
const timestamp = '1710000000';
|
||||||
|
const nonce = 'nonce';
|
||||||
|
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-');
|
||||||
|
const htmlPath = path.join(workspaceRoot, 'public', 'retry.html');
|
||||||
|
const generated = {
|
||||||
|
ok: true,
|
||||||
|
jobId: 'job-fresh-page-retry',
|
||||||
|
source: { mimeType: 'image/webp', width: 1280, height: 720 },
|
||||||
|
asset: {
|
||||||
|
id: 'asset-fresh-page-retry',
|
||||||
|
htmlSrc: 'images/retry-cover.webp',
|
||||||
|
publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp',
|
||||||
|
workspaceRelativePath: 'public/images/retry-cover.webp',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const firstHtml = previewReadyPageHtml({
|
||||||
|
title: 'Retry',
|
||||||
|
subtitle: '首次没有新缩略图',
|
||||||
|
cover: 'images/missing-cover.webp',
|
||||||
|
});
|
||||||
|
const retryHtml = previewReadyPageHtml({
|
||||||
|
title: 'Retry',
|
||||||
|
subtitle: '重试生成新缩略图',
|
||||||
|
cover: generated.asset.htmlSrc,
|
||||||
|
});
|
||||||
|
const pageImage = await sharp({
|
||||||
|
create: { width: 64, height: 64, channels: 3, background: '#cc6633' },
|
||||||
|
}).webp().toBuffer();
|
||||||
|
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||||
|
const wechatPayloads = [];
|
||||||
|
let routeCleared = false;
|
||||||
|
let startedSessions = 0;
|
||||||
|
|
||||||
|
const replyEvents = ({ requestId, html, includeImage = false }) => [
|
||||||
|
eventFrame({
|
||||||
|
type: 'Message',
|
||||||
|
request_id: requestId,
|
||||||
|
message: {
|
||||||
|
id: `${requestId}-tools`,
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
id: `${requestId}-page-skill`,
|
||||||
|
type: 'toolRequest',
|
||||||
|
toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } },
|
||||||
|
},
|
||||||
|
...(includeImage
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `${requestId}-image`,
|
||||||
|
type: 'toolRequest',
|
||||||
|
toolCall: {
|
||||||
|
value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${requestId}-image`,
|
||||||
|
type: 'toolResponse',
|
||||||
|
toolResult: {
|
||||||
|
status: 'success',
|
||||||
|
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: `${requestId}-write`,
|
||||||
|
type: 'toolRequest',
|
||||||
|
toolCall: {
|
||||||
|
value: {
|
||||||
|
name: 'sandbox-fs__write_file',
|
||||||
|
arguments: { path: 'public/retry.html', content: html },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
eventFrame({
|
||||||
|
type: 'Message',
|
||||||
|
request_id: requestId,
|
||||||
|
message: {
|
||||||
|
id: `${requestId}-final`,
|
||||||
|
role: 'assistant',
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: '页面已完成:https://example.com/MindSpace/user-1/public/retry.html',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
eventFrame({
|
||||||
|
type: 'Finish',
|
||||||
|
request_id: requestId,
|
||||||
|
token_state: { inputTokens: 1, outputTokens: 2 },
|
||||||
|
}),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const service = createBoundWechatService({
|
||||||
|
token,
|
||||||
|
config: { requireFreshPageThumbnail: true },
|
||||||
|
startAgentSession: async () => {
|
||||||
|
startedSessions += 1;
|
||||||
|
return { id: 'session-2' };
|
||||||
|
},
|
||||||
|
userAuth: {
|
||||||
|
async getWechatAgentRoute() {
|
||||||
|
return routeCleared ? null : { agentSessionId: 'session-1' };
|
||||||
|
},
|
||||||
|
async clearWechatAgentRoute() {
|
||||||
|
routeCleared = true;
|
||||||
|
},
|
||||||
|
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||||
|
assert.equal(agentSessionId, 'session-2');
|
||||||
|
},
|
||||||
|
async resolveWorkingDir() {
|
||||||
|
return workspaceRoot;
|
||||||
|
},
|
||||||
|
async getUserPublishLayout() {
|
||||||
|
return {
|
||||||
|
publishDir: workspaceRoot,
|
||||||
|
displayName: 'John',
|
||||||
|
username: 'john',
|
||||||
|
slug: 'john',
|
||||||
|
constraints: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionApiFetch: async (sessionId, pathname) => {
|
||||||
|
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (pathname === `/sessions/${sessionId}/reply`) {
|
||||||
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||||
|
if (sessionId === 'session-1') {
|
||||||
|
fs.writeFileSync(htmlPath, firstHtml, 'utf8');
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(htmlPath, retryHtml, 'utf8');
|
||||||
|
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
|
||||||
|
}
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (pathname === '/sessions/session-1/events') {
|
||||||
|
return new Response(
|
||||||
|
replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathname === '/sessions/session-2/events') {
|
||||||
|
return new Response(
|
||||||
|
replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected session path: ${sessionId} ${pathname}`);
|
||||||
|
},
|
||||||
|
wechatFetch: async (url, init = {}) => {
|
||||||
|
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||||
|
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||||
|
wechatPayloads.push(JSON.parse(init.body));
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected wechat url: ${url}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalRandomUuid = crypto.randomUUID;
|
||||||
|
crypto.randomUUID = (() => {
|
||||||
|
const ids = ['req-thumbnail-first', 'req-thumbnail-retry'];
|
||||||
|
return () => ids.shift() ?? 'req-thumbnail-retry';
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
const result = await service.handleInboundMessage(
|
||||||
|
inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }),
|
||||||
|
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 200);
|
||||||
|
await result.task;
|
||||||
|
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'retry.thumbnail.svg')), true);
|
||||||
|
} finally {
|
||||||
|
crypto.randomUUID = originalRandomUuid;
|
||||||
|
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(routeCleared, true);
|
||||||
|
assert.equal(startedSessions, 1);
|
||||||
|
assert.equal(wechatPayloads.length, 1);
|
||||||
|
assert.equal(wechatPayloads[0].msgtype, 'text');
|
||||||
|
assert.match(wechatPayloads[0].text.content, /retry\.html/);
|
||||||
|
assert.doesNotMatch(wechatPayloads[0].text.content, /没有完成服务号要求的本轮新缩略图/);
|
||||||
|
});
|
||||||
|
|
||||||
test('wechat mp standalone image intent sends a native image message and text', async () => {
|
test('wechat mp standalone image intent sends a native image message and text', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' }
|
|||||||
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
|
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
|
||||||
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
|
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
|
||||||
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
|
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
|
||||||
|
'同一正式页面在 Agent 层只调用一次 generate_image;工具内部会完成语义重试。返回失败后禁止自行更换提示词并重复调用,交由服务号交付层统一重试。',
|
||||||
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
|
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
|
||||||
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
|
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
|
||||||
inlineInstruction,
|
inlineInstruction,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
WECHAT_PAGE_THUMBNAIL_MODE,
|
WECHAT_PAGE_THUMBNAIL_MODE,
|
||||||
} from './image-generation-policy.mjs';
|
} from './image-generation-policy.mjs';
|
||||||
import { classifyWechatIntent } from './intent/classifier.mjs';
|
import { classifyWechatIntent } from './intent/classifier.mjs';
|
||||||
|
import {
|
||||||
|
buildPageGenerateAgentPrompt,
|
||||||
|
buildPagePublishFailureText,
|
||||||
|
} from './prompts/page-generate.mjs';
|
||||||
|
|
||||||
test('service-account page always requires a fresh thumbnail without forcing body images', () => {
|
test('service-account page always requires a fresh thumbnail without forcing body images', () => {
|
||||||
const policy = resolveWechatImageGenerationPolicy({
|
const policy = resolveWechatImageGenerationPolicy({
|
||||||
@@ -18,7 +22,11 @@ test('service-account page always requires a fresh thumbnail without forcing bod
|
|||||||
assert.equal(policy.imageGenerationMode, 'required');
|
assert.equal(policy.imageGenerationMode, 'required');
|
||||||
assert.equal(policy.inlineImageMode, 'auto');
|
assert.equal(policy.inlineImageMode, 'auto');
|
||||||
assert.equal(policy.standaloneImageMode, 'auto');
|
assert.equal(policy.standaloneImageMode, 'auto');
|
||||||
assert.match(buildWechatPageImageInstruction(policy), /每个本轮交付给用户的正式内容页面/);
|
const instruction = buildWechatPageImageInstruction(policy);
|
||||||
|
assert.match(instruction, /每个本轮交付给用户的正式内容页面/);
|
||||||
|
assert.match(instruction, /Agent 层只调用一次 generate_image/);
|
||||||
|
assert.match(instruction, /工具内部会完成语义重试/);
|
||||||
|
assert.match(instruction, /禁止自行更换提示词并重复调用/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no-image page request disables body images but keeps required fresh thumbnail', () => {
|
test('no-image page request disables body images but keeps required fresh thumbnail', () => {
|
||||||
@@ -62,3 +70,20 @@ test('explicitly declining a page keeps standalone image generation out of page
|
|||||||
assert.equal(policy.pageThumbnailMode, 'auto');
|
assert.equal(policy.pageThumbnailMode, 'auto');
|
||||||
assert.equal(policy.standaloneImageMode, 'required');
|
assert.equal(policy.standaloneImageMode, 'required');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('page generation prompt forbids unsupported commercial and security claims', () => {
|
||||||
|
const prompt = buildPageGenerateAgentPrompt({
|
||||||
|
agentText: '生成 TKMind 舌战群儒页面',
|
||||||
|
msgId: 'message-1',
|
||||||
|
});
|
||||||
|
assert.match(prompt, /禁止编造用户未提供的产品指标、客户数量、SLA、认证、案例、排名或承诺/);
|
||||||
|
assert.match(prompt, /不得杜撰数字/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing-thumbnail copy preserves the page and does not ask for the full requirement again', () => {
|
||||||
|
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
||||||
|
assert.match(text, /页面内容已经保留/);
|
||||||
|
assert.match(text, /重试上次页面/);
|
||||||
|
assert.match(text, /无需重新描述完整需求/);
|
||||||
|
assert.doesNotMatch(text, /重发一次完整页面需求/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
|
|||||||
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user <uid>);有 error 则补元数据后再回复链接。',
|
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user <uid>);有 error 则补元数据后再回复链接。',
|
||||||
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
|
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
|
||||||
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
|
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
|
||||||
|
'9. 禁止编造用户未提供的产品指标、客户数量、SLA、认证、案例、排名或承诺;没有可靠依据时使用定性描述,不得杜撰数字。',
|
||||||
'',
|
'',
|
||||||
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
||||||
`用户需求:${topic}`,
|
`用户需求:${topic}`,
|
||||||
@@ -57,7 +58,7 @@ export function buildPagePublishFailureText({
|
|||||||
if (missingFreshThumbnail) {
|
if (missingFreshThumbnail) {
|
||||||
return [
|
return [
|
||||||
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
|
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
|
||||||
'请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。',
|
'页面内容已经保留。请稍后回复“重试上次页面”,无需重新描述完整需求。',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
if (missingPlatformBrand) {
|
if (missingPlatformBrand) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
parseMindspaceCoverMeta,
|
||||||
|
upsertMindspaceCoverMeta,
|
||||||
|
} from '../../mindspace-cover-meta.mjs';
|
||||||
|
|
||||||
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
|
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ function normalizeGeneratedImage(result) {
|
|||||||
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
|
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
|
||||||
return {
|
return {
|
||||||
jobId: String(result.jobId),
|
jobId: String(result.jobId),
|
||||||
|
purpose: String(result.purpose ?? '').trim() || null,
|
||||||
mimeType,
|
mimeType,
|
||||||
assetId: String(asset.id ?? '').trim() || null,
|
assetId: String(asset.id ?? '').trim() || null,
|
||||||
htmlSrc: htmlSrc || null,
|
htmlSrc: htmlSrc || null,
|
||||||
@@ -141,6 +147,7 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
|
|||||||
ok: false,
|
ok: false,
|
||||||
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
|
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
|
||||||
artifact,
|
artifact,
|
||||||
|
cover: cover || null,
|
||||||
matches,
|
matches,
|
||||||
skippedArtifacts,
|
skippedArtifacts,
|
||||||
};
|
};
|
||||||
@@ -150,3 +157,103 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
|
|||||||
}
|
}
|
||||||
return { ok: true, reason: null, matches, skippedArtifacts };
|
return { ok: true, reason: null, matches, skippedArtifacts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueArtifactsByLocalPath(artifacts = []) {
|
||||||
|
const unique = new Map();
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const localPath = String(artifact?.localPath ?? '').trim();
|
||||||
|
if (localPath) unique.set(localPath, artifact);
|
||||||
|
}
|
||||||
|
return [...unique.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWriteHtml(localPath, content) {
|
||||||
|
const tempPath = path.join(
|
||||||
|
path.dirname(localPath),
|
||||||
|
`.${path.basename(localPath)}.wechat-thumbnail-${process.pid}-${crypto.randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tempPath, content, 'utf8');
|
||||||
|
fs.renameSync(tempPath, localPath);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup only; the destination write already decided the result.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repairUnambiguousFreshWechatPageThumbnail({
|
||||||
|
artifacts = [],
|
||||||
|
images = [],
|
||||||
|
currentRunHtmlArtifacts = [],
|
||||||
|
verificationReason = '',
|
||||||
|
} = {}) {
|
||||||
|
if (!['missing_generated_cover', 'cover_not_from_current_run'].includes(verificationReason)) {
|
||||||
|
return { ok: false, reason: 'verification_not_repairable' };
|
||||||
|
}
|
||||||
|
const eligibleArtifacts = uniqueArtifactsByLocalPath(
|
||||||
|
artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact)),
|
||||||
|
);
|
||||||
|
const pageImages = images.filter((image) => image?.purpose === 'hero' || !image?.purpose);
|
||||||
|
if (eligibleArtifacts.length !== 1 || pageImages.length !== 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'ambiguous_page_image_mapping',
|
||||||
|
artifactCount: eligibleArtifacts.length,
|
||||||
|
imageCount: pageImages.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = eligibleArtifacts[0];
|
||||||
|
const image = pageImages[0];
|
||||||
|
const cover = String(image?.htmlSrc ?? '').trim();
|
||||||
|
const localPath = String(artifact?.localPath ?? '').trim();
|
||||||
|
if (!cover || !localPath || !fs.existsSync(localPath)) {
|
||||||
|
return { ok: false, reason: 'repair_source_unavailable', artifact, image };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRelativePath = String(artifact.relativePath ?? '').trim().replace(/\\/g, '/');
|
||||||
|
const currentRunArtifact = currentRunHtmlArtifacts.find(
|
||||||
|
(candidate) => String(candidate?.relativePath ?? '').trim().replace(/\\/g, '/') === normalizedRelativePath,
|
||||||
|
);
|
||||||
|
const sourceHtml = typeof currentRunArtifact?.content === 'string'
|
||||||
|
? currentRunArtifact.content
|
||||||
|
: fs.readFileSync(localPath, 'utf8');
|
||||||
|
const coverMeta = parseMindspaceCoverMeta(sourceHtml);
|
||||||
|
if (!coverMeta) {
|
||||||
|
return { ok: false, reason: 'valid_cover_meta_required', artifact, image };
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairedHtml = upsertMindspaceCoverMeta(sourceHtml, { cover });
|
||||||
|
atomicWriteHtml(localPath, repairedHtml);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: null,
|
||||||
|
artifact,
|
||||||
|
image,
|
||||||
|
cover,
|
||||||
|
restoredCurrentRunHtml: Boolean(currentRunArtifact),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeFreshWechatThumbnailVerification(verification, images = []) {
|
||||||
|
return {
|
||||||
|
reason: String(verification?.reason ?? 'unknown'),
|
||||||
|
artifact: verification?.artifact
|
||||||
|
? {
|
||||||
|
relativePath: String(verification.artifact.relativePath ?? ''),
|
||||||
|
localPath: path.basename(String(verification.artifact.localPath ?? '')),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
cover: String(verification?.cover ?? ''),
|
||||||
|
matches: Array.isArray(verification?.matches) ? verification.matches.length : 0,
|
||||||
|
images: images.map((image) => ({
|
||||||
|
jobId: String(image?.jobId ?? ''),
|
||||||
|
purpose: String(image?.purpose ?? ''),
|
||||||
|
htmlSrc: String(image?.htmlSrc ?? ''),
|
||||||
|
workspaceRelativePath: String(image?.workspaceRelativePath ?? ''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
collectWechatGeneratedImages,
|
collectWechatGeneratedImages,
|
||||||
extractMindspaceCoverPath,
|
extractMindspaceCoverPath,
|
||||||
|
repairUnambiguousFreshWechatPageThumbnail,
|
||||||
verifyFreshWechatPageThumbnails,
|
verifyFreshWechatPageThumbnails,
|
||||||
} from './generated-thumbnail.mjs';
|
} from './generated-thumbnail.mjs';
|
||||||
|
|
||||||
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
|
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
|
||||||
const result = {
|
const result = {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
purpose: 'hero',
|
||||||
jobId,
|
jobId,
|
||||||
source: { mimeType: 'image/webp' },
|
source: { mimeType: 'image/webp' },
|
||||||
asset: {
|
asset: {
|
||||||
@@ -115,3 +117,60 @@ test('auxiliary admin pages do not require a separate generated thumbnail', () =
|
|||||||
fs.rmSync(root, { recursive: true, force: true });
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('one current-run hero repairs one page from the current write snapshot and re-verifies', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-repair-cover-'));
|
||||||
|
try {
|
||||||
|
const htmlPath = path.join(root, 'page.html');
|
||||||
|
const diskHtml = '<html><head><meta name="mindspace-cover" content=\'{"tag":"页面","subtitle":"磁盘旧副本"}\'></head><body>disk copy</body></html>';
|
||||||
|
const currentRunHtml = '<html><head><meta name="mindspace-cover" content=\'{"tag":"页面","subtitle":"本轮副本"}\'></head><body>current run copy</body></html>';
|
||||||
|
fs.writeFileSync(htmlPath, diskHtml, 'utf8');
|
||||||
|
const artifacts = [{ localPath: htmlPath, relativePath: 'public/page.html' }];
|
||||||
|
const images = collectWechatGeneratedImages(generatedImageMessages());
|
||||||
|
|
||||||
|
const failed = verifyFreshWechatPageThumbnails(artifacts, images);
|
||||||
|
assert.equal(failed.reason, 'missing_generated_cover');
|
||||||
|
const repaired = repairUnambiguousFreshWechatPageThumbnail({
|
||||||
|
artifacts,
|
||||||
|
images,
|
||||||
|
currentRunHtmlArtifacts: [{ relativePath: 'public/page.html', content: currentRunHtml }],
|
||||||
|
verificationReason: failed.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(repaired.ok, true);
|
||||||
|
assert.equal(repaired.restoredCurrentRunHtml, true);
|
||||||
|
const written = fs.readFileSync(htmlPath, 'utf8');
|
||||||
|
assert.match(written, /current run copy/);
|
||||||
|
assert.doesNotMatch(written, /disk copy/);
|
||||||
|
assert.equal(extractMindspaceCoverPath(written), 'images/fresh.webp');
|
||||||
|
assert.equal(verifyFreshWechatPageThumbnails(artifacts, images).ok, true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thumbnail repair refuses an ambiguous page-to-image mapping without changing the page', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-ambiguous-repair-'));
|
||||||
|
try {
|
||||||
|
const htmlPath = path.join(root, 'page.html');
|
||||||
|
const original = '<meta name="mindspace-cover" content=\'{"tag":"页面"}\'>';
|
||||||
|
fs.writeFileSync(htmlPath, original, 'utf8');
|
||||||
|
const artifacts = [{ localPath: htmlPath, relativePath: 'public/page.html' }];
|
||||||
|
const images = collectWechatGeneratedImages([
|
||||||
|
...generatedImageMessages({ jobId: 'job-1', htmlSrc: 'images/one.webp' }),
|
||||||
|
...generatedImageMessages({ jobId: 'job-2', htmlSrc: 'images/two.webp' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const repaired = repairUnambiguousFreshWechatPageThumbnail({
|
||||||
|
artifacts,
|
||||||
|
images,
|
||||||
|
verificationReason: 'missing_generated_cover',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(repaired.ok, false);
|
||||||
|
assert.equal(repaired.reason, 'ambiguous_page_image_mapping');
|
||||||
|
assert.equal(fs.readFileSync(htmlPath, 'utf8'), original);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user