@yuhan1124/draw-prompt 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,7 +135,7 @@ overlay --image out.png --spec spec.json --out final.png # 按 overlay spec
135
135
  visual-check --image final.png --spec spec.json # 单图质量门:尺寸/画幅/亮度/对比度
136
136
  edit-check --reference ref.png --output edited.png # 改图质量门:主体保留 + 有效变化
137
137
  intent-check --request "原始需求" --prompt "生成prompt" # 检查是否新增/偏离用户未要求内容
138
- visual-regress references/visual-cases.jsonl # 开发仓库本地:多场景 prompt/成品图回归,支持 expect_/forbid_ 断言
138
+ visual-regress references/visual-cases.jsonl # 开发仓库本地:多场景/多输出 prompt 回归,支持 expect_/forbid_ 断言
139
139
  lint --prompt "…" [--asset-type poster] [--text 必显文字] # 生图转化硬约束检查
140
140
  benchmark references/golden-cases.jsonl --runs 3 # 开发仓库本地:golden cases 稳定性基准
141
141
  revise --sample-id last --reason text_error # 按失败分类修订 Prompt
@@ -174,7 +174,7 @@ status # 数据 + 下游通道健康
174
174
  4. 只有当用户明确要求“文字必须绝对准确/可后处理”或出图反馈属于 `text_error` 时,才切到 `--strict-text` + `overlay` 两段式兜底。
175
175
  5. 用 `visual-check` 验证成品图尺寸、画幅、亮度、对比度和基础细节。
176
176
  6. 参考图改图用 `edit-check` 验证“主体保留 + 背景/目标确实变化”。
177
- 7. 模板或策略变动后,在开发仓库本地跑 `visual-regress references/visual-cases.jsonl`,确认多场景回归通过;用 `expect_asset_type`、`expect_aspect`、`expect_required_text`、`forbid_required_text`、`expect_prompt_contains`、`forbid_prompt_contains` 把真实场景的产品意图固化成门禁。
177
+ 7. 模板或策略变动后,在开发仓库本地跑 `visual-regress references/visual-cases.jsonl`,确认多场景回归通过;它会真实编译 `convert`、`rewrite`、`edit`、`variants`、`series`、`adapt`、`compose`、`brand`、`character`、`data-viz` 等单图/多输出入口。用 `expect_count`、`expect_asset_type(s)`、`expect_aspect(s)`、`expect_required_text(_all)`、`forbid_required_text`、`expect_safety_rewrite`、`expect_prompt_contains`、`forbid_prompt_contains` 把真实场景的产品意图固化成门禁。
178
178
 
179
179
  这条链路的默认目标不是替 gpt-image-2 重做排版引擎,而是减少跑偏、遗漏和廉价风格;
180
180
  两段式 overlay 只是文字极端稳定性兜底,不作为普通用户的默认体验。
package/SKILL.md CHANGED
@@ -8,7 +8,7 @@ description: >-
8
8
  画图的指令"、"优化我的出图 prompt"、"按我的风格生成 prompt",或在用 GPT Image 2 /
9
9
  gpt-image-2 出图前需要一段精准提示词时,使用本 skill。
10
10
  metadata:
11
- version: 0.4.11
11
+ version: 0.4.13
12
12
  openclaw:
13
13
  anyBins: ["uv", "python3"]
14
14
  ---
@@ -168,7 +168,7 @@ overlay --image out.png --spec spec.json --out final.png # 精确中文字/
168
168
  visual-check --image final.png --spec spec.json # 单图质量门
169
169
  edit-check --reference ref.png --output edited.png # 参考图改图质量门
170
170
  intent-check --request "原始需求" --prompt "生成prompt" # 意图保真检查
171
- visual-regress references/visual-cases.jsonl # 开发仓库本地:多场景回归,支持 expect_/forbid_ 断言
171
+ visual-regress references/visual-cases.jsonl # 开发仓库本地:多场景/多输出回归,支持 expect_/forbid_ 断言
172
172
  lint --prompt "…" [--asset-type poster] [--text 必显文字] # 生图转化硬约束检查
173
173
  benchmark references/golden-cases.jsonl --runs 3 # 开发仓库本地:golden cases 转化稳定性基准
174
174
  revise --sample-id last --reason text_error # 按失败分类修订 Prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuhan1124/draw-prompt",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "Convert natural-language image requests into high-quality gpt-image-2 prompts and Codex handoff blocks.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -369,7 +369,7 @@ prompt,再交给下游出图。
369
369
  | `ui_dashboard` | ui | 侧边栏、顶部栏、KPI、图表、表格 |
370
370
  | `slide_corporate_report` | slide | 保留用户显式区域、栏目/行列结构、图标位置、页脚和留白 |
371
371
  | `diagram_rag` | diagram | User -> Retriever -> Vector DB -> LLM -> Answer |
372
- | `diagram_system` | diagram | 分层盒子、有向箭头、图例 |
372
+ | `diagram_system` | diagram | 分层盒子、有向箭头、只保留用户要求的标签 |
373
373
 
374
374
  ## 质量门
375
375
 
@@ -138,7 +138,7 @@ def ensure_home() -> None:
138
138
 
139
139
 
140
140
  SCHEMA_VERSION = 1
141
- COMPILER_VERSION = "0.4.11"
141
+ COMPILER_VERSION = "0.4.13"
142
142
 
143
143
 
