@yuhan1124/draw-prompt 0.4.9 → 0.4.10

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
@@ -72,6 +72,7 @@ npx --yes --registry=https://registry.npmjs.org/ @yuhan1124/draw-prompt@latest i
72
72
  `visual-cases.jsonl` 不会进入 npm 包或安装目录。安装后可直接跑 `doctor`,它会检查
73
73
  包文件、版本一致性、核心单图转化、真实长输入 `compose` 链路,以及
74
74
  `variants` / `series` / `adapt` 等主工作流 smoke。
75
+ `brand` / `character` / `data-viz` / `edit` / `rewrite` 这类主入口也在 `doctor` 中做命令级 smoke。
75
76
 
76
77
  开发者本地调试也可以软链 repo:
77
78
 
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.9
11
+ version: 0.4.10
12
12
  openclaw:
13
13
  anyBins: ["uv", "python3"]
14
14
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuhan1124/draw-prompt",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
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": {
@@ -45,8 +45,10 @@ DRAW_PROMPT_HOME 覆盖),不进 git repo。
45
45
  from __future__ import annotations
46
46
 
47
47
  import argparse
48
+ import contextlib
48
49
  import csv
49
50
  import hashlib
51
+ import io
50
52
  import json
51
53
  import os
52
54
  import re
@@ -136,7 +138,7 @@ def ensure_home() -> None:
136
138
 
137
139
 
138
140
  SCHEMA_VERSION = 1
139
- COMPILER_VERSION = "0.4.9"
141
+ COMPILER_VERSION = "0.4.10"
140
142
 
141
143
 
142
144
  PACKAGED_SKILL_FILES = [
@@ -262,6 +264,38 @@ DOCTOR_WORKFLOW_CASES = [
262
264
  },
263
265
  ]
264
266
 
267
+ DOCTOR_COMMAND_CASES = [
268
+ {
269
+ "id": "edit-preserve-change-contract",
270
+ "kind": "edit",
271
+ "expect_prompt": ["Preserve exactly: cup silhouette.", "Change only: background only."],
272
+ },
273
+ {
274
+ "id": "brand-compile",
275
+ "kind": "brand",
276
+ "expect_required_text": ["山川茶事"],
277
+ "expect_prompt": ["Brand system for invented brand"],
278
+ },
279
+ {
280
+ "id": "character-reference-and-scene",
281
+ "kind": "character",
282
+ "expect_prompt": ["Mira", "same identity anchors"],
283
+ },
284
+ {
285
+ "id": "data-viz-csv",
286
+ "kind": "data-viz",
287
+ "expect_title": "转化率趋势",
288
+ "expect_columns": ["week", "conversion"],
289
+ },
290
+ {
291
+ "id": "rewrite-risky-ip",
292
+ "kind": "rewrite",
293
+ "expect_safety_rewrite": True,
294
+ "forbid_prompt": ["迪士尼"],
295
+ "expect_prompt": ["original"],
296
+ },
297
+ ]
298
+
265
299
 
266
300
  def package_root() -> Path:
267
301
  return Path(__file__).resolve().parent.parent
