@yuhan1124/draw-prompt 0.4.6 → 0.4.8

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
@@ -35,6 +35,7 @@ Prompt 的默认结构是三段:`User visual brief` 原始视觉需求、`Styl
35
35
 
36
36
  ```bash
37
37
  npx --yes --registry=https://registry.npmjs.org/ @yuhan1124/draw-prompt@latest status
38
+ npx --yes --registry=https://registry.npmjs.org/ @yuhan1124/draw-prompt@latest doctor
38
39
  npx --yes --registry=https://registry.npmjs.org/ @yuhan1124/draw-prompt@latest convert "茶饮新品海报,写冷泡系列"
39
40
  npx --yes --registry=https://registry.npmjs.org/ @yuhan1124/draw-prompt@latest install-skill --target codex
40
41
  ```
@@ -68,7 +69,8 @@ npx --yes --registry=https://registry.npmjs.org/ @yuhan1124/draw-prompt@latest i
68
69
 
69
70
  `install-skill` 默认复制 npm 包内的运行文件,避免软链到 npx 缓存导致后续路径失效。它只复制
70
71
  `SKILL.md`、CLI、references 和必要元数据;开发仓库里的 `tests/`、`tmp/`、`golden-cases.jsonl`、
71
- `visual-cases.jsonl` 不会进入 npm 包或安装目录。
72
+ `visual-cases.jsonl` 不会进入 npm 包或安装目录。安装后可直接跑 `doctor`,它会检查
73
+ 包文件、版本一致性、核心单图转化和一条真实长输入 `compose` 链路。
72
74
 
73
75
  开发者本地调试也可以软链 repo:
74
76
 
@@ -116,6 +118,7 @@ Harness,作为下一次转化的辅助信号。
116
118
 
117
119
  ```
118
120
  convert "自然语言画图需求" [--style-preset premium] [--strict-text] [--out p] # 默认 single-pass:需求 → Prompt / handoff
121
+ doctor # 安装后运行时自检:包文件、版本、核心场景转化
119
122
  compose "长输入/文档内容" --max-images 6 # 长输入 → 多图视觉计划
120
123
  variants "自然语言画图需求" --style-presets all # 同一输入 → 多风格 Prompt 组
121
124
  styles --json # 查看内置风格预设
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.6
11
+ version: 0.4.8
12
12
  openclaw:
13
13
  anyBins: ["uv", "python3"]
14
14
  ---
@@ -61,6 +61,7 @@ prompt」「给 Codex 出图的指令」「优化这条出图 prompt」等意图
61
61
 
62
62
  2. **优先自动转化,并选对入口**。能直接交付时,按场景运行对应命令:
63
63
  - 安装到 agent skill 目录:`prompt_cli.py install-skill --target codex|claude [--force]`
64
+ - 安装后自检:`prompt_cli.py doctor`
64
65
  - 单图:`prompt_cli.py convert "<自然语言画图需求>" [--style-preset premium] [--strict-text] [--out <path>] [--record-pending]`
65
66
  - 长输入整理成多张图:`prompt_cli.py compose "<长输入>" --max-images 6 [--style-preset corporate] [--strict-text]`
66
67
  - 同一输入多风格探索:`prompt_cli.py variants "<自然语言画图需求>" --style-presets all`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuhan1124/draw-prompt",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
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": {
@@ -136,7 +136,7 @@ def ensure_home() -> None:
136
136
 
137
137
 
138
138
  SCHEMA_VERSION = 1
139
- COMPILER_VERSION = "0.4.6"
139
+ COMPILER_VERSION = "0.4.8"
140
140
 
141
141
 
