diff --git a/.env.example b/.env.example index ad8a3de..4b8df20 100644 --- a/.env.example +++ b/.env.example @@ -265,6 +265,8 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # IMAGE_MAKE_SEMANTIC_REVIEW_ENABLED=1 # IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE=70 # 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。 # MINDSPACE_AGENT_API_BASE_URL=http://127.0.0.1:8081/api diff --git a/docs/image-make-integration.md b/docs/image-make-integration.md index 3a18a1c..a6d88f4 100644 --- a/docs/image-make-integration.md +++ b/docs/image-make-integration.md @@ -116,6 +116,10 @@ IMAGE_MAKE_MEMIND_CONFIG_TOKEN=<与 Portal IMAGE_MAKE_TOKEN 相同的值> 配置 `H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=0` 可只关闭服务号“页面必须新缩略图”硬门,作为紧急回滚; 默认开启。该开关不会修改 H5 聊天图片策略。 +配置 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=1` 可开启服务号缩略图确定性修复,默认关闭。修复仅在一个正式页面 +与一张本轮 hero 图片的映射唯一时,把本轮 `asset.htmlSrc` 写回 `mindspace-cover.cover` 并重新执行完整校验; +多页、多图、缺少本轮新图或元数据无效时继续 fail closed。 + ## 发布前置条件 Portal runtime 不包含独立 `image_make` 服务。正式启用前必须确保: diff --git a/docs/incidents/2026-07-22-wechat-fresh-thumbnail-delivery.md b/docs/incidents/2026-07-22-wechat-fresh-thumbnail-delivery.md new file mode 100644 index 0000000..8473183 --- /dev/null +++ b/docs/incidents/2026-07-22-wechat-fresh-thumbnail-delivery.md @@ -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` 或移除;新缩略图硬门仍保持开启。 diff --git a/mindspace-image-generation.mjs b/mindspace-image-generation.mjs index 612626c..46e9204 100644 --- a/mindspace-image-generation.mjs +++ b/mindspace-image-generation.mjs @@ -13,6 +13,29 @@ function htmlSrcForWorkspaceAsset(workspaceRelativePath) { 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) { const widthKey = `IMAGE_MAKE_${spec.envPrefix}_WIDTH`; const heightKey = `IMAGE_MAKE_${spec.envPrefix}_HEIGHT`; @@ -65,7 +88,12 @@ export function createMindSpaceImageGenerationService({ const maxAttempts = Number.isFinite(configuredAttempts) ? Math.max(1, Math.min(3, Math.floor(configuredAttempts))) : 3; - const baseIdempotencyKey = idempotencyKey || `imgreq_${crypto.randomUUID()}`; + const baseIdempotencyKey = resolveImageMakeIdempotencyKey({ + idempotencyKey, + purpose, + prompt, + negativePrompt, + }); let generated = null; let acceptedReview = null; let retryFeedback = ''; @@ -76,7 +104,7 @@ export function createMindSpaceImageGenerationService({ : prompt; const attemptKey = attempt === 1 ? baseIdempotencyKey - : `${baseIdempotencyKey.slice(0, 160)}-review-${attempt}`; + : boundedIdempotencyKey(baseIdempotencyKey, `-review-${attempt}`); generated = await imageMakeClient.generateImage({ prompt: attemptPrompt, negativePrompt, diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs index 9821cbd..4d88de4 100644 --- a/mindspace-image-generation.test.mjs +++ b/mindspace-image-generation.test.mjs @@ -214,6 +214,55 @@ test('MindSpace image generation retries a semantic mismatch and stores only a r 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 () => { let generateCount = 0; let storeCount = 0; diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index 610ff9e..c6565db 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -88,6 +88,7 @@ export function loadWechatMpConfig(env = process.env) { acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0', mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS), 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() ?? '', }; } diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 6d45803..16d8e12 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -6,7 +6,11 @@ import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.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 { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { @@ -41,6 +45,8 @@ import { import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs'; import { collectWechatGeneratedImages, + repairUnambiguousFreshWechatPageThumbnail, + summarizeFreshWechatThumbnailVerification, verifyFreshWechatPageThumbnails, } from './wechat/verify/generated-thumbnail.mjs'; import { resolveBillingTokenState } from './billing-token-state.mjs'; @@ -1513,6 +1519,7 @@ export function createWechatMpService({ }; } const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true; + const repairFreshPageThumbnail = config.repairFreshPageThumbnail === true; config = { ...loadWechatMpConfig({}), ...config, @@ -1531,6 +1538,7 @@ export function createWechatMpService({ ? config.mediaAnalysisGrayUsers : [], requireFreshPageThumbnail, + repairFreshPageThumbnail, asrTarget: config.asrTarget || DEFAULT_ASR_TARGET, }; @@ -1845,7 +1853,38 @@ export function createWechatMpService({ }) => { if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; 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) { try { for (const match of verification.matches) { @@ -1857,8 +1896,11 @@ export function createWechatMpService({ } return; } catch (error) { - verification.ok = false; - verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`; + verification = { + ...verification, + ok: false, + reason: `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`, + }; } } const text = buildPagePublishFailureText({ missingFreshThumbnail: true }); diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 9af6740..bcd1a7a 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -864,11 +864,13 @@ test('loadWechatMpConfig requires full config and enable flag', () => { assert.equal(config.enabled, true); assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login'); assert.equal(config.requireFreshPageThumbnail, true); + assert.equal(config.repairFreshPageThumbnail, false); assert.deepEqual(config.generatedImagePublicBaseUrls, [ 'https://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_THUMBNAIL_REPAIR: '1' }).repairFreshPageThumbnail, true); }); test('verifyWechatMpSignature accepts valid signature', () => { @@ -4949,7 +4951,7 @@ test('wechat mp service exposes route status and can recreate route for bound us 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 timestamp = '1710000000'; const nonce = 'nonce'; @@ -4958,6 +4960,7 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai const wechatPayloads = []; const generated = { ok: true, + purpose: 'hero', jobId: 'job-fresh-page', source: { mimeType: 'image/webp', width: 1280, height: 720 }, asset: { @@ -4967,18 +4970,13 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai workspaceRelativePath: 'public/images/fresh-page.webp', }, }; - const pageHtml = previewReadyPageHtml({ - title: 'Fresh', - subtitle: '本轮新缩略图', - cover: generated.asset.htmlSrc, - }); const pageImage = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#3366cc' }, }).webp().toBuffer(); const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`; const service = createBoundWechatService({ token, - config: { requireFreshPageThumbnail: true }, + config: { requireFreshPageThumbnail: true, repairFreshPageThumbnail: true }, userAuth: { async resolveWorkingDir() { return workspaceRoot; @@ -5031,16 +5029,6 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai 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 }, - }, - }, - }, ], }, }), @@ -5073,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.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.writeFileSync( htmlPath, - pageHtml, + previewReadyPageHtml({ title: 'Fresh', subtitle: '交付前磁盘副本' }), 'utf8', ); fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true }); @@ -5116,6 +5106,10 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai assert.equal(result.status, 200); await result.task; assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true); + assert.match( + fs.readFileSync(htmlPath, 'utf8'), + /"cover":"images\/fresh-page\.webp"/, + ); } finally { crypto.randomUUID = originalRandomUuid; fs.rmSync(workspaceRoot, { recursive: true, force: true }); diff --git a/wechat/image-generation-policy.mjs b/wechat/image-generation-policy.mjs index cced31f..4642da2 100644 --- a/wechat/image-generation-policy.mjs +++ b/wechat/image-generation-policy.mjs @@ -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 写 `` 供交付守卫识别。', inlineInstruction, diff --git a/wechat/image-generation-policy.test.mjs b/wechat/image-generation-policy.test.mjs index 678ab13..2b1b85c 100644 --- a/wechat/image-generation-policy.test.mjs +++ b/wechat/image-generation-policy.test.mjs @@ -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, /重发一次完整页面需求/); +}); diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index f5fca1d..b8af5c3 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -41,6 +41,7 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP '6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user );有 error 则补元数据后再回复链接。', '7. 页脚加 `