@@ -2519,12 +2553,69 @@ def infer_aspect(request: str, asset_type: str, override: str | None, profile: d
2519
2553
  return "3:4"
2520
2554
  if any(k in lower for k in ["方图", "方形", "square"]):
2521
2555
  return "1:1"
2556
+ if asset_type == "ui":
2557
+ desktop_terms = [
2558
+ "saas",
2559
+ "后台",
2560
+ "控制台",
2561
+ "dashboard",
2562
+ "仪表盘",
2563
+ "看板",
2564
+ "网页",
2565
+ "web",
2566
+ "desktop",
2567
+ "左侧导航",
2568
+ "顶部状态栏",
2569
+ "右侧",
2570
+ ]
2571
+ mobile_terms = ["手机", "mobile", "app 首页", "小程序", "9:16"]
2572
+ if any(term in lower for term in desktop_terms) and not any(term in lower for term in mobile_terms):
2573
+ return "16:9"
2522
2574
  prof = str(profile.get("default_aspect") or "").strip()
2523
2575
  if prof and prof != "未设置":
2524
2576
  return prof
2525
2577
  return str(ASSET_ROUTES[asset_type]["aspect"])
2526
2578
 
2527
2579
 
2580
+ NON_DISPLAY_TEXTS = {
2581
+ "顶部导航",
2582
+ "底部导航",
2583
+ "左侧导航",
2584
+ "右侧导航",
2585
+ "导航栏",
2586
+ "顶部栏",
2587
+ "底部栏",
2588
+ "状态栏",
2589
+ "顶部状态栏",
2590
+ "底部状态栏",
2591
+ "箭头关系",
2592
+ "箭头",
2593
+ "关系",
2594
+ "标签",
2595
+ "卖点标签",
2596
+ "购买转化氛围",
2597
+ "但不要过度促销",
2598
+ }
2599
+
2600
+
2601
+ def is_non_display_text(text: str) -> bool:
2602
+ value = text.strip(" \t\n\r,,、。;;::.!?!?")
2603
+ lower = value.lower()
2604
+ if value in NON_DISPLAY_TEXTS:
2605
+ return True
2606
+ if lower.startswith(("不要", "不需要", "不能", "不得", "但不要", "avoid ", "do not ")):
2607
+ return True
2608
+ if re.fullmatch(r"\d+\s*%\s*(?:height|width|高|宽|高度|宽度)", lower):
2609
+ return True
2610
+ if re.fullmatch(r"\d+\s*(?:px|pt|rem|em)\s*(?:height|width|高|宽|高度|宽度)?", lower):
2611
+ return True
2612
+ if re.fullmatch(r"\d+\s*[:x×]\s*\d+", lower):
2613
+ return True
2614
+ if value.endswith("氛围"):
2615
+ return True
2616
+ return False
2617
+
2618
+
2528
2619
  def infer_size(aspect: str, override: str | None, asset_type: str) -> str:
2529
2620
  if override:
2530
2621
  return override
@@ -2564,7 +2655,7 @@ def extract_required_texts(request: str, explicit_texts: list[str]) -> list[str]
2564
2655
  return ""
2565
2656
  if re.fullmatch(r"[A-Za-z]{1,2}", text) and text.upper() not in {"AI", "UI"}:
2566
2657
  return ""
2567
- if text in {"顶部导航", "底部导航", "左侧导航", "右侧导航", "导航栏", "顶部栏", "状态栏", "箭头关系", "箭头", "关系"}:
2658
+ if is_non_display_text(text):
2568
2659
  return ""
2569
2660
  return text.strip(" \t\n\r,,、。;;::.!?!?")
2570
2661
 
@@ -2671,7 +2762,7 @@ def merge_texts(primary: list[str], extra: list[str]) -> list[str]:
2671
2762
  continue
2672
2763
  if re.fullmatch(r"[A-Za-z]{1,2}", text) and text.upper() not in {"AI", "UI"}:
2673
2764
  continue
2674
- if text in {"顶部导航", "底部导航", "左侧导航", "右侧导航", "导航栏", "顶部栏", "状态栏", "箭头关系", "箭头", "关系"}:
2765
+ if is_non_display_text(text):
2675
2766
  continue
2676
2767
  key = re.sub(r"\s+", "", text)
2677
2768
  if text and key not in seen:
@@ -2693,6 +2784,10 @@ def extract_structural_labels(request: str, asset_type: str) -> list[str]:
2693
2784
  rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)\s*(?:包括|包含|有|为)[::\s]*([^。;;\n]{{2,180}})",
2694
2785
  rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)[^。;;\n::]{{0,16}}[::]([^。;;\n]{{2,180}})",
2695
2786
  ]
2787
+ if asset_type in {"diagram", "infographic", "slide", "ui"}:
2788
+ patterns.append(
2789
+ r"(?:图中|图里|画面中|架构图中|系统图中|流程图中)\s*(?:需要)?(?:要有|应有|需有|有)[::\s]*([^。;;\n]{2,180})"
2790
+ )
2696
2791
  for pattern in patterns:
2697
2792
  for match in re.finditer(pattern, request, flags=re.IGNORECASE):
2698
2793
  value = re.split(stop_words, match.group(1), maxsplit=1, flags=re.IGNORECASE)[0]
