validate-plan.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. import { readFile } from "node:fs/promises";
  2. const filename = process.argv[2];
  3. if (!filename) throw new Error("Usage: node validate-plan.mjs <prompt-plan.json>");
  4. const plan = JSON.parse(await readFile(filename, "utf8"));
  5. const errors = [];
  6. const isText = (value) => typeof value === "string" && value.trim().length > 0;
  7. const asArray = (value) => Array.isArray(value) ? value : [];
  8. const countValues = (values) => new Map(values.map((value) => [
  9. value,
  10. values.filter((item) => item === value).length,
  11. ]));
  12. if (plan.schema_version !== "0.6") errors.push("schema_version must be 0.6");
  13. if (!isText(plan.run_id)) errors.push("run_id is required");
  14. if (!new Set(["ready", "needs_input"]).has(plan.status)) errors.push("status must be ready or needs_input");
  15. if (plan.user_output_mode !== "images_only") errors.push("user_output_mode must be images_only");
  16. if (!isText(plan.request?.instruction)) errors.push("request.instruction is required");
  17. if (!Array.isArray(plan.request?.reference_images)) errors.push("request.reference_images must be an array");
  18. if (!isText(plan.context?.platform)) errors.push("context.platform is required");
  19. if (!new Set(["available", "unavailable"]).has(plan.context?.voc?.status)) errors.push("context.voc.status is invalid");
  20. if (!Array.isArray(plan.context?.voc?.findings)) errors.push("context.voc.findings must be an array");
  21. const understanding = plan.product_understanding ?? {};
  22. if (!isText(understanding.category)) errors.push("product_understanding.category is required");
  23. if (!isText(understanding.product_name?.value)) errors.push("product_understanding.product_name.value is required");
  24. if (!isText(understanding.product_name?.basis)) errors.push("product_understanding.product_name.basis is required");
  25. if (!new Set(["high", "medium", "low", "unknown"]).has(understanding.product_name?.confidence)) {
  26. errors.push("product_understanding.product_name.confidence is invalid");
  27. }
  28. for (const key of [
  29. "visible_facts",
  30. "verified_visible_facts",
  31. "inferred_attributes",
  32. "unknown_features",
  33. "provided_facts",
  34. "candidate_selling_points",
  35. "creative_positioning",
  36. "suggested_audiences",
  37. "suggested_scenes",
  38. "unsupported_claims",
  39. "unknowns",
  40. ]) {
  41. if (!Array.isArray(understanding[key])) errors.push(`product_understanding.${key} must be an array`);
  42. }
  43. if (!understanding.visible_specs || typeof understanding.visible_specs !== "object" || Array.isArray(understanding.visible_specs)) {
  44. errors.push("product_understanding.visible_specs must be an object");
  45. }
  46. const identityManifest = understanding.identity_manifest ?? {};
  47. for (const key of ["must_preserve", "must_not_add", "unknown_do_not_assert"]) {
  48. if (!Array.isArray(identityManifest[key])) errors.push(`product_understanding.identity_manifest.${key} must be an array`);
  49. }
  50. if (asArray(identityManifest.must_preserve).length < 3) {
  51. errors.push("product_understanding.identity_manifest.must_preserve requires at least three features");
  52. }
  53. if (!isText(identityManifest.product_lock)) {
  54. errors.push("product_understanding.identity_manifest.product_lock is required");
  55. } else {
  56. const identityLockLength = [...identityManifest.product_lock].length;
  57. if (identityLockLength < 20 || identityLockLength > 160) {
  58. errors.push(`product_understanding.identity_manifest.product_lock must be 20..160 characters (${identityLockLength})`);
  59. }
  60. }
  61. const sellingPoints = asArray(understanding.candidate_selling_points);
  62. const validSellingPointBasisRefs = new Set(["user_instruction"]);
  63. asArray(understanding.visible_facts).forEach((_, index) => validSellingPointBasisRefs.add(`visible_fact:${index + 1}`));
  64. asArray(understanding.provided_facts).forEach((_, index) => validSellingPointBasisRefs.add(`provided_fact:${index + 1}`));
  65. asArray(plan.context?.voc?.findings).forEach((_, index) => validSellingPointBasisRefs.add(`voc:${index + 1}`));
  66. if (plan.status === "ready" && sellingPoints.length < 2) {
  67. errors.push("ready plan requires at least two candidate_selling_points");
  68. }
  69. const sellingPointIds = [];
  70. for (const [index, point] of sellingPoints.entries()) {
  71. if (!isText(point?.id)) errors.push(`candidate_selling_points[${index}].id is required`);
  72. if (!isText(point?.value)) errors.push(`candidate_selling_points[${index}].value is required`);
  73. if (!Array.isArray(point?.basis_refs) || point.basis_refs.length === 0) {
  74. errors.push(`candidate_selling_points[${index}].basis_refs must be non-empty`);
  75. } else {
  76. for (const reference of point.basis_refs) {
  77. if (!validSellingPointBasisRefs.has(reference)) {
  78. errors.push(`candidate_selling_points[${index}].basis_refs contains unknown reference: ${reference}`);
  79. }
  80. }
  81. }
  82. if (!new Set(["high", "medium", "low", "unknown"]).has(point?.confidence)) {
  83. errors.push(`candidate_selling_points[${index}].confidence is invalid`);
  84. }
  85. sellingPointIds.push(point?.id);
  86. }
  87. if (new Set(sellingPointIds).size !== sellingPointIds.length) {
  88. errors.push("candidate_selling_points ids must be unique");
  89. }
  90. for (const key of ["product_invariants", "visual_system", "prohibited_changes"]) {
  91. if (!Array.isArray(plan.global_consistency_lock?.[key])) errors.push(`global_consistency_lock.${key} must be an array`);
  92. }
  93. const requiredAllocation = { hero: 1, scene: 2, selling_point: 2, decision_support: 2 };
  94. for (const [key, value] of Object.entries(requiredAllocation)) {
  95. if (plan.set_blueprint?.business_allocation?.[key] !== value) {
  96. errors.push(`set_blueprint.business_allocation.${key} must be ${value}`);
  97. }
  98. }
  99. if (!isText(plan.set_blueprint?.visual_thesis)) errors.push("set_blueprint.visual_thesis is required");
  100. for (const key of ["narrative_arc", "shared_visual_dna", "intentional_variations"]) {
  101. if (!Array.isArray(plan.set_blueprint?.[key]) || plan.set_blueprint[key].length === 0) {
  102. errors.push(`set_blueprint.${key} must be a non-empty array`);
  103. }
  104. }
  105. if (!Array.isArray(plan.images)) errors.push("images must be an array");
  106. if (plan.status === "ready" && plan.images?.length !== 7) errors.push("ready plan must contain exactly seven images");
  107. const businessCategories = new Set(Object.keys(requiredAllocation));
  108. const renderFamiliesByCategory = {
  109. hero: new Set(["isolated_product"]),
  110. scene: new Set(["environmental_scene", "human_interaction", "usage_demonstration"]),
  111. selling_point: new Set(["material_demonstration", "feature_demonstration", "annotated_detail"]),
  112. decision_support: new Set([
  113. "multi_angle",
  114. "usage_sequence",
  115. "package_contents",
  116. "scale_context",
  117. "comparison_sequence",
  118. "structured_information",
  119. ]),
  120. };
  121. const sceneComplexities = new Set(["simple", "medium", "complex"]);
  122. const moduleNames = [
  123. "product_lock",
  124. "image_job",
  125. "scene",
  126. "composition",
  127. "lighting_material",
  128. "camera_style",
  129. "text_layout",
  130. "negative_constraints",
  131. ];
  132. const complexityRules = {
  133. simple: { promptMin: 100, sceneMin: 12 },
  134. medium: { promptMin: 220, sceneMin: 60 },
  135. complex: { promptMin: 360, sceneMin: 160 },
  136. };
  137. const highRiskClaimTerms = [
  138. "防水", "抗菌", "临床", "认证", "销量第一", "最畅销", "耐磨", "耐用", "续航",
  139. "兼容", "承重", "保质期", "无毒", "不刺激", "留香", "香调", "成分", "浓度", "功效",
  140. ];
  141. const evidenceText = JSON.stringify([
  142. plan.request?.instruction,
  143. ...asArray(understanding.provided_facts),
  144. ...sellingPoints.map((item) => item.value),
  145. understanding.visible_specs ?? {},
  146. ...asArray(plan.context?.voc?.findings),
  147. ]);
  148. const knownEvidenceRefs = new Set(["user_instruction"]);
  149. asArray(understanding.visible_facts).forEach((_, index) => knownEvidenceRefs.add(`visible_fact:${index + 1}`));
  150. asArray(understanding.provided_facts).forEach((_, index) => knownEvidenceRefs.add(`provided_fact:${index + 1}`));
  151. asArray(understanding.creative_positioning).forEach((_, index) => knownEvidenceRefs.add(`creative_positioning:${index + 1}`));
  152. asArray(understanding.suggested_audiences).forEach((_, index) => knownEvidenceRefs.add(`suggested_audience:${index + 1}`));
  153. asArray(understanding.suggested_scenes).forEach((_, index) => knownEvidenceRefs.add(`suggested_scene:${index + 1}`));
  154. asArray(plan.context?.voc?.findings).forEach((_, index) => knownEvidenceRefs.add(`voc:${index + 1}`));
  155. Object.keys(understanding.visible_specs ?? {}).forEach((key) => knownEvidenceRefs.add(`visible_spec:${key}`));
  156. sellingPointIds.filter(isText).forEach((id) => knownEvidenceRefs.add(id));
  157. const ids = [];
  158. const decisions = [];
  159. const shotSpecs = [];
  160. const setDesigns = [];
  161. const creativeBodies = [];
  162. const categoryValues = [];
  163. const sellingImages = [];
  164. const categoryRenderFamilies = new Map();
  165. const staticProductFamilies = new Set(["isolated_product", "material_demonstration", "annotated_detail"]);
  166. let staticProductCount = 0;
  167. const requiredShotText = [
  168. "narrative_job",
  169. "composition_family",
  170. "shot_scale",
  171. "camera_angle",
  172. "product_placement",
  173. "depth_structure",
  174. "background_family",
  175. "lighting_family",
  176. "interaction_mode",
  177. "information_density",
  178. ];
  179. const cameraAxes = new Set(["front", "three_quarter_left", "three_quarter_right", "side", "top", "low", "macro"]);
  180. const framingModes = new Set(["full_product", "product_in_context", "detail", "multi_panel", "human_action"]);
  181. const productPoses = new Set(["upright", "laid_flat", "held", "in_use", "multi_view", "detail_crop", "arranged_sequence"]);
  182. for (const [index, image] of asArray(plan.images).entries()) {
  183. ids.push(image.id);
  184. decisions.push(image.decision_subject);
  185. categoryValues.push(image.business_category);
  186. if (!isText(image.id)) errors.push(`images[${index}].id is required`);
  187. if (!isText(image.role)) errors.push(`images[${index}].role is required`);
  188. if (!businessCategories.has(image.business_category)) {
  189. errors.push(`images[${index}].business_category is invalid`);
  190. }
  191. if (!isText(image.decision_subject)) errors.push(`images[${index}].decision_subject is required`);
  192. if (!Array.isArray(image.evidence_refs) || image.evidence_refs.length === 0) {
  193. errors.push(`images[${index}].evidence_refs must be non-empty`);
  194. } else {
  195. for (const reference of image.evidence_refs) {
  196. if (!knownEvidenceRefs.has(reference)) {
  197. errors.push(`images[${index}].evidence_refs contains unknown reference: ${reference}`);
  198. }
  199. }
  200. }
  201. if (typeof image.selling_point_id !== "string") errors.push(`images[${index}].selling_point_id must be a string`);
  202. if (!isText(image.purpose)) errors.push(`images[${index}].purpose is required`);
  203. if (!Array.isArray(image.voc_refs)) errors.push(`images[${index}].voc_refs must be an array`);
  204. const setDesign = image.set_design ?? {};
  205. setDesigns.push(setDesign);
  206. const allowedRenderFamilies = renderFamiliesByCategory[image.business_category];
  207. if (!allowedRenderFamilies?.has(setDesign.render_family)) {
  208. errors.push(`images[${index}].set_design.render_family is invalid for ${image.business_category ?? "unknown"}`);
  209. }
  210. if (setDesign.render_family === "structured_information") {
  211. errors.push(`images[${index}] structured_information is unavailable until a deterministic text compositor is implemented`);
  212. }
  213. if (staticProductFamilies.has(setDesign.render_family)) staticProductCount += 1;
  214. if (businessCategories.has(image.business_category) && isText(setDesign.render_family)) {
  215. const values = categoryRenderFamilies.get(image.business_category) ?? [];
  216. values.push(setDesign.render_family);
  217. categoryRenderFamilies.set(image.business_category, values);
  218. }
  219. if (!sceneComplexities.has(setDesign.scene_complexity)) {
  220. errors.push(`images[${index}].set_design.scene_complexity is invalid`);
  221. }
  222. if (!isText(setDesign.unique_visual_hook)) errors.push(`images[${index}].set_design.unique_visual_hook is required`);
  223. if (!isText(setDesign.contrast_with_previous)) errors.push(`images[${index}].set_design.contrast_with_previous is required`);
  224. if (image.business_category === "selling_point") {
  225. sellingImages.push(image);
  226. if (!sellingPointIds.includes(image.selling_point_id)) {
  227. errors.push(`images[${index}].selling_point_id must match a candidate_selling_points id`);
  228. }
  229. if (!image.evidence_refs?.includes(image.selling_point_id)) {
  230. errors.push(`images[${index}].evidence_refs must include its selling_point_id`);
  231. }
  232. } else if (isText(image.selling_point_id)) {
  233. errors.push(`images[${index}].selling_point_id is only allowed for selling_point images`);
  234. }
  235. for (const key of requiredShotText) {
  236. if (!isText(image.shot_spec?.[key])) errors.push(`images[${index}].shot_spec.${key} is required`);
  237. }
  238. if (!cameraAxes.has(image.shot_spec?.camera_axis)) {
  239. errors.push(`images[${index}].shot_spec.camera_axis is invalid`);
  240. }
  241. if (!framingModes.has(image.shot_spec?.framing_mode)) {
  242. errors.push(`images[${index}].shot_spec.framing_mode is invalid`);
  243. }
  244. if (!productPoses.has(image.shot_spec?.product_pose)) {
  245. errors.push(`images[${index}].shot_spec.product_pose is invalid`);
  246. }
  247. const occupancy = image.shot_spec?.product_occupancy_pct;
  248. if (!(Number.isFinite(occupancy) && occupancy >= 10 && occupancy <= 95)) {
  249. errors.push(`images[${index}].shot_spec.product_occupancy_pct must be 10..95`);
  250. }
  251. shotSpecs.push(image.shot_spec ?? {});
  252. const promptModules = image.prompt_modules ?? {};
  253. for (const moduleName of moduleNames) {
  254. if (typeof promptModules[moduleName] !== "string") {
  255. errors.push(`images[${index}].prompt_modules.${moduleName} must be a string`);
  256. }
  257. }
  258. for (const moduleName of ["product_lock", "image_job", "scene", "composition", "lighting_material", "negative_constraints"]) {
  259. if (!isText(promptModules[moduleName])) errors.push(`images[${index}].prompt_modules.${moduleName} is required`);
  260. }
  261. const productLockLength = [...(promptModules.product_lock ?? "")].length;
  262. if (productLockLength < 20 || productLockLength > 160) {
  263. errors.push(`images[${index}].prompt_modules.product_lock must be 20..160 characters (${productLockLength})`);
  264. }
  265. if (isText(identityManifest.product_lock) && promptModules.product_lock !== identityManifest.product_lock) {
  266. errors.push(`images[${index}].prompt_modules.product_lock must exactly match product_understanding.identity_manifest.product_lock`);
  267. }
  268. const positivePromptText = [
  269. promptModules.image_job,
  270. promptModules.scene,
  271. promptModules.composition,
  272. promptModules.lighting_material,
  273. promptModules.camera_style,
  274. ].filter(Boolean).join(" ");
  275. for (const unknownFeature of [
  276. ...asArray(understanding.unknown_features),
  277. ...asArray(identityManifest.unknown_do_not_assert),
  278. ].filter(isText)) {
  279. if (positivePromptText.includes(unknownFeature)) {
  280. errors.push(`images[${index}] positively asserts unknown product feature: ${unknownFeature}`);
  281. }
  282. }
  283. const absentText = asArray(identityManifest.must_not_add).filter(isText).join(" ").toLowerCase();
  284. const positiveLower = positivePromptText.toLowerCase();
  285. const contradictionGroups = [
  286. { absent: ["纽扣", "button"], positive: ["单排扣", "双排扣", "single-breasted", "double-breasted", "buttoned"] },
  287. { absent: ["口袋", "pocket"], positive: ["口袋", "pocket"] },
  288. { absent: ["拉链", "zipper"], positive: ["拉链", "zipper", "zip closure"] },
  289. { absent: ["腰带", "belt"], positive: ["腰带", "belted", "belt"] },
  290. ];
  291. for (const group of contradictionGroups) {
  292. if (
  293. group.absent.some((term) => absentText.includes(term))
  294. && group.positive.some((term) => positiveLower.includes(term))
  295. ) {
  296. errors.push(`images[${index}] prompt contradicts identity_manifest.must_not_add`);
  297. break;
  298. }
  299. }
  300. const usesPanelLayout = /三格|多格|分格|面板|横向序列|纵向序列|separate panels|multi[- ]?panel|\bpanel\b|\bgrid\b/i.test(positivePromptText);
  301. if (usesPanelLayout && image.shot_spec?.framing_mode !== "multi_panel") {
  302. errors.push(`images[${index}] uses a panel/grid layout without framing_mode=multi_panel`);
  303. }
  304. const complexityRule = complexityRules[setDesign.scene_complexity];
  305. const sceneLength = [...(promptModules.scene ?? "")].length;
  306. if (complexityRule && sceneLength < complexityRule.sceneMin) {
  307. errors.push(`images[${index}].prompt_modules.scene is too short for ${setDesign.scene_complexity} complexity (${sceneLength} < ${complexityRule.sceneMin})`);
  308. }
  309. if (setDesign.scene_complexity === "complex") {
  310. if (!isText(promptModules.camera_style)) {
  311. errors.push(`images[${index}].prompt_modules.camera_style is required for complex scenes`);
  312. }
  313. if (sceneLength <= productLockLength * 1.25) {
  314. errors.push(`images[${index}] complex scene detail must be longer than the product lock`);
  315. }
  316. }
  317. if (isText(promptModules.text_layout)) {
  318. errors.push(`images[${index}].prompt_modules.text_layout must be empty until deterministic text compositing is available`);
  319. }
  320. if (!isText(image.generation_prompt)) errors.push(`images[${index}].generation_prompt is required`);
  321. const promptLength = [...(image.generation_prompt ?? "")].length;
  322. if (complexityRule && promptLength < complexityRule.promptMin) {
  323. errors.push(`images[${index}].generation_prompt is too short for ${setDesign.scene_complexity} complexity (${promptLength} < ${complexityRule.promptMin})`);
  324. }
  325. if (promptLength > 800) errors.push(`images[${index}].generation_prompt exceeds 800 characters (${promptLength})`);
  326. const normalizedPrompt = (image.generation_prompt ?? "").replace(/\s+/g, "");
  327. for (const moduleName of moduleNames) {
  328. const moduleText = (promptModules[moduleName] ?? "").replace(/\s+/g, "");
  329. if (moduleText && !normalizedPrompt.includes(moduleText)) {
  330. errors.push(`images[${index}].generation_prompt does not contain prompt_modules.${moduleName}`);
  331. }
  332. }
  333. if (typeof image.negative_prompt !== "string") errors.push(`images[${index}].negative_prompt must be a string`);
  334. const finalRequestPrompt = image.negative_prompt?.trim()
  335. ? `${image.generation_prompt}\n禁止:${image.negative_prompt}`
  336. : image.generation_prompt;
  337. if ([...(finalRequestPrompt ?? "")].length > 800) {
  338. errors.push(`images[${index}] final request prompt exceeds 800 characters after negative_prompt is appended`);
  339. }
  340. const negativeTokens = (image.negative_prompt ?? "").toLowerCase().split(/[,,、;;\s]+/).filter(Boolean);
  341. if (negativeTokens.includes("logo") || negativeTokens.includes("文字") || negativeTokens.includes("品牌")) {
  342. errors.push(`images[${index}].negative_prompt conflicts with required product label; use 额外文字/额外品牌标识 instead`);
  343. }
  344. if (!Array.isArray(image.overlay_copy)) {
  345. errors.push(`images[${index}].overlay_copy must be an array`);
  346. } else if (image.overlay_copy.length > 0) {
  347. errors.push(`images[${index}].overlay_copy must stay empty until deterministic text compositing is available`);
  348. }
  349. if (!Array.isArray(image.reference_images) || image.reference_images.length === 0) {
  350. errors.push(`images[${index}].reference_images must contain a product reference`);
  351. }
  352. if (image.model_parameters?.adapter !== "jimeng_img_v4") errors.push(`images[${index}].model_parameters.adapter must be jimeng_img_v4`);
  353. if (!Number.isInteger(image.model_parameters?.width) || !Number.isInteger(image.model_parameters?.height)) {
  354. errors.push(`images[${index}] requires integer width and height`);
  355. }
  356. if (!(image.model_parameters?.scale >= 0 && image.model_parameters.scale <= 1)) {
  357. errors.push(`images[${index}].model_parameters.scale must be 0..1`);
  358. }
  359. if (image.model_parameters?.force_single !== true) errors.push(`images[${index}].model_parameters.force_single must be true`);
  360. if (!Array.isArray(image.acceptance_checks) || image.acceptance_checks.length === 0) {
  361. errors.push(`images[${index}].acceptance_checks must be non-empty`);
  362. }
  363. creativeBodies.push([
  364. image.decision_subject,
  365. setDesign.unique_visual_hook,
  366. promptModules.image_job,
  367. promptModules.scene,
  368. promptModules.composition,
  369. ].filter(Boolean).join(" ").replace(/\s+/g, "").toLowerCase());
  370. const claimText = [
  371. promptModules.image_job,
  372. promptModules.scene,
  373. ...asArray(image.overlay_copy),
  374. ].filter(Boolean).join(" ");
  375. for (const claim of asArray(understanding.unsupported_claims).filter(isText)) {
  376. if (claimText.includes(claim)) errors.push(`images[${index}] uses unsupported claim: ${claim}`);
  377. }
  378. for (const term of highRiskClaimTerms) {
  379. if (claimText.includes(term) && !evidenceText.includes(term)) {
  380. errors.push(`images[${index}] uses unsupported high-risk claim term: ${term}`);
  381. }
  382. }
  383. }
  384. if (plan.status === "ready") {
  385. const expected = ["01", "02", "03", "04", "05", "06", "07"];
  386. if (JSON.stringify(ids) !== JSON.stringify(expected)) errors.push("image IDs must be ordered 01 through 07");
  387. if (plan.images?.[0]?.business_category !== "hero") errors.push("images[0] must be the hero image");
  388. if (plan.images?.[0]?.set_design?.render_family !== "isolated_product") {
  389. errors.push("images[0] must use isolated_product");
  390. }
  391. const categoryCounts = countValues(categoryValues);
  392. for (const [category, required] of Object.entries(requiredAllocation)) {
  393. if ((categoryCounts.get(category) ?? 0) !== required) {
  394. errors.push(`business_category ${category} must appear exactly ${required} time(s)`);
  395. }
  396. }
  397. if (new Set(decisions).size !== decisions.length) errors.push("decision_subject must be unique across all seven images");
  398. if (sellingImages.length === 2 && sellingImages[0].selling_point_id === sellingImages[1].selling_point_id) {
  399. errors.push("selling_point images must use different selling_point_id values");
  400. }
  401. for (const category of ["scene", "selling_point", "decision_support"]) {
  402. const values = categoryRenderFamilies.get(category) ?? [];
  403. if (values.length === 2 && new Set(values).size !== 2) {
  404. errors.push(`${category} images must use different render_family values`);
  405. }
  406. }
  407. if (staticProductCount > 2) {
  408. errors.push(`static product render families may appear at most twice across the set (${staticProductCount})`);
  409. }
  410. const diversityRules = [
  411. ["narrative_job", 7, 1],
  412. ["composition_family", 5, 2],
  413. ["shot_scale", 4, 2],
  414. ["camera_angle", 4, 3],
  415. ["background_family", 5, 2],
  416. ["lighting_family", 4, 2],
  417. ["interaction_mode", 5, 2],
  418. ["camera_axis", 4, 2],
  419. ["framing_mode", 4, 2],
  420. ];
  421. for (const [field, minimumUnique, maximumRepeats] of diversityRules) {
  422. const values = shotSpecs.map((spec) => spec?.[field]).filter(isText);
  423. const counts = countValues(values);
  424. if (new Set(values).size < minimumUnique) errors.push(`shot_spec.${field} requires at least ${minimumUnique} distinct values`);
  425. for (const [value, count] of counts) {
  426. if (count > maximumRepeats) errors.push(`shot_spec.${field} repeats too often: ${value} (${count})`);
  427. }
  428. }
  429. const framingCounts = countValues(shotSpecs.map((spec) => spec?.framing_mode).filter(isText));
  430. if ((framingCounts.get("detail") ?? 0) > 1) {
  431. errors.push("shot_spec.framing_mode=detail may appear at most once across the set");
  432. }
  433. if ((framingCounts.get("multi_panel") ?? 0) > 1) {
  434. errors.push("shot_spec.framing_mode=multi_panel may appear at most once across the set");
  435. }
  436. const occupancies = shotSpecs.map((spec) => spec?.product_occupancy_pct).filter(Number.isFinite);
  437. if (occupancies.length === 7 && Math.max(...occupancies) - Math.min(...occupancies) < 35) {
  438. errors.push("product occupancy range must span at least 35 percentage points across the set");
  439. }
  440. const nonHeroSignatures = shotSpecs.slice(1).map((spec) => [
  441. spec.camera_axis,
  442. spec.framing_mode,
  443. spec.product_pose,
  444. ].join("|"));
  445. if (new Set(nonHeroSignatures).size !== nonHeroSignatures.length) {
  446. errors.push("all non-hero images require distinct camera_axis + framing_mode + product_pose visual signatures");
  447. }
  448. for (let left = 1; left < shotSpecs.length; left += 1) {
  449. for (let right = left + 1; right < shotSpecs.length; right += 1) {
  450. const a = shotSpecs[left];
  451. const b = shotSpecs[right];
  452. if (
  453. a.camera_axis === b.camera_axis
  454. && a.framing_mode === b.framing_mode
  455. && Math.abs(a.product_occupancy_pct - b.product_occupancy_pct) < 15
  456. ) {
  457. errors.push(`images ${ids[left]} and ${ids[right]} reuse the same camera axis/framing with insufficient occupancy contrast`);
  458. }
  459. }
  460. }
  461. const tokens = (text) => {
  462. const latin = text.match(/[a-z0-9]{3,}/g) ?? [];
  463. const hanRuns = text.match(/\p{Script=Han}+/gu) ?? [];
  464. const hanBigrams = hanRuns.flatMap((run) => Array.from(
  465. { length: Math.max(0, run.length - 1) },
  466. (_, index) => run.slice(index, index + 2),
  467. ));
  468. return new Set([...latin, ...hanBigrams]);
  469. };
  470. const similarity = (left, right) => {
  471. const a = tokens(left);
  472. const b = tokens(right);
  473. const intersection = [...a].filter((item) => b.has(item)).length;
  474. const union = new Set([...a, ...b]).size;
  475. return union ? intersection / union : 0;
  476. };
  477. for (let left = 0; left < creativeBodies.length; left += 1) {
  478. for (let right = left + 1; right < creativeBodies.length; right += 1) {
  479. const score = similarity(creativeBodies[left], creativeBodies[right]);
  480. if (score > 0.72) errors.push(`creative prompts ${ids[left]} and ${ids[right]} are too similar (${score.toFixed(2)})`);
  481. }
  482. }
  483. }
  484. const secretKey = /(token|api.?key|authorization|password|secret)/i;
  485. const scan = (value, path = "plan") => {
  486. if (!value || typeof value !== "object") return;
  487. for (const [key, child] of Object.entries(value)) {
  488. if (secretKey.test(key)) errors.push(`${path}.${key} must not contain credentials`);
  489. scan(child, `${path}.${key}`);
  490. }
  491. };
  492. scan(plan);
  493. if (errors.length) {
  494. console.error(JSON.stringify({ valid: false, errors }, null, 2));
  495. process.exit(1);
  496. }
  497. console.log(JSON.stringify({ valid: true, run_id: plan.run_id, images: plan.images.length }, null, 2));