import { readFile } from "node:fs/promises"; const filename = process.argv[2]; if (!filename) throw new Error("Usage: node validate-plan.mjs "); const plan = JSON.parse(await readFile(filename, "utf8")); const errors = []; const isText = (value) => typeof value === "string" && value.trim().length > 0; const asArray = (value) => Array.isArray(value) ? value : []; const countValues = (values) => new Map(values.map((value) => [ value, values.filter((item) => item === value).length, ])); if (plan.schema_version !== "0.6") errors.push("schema_version must be 0.6"); if (!isText(plan.run_id)) errors.push("run_id is required"); if (!new Set(["ready", "needs_input"]).has(plan.status)) errors.push("status must be ready or needs_input"); if (plan.user_output_mode !== "images_only") errors.push("user_output_mode must be images_only"); if (!isText(plan.request?.instruction)) errors.push("request.instruction is required"); if (!Array.isArray(plan.request?.reference_images)) errors.push("request.reference_images must be an array"); if (!isText(plan.context?.platform)) errors.push("context.platform is required"); if (!new Set(["available", "unavailable"]).has(plan.context?.voc?.status)) errors.push("context.voc.status is invalid"); if (!Array.isArray(plan.context?.voc?.findings)) errors.push("context.voc.findings must be an array"); const understanding = plan.product_understanding ?? {}; if (!isText(understanding.category)) errors.push("product_understanding.category is required"); if (!isText(understanding.product_name?.value)) errors.push("product_understanding.product_name.value is required"); if (!isText(understanding.product_name?.basis)) errors.push("product_understanding.product_name.basis is required"); if (!new Set(["high", "medium", "low", "unknown"]).has(understanding.product_name?.confidence)) { errors.push("product_understanding.product_name.confidence is invalid"); } for (const key of [ "visible_facts", "verified_visible_facts", "inferred_attributes", "unknown_features", "provided_facts", "candidate_selling_points", "creative_positioning", "suggested_audiences", "suggested_scenes", "unsupported_claims", "unknowns", ]) { if (!Array.isArray(understanding[key])) errors.push(`product_understanding.${key} must be an array`); } if (!understanding.visible_specs || typeof understanding.visible_specs !== "object" || Array.isArray(understanding.visible_specs)) { errors.push("product_understanding.visible_specs must be an object"); } const identityManifest = understanding.identity_manifest ?? {}; for (const key of ["must_preserve", "must_not_add", "unknown_do_not_assert"]) { if (!Array.isArray(identityManifest[key])) errors.push(`product_understanding.identity_manifest.${key} must be an array`); } if (asArray(identityManifest.must_preserve).length < 3) { errors.push("product_understanding.identity_manifest.must_preserve requires at least three features"); } if (!isText(identityManifest.product_lock)) { errors.push("product_understanding.identity_manifest.product_lock is required"); } else { const identityLockLength = [...identityManifest.product_lock].length; if (identityLockLength < 20 || identityLockLength > 160) { errors.push(`product_understanding.identity_manifest.product_lock must be 20..160 characters (${identityLockLength})`); } } const sellingPoints = asArray(understanding.candidate_selling_points); const validSellingPointBasisRefs = new Set(["user_instruction"]); asArray(understanding.visible_facts).forEach((_, index) => validSellingPointBasisRefs.add(`visible_fact:${index + 1}`)); asArray(understanding.provided_facts).forEach((_, index) => validSellingPointBasisRefs.add(`provided_fact:${index + 1}`)); asArray(plan.context?.voc?.findings).forEach((_, index) => validSellingPointBasisRefs.add(`voc:${index + 1}`)); if (plan.status === "ready" && sellingPoints.length < 2) { errors.push("ready plan requires at least two candidate_selling_points"); } const sellingPointIds = []; for (const [index, point] of sellingPoints.entries()) { if (!isText(point?.id)) errors.push(`candidate_selling_points[${index}].id is required`); if (!isText(point?.value)) errors.push(`candidate_selling_points[${index}].value is required`); if (!Array.isArray(point?.basis_refs) || point.basis_refs.length === 0) { errors.push(`candidate_selling_points[${index}].basis_refs must be non-empty`); } else { for (const reference of point.basis_refs) { if (!validSellingPointBasisRefs.has(reference)) { errors.push(`candidate_selling_points[${index}].basis_refs contains unknown reference: ${reference}`); } } } if (!new Set(["high", "medium", "low", "unknown"]).has(point?.confidence)) { errors.push(`candidate_selling_points[${index}].confidence is invalid`); } sellingPointIds.push(point?.id); } if (new Set(sellingPointIds).size !== sellingPointIds.length) { errors.push("candidate_selling_points ids must be unique"); } for (const key of ["product_invariants", "visual_system", "prohibited_changes"]) { if (!Array.isArray(plan.global_consistency_lock?.[key])) errors.push(`global_consistency_lock.${key} must be an array`); } const requiredAllocation = { hero: 1, scene: 2, selling_point: 2, decision_support: 2 }; for (const [key, value] of Object.entries(requiredAllocation)) { if (plan.set_blueprint?.business_allocation?.[key] !== value) { errors.push(`set_blueprint.business_allocation.${key} must be ${value}`); } } if (!isText(plan.set_blueprint?.visual_thesis)) errors.push("set_blueprint.visual_thesis is required"); for (const key of ["narrative_arc", "shared_visual_dna", "intentional_variations"]) { if (!Array.isArray(plan.set_blueprint?.[key]) || plan.set_blueprint[key].length === 0) { errors.push(`set_blueprint.${key} must be a non-empty array`); } } if (!Array.isArray(plan.images)) errors.push("images must be an array"); if (plan.status === "ready" && plan.images?.length !== 7) errors.push("ready plan must contain exactly seven images"); const businessCategories = new Set(Object.keys(requiredAllocation)); const renderFamiliesByCategory = { hero: new Set(["isolated_product"]), scene: new Set(["environmental_scene", "human_interaction", "usage_demonstration"]), selling_point: new Set(["material_demonstration", "feature_demonstration", "annotated_detail"]), decision_support: new Set([ "multi_angle", "usage_sequence", "package_contents", "scale_context", "comparison_sequence", "structured_information", ]), }; const sceneComplexities = new Set(["simple", "medium", "complex"]); const moduleNames = [ "product_lock", "image_job", "scene", "composition", "lighting_material", "camera_style", "text_layout", "negative_constraints", ]; const complexityRules = { simple: { promptMin: 100, sceneMin: 12 }, medium: { promptMin: 220, sceneMin: 60 }, complex: { promptMin: 360, sceneMin: 160 }, }; const highRiskClaimTerms = [ "防水", "抗菌", "临床", "认证", "销量第一", "最畅销", "耐磨", "耐用", "续航", "兼容", "承重", "保质期", "无毒", "不刺激", "留香", "香调", "成分", "浓度", "功效", ]; const evidenceText = JSON.stringify([ plan.request?.instruction, ...asArray(understanding.provided_facts), ...sellingPoints.map((item) => item.value), understanding.visible_specs ?? {}, ...asArray(plan.context?.voc?.findings), ]); const knownEvidenceRefs = new Set(["user_instruction"]); asArray(understanding.visible_facts).forEach((_, index) => knownEvidenceRefs.add(`visible_fact:${index + 1}`)); asArray(understanding.provided_facts).forEach((_, index) => knownEvidenceRefs.add(`provided_fact:${index + 1}`)); asArray(understanding.creative_positioning).forEach((_, index) => knownEvidenceRefs.add(`creative_positioning:${index + 1}`)); asArray(understanding.suggested_audiences).forEach((_, index) => knownEvidenceRefs.add(`suggested_audience:${index + 1}`)); asArray(understanding.suggested_scenes).forEach((_, index) => knownEvidenceRefs.add(`suggested_scene:${index + 1}`)); asArray(plan.context?.voc?.findings).forEach((_, index) => knownEvidenceRefs.add(`voc:${index + 1}`)); Object.keys(understanding.visible_specs ?? {}).forEach((key) => knownEvidenceRefs.add(`visible_spec:${key}`)); sellingPointIds.filter(isText).forEach((id) => knownEvidenceRefs.add(id)); const ids = []; const decisions = []; const shotSpecs = []; const setDesigns = []; const creativeBodies = []; const categoryValues = []; const sellingImages = []; const categoryRenderFamilies = new Map(); const staticProductFamilies = new Set(["isolated_product", "material_demonstration", "annotated_detail"]); let staticProductCount = 0; const requiredShotText = [ "narrative_job", "composition_family", "shot_scale", "camera_angle", "product_placement", "depth_structure", "background_family", "lighting_family", "interaction_mode", "information_density", ]; const cameraAxes = new Set(["front", "three_quarter_left", "three_quarter_right", "side", "top", "low", "macro"]); const framingModes = new Set(["full_product", "product_in_context", "detail", "multi_panel", "human_action"]); const productPoses = new Set(["upright", "laid_flat", "held", "in_use", "multi_view", "detail_crop", "arranged_sequence"]); for (const [index, image] of asArray(plan.images).entries()) { ids.push(image.id); decisions.push(image.decision_subject); categoryValues.push(image.business_category); if (!isText(image.id)) errors.push(`images[${index}].id is required`); if (!isText(image.role)) errors.push(`images[${index}].role is required`); if (!businessCategories.has(image.business_category)) { errors.push(`images[${index}].business_category is invalid`); } if (!isText(image.decision_subject)) errors.push(`images[${index}].decision_subject is required`); if (!Array.isArray(image.evidence_refs) || image.evidence_refs.length === 0) { errors.push(`images[${index}].evidence_refs must be non-empty`); } else { for (const reference of image.evidence_refs) { if (!knownEvidenceRefs.has(reference)) { errors.push(`images[${index}].evidence_refs contains unknown reference: ${reference}`); } } } if (typeof image.selling_point_id !== "string") errors.push(`images[${index}].selling_point_id must be a string`); if (!isText(image.purpose)) errors.push(`images[${index}].purpose is required`); if (!Array.isArray(image.voc_refs)) errors.push(`images[${index}].voc_refs must be an array`); const setDesign = image.set_design ?? {}; setDesigns.push(setDesign); const allowedRenderFamilies = renderFamiliesByCategory[image.business_category]; if (!allowedRenderFamilies?.has(setDesign.render_family)) { errors.push(`images[${index}].set_design.render_family is invalid for ${image.business_category ?? "unknown"}`); } if (setDesign.render_family === "structured_information") { errors.push(`images[${index}] structured_information is unavailable until a deterministic text compositor is implemented`); } if (staticProductFamilies.has(setDesign.render_family)) staticProductCount += 1; if (businessCategories.has(image.business_category) && isText(setDesign.render_family)) { const values = categoryRenderFamilies.get(image.business_category) ?? []; values.push(setDesign.render_family); categoryRenderFamilies.set(image.business_category, values); } if (!sceneComplexities.has(setDesign.scene_complexity)) { errors.push(`images[${index}].set_design.scene_complexity is invalid`); } if (!isText(setDesign.unique_visual_hook)) errors.push(`images[${index}].set_design.unique_visual_hook is required`); if (!isText(setDesign.contrast_with_previous)) errors.push(`images[${index}].set_design.contrast_with_previous is required`); if (image.business_category === "selling_point") { sellingImages.push(image); if (!sellingPointIds.includes(image.selling_point_id)) { errors.push(`images[${index}].selling_point_id must match a candidate_selling_points id`); } if (!image.evidence_refs?.includes(image.selling_point_id)) { errors.push(`images[${index}].evidence_refs must include its selling_point_id`); } } else if (isText(image.selling_point_id)) { errors.push(`images[${index}].selling_point_id is only allowed for selling_point images`); } for (const key of requiredShotText) { if (!isText(image.shot_spec?.[key])) errors.push(`images[${index}].shot_spec.${key} is required`); } if (!cameraAxes.has(image.shot_spec?.camera_axis)) { errors.push(`images[${index}].shot_spec.camera_axis is invalid`); } if (!framingModes.has(image.shot_spec?.framing_mode)) { errors.push(`images[${index}].shot_spec.framing_mode is invalid`); } if (!productPoses.has(image.shot_spec?.product_pose)) { errors.push(`images[${index}].shot_spec.product_pose is invalid`); } const occupancy = image.shot_spec?.product_occupancy_pct; if (!(Number.isFinite(occupancy) && occupancy >= 10 && occupancy <= 95)) { errors.push(`images[${index}].shot_spec.product_occupancy_pct must be 10..95`); } shotSpecs.push(image.shot_spec ?? {}); const promptModules = image.prompt_modules ?? {}; for (const moduleName of moduleNames) { if (typeof promptModules[moduleName] !== "string") { errors.push(`images[${index}].prompt_modules.${moduleName} must be a string`); } } for (const moduleName of ["product_lock", "image_job", "scene", "composition", "lighting_material", "negative_constraints"]) { if (!isText(promptModules[moduleName])) errors.push(`images[${index}].prompt_modules.${moduleName} is required`); } const productLockLength = [...(promptModules.product_lock ?? "")].length; if (productLockLength < 20 || productLockLength > 160) { errors.push(`images[${index}].prompt_modules.product_lock must be 20..160 characters (${productLockLength})`); } if (isText(identityManifest.product_lock) && promptModules.product_lock !== identityManifest.product_lock) { errors.push(`images[${index}].prompt_modules.product_lock must exactly match product_understanding.identity_manifest.product_lock`); } const positivePromptText = [ promptModules.image_job, promptModules.scene, promptModules.composition, promptModules.lighting_material, promptModules.camera_style, ].filter(Boolean).join(" "); for (const unknownFeature of [ ...asArray(understanding.unknown_features), ...asArray(identityManifest.unknown_do_not_assert), ].filter(isText)) { if (positivePromptText.includes(unknownFeature)) { errors.push(`images[${index}] positively asserts unknown product feature: ${unknownFeature}`); } } const absentText = asArray(identityManifest.must_not_add).filter(isText).join(" ").toLowerCase(); const positiveLower = positivePromptText.toLowerCase(); const contradictionGroups = [ { absent: ["纽扣", "button"], positive: ["单排扣", "双排扣", "single-breasted", "double-breasted", "buttoned"] }, { absent: ["口袋", "pocket"], positive: ["口袋", "pocket"] }, { absent: ["拉链", "zipper"], positive: ["拉链", "zipper", "zip closure"] }, { absent: ["腰带", "belt"], positive: ["腰带", "belted", "belt"] }, ]; for (const group of contradictionGroups) { if ( group.absent.some((term) => absentText.includes(term)) && group.positive.some((term) => positiveLower.includes(term)) ) { errors.push(`images[${index}] prompt contradicts identity_manifest.must_not_add`); break; } } const usesPanelLayout = /三格|多格|分格|面板|横向序列|纵向序列|separate panels|multi[- ]?panel|\bpanel\b|\bgrid\b/i.test(positivePromptText); if (usesPanelLayout && image.shot_spec?.framing_mode !== "multi_panel") { errors.push(`images[${index}] uses a panel/grid layout without framing_mode=multi_panel`); } const complexityRule = complexityRules[setDesign.scene_complexity]; const sceneLength = [...(promptModules.scene ?? "")].length; if (complexityRule && sceneLength < complexityRule.sceneMin) { errors.push(`images[${index}].prompt_modules.scene is too short for ${setDesign.scene_complexity} complexity (${sceneLength} < ${complexityRule.sceneMin})`); } if (setDesign.scene_complexity === "complex") { if (!isText(promptModules.camera_style)) { errors.push(`images[${index}].prompt_modules.camera_style is required for complex scenes`); } if (sceneLength <= productLockLength * 1.25) { errors.push(`images[${index}] complex scene detail must be longer than the product lock`); } } if (isText(promptModules.text_layout)) { errors.push(`images[${index}].prompt_modules.text_layout must be empty until deterministic text compositing is available`); } if (!isText(image.generation_prompt)) errors.push(`images[${index}].generation_prompt is required`); const promptLength = [...(image.generation_prompt ?? "")].length; if (complexityRule && promptLength < complexityRule.promptMin) { errors.push(`images[${index}].generation_prompt is too short for ${setDesign.scene_complexity} complexity (${promptLength} < ${complexityRule.promptMin})`); } if (promptLength > 800) errors.push(`images[${index}].generation_prompt exceeds 800 characters (${promptLength})`); const normalizedPrompt = (image.generation_prompt ?? "").replace(/\s+/g, ""); for (const moduleName of moduleNames) { const moduleText = (promptModules[moduleName] ?? "").replace(/\s+/g, ""); if (moduleText && !normalizedPrompt.includes(moduleText)) { errors.push(`images[${index}].generation_prompt does not contain prompt_modules.${moduleName}`); } } if (typeof image.negative_prompt !== "string") errors.push(`images[${index}].negative_prompt must be a string`); const finalRequestPrompt = image.negative_prompt?.trim() ? `${image.generation_prompt}\n禁止:${image.negative_prompt}` : image.generation_prompt; if ([...(finalRequestPrompt ?? "")].length > 800) { errors.push(`images[${index}] final request prompt exceeds 800 characters after negative_prompt is appended`); } const negativeTokens = (image.negative_prompt ?? "").toLowerCase().split(/[,,、;;\s]+/).filter(Boolean); if (negativeTokens.includes("logo") || negativeTokens.includes("文字") || negativeTokens.includes("品牌")) { errors.push(`images[${index}].negative_prompt conflicts with required product label; use 额外文字/额外品牌标识 instead`); } if (!Array.isArray(image.overlay_copy)) { errors.push(`images[${index}].overlay_copy must be an array`); } else if (image.overlay_copy.length > 0) { errors.push(`images[${index}].overlay_copy must stay empty until deterministic text compositing is available`); } if (!Array.isArray(image.reference_images) || image.reference_images.length === 0) { errors.push(`images[${index}].reference_images must contain a product reference`); } if (image.model_parameters?.adapter !== "jimeng_img_v4") errors.push(`images[${index}].model_parameters.adapter must be jimeng_img_v4`); if (!Number.isInteger(image.model_parameters?.width) || !Number.isInteger(image.model_parameters?.height)) { errors.push(`images[${index}] requires integer width and height`); } if (!(image.model_parameters?.scale >= 0 && image.model_parameters.scale <= 1)) { errors.push(`images[${index}].model_parameters.scale must be 0..1`); } if (image.model_parameters?.force_single !== true) errors.push(`images[${index}].model_parameters.force_single must be true`); if (!Array.isArray(image.acceptance_checks) || image.acceptance_checks.length === 0) { errors.push(`images[${index}].acceptance_checks must be non-empty`); } creativeBodies.push([ image.decision_subject, setDesign.unique_visual_hook, promptModules.image_job, promptModules.scene, promptModules.composition, ].filter(Boolean).join(" ").replace(/\s+/g, "").toLowerCase()); const claimText = [ promptModules.image_job, promptModules.scene, ...asArray(image.overlay_copy), ].filter(Boolean).join(" "); for (const claim of asArray(understanding.unsupported_claims).filter(isText)) { if (claimText.includes(claim)) errors.push(`images[${index}] uses unsupported claim: ${claim}`); } for (const term of highRiskClaimTerms) { if (claimText.includes(term) && !evidenceText.includes(term)) { errors.push(`images[${index}] uses unsupported high-risk claim term: ${term}`); } } } if (plan.status === "ready") { const expected = ["01", "02", "03", "04", "05", "06", "07"]; if (JSON.stringify(ids) !== JSON.stringify(expected)) errors.push("image IDs must be ordered 01 through 07"); if (plan.images?.[0]?.business_category !== "hero") errors.push("images[0] must be the hero image"); if (plan.images?.[0]?.set_design?.render_family !== "isolated_product") { errors.push("images[0] must use isolated_product"); } const categoryCounts = countValues(categoryValues); for (const [category, required] of Object.entries(requiredAllocation)) { if ((categoryCounts.get(category) ?? 0) !== required) { errors.push(`business_category ${category} must appear exactly ${required} time(s)`); } } if (new Set(decisions).size !== decisions.length) errors.push("decision_subject must be unique across all seven images"); if (sellingImages.length === 2 && sellingImages[0].selling_point_id === sellingImages[1].selling_point_id) { errors.push("selling_point images must use different selling_point_id values"); } for (const category of ["scene", "selling_point", "decision_support"]) { const values = categoryRenderFamilies.get(category) ?? []; if (values.length === 2 && new Set(values).size !== 2) { errors.push(`${category} images must use different render_family values`); } } if (staticProductCount > 2) { errors.push(`static product render families may appear at most twice across the set (${staticProductCount})`); } const diversityRules = [ ["narrative_job", 7, 1], ["composition_family", 5, 2], ["shot_scale", 4, 2], ["camera_angle", 4, 3], ["background_family", 5, 2], ["lighting_family", 4, 2], ["interaction_mode", 5, 2], ["camera_axis", 4, 2], ["framing_mode", 4, 2], ]; for (const [field, minimumUnique, maximumRepeats] of diversityRules) { const values = shotSpecs.map((spec) => spec?.[field]).filter(isText); const counts = countValues(values); if (new Set(values).size < minimumUnique) errors.push(`shot_spec.${field} requires at least ${minimumUnique} distinct values`); for (const [value, count] of counts) { if (count > maximumRepeats) errors.push(`shot_spec.${field} repeats too often: ${value} (${count})`); } } const framingCounts = countValues(shotSpecs.map((spec) => spec?.framing_mode).filter(isText)); if ((framingCounts.get("detail") ?? 0) > 1) { errors.push("shot_spec.framing_mode=detail may appear at most once across the set"); } if ((framingCounts.get("multi_panel") ?? 0) > 1) { errors.push("shot_spec.framing_mode=multi_panel may appear at most once across the set"); } const occupancies = shotSpecs.map((spec) => spec?.product_occupancy_pct).filter(Number.isFinite); if (occupancies.length === 7 && Math.max(...occupancies) - Math.min(...occupancies) < 35) { errors.push("product occupancy range must span at least 35 percentage points across the set"); } const nonHeroSignatures = shotSpecs.slice(1).map((spec) => [ spec.camera_axis, spec.framing_mode, spec.product_pose, ].join("|")); if (new Set(nonHeroSignatures).size !== nonHeroSignatures.length) { errors.push("all non-hero images require distinct camera_axis + framing_mode + product_pose visual signatures"); } for (let left = 1; left < shotSpecs.length; left += 1) { for (let right = left + 1; right < shotSpecs.length; right += 1) { const a = shotSpecs[left]; const b = shotSpecs[right]; if ( a.camera_axis === b.camera_axis && a.framing_mode === b.framing_mode && Math.abs(a.product_occupancy_pct - b.product_occupancy_pct) < 15 ) { errors.push(`images ${ids[left]} and ${ids[right]} reuse the same camera axis/framing with insufficient occupancy contrast`); } } } const tokens = (text) => { const latin = text.match(/[a-z0-9]{3,}/g) ?? []; const hanRuns = text.match(/\p{Script=Han}+/gu) ?? []; const hanBigrams = hanRuns.flatMap((run) => Array.from( { length: Math.max(0, run.length - 1) }, (_, index) => run.slice(index, index + 2), )); return new Set([...latin, ...hanBigrams]); }; const similarity = (left, right) => { const a = tokens(left); const b = tokens(right); const intersection = [...a].filter((item) => b.has(item)).length; const union = new Set([...a, ...b]).size; return union ? intersection / union : 0; }; for (let left = 0; left < creativeBodies.length; left += 1) { for (let right = left + 1; right < creativeBodies.length; right += 1) { const score = similarity(creativeBodies[left], creativeBodies[right]); if (score > 0.72) errors.push(`creative prompts ${ids[left]} and ${ids[right]} are too similar (${score.toFixed(2)})`); } } } const secretKey = /(token|api.?key|authorization|password|secret)/i; const scan = (value, path = "plan") => { if (!value || typeof value !== "object") return; for (const [key, child] of Object.entries(value)) { if (secretKey.test(key)) errors.push(`${path}.${key} must not contain credentials`); scan(child, `${path}.${key}`); } }; scan(plan); if (errors.length) { console.error(JSON.stringify({ valid: false, errors }, null, 2)); process.exit(1); } console.log(JSON.stringify({ valid: true, run_id: plan.run_id, images: plan.images.length }, null, 2));