@@ -2712,7 +2807,7 @@ def extract_structural_labels(request: str, asset_type: str) -> list[str]:
2712
2807
  continue
2713
2808
  if re.search(r"箭头.*(?:关系|展示|输入|输出)|(?:关系|展示|输入|输出).*箭头|展示输入输出关系", part):
2714
2809
  continue
2715
- if part in {"顶部", "底部", "左侧", "右侧", "上方", "下方", "中间", "顶部导航", "底部导航", "左侧导航", "右侧导航", "导航栏", "顶部栏", "状态栏", "箭头关系", "箭头", "关系"}:
2810
+ if part in {"顶部", "底部", "左侧", "右侧", "上方", "下方", "中间"} or is_non_display_text(part):
2716
2811
  continue
2717
2812
  if re.fullmatch(r"\d+(?:\s*:\s*\d+)?", part):
2718
2813
  continue
@@ -3197,12 +3292,16 @@ def build_spec(args: argparse.Namespace) -> dict:
3197
3292
 
3198
3293
 
3199
3294
  def exact_text_block(texts: list[str]) -> str:
3295
+ non_display_rule = (
3296
+ "Do not render layout measurements, area percentages, placement notes, placeholder source notes, "
3297
+ "or prompt instruction phrases as visible in-image text unless they are explicitly listed above as exact strings."
3298
+ )
3200
3299
  if not texts:
3201
- return "No required in-image text unless explicitly useful; avoid decorative fake text."
3300
+ return f"No required in-image text unless explicitly useful; avoid decorative fake text. {non_display_rule}"
3202
3301
  quoted = " / ".join(f'"{t}"' for t in texts)
3203
3302
  return (
3204
3303
  "The image must accurately display these exact strings, unchanged and large enough to read: "
3205
- f"{quoted}. Use crisp, legible typography; keep Chinese text exactly as written."
3304
+ f"{quoted}. Use crisp, legible typography; keep Chinese text exactly as written. {non_display_rule}"
3206
3305
  )
3207
3306
 
3208
3307
 
@@ -3559,6 +3658,8 @@ def request_mentions_any(request: str, variants: list[str]) -> bool:
3559
3658
  def intent_group_allowed_by_context(group: str, request: str, spec: dict) -> bool:
3560
3659
  lower = request.lower()
3561
3660
  asset_type = str(spec.get("asset_type") or "")
3661
+ if group == "background_scene":
3662
+ return any(k in lower for k in ["场景", "背景", "环境", "scene", "background", "environment", "beside", "inside", "outside"])
3562
3663
  if group == "cards" and asset_type == "ui":
3563
3664
  return any(k in lower for k in ["首页", "展示", "列表", "作品", "项目", "内容", "recent", "gallery", "feed"])
3564
3665
  if group == "modules" and asset_type in {"diagram", "infographic", "slide"}:
@@ -6022,6 +6123,174 @@ def doctor_check_workflow_case(case: dict) -> dict:
6022
6123
  }
6023
6124
 
6024
6125
 