TKMind · 智趣

`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。', '8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。', + '9. 禁止编造用户未提供的产品指标、客户数量、SLA、认证、案例、排名或承诺;没有可靠依据时使用定性描述,不得杜撰数字。', '', buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }), `用户需求:${topic}`, @@ -57,7 +58,7 @@ export function buildPagePublishFailureText({ if (missingFreshThumbnail) { return [ '这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。', - '请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。', + '页面内容已经保留。请稍后回复“重试上次页面”,无需重新描述完整需求。', ].join('\n'); } if (missingPlatformBrand) { diff --git a/wechat/verify/generated-thumbnail.mjs b/wechat/verify/generated-thumbnail.mjs index 8bcf98a..9f02e78 100644 --- a/wechat/verify/generated-thumbnail.mjs +++ b/wechat/verify/generated-thumbnail.mjs @@ -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 ?? ''), + })), + }; +} diff --git a/wechat/verify/generated-thumbnail.test.mjs b/wechat/verify/generated-thumbnail.test.mjs index e801994..ec65040 100644 --- a/wechat/verify/generated-thumbnail.test.mjs +++ b/wechat/verify/generated-thumbnail.test.mjs @@ -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 = 'disk copy'; + const currentRunHtml = 'current run copy'; + 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 = ''; + 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 }); + } +});