142
142
  PACKAGED_SKILL_FILES = [
@@ -154,6 +154,86 @@ PACKAGED_SKILL_FILES = [
154
154
  "references/harness.md",
155
155
  ]
156
156
 
157
+ DOCTOR_CASES = [
158
+ {
159
+ "id": "social-plain-label",
160
+ "request": "小红书封面,主题 30分钟搭好AI知识库,副标题 从资料整理到智能问答,不要真人,1:1",
161
+ "asset_type": "poster",
162
+ "style_preset": "creator-economy",
163
+ "expect_asset_type": "poster",
164
+ "expect_template_id": "poster_social_cover",
165
+ "expect_aspect": "1:1",
166
+ "expect_required_text": ["30分钟搭好AI知识库", "从资料整理到智能问答"],
167
+ "forbid_prompt": ["price/offer block"],
168
+ },
169
+ {
170
+ "id": "saas-dashboard",
171
+ "request": "SaaS 看板,需要包含通过率、失败数、平均评分、待修复,16:9",
172
+ "asset_type": "ui",
173
+ "style_preset": "clean-ui",
174
+ "expect_asset_type": "ui",
175
+ "expect_template_id": "ui_dashboard",
176
+ "expect_aspect": "16:9",
177
+ "expect_required_text": ["通过率", "失败数", "平均评分", "待修复"],
178
+ "forbid_prompt": ["chart panel", "data table"],
179
+ },
180
+ {
181
+ "id": "event-poster",
182
+ "request": "设计一张 AI 产品发布会海报,4:5,标题 Prompt Craft 2026,副标题是 Build visual ideas faster,时间 2026.08.18,地点 Shanghai。",
183
+ "asset_type": "poster",
184
+ "style_preset": "event-poster",
185
+ "expect_asset_type": "poster",
186
+ "expect_template_id": "poster_event",
187
+ "expect_aspect": "4:5",
188
+ "expect_required_text": ["Prompt Craft 2026", "Build visual ideas faster", "2026.08.18", "Shanghai"],
189
+ "forbid_prompt": ["price/offer block"],
190
+ },
191
+ {
192
+ "id": "architecture-diagram",
193
+ "request": "draw-prompt 产品架构图,16:9。需要展示用户输入、场景识别、风格预设、Prompt 编译器、意图保持检查、质量门、Image 2 出图、样本反馈学习。",
194
+ "asset_type": "diagram",
195
+ "style_preset": "systems-map",
196
+ "expect_asset_type": "diagram",
197
+ "expect_template_id": "diagram_system",
198
+ "expect_aspect": "16:9",
199
+ "expect_required_text": ["用户输入", "场景识别", "Prompt 编译器", "质量门", "Image 2 出图", "样本反馈学习"],
200
+ "forbid_prompt": ["academic system-architecture figure"],
201
+ },
202
+ {
203
+ "id": "architecture-with-app-web",
204
+ "request": "画一张 16:9 中文技术架构图:AI 客服工单自动化系统。结构从左到右:用户入口(App、Web、企业微信)进入消息网关;再到意图识别、知识库检索、工单创建、人工坐席协同;底部有监控与质检层,包含响应时长、解决率、满意度、风险拦截;右侧输出自动回复、工单流转、运营报表。要求企业级、清晰、不要像宣传海报,中文标签要尽量清楚。",
205
+ "style_preset": "technical-blueprint",
206
+ "expect_asset_type": "diagram",
207
+ "expect_template_id": "diagram_system",
208
+ "expect_aspect": "16:9",
209
+ "expect_required_text": ["用户入口", "App", "Web", "企业微信", "消息网关", "意图识别", "知识库检索", "工单创建", "人工坐席协同", "响应时长", "解决率", "满意度", "风险拦截", "自动回复", "工单流转", "运营报表"],
210
+ "forbid_prompt": ["UI mockup", "dashboard workspace"],
211
+ },
212
+ ]
213
+
214
+ DOCTOR_COMPOSE_CASES = [
215
+ {
216
+ "id": "long-input-product-plan",
217
+ "request": (
218
+ "请把下面这段产品方案整理成 4 张图用于路演:我们要做一个 AI 生图 Prompt 转化 skill。"
219
+ "目标用户是经常把长文档、产品需求、运营数据、架构说明变成图片的研发和运营同学。"
220
+ "当前痛点是直接把长输入丢给模型容易跑偏,常见问题包括风格不稳定、中文标签遗漏、架构图被画成 UI、产品图漏卖点、活动战报颜色跑偏。"
221
+ "方案包括:场景识别、风格预设、意图保持检查、质量门、真实 Image 2 验证、样本反馈学习。"
222
+ "上线标准是安装后 doctor 通过、常见场景开箱高质量、NPM 公网安装可用。"
223
+ ),
224
+ "max_images": 4,
225
+ "style_preset": "corporate",
226
+ "expect_count": 4,
227
+ "expect_no_asset_type": "illustration",
228
+ "expect_required_text_by_index": {
229
+ 0: ["目标用户"],
230
+ 1: ["当前痛点"],
231
+ 2: ["方案", "场景识别", "风格预设", "质量门"],
232
+ 3: ["上线标准", "NPM 公网安装可用"],
233
+ },
234
+ }
235
+ ]
236
+
157
237
 
158
238
  def package_root() -> Path:
159
239
  return Path(__file__).resolve().parent.parent
@@ -2378,9 +2458,9 @@ def route_asset_type(request: str, override: str | None = None) -> str:
2378
2458
  lower = request.lower()
2379
2459
  explicit_routes = [
2380
2460
  ("slide", ["powerpoint", "slide", "presentation", "ppt", "幻灯片", "演示文稿", "汇报单页"]),
2381
- ("ui", ["ui", "界面", "app", "dashboard", "仪表盘", "看板", "saas", "后台", "控制台", "网页", "mockup"]),
2382
2461
  ("diagram", ["架构图", "系统图", "流程架构", "architecture diagram", "system diagram"]),
2383
2462
  ("infographic", ["信息图", "infographic", "图解", "流程图", "时间线"]),
2463
+ ("ui", ["ui", "界面", "app", "dashboard", "仪表盘", "看板", "saas", "后台", "控制台", "网页", "mockup"]),
2384
2464
  ("poster", ["海报", "poster", "banner", "主视觉", "kv", "封面"]),
2385
2465
  ("logo", ["logo", "品牌标识", "字标", "visual identity"]),
2386
2466
  ]
@@ -2452,7 +2532,7 @@ def extract_required_texts(request: str, explicit_texts: list[str]) -> list[str]
2452
2532
  text = re.sub(r"(?:要)?(?:渲染)?(?:清晰|清楚|可读|明显)$", "", text)
2453
2533
  text = re.sub(r"(?:这些)?元素$", "", text)
2454
2534
  text = re.sub(r"(?:[一二三四五六七八九十\d]+个)?步骤$", "", text)
2455
- if re.search(r"箭头.*关系|关系.*箭头|展示输入输出关系", text):
2535
+ if re.search(r"箭头.*(?:关系|展示|输入|输出)|(?:关系|展示|输入|输出).*箭头|展示输入输出关系", text):
2456
2536
  return ""
2457
2537
  if re.fullmatch(r"[A-Za-z]{1,2}", text) and text.upper() not in {"AI", "UI"}:
2458
2538
  return ""
@@ -2558,7 +2638,7 @@ def merge_texts(primary: list[str], extra: list[str]) -> list[str]:
2558
2638
  text = re.sub(r"(?:这些)?元素$", "", text)
2559
2639
  text = re.sub(r"(?:[一二三四五六七八九十\d]+个)?步骤$", "", text)
2560
2640
  text = text.strip(" \t\n\r,,、。;;::.!?!?")
2561
- if re.search(r"箭头.*关系|关系.*箭头|展示输入输出关系", text):
2641
+ if re.search(r"箭头.*(?:关系|展示|输入|输出)|(?:关系|展示|输入|输出).*箭头|展示输入输出关系", text):
2562
2642
  continue
2563
2643
  if re.fullmatch(r"[A-Za-z]{1,2}", text) and text.upper() not in {"AI", "UI"}:
2564
2644
  continue
@@ -2572,29 +2652,39 @@ def merge_texts(primary: list[str], extra: list[str]) -> list[str]:
2572
2652
 
2573
2653
 
2574
2654
  def extract_structural_labels(request: str, asset_type: str) -> list[str]:
2575
- if asset_type not in {"diagram", "infographic", "slide", "ui", "poster"}:
2655
+ if asset_type not in {"diagram", "infographic", "slide", "ui", "poster", "product"}:
2576
2656
  return []
2577
2657
  candidates: list[tuple[int, str]] = []
2578
- list_intro = r"(?:需要)?(?:展示|呈现|列出|包含|包括|含有|分为|覆盖)"
2658
+ list_intro = r"(?:需要)?(?:展示|呈现|列出|包含|包括|含有|分为|覆盖|输出)"
2579
2659
  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)"