6126
+ def doctor_run_json_command(func, args: argparse.Namespace) -> tuple[int, dict, str]:
6127
+ stdout = io.StringIO()
6128
+ with contextlib.redirect_stdout(stdout):
6129
+ code = func(args)
6130
+ output = stdout.getvalue()
6131
+ data = json.loads(output)
6132
+ return code, data, output
6133
+
6134
+
6135
+ def doctor_collect_quality(findings: list[dict], scope: str, payload: dict) -> None:
6136
+ for item in payload.get("lint") or []:
6137
+ findings.append({"severity": item["severity"], "rule": f"{scope}.lint.{item['rule']}", "message": item["message"]})
6138
+ for item in payload.get("intent_check") or []:
6139
+ findings.append({"severity": item["severity"], "rule": f"{scope}.intent.{item['rule']}", "message": item["message"]})
6140
+
6141
+
6142
+ def doctor_check_command_case(case: dict) -> dict:
6143
+ findings: list[dict] = []
6144
+ kind = case.get("kind")
6145
+ data: dict = {}
6146
+ prompt_blobs: list[str] = []
6147
+ cleanup_paths: list[Path] = []
6148
+ try:
6149
+ if kind == "edit":
6150
+ code, data, _ = doctor_run_json_command(
6151
+ cmd_edit,
6152
+ argparse.Namespace(
6153
+ goal="把杯子背景换成夏季冷泡茶吧台",
6154
+ reference=["product:/tmp/cup.png"],
6155
+ preserve=["cup silhouette"],
6156
+ change=["background only"],
6157
+ text=[],
6158
+ aspect="3:4",
6159
+ asset_type="product",
6160
+ quality="high",
6161
+ style=None,
6162
+ target="raw",
6163
+ out=None,
6164
+ json=True,
6165
+ ),
6166
+ )
6167
+ if code != 0:
6168
+ doctor_add(findings, "error", "command.exit", f"edit 返回非零状态:{code}")
6169
+ doctor_collect_quality(findings, "edit", data)
6170
+ prompt_blobs.append(str(data.get("prompt") or ""))
6171
+ elif kind == "brand":
6172
+ code, data, _ = doctor_run_json_command(
6173
+ cmd_brand,
6174
+ argparse.Namespace(
6175
+ name="山川茶事",
6176
+ industry="茶饮",
6177
+ palette="moss green,ivory",
6178
+ avoid=None,
6179
+ values=None,
6180
+ style=None,
6181
+ request="新品冷泡茶品牌主视觉",
6182
+ asset_type="poster",
6183
+ style_preset=None,
6184
+ text=["山川茶事"],
6185
+ strict_text=False,
6186
+ target="raw",
6187
+ json=True,
6188
+ ),
6189
+ )
6190
+ if code != 0:
6191
+ doctor_add(findings, "error", "command.exit", f"brand 返回非零状态:{code}")
6192
+ compiled = data.get("compiled") or {}
6193
+ doctor_collect_quality(findings, "brand.compiled", compiled)
6194
+ prompt_blobs.extend([str(data.get("brand_prompt_block") or ""), str(compiled.get("prompt") or "")])
6195
+ labels = set((compiled.get("spec") or {}).get("required_text") or [])
6196
+ for text_item in case.get("expect_required_text") or []:
6197
+ if text_item not in labels:
6198
+ doctor_add(findings, "error", "command.required_text_missing", f"brand 缺少必显文字:{text_item}")
6199
+ elif kind == "character":
6200
+ code, data, _ = doctor_run_json_command(
6201
+ cmd_character,
6202
+ argparse.Namespace(
6203
+ name="Mira",
6204
+ description="short silver hair, amber eyes, explorer coat",
6205
+ scene=["standing beside a glowing archive machine"],
6206
+ style=None,
6207
+ outfit=None,
6208
+ palette=None,
6209
+ style_preset=None,
6210
+ target="raw",
6211
+ json=True,
6212
+ ),
6213
+ )
6214
+ if code != 0:
6215
+ doctor_add(findings, "error", "command.exit", f"character 返回非零状态:{code}")
6216
+ reference = data.get("reference_sheet") or {}
6217
+ doctor_collect_quality(findings, "character.reference", reference)
6218
+ prompt_blobs.append(str(reference.get("prompt") or ""))
6219
+ for idx, scene in enumerate(data.get("scene_prompts") or [], start=1):
6220
+ doctor_collect_quality(findings, f"character.scene.{idx}", scene)
6221
+ prompt_blobs.append(str(scene.get("prompt") or ""))
6222
+ elif kind == "data-viz":
6223
+ with NamedTemporaryFile("w", suffix=".csv", encoding="utf-8", delete=False) as tmp:
6224
+ tmp.write("week,conversion\nW1,0.12\nW2,0.18\n")
6225
+ tmp_path = Path(tmp.name)
6226
+ cleanup_paths.append(tmp_path)
6227
+ code, data, _ = doctor_run_json_command(
6228
+ cmd_data_viz,
6229
+ argparse.Namespace(
6230
+ file=str(tmp_path),
6231
+ request="展示转化率趋势",
6232
+ title="转化率趋势",
6233
+ chart_type=None,
6234
+ strict_text=False,
6235
+ json=True,
6236
+ ),
6237
+ )
6238
+ if code != 0:
6239
+ doctor_add(findings, "error", "command.exit", f"data-viz 返回非零状态:{code}")
6240
+ doctor_collect_quality(findings, "data-viz", data)
6241
+ prompt_blobs.append(str(data.get("prompt") or ""))
6242
+ chart_spec = data.get("chart_data_spec") or {}
6243
+ if case.get("expect_title") and chart_spec.get("title") != case["expect_title"]:
6244
+ doctor_add(findings, "error", "command.title", f"data-viz 标题错误:{chart_spec.get('title')}")
6245
+ for column in case.get("expect_columns") or []:
6246
+ if column not in (chart_spec.get("columns") or []):
6247
+ doctor_add(findings, "error", "command.column_missing", f"data-viz 缺少列:{column}")
6248
+ elif kind == "rewrite":
6249
+ code, data, _ = doctor_run_json_command(
6250
+ cmd_rewrite,
6251
+ argparse.Namespace(
6252
+ prompt_text=["做一个迪士尼风格的角色海报,好看高级"],
6253
+ file=None,
6254
+ asset_type="poster",
6255
+ style=None,
6256
+ style_preset=None,
6257
+ strict_text=False,
6258
+ text=[],
6259
+ target="raw",
6260
+ json=True,
6261
+ ),
6262
+ )
6263
+ if code != 0:
6264
+ doctor_add(findings, "error", "command.exit", f"rewrite 返回非零状态:{code}")
6265
+ doctor_collect_quality(findings, "rewrite", data)
6266
+ prompt_blobs.append(str(data.get("prompt") or ""))
6267
+ safety = (data.get("spec") or {}).get("safety_rewrite") or []
6268
+ if case.get("expect_safety_rewrite") and not safety:
6269
+ doctor_add(findings, "error", "command.safety_rewrite", "rewrite 未触发风险引用改写")
6270
+ else:
6271
+ doctor_add(findings, "error", "command.kind", f"未知命令类型:{kind}")
6272
+ finally:
6273
+ for path in cleanup_paths:
6274
+ try:
6275
+ path.unlink()
6276
+ except OSError:
6277
+ pass
6278
+
6279
+ joined_prompt = "\n".join(prompt_blobs)
6280
+ for phrase in case.get("expect_prompt") or []:
6281
+ if phrase not in joined_prompt:
6282
+ doctor_add(findings, "error", "command.prompt_missing", f"{kind} 输出缺少片段:{phrase}")
6283
+ for phrase in case.get("forbid_prompt") or []:
6284
+ if phrase in joined_prompt:
6285
+ doctor_add(findings, "error", "command.forbidden_prompt", f"{kind} 输出出现不应出现的片段:{phrase}")
6286
+ return {
6287
+ "name": f"command-{case['id']}",
6288
+ "pass": not has_lint_error(findings),
6289
+ "kind": kind,
6290
+ "findings": findings,
6291
+ }
6292
+
6293
+
6025
6294
  def cmd_doctor(args: argparse.Namespace) -> int:
6026
6295
  root = package_root()
6027
6296
  checks = [doctor_check_package(root)]
@@ -6040,6 +6309,11 @@ def cmd_doctor(args: argparse.Namespace) -> int:
6040
6309
  checks.append(doctor_check_workflow_case(case))
6041
6310
  except Exception as exc:
6042
6311
  checks.append({"name": f"workflow-{case.get('id', 'case')}", "pass": False, "findings": [{"severity": "error", "rule": "workflow.exception", "message": str(exc)}]})
6312
+ for case in DOCTOR_COMMAND_CASES:
6313
+ try:
6314
+ checks.append(doctor_check_command_case(case))
6315
+ except Exception as exc:
6316
+ checks.append({"name": f"command-{case.get('id', 'case')}", "pass": False, "findings": [{"severity": "error", "rule": "command.exception", "message": str(exc)}]})
6043
6317
  passed = all(item["pass"] for item in checks)
6044
6318
  summary = {"pass": passed, "version": COMPILER_VERSION, "checks": len(checks), "failed": len([item for item in checks if not item["pass"]])}
6045
6319
  if args.json: