fix: recover WeChat page thumbnail delivery
Memind CI / Test, build, and release guards (pull_request) Successful in 5m1s

This commit is contained in:
john
2026-07-22 11:51:15 +08:00
parent 57d2c14fca
commit cb62b36cd5
13 changed files with 366 additions and 26 deletions
+1
View File
@@ -86,6 +86,7 @@ export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' }
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
'同一正式页面在 Agent 层只调用一次 generate_image;工具内部会完成语义重试。返回失败后禁止自行更换提示词并重复调用,交由服务号交付层统一重试。',
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
inlineInstruction,
+26 -1
View File
@@ -7,6 +7,10 @@ import {
WECHAT_PAGE_THUMBNAIL_MODE,
} from './image-generation-policy.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', () => {
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.inlineImageMode, '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', () => {
@@ -62,3 +70,20 @@ test('explicitly declining a page keeps standalone image generation out of page
assert.equal(policy.pageThumbnailMode, 'auto');
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, /重发一次完整页面需求/);
});
+2 -1
View File
@@ -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 则补元数据后再回复链接。',
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
'9. 禁止编造用户未提供的产品指标、客户数量、SLA、认证、案例、排名或承诺;没有可靠依据时使用定性描述,不得杜撰数字。',
'',
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
`用户需求:${topic}`,
@@ -57,7 +58,7 @@ export function buildPagePublishFailureText({
if (missingFreshThumbnail) {
return [
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
'请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。',
'页面内容已经保留。请稍后回复“重试上次页面”,无需重新描述完整需求。',
].join('\n');
}
if (missingPlatformBrand) {
+107
View File
@@ -1,5 +1,10 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
parseMindspaceCoverMeta,
upsertMindspaceCoverMeta,
} from '../../mindspace-cover-meta.mjs';
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
@@ -29,6 +34,7 @@ function normalizeGeneratedImage(result) {
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
return {
jobId: String(result.jobId),
purpose: String(result.purpose ?? '').trim() || null,
mimeType,
assetId: String(asset.id ?? '').trim() || null,
htmlSrc: htmlSrc || null,
@@ -141,6 +147,7 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
ok: false,
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
artifact,
cover: cover || null,
matches,
skippedArtifacts,
};
@@ -150,3 +157,103 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
}
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 {
collectWechatGeneratedImages,
extractMindspaceCoverPath,
repairUnambiguousFreshWechatPageThumbnail,
verifyFreshWechatPageThumbnails,
} from './generated-thumbnail.mjs';
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
const result = {
ok: true,
purpose: 'hero',
jobId,
source: { mimeType: 'image/webp' },
asset: {
@@ -115,3 +117,60 @@ test('auxiliary admin pages do not require a separate generated thumbnail', () =
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 });
}
});