144
144
  PACKAGED_SKILL_FILES = [
@@ -2320,7 +2320,7 @@ TEMPLATE_DEFS = {
2320
2320
  "diagram_system": {
2321
2321
  "asset_type": "diagram",
2322
2322
  "label": "系统架构图",
2323
- "layout": "layered system boxes with directional arrows and a small legend",
2323
+ "layout": "layered system boxes with directional arrows",
2324
2324
  "keywords": ["架构", "系统", "模块", "服务", "流程"],
2325
2325
  },
2326
2326
  "product_hero": {
@@ -2593,6 +2593,9 @@ NON_DISPLAY_TEXTS = {
2593
2593
  "关系",
2594
2594
  "标签",
2595
2595
  "卖点标签",
2596
+ "主视觉",
2597
+ "产品主视觉",
2598
+ "核心",
2596
2599
  "购买转化氛围",
2597
2600
  "但不要过度促销",
2598
2601
  }
@@ -2642,12 +2645,13 @@ def extract_required_texts(request: str, explicit_texts: list[str]) -> list[str]
2642
2645
 
2643
2646
  def clean_text(text: str) -> str:
2644
2647
  text = text.strip(" \t\n\r,,、。;;::.!?!?")
2645
- text = re.sub(r"^(?:[^::]{0,12}(?:指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支))[::]", "", text)
2648
+ text = re.sub(r"^(?:[^::]{0,20}(?:活动路径|指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支|路径|卡))[::]", "", text)
2646
2649
  text = re.sub(r"^(?:顶部|底部|中间|左侧|右侧|上方|下方|首页|页面)?(?:主标题|副标题|标题)\s+", "", text)
2647
2650
  text = re.sub(r"^(?:问候语|核心卡片|主要按钮|主按钮|按钮)\s+", "", text)
2648
2651
  text = re.sub(r"^(?:是|为|叫)\s+", "", text)
2649
2652
  text = re.sub(r"^(?:一个|一枚|一项)?(?:明显的|醒目的|主要的|primary\s+)?(.{1,16})按钮$", r"\1", text, flags=re.IGNORECASE)
2650
- text = re.sub(r"(?:网格|列表|区域|模块)$", "", text)
2653
+ text = re.sub(r"(?:[一二三四五六七八九十\d]+个)?(?:网格|列表|区域|模块|卡片)$", "", text)
2654
+ text = re.sub(r"\s*[一二三四五六七八九十\d]+个$", "", text)
2651
2655
  text = re.sub(r"(?:要)?(?:渲染)?(?:清晰|清楚|可读|明显)$", "", text)
2652
2656
  text = re.sub(r"(?:这些)?元素$", "", text)
2653
2657
  text = re.sub(r"(?:[一二三四五六七八九十\d]+个)?步骤$", "", text)
@@ -2748,12 +2752,13 @@ def merge_texts(primary: list[str], extra: list[str]) -> list[str]:
2748
2752
  seen: set[str] = set()
2749
2753
  for item in primary + extra:
2750
2754
  text = item.strip(" \t\n\r,,、。;;::.!?!?")
2751
- text = re.sub(r"^(?:[^::]{0,12}(?:指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支))[::]", "", text)
2755
+ text = re.sub(r"^(?:[^::]{0,20}(?:活动路径|指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支|路径|卡))[::]", "", text)
2752
2756
  text = re.sub(r"^(?:顶部|底部|中间|左侧|右侧|上方|下方|首页|页面)?(?:主标题|副标题|标题)\s+", "", text)
2753
2757
  text = re.sub(r"^(?:问候语|核心卡片|主要按钮|主按钮|按钮)\s+", "", text)
2754
2758
  text = re.sub(r"^(?:是|为|叫)\s+", "", text)
2755
2759
  text = re.sub(r"^(?:一个|一枚|一项)?(?:明显的|醒目的|主要的|primary\s+)?(.{1,16})按钮$", r"\1", text, flags=re.IGNORECASE)
2756
- text = re.sub(r"(?:网格|列表|区域|模块)$", "", text)
2760
+ text = re.sub(r"(?:[一二三四五六七八九十\d]+个)?(?:网格|列表|区域|模块|卡片)$", "", text)
2761
+ text = re.sub(r"\s*[一二三四五六七八九十\d]+个$", "", text)
2757
2762
  text = re.sub(r"(?:要)?(?:渲染)?(?:清晰|清楚|可读|明显)$", "", text)
2758
2763
  text = re.sub(r"(?:这些)?元素$", "", text)
2759
2764
  text = re.sub(r"(?:[一二三四五六七八九十\d]+个)?步骤$", "", text)
@@ -2768,19 +2773,30 @@ def merge_texts(primary: list[str], extra: list[str]) -> list[str]:
2768
2773
  if text and key not in seen:
2769
2774
  seen.add(key)
2770
2775
  merged.append(text)
2771
- return merged
2776
+ pruned: list[str] = []
2777
+ compact_items = [(text, re.sub(r"[\s,,、。;;::.!?!?]+", "", text)) for text in merged]
2778
+ for idx, (text, key) in enumerate(compact_items):
2779
+ if len(key) <= 8 and any(
2780
+ idx != other_idx and key and key in other_key and len(other_key) > len(key)
2781
+ for other_idx, (_, other_key) in enumerate(compact_items)
2782
+ ):
2783
+ continue
2784
+ pruned.append(text)
2785
+ return pruned
2772
2786
 
2773
2787
 
2774
2788
  def extract_structural_labels(request: str, asset_type: str) -> list[str]:
2775
2789
  if asset_type not in {"diagram", "infographic", "slide", "ui", "poster", "product"}:
2776
2790
  return []
2777
2791
  candidates: list[tuple[int, str]] = []
2778
- list_intro = r"(?:需要)?(?:展示|呈现|列出|包含|包括|含有|分为|覆盖|输出)"
2792
+ list_intro = r"(?:需要)?(?:展示|呈现|列出|包含|包括|含有|分为|覆盖)"
2779
2793
  stop_words = r"(?:\b16\s*:\s*9\b|\b9\s*:\s*16\b|\b3\s*:\s*4\b|\b1\s*:\s*1\b|适合|用于|画幅|aspect|高质量|高清|clean|corporate)"
2780
2794
  patterns = [
2781
2795
  rf"(?:结构(?:从左到右|从右到左|从上到下|从下到上)?|链路|流程)\s*[::]\s*([^。\n]{{2,240}})",
2782
2796
  rf"(?:数据|指标|核心数据|关键指标|卖点|亮点|功能点|特性|活动路径|路径)\s*(?:包括|包含|有|为)?[::\s]*([^。;;\n]{{2,180}})",
2783
2797
  rf"{list_intro}\s*(?:这些|以下|对应的)?(?:模块|部分|层|栏目|节点|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)?[::\s]*([^。;;\n]{{2,180}})",
2798
+ rf"(?:顶部|底部|中间|左侧|右侧|上方|下方|最终|末端)?\s*输出\s*(?!可交给|为|成|到|给)([^。;;\n]{{2,180}})",
2799
+ rf"(?:顶部|底部|中间|左侧|右侧|上方|下方|主区域|页面|首页)?\s*(?:有|显示|放置|排列|提供)\s*([^。;;\n]{{2,180}}?(?:[一二三四五六七八九十\d]+个)?(?:模块|卡片|按钮|入口|标签|列表))",
2784
2800
  rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)\s*(?:包括|包含|有|为)[::\s]*([^。;;\n]{{2,180}})",
2785
2801
  rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)[^。;;\n::]{{0,16}}[::]([^。;;\n]{{2,180}})",
2786
2802
  ]
@@ -2790,17 +2806,23 @@ def extract_structural_labels(request: str, asset_type: str) -> list[str]:
2790
2806
  )