2580
2660
  patterns = [
2581
- rf"{list_intro}\s*(?:这些|以下|对应的)?(?:模块|部分|层|栏目|节点|入口|能力|场景|列表|指标卡|卡片|步骤|分支)?[::\s]*([^。;;\n]{{2,180}})",
2582
- rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支)\s*(?:包括|包含|有|为)[::\s]*([^。;;\n]{{2,180}})",
2583
- rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支)[^。;;\n::]{{0,16}}[::]([^。;;\n]{{2,180}})",
2661
+ rf"(?:结构(?:从左到右|从右到左|从上到下|从下到上)?|链路|流程)\s*[::]\s*([^。\n]{{2,240}})",
2662
+ rf"(?:数据|指标|核心数据|关键指标|卖点|亮点|功能点|特性|活动路径|路径)\s*(?:包括|包含|有|为)?[::\s]*([^。;;\n]{{2,180}})",
2663
+ rf"{list_intro}\s*(?:这些|以下|对应的)?(?:模块|部分|层|栏目|节点|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)?[::\s]*([^。;;\n]{{2,180}})",
2664
+ rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)\s*(?:包括|包含|有|为)[::\s]*([^。;;\n]{{2,180}})",
2665
+ rf"(?:模块|节点|栏目|部分|层|入口|能力|场景|列表|指标卡|卡片|步骤|分支|数据|指标|卖点|亮点|功能点|特性|路径)[^。;;\n::]{{0,16}}[::]([^。;;\n]{{2,180}})",
2584
2666
  ]
2585
2667
  for pattern in patterns:
2586
2668
  for match in re.finditer(pattern, request, flags=re.IGNORECASE):