2791
2807
  for pattern in patterns:
2792
2808
  for match in re.finditer(pattern, request, flags=re.IGNORECASE):
2809
+ if re.search(r"\bicon\s*:|图标", match.group(0), flags=re.IGNORECASE):
2810
+ continue
2793
2811
  value = re.split(stop_words, match.group(1), maxsplit=1, flags=re.IGNORECASE)[0]
2794
2812
  value = re.sub(r"[()()]", "、", value)
2795
2813
  value = re.sub(r"(?:进入|再到|到|输出|->|→)", "、", value)
2796
2814
  for part_match in re.finditer(r"[^、,,;;|]+", value):
2797
2815
  part = part_match.group(0).strip(" \t\n\r,,、::")
2798
- part = re.sub(r"^(?:[^::]{0,12}(?:指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支))[::]", "", part)
2816
+ part = re.sub(r"^(?:[^::]{0,20}(?:活动路径|指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支|路径|卡))[::]", "", part)
2799
2817
  part = re.sub(r"^(?:和|与|及|以及|and|再|则)\s*", "", part, flags=re.IGNORECASE).strip()
2800
2818
  part = re.sub(r"^(?:底部有|顶部有|左侧有|右侧有|上方有|下方有)\s*", "", part)
2801
2819
  part = re.sub(r"^(?:包含|包括|含有)\s*", "", part)
2820
+ part = re.sub(r"^(?:问候语|核心卡片|主要按钮|主按钮|按钮)\s+", "", part)
2821
+ part = re.sub(r"^(?:一个|一枚|一项)?(?:明显的|醒目的|主要的|primary\s+)?(.{1,16})按钮$", r"\1", part, flags=re.IGNORECASE)
2802
2822
  part = re.sub(r"\s*(?:和|与|及|以及|and)$", "", part, flags=re.IGNORECASE).strip()
2803
2823
  part = re.sub(r"(?:这些)?元素$", "", part)
2824
+ part = re.sub(r"(?:[一二三四五六七八九十\d]+个)?(?:网格|列表|区域|模块|卡片)$", "", part)
2825
+ part = re.sub(r"\s*[一二三四五六七八九十\d]+个$", "", part)
2804
2826
  part = re.sub(r"(?:[一二三四五六七八九十\d]+个)?步骤$", "", part)
2805
2827
  part = part.strip(" \t\n\r,,、::")
2806
2828
  if not part:
@@ -2854,7 +2876,7 @@ def infer_palette_constraints(request: str) -> list[str]:
2854
2876
  ]
2855
2877
  constraints: list[str] = []
2856
2878
  seen: set[str] = set()
2857
- for sentence in re.split(r"[。;;\n]", request):
2879
+ for sentence in re.split(r"[。;;\n]|(?<=[A-Za-z)])\.\s+", request):
2858
2880
  for fragment in re.split(r"[,,]", sentence):
2859
2881
  fragment = fragment.strip(" \t\r\n。.;;,,")
2860
2882
  if not fragment:
@@ -3112,7 +3134,7 @@ def infer_must_include(asset_type: str, template_id: str, texts: list[str], stri
3112
3134
  "ui": ["requested screen type", "requested UI sections", "clear component hierarchy"],
3113
3135
  "infographic": ["requested information units", "requested relationships", "clear visual hierarchy"],
3114
3136
  "slide": ["widescreen slide canvas", "all requested sections", "requested visual motifs", "crisp readable text hierarchy"],
3115
- "diagram": ["labeled components", "directional arrows", "legend or flow semantics"],
3137
+ "diagram": ["labeled components", "directional arrows", "clear flow semantics"],
3116
3138
  "product": ["single hero product", "visible material texture", "controlled studio lighting"],
3117
3139
  "photography": ["realistic subject", "specific scene details", "natural imperfections"],
3118
3140
  "character": ["consistent character identity", "turnaround views", "expression close-ups"],
@@ -3451,7 +3473,7 @@ def render_prompt(spec: dict) -> str:
3451
3473
  brief_block,
3452
3474
  f"Style / quality envelope for diagram: {diagram_style}. This only controls presentation polish; the user brief wins.",
3453
3475
  f"Quality controls: crisp typography, clear node grouping, balanced white space, precise alignment, high contrast, restrained palette ({diagram_palette}).",
3454
- f"Recommended structure: {layout}. Use boxes, groups, arrows, and a feedback loop only where they match the brief.",
3476
+ f"Recommended structure: {layout}. Use boxes, groups, arrows, and a feedback loop only where they match the brief; do not add a legend or extra explanatory labels unless the brief asks for them.",
3455
3477
  "Do not over-template the diagram. Preserve the user's named modules, reading order, and product-review context.",
3456
3478
  f"Must include: {must_include}.",
3457
3479
  text_block,
@@ -4867,9 +4889,25 @@ def cmd_edit_check(args: argparse.Namespace) -> int:
4867
4889
 
4868
4890
  def visual_case_compile(case: dict) -> dict:
4869
4891
  tool = str(case.get("tool") or case.get("cmd") or "convert")
4870
- if tool in {"convert", "rewrite", "adapt", "series", "compose", "brand", "character", "data-viz"}:
4892
+ if tool in {"convert", "rewrite"}:
4871
4893
  compiled = compile_visual_case(case, target=case.get("target") or "codex-image")
4872
- return {**compiled, "tool": tool}
4894
+ return with_compiled_items({**compiled, "tool": tool}, [{"id": case.get("id") or tool, **compiled}])
4895
+ if tool == "edit":
4896
+ return compile_visual_edit_case(case)
4897
+ if tool == "variants":
4898
+ return compile_visual_variants_case(case)
4899
+ if tool == "series":
4900
+ return compile_visual_series_case(case)
4901
+ if tool == "adapt":
4902
+ return compile_visual_adapt_case(case)
4903
+ if tool == "compose":
4904
+ return compile_visual_compose_case(case)
4905
+ if tool == "brand":
4906
+ return compile_visual_brand_case(case)
4907
+ if tool == "character":
4908
+ return compile_visual_character_case(case)
4909
+ if tool == "data-viz":
4910
+ return compile_visual_data_viz_case(case)
4873
4911
  raise ValueError(f"visual-regress 暂不支持 tool={tool}")
4874
4912
 
4875
4913
 
@@ -4918,6 +4956,9 @@ def cmd_visual_regress(args: argparse.Namespace) -> int:
4918
4956
  "prompt_digest": compiled["prompt_digest"],
4919
4957
  "asset_type": compiled["spec"]["asset_type"],
4920
4958
  "aspect": compiled["spec"]["aspect"],
4959
+ "item_count": len(compiled.get("items") or [compiled]),
4960
+ "asset_types": [(entry.get("spec") or {}).get("asset_type") for entry in (compiled.get("items") or [compiled])],
4961
+ "aspects": [(entry.get("spec") or {}).get("aspect") for entry in (compiled.get("items") or [compiled])],
4921
4962
  "lint": lint,
4922
4963
  "intent_check": intent,
4923
4964
  "expectation_findings": expectation_findings,
@@ -5034,6 +5075,293 @@ def compile_visual_case(
5034
5075
  }
5035
5076
 
5036
5077
 
5078
+ def with_compiled_items(compiled: dict, items: list[dict]) -> dict:
5079
+ normalized = []
5080
+ for idx, item in enumerate(items, start=1):
5081
+ normalized.append(
5082
+ {
5083
+ "id": item.get("id") or f"item-{idx:02d}",
5084
+ "spec": item["spec"],
5085
+ "prompt": item["prompt"],
5086
+ "prompt_digest": item.get("prompt_digest") or prompt_digest(item["prompt"]),
5087
+ "lint": item.get("lint") or [],
5088
+ "intent_check": item.get("intent_check") or [],
5089
+ "handoff": item.get("handoff"),
5090
+ "text_overlay_spec": item.get("text_overlay_spec"),
5091
+ "acceptance_criteria": item.get("acceptance_criteria", []),
5092
+ }
5093
+ )
5094
+ if len(normalized) == 1:
5095
+ out = dict(compiled)
5096
+ out["items"] = normalized
5097
+ return out
5098
+ joined_digests = "|".join(item["prompt_digest"] for item in normalized)
5099
+ bundle = dict(compiled)
5100
+ bundle.update(
5101
+ {
5102
+ "spec": {
5103
+ "asset_type": "bundle",
5104
+ "aspect": "multi",
5105
+ "template_id": "multi_output",
5106
+ "required_text": [],
5107
+ "strict_text": False,
5108
+ },
5109
+ "prompt": "\n\n".join(item["prompt"] for item in normalized),
5110
+ "prompt_digest": prompt_digest(joined_digests),
5111
+ "lint": [finding for item in normalized for finding in item["lint"]],
5112
+ "intent_check": [finding for item in normalized for finding in item["intent_check"]],
5113
+ "handoff": None,
5114
+ "text_overlay_spec": None,
5115
+ "acceptance_criteria": [],
5116
+ "items": normalized,
5117
+ }
5118
+ )
5119
+ return bundle
5120
+
5121
+
5122
+ def visual_case_request(case: dict) -> str:
5123
+ request = case.get("request") or case.get("brief") or case.get("prompt_request") or case.get("input") or case.get("text")
5124
+ if isinstance(request, list):
5125
+ return "\n".join(str(item) for item in request)
5126
+ return str(request or "")
5127
+
5128
+
5129
+ def compile_visual_variants_case(case: dict) -> dict:
5130
+ request = visual_case_request(case)
5131
+ if not request:
5132
+ raise ValueError("variants case 缺少 request 字段")
5133
+ presets = parse_style_preset_list(str(case.get("style_presets") or ",".join(DEFAULT_VARIANT_PRESETS)), DEFAULT_VARIANT_PRESETS)
5134
+ custom_styles = case_list(case.get("custom_style") or case.get("custom_styles"))
5135
+ entries: list[dict] = [{"style_preset": preset, "style": case.get("shared_style")} for preset in presets]
5136
+ for custom in custom_styles:
5137
+ entries.append({"style_preset": "auto", "style": "; ".join([str(case.get("shared_style") or ""), custom]).strip("; "), "custom_style": custom})
5138
+ items = []
5139
+ for idx, entry in enumerate(entries, start=1):
5140
+ variant_case = dict(case)
5141
+ variant_case.update(
5142
+ {
5143
+ "request": request,
5144
+ "style": entry.get("style"),
5145
+ "style_preset": entry["style_preset"],
5146
+ "tags": "visual-regress,variants",
5147
+ }
5148
+ )
5149
+ variant_case.pop("tool", None)
5150
+ compiled = compile_visual_case(variant_case, target=case.get("target") or "codex-image", include_handoff=False)
5151
+ items.append({"id": f"variant-{idx:02d}", **compiled})
5152
+ return with_compiled_items({"tool": "variants", "count": len(items)}, items)
5153
+
5154
+
5155
+ def compile_visual_edit_case(case: dict) -> dict:
5156
+ goal = str(case.get("goal") or visual_case_request(case))
5157
+ if not goal:
5158
+ raise ValueError("edit case 缺少 goal/request 字段")
5159
+ references = [parse_reference(value) for value in case_list(case.get("reference") or case.get("references"))]
5160
+ preserve = case_list(case.get("preserve")) or ["main subject identity, silhouette, material cues, and composition anchors"]
5161
+ changes = case_list(case.get("change")) or [goal]
5162
+ required_text = case_list(case.get("text") or case.get("required_text"))
5163
+ aspect = str(case.get("aspect") or "3:4")
5164
+ asset_type = str(case.get("asset_type") or "product")
5165
+ quality = str(case.get("quality") or "high")
5166
+ reference_block = "; ".join(f"{item['role']}={item['ref']}" for item in references) or "provided reference image(s)"
5167
+ prompt = "\n".join(
5168
+ [
5169
+ f"Edit the provided reference image(s) into a {aspect} {asset_type} result for: {goal}.",
5170
+ f"References: {reference_block}.",
5171
+ "Preserve exactly: " + "; ".join(preserve) + ".",
5172
+ "Change only: " + "; ".join(changes) + ".",
5173
+ f"Visual target: {case.get('style') or 'production-quality realistic edit, consistent lighting, no visible seams'}; quality={quality}.",
5174
+ exact_text_block(required_text),
5175
+ "Avoid: identity drift; unwanted background changes; mismatched perspective; fake logos; garbled text; low-resolution artifacts.",
5176
+ ]
5177
+ )
5178
+ lint = lint_prompt(prompt, asset_type, quality, required_text)
5179
+ spec = {
5180
+ "asset_type": asset_type,
5181
+ "aspect": aspect,
5182
+ "template_id": "edit",
5183
+ "required_text": required_text,
5184
+ "strict_text": False,
5185
+ "quality": quality,
5186
+ }
5187
+ compiled = {
5188
+ "spec": spec,
5189
+ "prompt": prompt,
5190
+ "prompt_digest": prompt_digest(prompt),
5191
+ "lint": lint,
5192
+ "intent_check": [],
5193
+ "handoff": None,
5194
+ "text_overlay_spec": None,
5195
+ "acceptance_criteria": [],
5196
+ }
5197
+ return with_compiled_items({"tool": "edit", **compiled}, [{"id": "edit-01", **compiled}])
5198
+
5199
+
5200
+ def compile_visual_series_case(case: dict) -> dict:
5201
+ raw_items = case.get("briefs") or case.get("items") or case.get("series") or []
5202
+ if isinstance(raw_items, str):
5203
+ raw_items = [raw_items]
5204
+ if not raw_items:
5205
+ request = visual_case_request(case)
5206
+ if request:
5207
+ raw_items = [request]
5208
+ if not raw_items:
5209
+ raise ValueError("series case 缺少 briefs/items/request")
5210
+ shared_style = str(case.get("style") or case.get("shared_style") or "single coherent series style, same camera language, same palette discipline, same visual density")
5211
+ items = []
5212
+ for idx, item in enumerate(raw_items, start=1):
5213
+ item_case = dict(item) if isinstance(item, dict) else {"request": str(item)}
5214
+ brief = str(item_case.get("request") or item_case.get("brief") or "")
5215
+ if not brief:
5216
+ raise ValueError(f"series 第 {idx} 项缺少 request/brief")
5217
+ item_case["request"] = brief
5218
+ if case.get("asset_type") and not item_case.get("asset_type"):
5219
+ item_case["asset_type"] = case.get("asset_type")
5220
+ item_case["style"] = "; ".join([shared_style, str(item_case.get("style") or "").strip()]).strip("; ")
5221
+ for key in ("palette", "style_preset", "strict_text", "target"):
5222
+ if case.get(key) is not None and item_case.get(key) is None:
5223
+ item_case[key] = case.get(key)
5224
+ item_case["tags"] = "visual-regress,series"
5225
+ compiled = compile_visual_case(item_case, target=case.get("target") or "codex-image", include_handoff=False)
5226
+ items.append({"id": f"series-{idx:02d}", **compiled})
5227
+ return with_compiled_items({"tool": "series", "count": len(items)}, items)
5228
+
5229
+
5230
+ def compile_visual_adapt_case(case: dict) -> dict:
5231
+ request = visual_case_request(case)
5232
+ if not request:
5233
+ raise ValueError("adapt case 缺少 request 字段")
5234
+ aspects = split_csv(str(case.get("aspects") or case.get("aspect") or "1:1,3:4,16:9,9:16"))
5235
+ items = []
5236
+ for idx, aspect in enumerate(aspects, start=1):
5237
+ asset_type = str(case.get("asset_type") or route_asset_type(request))
5238
+ item_case = dict(case)
5239
+ item_case.update(
5240
+ {
5241
+ "request": request,
5242
+ "asset_type": asset_type,
5243
+ "aspect": aspect,
5244
+ "layout": case.get("layout") or adapt_layout_for_aspect(aspect, asset_type),
5245
+ "tags": "visual-regress,adapt",
5246
+ }
5247
+ )
5248
+ item_case.pop("tool", None)
5249
+ compiled = compile_visual_case(item_case, target=case.get("target") or "codex-image", include_handoff=False)
5250
+ items.append({"id": f"adapt-{idx:02d}", **compiled})
5251
+ return with_compiled_items({"tool": "adapt", "count": len(items)}, items)
5252
+
5253
+
5254
+ def compile_visual_compose_case(case: dict) -> dict:
5255
+ text = visual_case_request(case)
5256
+ if not text:
5257
+ raise ValueError("compose case 缺少 request/input 字段")
5258
+ max_images = int(case.get("max_images") or 6)
5259
+ shared_style = str(case.get("shared_style") or infer_compose_style(text))
5260
+ chunks = split_document_sections(text, max_images)
5261
+ items = []
5262
+ for idx, chunk in enumerate(chunks, start=1):
5263
+ asset_type = choose_compose_asset(chunk, idx - 1)
5264
+ labels = extract_visual_labels(chunk, asset_type)
5265
+ purpose = compose_purpose(asset_type, idx)
5266
+ item_case = {
5267
+ "request": f"{purpose}。根据这段内容生成对应画面:{chunk}",
5268
+ "asset_type": asset_type,
5269
+ "style": shared_style,
5270
+ "style_preset": case.get("style_preset"),
5271
+ "palette": case.get("palette"),
5272
+ "text": labels if (case.get("strict_text") or asset_type in {"diagram", "infographic", "ui"}) else [],
5273
+ "strict_text": bool(case.get("strict_text", False)),
5274
+ "target": case.get("target") or "codex-image",
5275
+ "tags": "visual-regress,compose",
5276
+ }
5277
+ compiled = compile_visual_case(item_case, target=case.get("target") or "codex-image", include_handoff=False)
5278
+ items.append({"id": f"compose-{idx:02d}", **compiled})
5279
+ return with_compiled_items({"tool": "compose", "count": len(items), "shared_style": shared_style}, items)
5280
+
5281
+
5282
+ def compile_visual_brand_case(case: dict) -> dict:
5283
+ name = str(case.get("name") or "")
5284
+ request = visual_case_request(case)
5285
+ if not name:
5286
+ raise ValueError("brand case 缺少 name 字段")
5287
+ brand_block = "\n".join(
5288
+ [
5289
+ f"Brand system for invented brand \"{name}\":",
5290
+ f"- Industry: {case.get('industry') or 'unspecified'}.",
5291
+ f"- Values: {', '.join(case_list(case.get('values'))) or 'clear, trustworthy, distinctive'}.",
5292
+ f"- Palette: {case.get('palette') or '2-3 controlled brand colors plus neutral support'}.",
5293
+ f"- Style: {case.get('style') or 'original, consistent, premium but restrained brand system'}.",
5294
+ "- Rules: keep mark shapes original; use the same spacing, palette, and typography logic across assets; no real brand logos.",
5295
+ ]
5296
+ )
5297
+ item_case = dict(case)
5298
+ item_case.update({"request": f"{request}\n{brand_block}" if request else brand_block, "text": case.get("text") or [name], "tags": "visual-regress,brand"})
5299
+ item_case.pop("tool", None)
5300
+ compiled = compile_visual_case(item_case, target=case.get("target") or "codex-image", include_handoff=False)
5301
+ return with_compiled_items({"tool": "brand", **compiled}, [{"id": "brand-01", **compiled}])
5302
+
5303
+
5304
+ def compile_visual_character_case(case: dict) -> dict:
5305
+ name = str(case.get("name") or "")
5306
+ description = str(case.get("description") or visual_case_request(case))
5307
+ if not name or not description:
5308
+ raise ValueError("character case 缺少 name/description")
5309
+ identity = (
5310
+ f"Original character \"{name}\": {description}. "
5311
+ f"Outfit anchors: {case.get('outfit') or 'stable signature outfit and silhouette'}. "
5312
+ f"Palette: {case.get('palette') or 'stable limited palette'}. Keep the same identity anchors in every image; no existing IP resemblance."
5313
+ )
5314
+ items = []
5315
+ reference_case = {"request": f"角色设定三视图和表情板:{identity}", "asset_type": "character", "style": case.get("style"), "style_preset": case.get("style_preset"), "text": [name], "tags": "visual-regress,character"}
5316
+ reference = compile_visual_case(reference_case, target=case.get("target") or "codex-image", include_handoff=False)
5317
+ items.append({"id": "character-reference", **reference})
5318
+ for idx, scene in enumerate(case_list(case.get("scene") or case.get("scenes")), start=1):
5319
+ scene_case = {"request": f"{identity} 场景图:{scene}", "asset_type": "illustration", "style": case.get("style"), "style_preset": case.get("style_preset"), "tags": "visual-regress,character-scene"}
5320
+ compiled = compile_visual_case(scene_case, target=case.get("target") or "codex-image", include_handoff=False)
5321
+ items.append({"id": f"character-scene-{idx:02d}", **compiled})
5322
+ return with_compiled_items({"tool": "character", "count": len(items)}, items)
5323
+
5324
+
5325
+ def compile_visual_data_viz_case(case: dict) -> dict:
5326
+ request = str(case.get("request") or "根据数据生成清晰的信息图")
5327
+ data_preview = read_data_preview(str(case.get("file") or "")) if case.get("file") else {"columns": [], "rows": [], "row_count": 0, "source": ""}
5328
+ title = infer_chart_title(request, str(case.get("title") or "") or None)
5329
+ chart_type = infer_chart_type(request, data_preview["columns"], str(case.get("chart_type") or "") or None)
5330
+ required_text = [] if case.get("strict_text") else [title]
5331
+ prompt = "\n".join(
5332
+ [
5333
+ f"Create a 16:9 high-quality data visualization infographic for: {request}.",
5334
+ f"Chart type: {chart_type}.",
5335
+ f"Data schema: columns={', '.join(data_preview['columns']) or 'not provided'}, rows={data_preview['row_count']}.",
5336
+ f"Rows preview for visual truthfulness: {json.dumps(data_preview['rows'], ensure_ascii=False)}.",
5337
+ "Layout: title band, main chart area, short insight callouts, compact legend, and optional source note.",
5338
+ "Use honest scales, aligned axes, restrained colors, and no decorative fake data.",
5339
+ f'The title must read exactly "{title}". Keep labels large and readable.' if required_text else "Strict chart text mode: reserve clean title, axis, legend, and callout zones; exact labels and numbers will be applied as deterministic vector/text overlay.",
5340
+ "Avoid: misleading charts; unreadable microtext; random numbers not present in the data; fake logos; visual clutter.",
5341
+ ]
5342
+ )
5343
+ lint = lint_prompt(prompt, "infographic", "high", required_text)
5344
+ spec = {
5345
+ "asset_type": "infographic",
5346
+ "aspect": "16:9",
5347
+ "template_id": "data_viz",
5348
+ "required_text": required_text,
5349
+ "strict_text": bool(case.get("strict_text")),
5350
+ "quality": "high",
5351
+ }
5352
+ compiled = {
5353
+ "spec": spec,
5354
+ "prompt": prompt,
5355
+ "prompt_digest": prompt_digest(prompt),
5356
+ "lint": lint,
5357
+ "intent_check": [],
5358
+ "handoff": None,
5359
+ "text_overlay_spec": None,
5360
+ "acceptance_criteria": [],
5361
+ }
5362
+ return with_compiled_items({"tool": "data-viz", **compiled}, [{"id": "data-viz-01", **compiled}])
5363
+
5364
+
5037
5365
  def case_list(value: object) -> list[str]:
5038
5366
  if value is None:
5039
5367
  return []
@@ -5043,12 +5371,25 @@ def case_list(value: object) -> list[str]:
5043
5371
 
5044
5372
 
5045
5373
  def visual_case_expectation_findings(case: dict, compiled: dict) -> list[dict]:
5046
- spec = compiled.get("spec") or {}
5047
- prompt = str(compiled.get("prompt") or "")
5374
+ items = compiled.get("items") or [compiled]
5375
+ specs = [(item.get("spec") or {}) for item in items]
5376
+ prompts = [str(item.get("prompt") or "") for item in items]
5377
+ spec = specs[0] if len(specs) == 1 else (compiled.get("spec") or {})
5378
+ prompt = "\n".join(prompts)
5048
5379
  findings: list[dict] = []
5049
5380
 
5381
+ expected_count = case.get("expect_count")
5382
+ if expected_count is not None and len(items) != int(expected_count):
5383
+ findings.append(
5384
+ {
5385
+ "severity": "error",
5386
+ "rule": "case.count_mismatch",
5387
+ "message": f"期望输出数量={expected_count},实际={len(items)}",
5388
+ }
5389
+ )
5390
+
5050
5391
  expected_asset = case.get("expect_asset_type")
5051
- if expected_asset and spec.get("asset_type") != expected_asset:
5392
+ if expected_asset and len(items) == 1 and spec.get("asset_type") != expected_asset:
5052
5393
  findings.append(
5053
5394
  {
5054
5395
  "severity": "error",
@@ -5056,9 +5397,20 @@ def visual_case_expectation_findings(case: dict, compiled: dict) -> list[dict]:
5056
5397
  "message": f"期望 asset_type={expected_asset},实际={spec.get('asset_type')}",
5057
5398
  }
5058
5399
  )
5400
+ expected_asset_types = case_list(case.get("expect_asset_types"))
5401
+ if expected_asset_types:
5402
+ actual = [str(item.get("asset_type") or "") for item in specs]
5403
+ if actual != expected_asset_types:
5404
+ findings.append(
5405
+ {
5406
+ "severity": "error",
5407
+ "rule": "case.asset_types_mismatch",
5408
+ "message": f"期望 asset_types={expected_asset_types},实际={actual}",
5409
+ }
5410
+ )
5059
5411
 
5060
5412
  expected_aspect = case.get("expect_aspect")
5061
- if expected_aspect and spec.get("aspect") != expected_aspect:
5413
+ if expected_aspect and len(items) == 1 and spec.get("aspect") != expected_aspect:
5062
5414
  findings.append(
5063
5415
  {
5064
5416
  "severity": "error",
@@ -5066,6 +5418,17 @@ def visual_case_expectation_findings(case: dict, compiled: dict) -> list[dict]:
5066
5418
  "message": f"期望 aspect={expected_aspect},实际={spec.get('aspect')}",
5067
5419
  }
5068
5420
  )
5421
+ expected_aspects = case_list(case.get("expect_aspects"))
5422
+ if expected_aspects:
5423
+ actual = [str(item.get("aspect") or "") for item in specs]
5424
+ if actual != expected_aspects:
5425
+ findings.append(
5426
+ {
5427
+ "severity": "error",
5428
+ "rule": "case.aspects_mismatch",
5429
+ "message": f"期望 aspects={expected_aspects},实际={actual}",
5430
+ }
5431
+ )
5069
5432
 
5070
5433
  expected_template = case.get("expect_template_id")
5071
5434
  if expected_template and spec.get("template_id") != expected_template:
@@ -5076,16 +5439,43 @@ def visual_case_expectation_findings(case: dict, compiled: dict) -> list[dict]:
5076
5439
  "message": f"期望 template_id={expected_template},实际={spec.get('template_id')}",
5077
5440
  }