2587
2669
  value = re.split(stop_words, match.group(1), maxsplit=1, flags=re.IGNORECASE)[0]
2670
+ value = re.sub(r"[()()]", "、", value)
2671
+ value = re.sub(r"(?:进入|再到|到|输出|->|→)", "、", value)
2588
2672
  for part_match in re.finditer(r"[^、,,;;/|]+", value):
2589
2673
  part = part_match.group(0).strip(" \t\n\r,,、::")
2590
2674
  part = re.sub(r"^(?:[^::]{0,12}(?:指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支))[::]", "", part)
2591
- part = re.sub(r"^(?:和|与|及|以及|and)\s*", "", part, flags=re.IGNORECASE).strip()
2675
+ part = re.sub(r"^(?:和|与|及|以及|and|再|则)\s*", "", part, flags=re.IGNORECASE).strip()
2676
+ part = re.sub(r"^(?:底部有|顶部有|左侧有|右侧有|上方有|下方有)\s*", "", part)
2677
+ part = re.sub(r"^(?:包含|包括|含有)\s*", "", part)
2592
2678
  part = re.sub(r"\s*(?:和|与|及|以及|and)$", "", part, flags=re.IGNORECASE).strip()
2593
2679
  part = re.sub(r"(?:这些)?元素$", "", part)
2594
2680
  part = re.sub(r"(?:[一二三四五六七八九十\d]+个)?步骤$", "", part)
2595
2681
  part = part.strip(" \t\n\r,,、::")
2596
2682
  if not part:
2597
2683
  continue
2684
+ if re.search(r"箭头.*(?:关系|展示|输入|输出)|(?:关系|展示|输入|输出).*箭头|展示输入输出关系", part):
2685
+ continue
2686
+ if part in {"顶部", "底部", "左侧", "右侧", "上方", "下方", "中间", "顶部导航", "底部导航", "左侧导航", "右侧导航", "导航栏", "顶部栏", "状态栏", "箭头关系", "箭头", "关系"}:
2687
+ continue
2598
2688
  if re.fullmatch(r"\d+(?:\s*:\s*\d+)?", part):
2599
2689
  continue
2600
2690
  if len(part) > 36:
@@ -2630,6 +2720,43 @@ def infer_style_anchors(request: str, override: str | None, profile: dict, prese
2630
2720
  return anchors[:4]
2631
2721
 
2632
2722
 
2723
+ def infer_palette_constraints(request: str) -> list[str]:
2724
+ color_terms = [
2725
+ "红", "橙", "黄", "绿", "青", "蓝", "紫", "粉", "黑", "白", "灰", "金", "银", "棕",
2726
+ "深色", "浅色", "渐变", "配色", "主色", "点缀", "背景",
2727
+ "red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "black", "white",
2728
+ "gray", "grey", "gold", "silver", "brown", "gradient", "palette", "color", "colour",
2729
+ ]
2730
+ constraints: list[str] = []
2731
+ seen: set[str] = set()
2732
+ for sentence in re.split(r"[。;;\n]", request):
2733
+ for fragment in re.split(r"[,,]", sentence):
2734
+ fragment = fragment.strip(" \t\r\n。.;;,,")
2735
+ if not fragment:
2736
+ continue
2737
+ lower = fragment.lower()
2738
+ if not any(term in lower for term in color_terms):
2739
+ continue
2740
+ if len(fragment) > 72:
2741
+ fragment = fragment[:72].rstrip()
2742
+ if "红" in fragment and any(marker in fragment for marker in ["不要太俗", "别太俗", "不能太俗", "不俗", "高级"]):
2743
+ normalized = "use a restrained, mature red campaign accent palette; keep red visibly present without garish saturated red"
2744
+ if normalized not in seen:
2745
+ seen.add(normalized)
2746
+ constraints.append(normalized)
2747
+ if len(constraints) >= 3:
2748
+ return constraints
2749
+ continue
2750
+ key = re.sub(r"\s+", "", fragment)
2751
+ if key in seen:
2752
+ continue
2753
+ seen.add(key)
2754
+ constraints.append(f"preserve requested color direction: {fragment}")
2755
+ if len(constraints) >= 3:
2756
+ return constraints
2757
+ return constraints
2758
+
2759
+
2633
2760
  def infer_negative(asset_type: str, texts: list[str], profile: dict, request: str = "", template_id: str = "") -> list[str]:
2634
2761
  negative = ["avoid vague generic AI gloss", "avoid clutter", "avoid adding content not requested by the user"]
2635
2762
  if texts:
@@ -3023,7 +3150,7 @@ def build_spec(args: argparse.Namespace) -> dict:
3023
3150
  "style_anchors": infer_style_anchors(safe_request, getattr(args, "style", None), profile, style_preset),
3024
3151
  "materials": split_csv(getattr(args, "materials", None)) or ["tactile, specific visible materials chosen for the subject"],
3025
3152
  "lighting": getattr(args, "lighting", None) or "controlled, readable light with clear subject hierarchy",
3026
- "palette": split_csv(getattr(args, "palette", None)) or ["restrained palette matched to the asset type"],
3153
+ "palette": split_csv(getattr(args, "palette", None)) or infer_palette_constraints(visual_request) or ["restrained palette matched to the asset type"],
3027
3154
  "negative": negative,
3028
3155
  "must_include": infer_must_include(asset_type, template_id, texts, bool(getattr(args, "strict_text", False))),
3029
3156
  "must_avoid": negative,
@@ -3157,7 +3284,7 @@ def render_prompt(spec: dict) -> str:
3157
3284
  if asset_type == "infographic":
3158
3285
  return "\n".join(
3159
3286
  [
3160
- f"Create a {aspect} educational infographic from the user visual brief below.",
3287
+ f"Create a {aspect} information graphic from the user visual brief below.",
3161
3288
  brief_block,
3162
3289
  style_quality_block(spec, label="infographic", extra=["Build a clear information hierarchy from the user's brief and preserve any named regions, section counts, and reading order."]),
3163
3290
  f"Composition support: {spec.get('template_label', 'infographic')}; layout guidance: {layout}.",
@@ -4783,6 +4910,30 @@ def split_document_sections(text: str, max_images: int) -> list[str]:
4783
4910
  if buf:
4784
4911
  chunks.append(" ".join(buf).strip())
4785
4912
 
4913
+ if len(chunks) <= 1:
4914
+ semantic_patterns = [
4915
+ r"(目标用户(?:是|:|:)[^。;;\n]+)",
4916
+ r"(用户场景(?:是|:|:)[^。;;\n]+)",
4917
+ r"(当前痛点(?:是|:|:)[^。;;\n]+)",
4918
+ r"(主要问题(?:是|:|:)[^。;;\n]+)",
4919
+ r"(方案(?:包括|是|:|:)[^。;;\n]+)",
4920
+ r"(核心能力(?:包括|是|:|:)[^。;;\n]+)",
4921
+ r"(上线标准(?:是|:|:)[^。;;\n]+)",
4922
+ r"(验收标准(?:是|:|:)[^。;;\n]+)",
4923
+ ]
4924
+ semantic_matches: list[tuple[int, str]] = []
4925
+ seen_spans: set[tuple[int, int]] = set()
4926
+ for pattern in semantic_patterns:
4927
+ for match in re.finditer(pattern, text):
4928
+ span = match.span(1)
4929
+ if span in seen_spans:
4930
+ continue
4931
+ seen_spans.add(span)
4932
+ semantic_matches.append((span[0], match.group(1).strip()))
4933
+ semantic_chunks = [chunk for _, chunk in sorted(semantic_matches, key=lambda item: item[0])]
4934
+ if len(semantic_chunks) >= 2:
4935
+ chunks = semantic_chunks
4936
+
4786
4937
  if len(chunks) <= 1:
4787
4938
  sentences = [s.strip() for s in re.split(r"(?<=[。!?!?;;])\s*", text) if s.strip()]
4788
4939
  chunks = []
@@ -4804,6 +4955,8 @@ def split_document_sections(text: str, max_images: int) -> list[str]:
4804
4955
 
4805
4956
  def choose_compose_asset(chunk: str, index: int) -> str:
4806
4957
  lower = chunk.lower()
4958
+ if re.search(r"^(?:目标用户|用户场景|当前痛点|主要问题|上线标准|验收标准)", chunk):
4959
+ return "infographic"
4807
4960
  if any(k in lower for k in ["架构", "系统", "模块", "链路", "rag", "llm", "retriever", "pipeline"]):
4808
4961
  return "diagram"
4809
4962
  if any(k in lower for k in ["数据", "指标", "报表", "趋势", "占比", "转化率", "漏斗", "图表"]):
@@ -4818,7 +4971,9 @@ def choose_compose_asset(chunk: str, index: int) -> str:
4818
4971
  return "character"
4819
4972
  if index == 0 and any(k in lower for k in ["标题", "主题", "发布", "活动", "封面", "总结"]):
4820
4973
  return "poster"
4821
- return "illustration"
4974
+ if any(k in lower for k in ["插画", "场景插图", "故事", "氛围图", "场景图", "scene illustration"]):
4975
+ return "illustration"
4976
+ return "infographic"
4822
4977
 
4823
4978
 
4824
4979
  def infer_compose_style(text: str) -> str:
@@ -4842,6 +4997,13 @@ def extract_visual_labels(chunk: str, asset_type: str, limit: int = 5) -> list[s
4842
4997
 
4843
4998
  for match in re.findall(r'"([^"\n]{1,28})"|“([^”\n]{1,28})”|「([^」\n]{1,28})」', chunk):
4844
4999
  add(next((m for m in match if m), ""))
5000
+ for match in re.finditer(r"(目标用户|用户场景|当前痛点|主要问题|方案|核心能力|上线标准|验收标准)\s*(?:是|包括|包含|:|:)\s*([^。;;\n]{2,140})", chunk):
5001
+ add(match.group(1))
5002
+ value = match.group(2)
5003
+ for part in re.split(r"[、,,/|]", value):
5004
+ part = re.sub(r"^(?:和|与|及|以及|and)\s*", "", part.strip(), flags=re.IGNORECASE)
5005
+ if 1 < len(part) <= 18:
5006
+ add(part)
4845
5007
  for match in re.findall(r"\b[A-Z][A-Za-z0-9_-]{1,20}\b", chunk):
4846
5008
  add(match)
4847
5009
  for match in re.findall(r"(?:标题|主题|模块|步骤|节点|页面)[::\s]*([^,。;;\n]{2,24})", chunk):
@@ -4860,7 +5022,7 @@ def compose_purpose(asset_type: str, index: int) -> str:
4860
5022
  "ui": "界面概念图",
4861
5023
  "product": "产品视觉图",
4862
5024
  "character": "角色设定图",
4863
- "illustration": "场景说明图",
5025
+ "illustration": "场景插画",
4864
5026
  }
4865
5027
  return f"{index}. {purpose_map.get(asset_type, '配图')}"
4866
5028
 
@@ -5607,6 +5769,180 @@ def npm_registry_status() -> str:
5607
5769
  return f"{registry}(建议 npx 加 --registry=https://registry.npmjs.org/)"
5608
5770
 
5609
5771
 
5772
+ def read_skill_version(path: Path) -> str:
5773
+ if not path.exists():
5774
+ return ""
5775
+ for line in path.read_text(encoding="utf-8").splitlines():
5776
+ match = re.match(r"\s*version:\s*([^\s]+)", line)
5777
+ if match:
5778
+ return match.group(1)
5779
+ return ""
5780
+
5781
+
5782
+ def doctor_add(findings: list[dict], severity: str, rule: str, message: str) -> None:
5783
+ findings.append({"severity": severity, "rule": rule, "message": message})
5784
+
5785
+
5786
+ def doctor_check_package(root: Path) -> dict:
5787
+ findings: list[dict] = []
5788
+ package_path = root / "package.json"
5789
+ package_version = ""
5790
+ if package_path.exists():
5791
+ try:
5792
+ package_version = str(json.loads(package_path.read_text(encoding="utf-8")).get("version") or "")
5793
+ except Exception as exc:
5794
+ doctor_add(findings, "error", "package.invalid_json", f"package.json 读取失败:{exc}")
5795
+ else:
5796
+ doctor_add(findings, "error", "package.missing", "缺少 package.json")
5797
+ skill_version = read_skill_version(root / "SKILL.md")
5798
+ if not skill_version:
5799
+ doctor_add(findings, "error", "skill.version_missing", "SKILL.md 缺少 metadata.version")
5800
+ if package_version and skill_version and package_version != skill_version:
5801
+ doctor_add(findings, "error", "version.package_skill_mismatch", f"package.json={package_version} SKILL.md={skill_version}")
5802
+ if package_version and package_version != COMPILER_VERSION:
5803
+ doctor_add(findings, "error", "version.compiler_mismatch", f"package.json={package_version} compiler={COMPILER_VERSION}")
5804
+ missing = [rel for rel in PACKAGED_SKILL_FILES if not (root / rel).exists()]
5805
+ for rel in missing:
5806
+ doctor_add(findings, "error", "package.file_missing", f"缺少运行文件:{rel}")
5807
+ style_count = len(available_style_presets(include_auto=False))
5808
+ if style_count < 400:
5809
+ doctor_add(findings, "error", "styles.too_few", f"内置风格数量过少:{style_count}")
5810
+ return {
5811
+ "name": "package",
5812
+ "pass": not has_lint_error(findings),
5813
+ "version": package_version or COMPILER_VERSION,
5814
+ "style_count": style_count,
5815
+ "missing_files": missing,
5816
+ "findings": findings,
5817
+ }
5818
+
5819
+
5820
+ def doctor_check_case(case: dict) -> dict:
5821
+ findings: list[dict] = []
5822
+ result = convert_for_benchmark(case)
5823
+ spec = result["spec"]
5824
+ prompt = result["prompt"]
5825
+ for item in result["lint"]:
5826
+ findings.append({"severity": item["severity"], "rule": f"lint.{item['rule']}", "message": item["message"]})
5827
+ for item in result["intent_check"]:
5828
+ findings.append({"severity": item["severity"], "rule": f"intent.{item['rule']}", "message": item["message"]})
5829
+ if case.get("expect_asset_type") and spec.get("asset_type") != case["expect_asset_type"]:
5830
+ doctor_add(findings, "error", "case.asset_type", f"期望 asset_type={case['expect_asset_type']},实际={spec.get('asset_type')}")
5831
+ if case.get("expect_template_id") and spec.get("template_id") != case["expect_template_id"]:
5832
+ doctor_add(findings, "error", "case.template_id", f"期望 template_id={case['expect_template_id']},实际={spec.get('template_id')}")
5833
+ if case.get("expect_aspect") and spec.get("aspect") != case["expect_aspect"]:
5834
+ doctor_add(findings, "error", "case.aspect", f"期望 aspect={case['expect_aspect']},实际={spec.get('aspect')}")
5835
+ labels = set(spec.get("required_text") or [])
5836
+ for text in case.get("expect_required_text") or []:
5837
+ if text not in labels:
5838
+ doctor_add(findings, "error", "case.required_text_missing", f"缺少必显文字:{text}")
5839
+ elif f'"{text}"' not in prompt:
5840
+ doctor_add(findings, "error", "case.required_text_not_quoted", f"Prompt 未逐字引用:{text}")
5841
+ prompt_lower = prompt.lower()
5842
+ for phrase in case.get("forbid_prompt") or []:
5843
+ if phrase.lower() in prompt_lower:
5844
+ doctor_add(findings, "error", "case.forbidden_prompt", f"Prompt 出现不应出现的片段:{phrase}")
5845
+ return {
5846
+ "name": case["id"],
5847
+ "pass": not has_lint_error(findings),
5848
+ "asset_type": spec.get("asset_type"),
5849
+ "template_id": spec.get("template_id"),
5850
+ "aspect": spec.get("aspect"),
5851
+ "prompt_digest": result["prompt_digest"],
5852
+ "required_text": spec.get("required_text") or [],
5853
+ "findings": findings,
5854
+ }
5855
+
5856
+
5857
+ def doctor_check_compose_case(case: dict) -> dict:
5858
+ findings: list[dict] = []
5859
+ text = str(case.get("request") or "")
5860
+ max_images = int(case.get("max_images") or 4)
5861
+ chunks = split_document_sections(text, max_images)
5862
+ shared_style = infer_compose_style(text)
5863
+ visual_plan: list[dict] = []
5864
+ for idx, chunk in enumerate(chunks, start=1):
5865
+ asset_type = choose_compose_asset(chunk, idx - 1)
5866
+ labels = extract_visual_labels(chunk, asset_type)
5867
+ purpose = compose_purpose(asset_type, idx)
5868
+ compiled = compile_visual_case(
5869
+ {
5870
+ "id": f"compose-{idx:02d}",
5871
+ "request": f"{purpose}。根据这段内容生成对应画面:{chunk}",
5872
+ "asset_type": asset_type,
5873
+ "style": shared_style,
5874
+ "style_preset": case.get("style_preset"),
5875
+ "text": labels if asset_type in {"diagram", "infographic", "ui"} else [],
5876
+ "target": "raw",
5877
+ "tags": "compose,long-input",
5878
+ },
5879
+ target="raw",
5880
+ include_handoff=False,
5881
+ )
5882
+ visual_plan.append({"chunk": chunk, "compiled": compiled})
5883
+ for item in compiled["lint"]:
5884
+ findings.append({"severity": item["severity"], "rule": f"compose.{idx}.lint.{item['rule']}", "message": item["message"]})
5885
+ for item in compiled["intent_check"]:
5886
+ findings.append({"severity": item["severity"], "rule": f"compose.{idx}.intent.{item['rule']}", "message": item["message"]})
5887
+
5888
+ if case.get("expect_count") and len(visual_plan) != int(case["expect_count"]):
5889
+ doctor_add(findings, "error", "compose.count", f"期望 {case['expect_count']} 张图,实际 {len(visual_plan)}")
5890
+ forbidden_asset = case.get("expect_no_asset_type")
5891
+ if forbidden_asset:
5892
+ for idx, item in enumerate(visual_plan, start=1):
5893
+ asset_type = item["compiled"]["spec"].get("asset_type")
5894
+ if asset_type == forbidden_asset:
5895
+ doctor_add(findings, "error", "compose.asset_type", f"第 {idx} 张图不应路由为 {forbidden_asset}")
5896
+ for raw_index, expected_texts in (case.get("expect_required_text_by_index") or {}).items():
5897
+ index = int(raw_index)
5898
+ if index >= len(visual_plan):
5899
+ doctor_add(findings, "error", "compose.index_missing", f"缺少第 {index + 1} 张图")
5900
+ continue
5901
+ labels = set(visual_plan[index]["compiled"]["spec"].get("required_text") or [])
5902
+ prompt = visual_plan[index]["compiled"]["prompt"]
5903
+ for text_item in expected_texts:
5904
+ if text_item not in labels:
5905
+ doctor_add(findings, "error", "compose.required_text_missing", f"第 {index + 1} 张图缺少必显文字:{text_item}")
5906
+ elif f'"{text_item}"' not in prompt:
5907
+ doctor_add(findings, "error", "compose.required_text_not_quoted", f"第 {index + 1} 张图 Prompt 未逐字引用:{text_item}")
5908
+ return {
5909
+ "name": f"compose-{case['id']}",
5910
+ "pass": not has_lint_error(findings),
5911
+ "count": len(visual_plan),
5912
+ "asset_types": [item["compiled"]["spec"].get("asset_type") for item in visual_plan],
5913
+ "prompt_digests": [item["compiled"]["prompt_digest"] for item in visual_plan],
5914
+ "required_text": [item["compiled"]["spec"].get("required_text") or [] for item in visual_plan],
5915
+ "findings": findings,
5916
+ }
5917
+
5918
+
5919
+ def cmd_doctor(args: argparse.Namespace) -> int:
5920
+ root = package_root()
5921
+ checks = [doctor_check_package(root)]
5922
+ for case in DOCTOR_CASES:
5923
+ try:
5924
+ checks.append(doctor_check_case(case))
5925
+ except Exception as exc:
5926
+ checks.append({"name": case.get("id", "case"), "pass": False, "findings": [{"severity": "error", "rule": "case.exception", "message": str(exc)}]})
5927
+ for case in DOCTOR_COMPOSE_CASES:
5928
+ try:
5929
+ checks.append(doctor_check_compose_case(case))
5930
+ except Exception as exc:
5931
+ checks.append({"name": f"compose-{case.get('id', 'case')}", "pass": False, "findings": [{"severity": "error", "rule": "compose.exception", "message": str(exc)}]})
5932
+ passed = all(item["pass"] for item in checks)
5933
+ summary = {"pass": passed, "version": COMPILER_VERSION, "checks": len(checks), "failed": len([item for item in checks if not item["pass"]])}
5934
+ if args.json:
5935
+ print(json.dumps({"summary": summary, "checks": checks}, ensure_ascii=False, indent=2))
5936
+ else:
5937
+ print(f"doctor: version={COMPILER_VERSION} checks={summary['checks']} pass={passed}")
5938
+ for item in checks:
5939
+ status = "PASS" if item["pass"] else "FAIL"
5940
+ print(f"- {item['name']}: {status}")
5941
+ for finding in item.get("findings", []):
5942
+ print(f" [{finding['severity']}] {finding['rule']}: {finding['message']}")
5943
+ return 0 if passed else 1
5944
+
5945
+
5610
5946
  def cmd_status(args: argparse.Namespace) -> int:
5611
5947
  print("draw-prompt 环境检查")
5612
5948
  print(f" 数据目录 : {data_home()} ({'存在' if data_home().exists() else '未创建'})")
@@ -5628,7 +5964,7 @@ def cmd_status(args: argparse.Namespace) -> int:
5628
5964
  print(f" codex CLI : {which('codex') or '未找到'}")
5629
5965
  plugin = Path.home() / ".claude" / "plugins" / "cache" / "codex-image-in-cc"
5630
5966
  print(f" codex-image: {'已安装' if plugin.exists() else '未安装(可 /codex-image:generate 出图)'}")
5631
- print(" 核心转化命令: convert / compose / variants / series / edit / brand / character / data-viz / rewrite / adapt")
5967
+ print(" 核心转化命令: doctor / convert / compose / variants / series / edit / brand / character / data-viz / rewrite / adapt")
5632
5968
  print(" 稳定性命令 : overlay / visual-check / edit-check / visual-regress / lint / intent-check / benchmark / revise / styles")
5633
5969
  return 0
5634
5970
 
@@ -5674,6 +6010,10 @@ def build_parser() -> argparse.ArgumentParser:
5674
6010
  pis.add_argument("--json", action="store_true")
5675
6011
  pis.set_defaults(func=cmd_install_skill)
5676
6012
 
6013
+ pdoc = sub.add_parser("doctor", help="安装后运行时自检:包文件、版本和核心场景转化")
6014
+ pdoc.add_argument("--json", action="store_true")
6015
+ pdoc.set_defaults(func=cmd_doctor)
6016
+
5677
6017
  pc = sub.add_parser("convert", help="自然语言画图需求 -> 高质量生图 Prompt / handoff")
5678
6018
  pc.add_argument("request_text", nargs="+", help="自然语言画图需求")
5679
6019
  pc.add_argument("--asset-type", choices=sorted(ASSET_ROUTES.keys()), help="覆盖自动识别的资产类型")