5078
5441
  )
5442
+ expected_templates = case_list(case.get("expect_template_ids"))
5443
+ if expected_templates:
5444
+ actual = [str(item.get("template_id") or "") for item in specs]
5445
+ if actual != expected_templates:
5446
+ findings.append(
5447
+ {
5448
+ "severity": "error",
5449
+ "rule": "case.template_ids_mismatch",
5450
+ "message": f"期望 template_ids={expected_templates},实际={actual}",
5451
+ }
5452
+ )
5079
5453
 
5080
- labels = set(spec.get("required_text") or [])
5454
+ if case.get("expect_safety_rewrite"):
5455
+ if not any(item.get("safety_rewrite") for item in specs):
5456
+ findings.append(
5457
+ {
5458
+ "severity": "error",
5459
+ "rule": "case.safety_rewrite_missing",
5460
+ "message": "期望触发安全改写,但 spec.safety_rewrite 为空",
5461
+ }
5462
+ )
5463
+
5464
+ labels_by_item = [set(item.get("required_text") or []) for item in specs]
5465
+ labels = set().union(*labels_by_item) if labels_by_item else set()
5081
5466
  for text in case_list(case.get("expect_required_text")):
5082
5467
  if text not in labels:
5083
5468
  findings.append({"severity": "error", "rule": "case.required_text_missing", "message": f"缺少必显文字:{text}"})
5084
- elif f'"{text}"' not in prompt and not spec.get("strict_text"):
5469
+ elif f'"{text}"' not in prompt and not any(item.get("strict_text") for item in specs):
5085
5470
  findings.append({"severity": "error", "rule": "case.required_text_not_quoted", "message": f"Prompt 未逐字引用:{text}"})
5086
5471
 
5472
+ for text in case_list(case.get("expect_required_text_all")):
5473
+ for idx, item_labels in enumerate(labels_by_item, start=1):
5474
+ if text not in item_labels:
5475
+ findings.append({"severity": "error", "rule": "case.required_text_all_missing", "message": f"第 {idx} 个输出缺少必显文字:{text}"})
5476
+
5087
5477
  for text in case_list(case.get("forbid_required_text")):
5088
- if text in labels:
5478
+ if any(text in item_labels for item_labels in labels_by_item):
5089
5479
  findings.append({"severity": "error", "rule": "case.required_text_forbidden", "message": f"不应作为必显文字:{text}"})
5090
5480
 
5091
5481
  for text in case_list(case.get("expect_prompt_contains")):