@yuhan1124/draw-prompt 0.4.9 → 0.4.11
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 +3 -2
- package/SKILL.md +2 -2
- package/package.json +1 -1
- package/scripts/prompt_cli.py +357 -9
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
|
|
|
@@ -134,7 +135,7 @@ overlay --image out.png --spec spec.json --out final.png # 按 overlay spec
|
|
|
134
135
|
visual-check --image final.png --spec spec.json # 单图质量门:尺寸/画幅/亮度/对比度
|
|
135
136
|
edit-check --reference ref.png --output edited.png # 改图质量门:主体保留 + 有效变化
|
|
136
137
|
intent-check --request "原始需求" --prompt "生成prompt" # 检查是否新增/偏离用户未要求内容
|
|
137
|
-
visual-regress references/visual-cases.jsonl # 开发仓库本地:多场景 prompt
|
|
138
|
+
visual-regress references/visual-cases.jsonl # 开发仓库本地:多场景 prompt/成品图回归,支持 expect_/forbid_ 断言
|
|
138
139
|
lint --prompt "…" [--asset-type poster] [--text 必显文字] # 生图转化硬约束检查
|
|
139
140
|
benchmark references/golden-cases.jsonl --runs 3 # 开发仓库本地:golden cases 稳定性基准
|
|
140
141
|
revise --sample-id last --reason text_error # 按失败分类修订 Prompt
|
|
@@ -173,7 +174,7 @@ status # 数据 + 下游通道健康
|
|
|
173
174
|
4. 只有当用户明确要求“文字必须绝对准确/可后处理”或出图反馈属于 `text_error` 时,才切到 `--strict-text` + `overlay` 两段式兜底。
|
|
174
175
|
5. 用 `visual-check` 验证成品图尺寸、画幅、亮度、对比度和基础细节。
|
|
175
176
|
6. 参考图改图用 `edit-check` 验证“主体保留 + 背景/目标确实变化”。
|
|
176
|
-
7. 模板或策略变动后,在开发仓库本地跑 `visual-regress references/visual-cases.jsonl
|
|
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
178
|
|
|
178
179
|
这条链路的默认目标不是替 gpt-image-2 重做排版引擎,而是减少跑偏、遗漏和廉价风格;
|
|
179
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
|
+
version: 0.4.11
|
|
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 #
|
|
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
package/scripts/prompt_cli.py
CHANGED
|
@@ -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.
|
|
141
|
+
COMPILER_VERSION = "0.4.11"
|
|
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
|
|
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
|
|
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,12 +2784,16 @@ 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]
|
|
2699
2794
|
value = re.sub(r"[()()]", "、", value)
|
|
2700
2795
|
value = re.sub(r"(?:进入|再到|到|输出|->|→)", "、", value)
|
|
2701
|
-
for part_match in re.finditer(r"[
|
|
2796
|
+
for part_match in re.finditer(r"[^、,,;;|]+", value):
|
|
2702
2797
|
part = part_match.group(0).strip(" \t\n\r,,、::")
|
|
2703
2798
|
part = re.sub(r"^(?:[^::]{0,12}(?:指标卡|卡片|列表|标题|模块|节点|步骤|栏目|分支))[::]", "", part)
|
|
2704
2799
|
part = re.sub(r"^(?:和|与|及|以及|and|再|则)\s*", "", part, flags=re.IGNORECASE).strip()
|
|
@@ -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"}:
|
|
@@ -4781,6 +4882,7 @@ def cmd_visual_regress(args: argparse.Namespace) -> int:
|
|
|
4781
4882
|
results = []
|
|
4782
4883
|
lint_errors = 0
|
|
4783
4884
|
intent_errors = 0
|
|
4885
|
+
expectation_errors = 0
|
|
4784
4886
|
visual_errors = 0
|
|
4785
4887
|
missing_images = 0
|
|
4786
4888
|
for idx, case in enumerate(cases, start=1):
|
|
@@ -4806,8 +4908,10 @@ def cmd_visual_regress(args: argparse.Namespace) -> int:
|
|
|
4806
4908
|
compiled = visual_case_compile(case)
|
|
4807
4909
|
lint = compiled["lint"]
|
|
4808
4910
|
intent = compiled.get("intent_check", [])
|
|
4911
|
+
expectation_findings = visual_case_expectation_findings(case, compiled)
|
|
4809
4912
|
lint_errors += sum(1 for item in lint if item.get("severity") == "error")
|
|
4810
4913
|
intent_errors += sum(1 for item in intent if item.get("severity") == "error")
|
|
4914
|
+
expectation_errors += sum(1 for item in expectation_findings if item.get("severity") == "error")
|
|
4811
4915
|
item = {
|
|
4812
4916
|
"id": case_id,
|
|
4813
4917
|
"scenario": case.get("scenario") or case.get("tool") or "convert",
|
|
@@ -4816,6 +4920,7 @@ def cmd_visual_regress(args: argparse.Namespace) -> int:
|
|
|
4816
4920
|
"aspect": compiled["spec"]["aspect"],
|
|
4817
4921
|
"lint": lint,
|
|
4818
4922
|
"intent_check": intent,
|
|
4923
|
+
"expectation_findings": expectation_findings,
|
|
4819
4924
|
"status": "compiled",
|
|
4820
4925
|
}
|
|
4821
4926
|
image = case.get("image") or case.get("output")
|
|
@@ -4850,15 +4955,20 @@ def cmd_visual_regress(args: argparse.Namespace) -> int:
|
|
|
4850
4955
|
"cases": len(cases),
|
|
4851
4956
|
"lint_error_count": lint_errors,
|
|
4852
4957
|
"intent_error_count": intent_errors,
|
|
4958
|
+
"expectation_error_count": expectation_errors,
|
|
4853
4959
|
"visual_error_count": visual_errors,
|
|
4854
4960
|
"missing_image_count": missing_images,
|
|
4855
|
-
"pass": lint_errors == 0 and intent_errors == 0 and visual_errors == 0 and missing_images == 0,
|
|
4961
|
+
"pass": lint_errors == 0 and intent_errors == 0 and expectation_errors == 0 and visual_errors == 0 and missing_images == 0,
|
|
4856
4962
|
}
|
|
4857
4963
|
output = {"summary": summary, "results": results}
|
|
4858
4964
|
if args.json:
|
|
4859
4965
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
4860
4966
|
else:
|
|
4861
|
-
print(
|
|
4967
|
+
print(
|
|
4968
|
+
f"visual-regress: cases={summary['cases']} pass={summary['pass']} "
|
|
4969
|
+
f"lint_errors={lint_errors} intent_errors={intent_errors} expectation_errors={expectation_errors} "
|
|
4970
|
+
f"visual_errors={visual_errors} missing_images={missing_images}"
|
|
4971
|
+
)
|
|
4862
4972
|
for item in results:
|
|
4863
4973
|
print(f"- {item['id']}: {item['status']} asset={item.get('asset_type', '?')} digest={item.get('prompt_digest', '?')}")
|
|
4864
4974
|
return 0 if summary["pass"] else 1
|
|
@@ -4924,6 +5034,71 @@ def compile_visual_case(
|
|
|
4924
5034
|
}
|
|
4925
5035
|
|
|
4926
5036
|
|
|
5037
|
+
def case_list(value: object) -> list[str]:
|
|
5038
|
+
if value is None:
|
|
5039
|
+
return []
|
|
5040
|
+
if isinstance(value, list):
|
|
5041
|
+
return [str(item) for item in value if str(item).strip()]
|
|
5042
|
+
return [str(value)] if str(value).strip() else []
|
|
5043
|
+
|
|
5044
|
+
|
|
5045
|
+
def visual_case_expectation_findings(case: dict, compiled: dict) -> list[dict]:
|
|
5046
|
+
spec = compiled.get("spec") or {}
|
|
5047
|
+
prompt = str(compiled.get("prompt") or "")
|
|
5048
|
+
findings: list[dict] = []
|
|
5049
|
+
|
|
5050
|
+
expected_asset = case.get("expect_asset_type")
|
|
5051
|
+
if expected_asset and spec.get("asset_type") != expected_asset:
|
|
5052
|
+
findings.append(
|
|
5053
|
+
{
|
|
5054
|
+
"severity": "error",
|
|
5055
|
+
"rule": "case.asset_type_mismatch",
|
|
5056
|
+
"message": f"期望 asset_type={expected_asset},实际={spec.get('asset_type')}",
|
|
5057
|
+
}
|
|
5058
|
+
)
|
|
5059
|
+
|
|
5060
|
+
expected_aspect = case.get("expect_aspect")
|
|
5061
|
+
if expected_aspect and spec.get("aspect") != expected_aspect:
|
|
5062
|
+
findings.append(
|
|
5063
|
+
{
|
|
5064
|
+
"severity": "error",
|
|
5065
|
+
"rule": "case.aspect_mismatch",
|
|
5066
|
+
"message": f"期望 aspect={expected_aspect},实际={spec.get('aspect')}",
|
|
5067
|
+
}
|
|
5068
|
+
)
|
|
5069
|
+
|
|
5070
|
+
expected_template = case.get("expect_template_id")
|
|
5071
|
+
if expected_template and spec.get("template_id") != expected_template:
|
|
5072
|
+
findings.append(
|
|
5073
|
+
{
|
|
5074
|
+
"severity": "error",
|
|
5075
|
+
"rule": "case.template_mismatch",
|
|
5076
|
+
"message": f"期望 template_id={expected_template},实际={spec.get('template_id')}",
|
|
5077
|
+
}
|
|
5078
|
+
)
|
|
5079
|
+
|
|
5080
|
+
labels = set(spec.get("required_text") or [])
|
|
5081
|
+
for text in case_list(case.get("expect_required_text")):
|
|
5082
|
+
if text not in labels:
|
|
5083
|
+
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"):
|
|
5085
|
+
findings.append({"severity": "error", "rule": "case.required_text_not_quoted", "message": f"Prompt 未逐字引用:{text}"})
|
|
5086
|
+
|
|
5087
|
+
for text in case_list(case.get("forbid_required_text")):
|
|
5088
|
+
if text in labels:
|
|
5089
|
+
findings.append({"severity": "error", "rule": "case.required_text_forbidden", "message": f"不应作为必显文字:{text}"})
|
|
5090
|
+
|
|
5091
|
+
for text in case_list(case.get("expect_prompt_contains")):
|
|
5092
|
+
if text not in prompt:
|
|
5093
|
+
findings.append({"severity": "error", "rule": "case.prompt_missing", "message": f"Prompt 缺少片段:{text}"})
|
|
5094
|
+
|
|
5095
|
+
for text in case_list(case.get("forbid_prompt_contains")):
|
|
5096
|
+
if text in prompt:
|
|
5097
|
+
findings.append({"severity": "error", "rule": "case.prompt_forbidden", "message": f"Prompt 不应包含片段:{text}"})
|
|
5098
|
+
|
|
5099
|
+
return findings
|
|
5100
|
+
|
|
5101
|
+
|
|
4927
5102
|
def has_lint_error(items: list[dict]) -> bool:
|
|
4928
5103
|
return any(item.get("severity") == "error" for item in items)
|
|
4929
5104
|
|
|
@@ -6022,6 +6197,174 @@ def doctor_check_workflow_case(case: dict) -> dict:
|
|
|
6022
6197
|
}
|
|
6023
6198
|
|
|
6024
6199
|
|
|
6200
|
+
def doctor_run_json_command(func, args: argparse.Namespace) -> tuple[int, dict, str]:
|
|
6201
|
+
stdout = io.StringIO()
|
|
6202
|
+
with contextlib.redirect_stdout(stdout):
|
|
6203
|
+
code = func(args)
|
|
6204
|
+
output = stdout.getvalue()
|
|
6205
|
+
data = json.loads(output)
|
|
6206
|
+
return code, data, output
|
|
6207
|
+
|
|
6208
|
+
|
|
6209
|
+
def doctor_collect_quality(findings: list[dict], scope: str, payload: dict) -> None:
|
|
6210
|
+
for item in payload.get("lint") or []:
|
|
6211
|
+
findings.append({"severity": item["severity"], "rule": f"{scope}.lint.{item['rule']}", "message": item["message"]})
|
|
6212
|
+
for item in payload.get("intent_check") or []:
|
|
6213
|
+
findings.append({"severity": item["severity"], "rule": f"{scope}.intent.{item['rule']}", "message": item["message"]})
|
|
6214
|
+
|
|
6215
|
+
|
|
6216
|
+
def doctor_check_command_case(case: dict) -> dict:
|
|
6217
|
+
findings: list[dict] = []
|
|
6218
|
+
kind = case.get("kind")
|
|
6219
|
+
data: dict = {}
|
|
6220
|
+
prompt_blobs: list[str] = []
|
|
6221
|
+
cleanup_paths: list[Path] = []
|
|
6222
|
+
try:
|
|
6223
|
+
if kind == "edit":
|
|
6224
|
+
code, data, _ = doctor_run_json_command(
|
|
6225
|
+
cmd_edit,
|
|
6226
|
+
argparse.Namespace(
|
|
6227
|
+
goal="把杯子背景换成夏季冷泡茶吧台",
|
|
6228
|
+
reference=["product:/tmp/cup.png"],
|
|
6229
|
+
preserve=["cup silhouette"],
|
|
6230
|
+
change=["background only"],
|
|
6231
|
+
text=[],
|
|
6232
|
+
aspect="3:4",
|
|
6233
|
+
asset_type="product",
|
|
6234
|
+
quality="high",
|
|
6235
|
+
style=None,
|
|
6236
|
+
target="raw",
|
|
6237
|
+
out=None,
|
|
6238
|
+
json=True,
|
|
6239
|
+
),
|
|
6240
|
+
)
|
|
6241
|
+
if code != 0:
|
|
6242
|
+
doctor_add(findings, "error", "command.exit", f"edit 返回非零状态:{code}")
|
|
6243
|
+
doctor_collect_quality(findings, "edit", data)
|
|
6244
|
+
prompt_blobs.append(str(data.get("prompt") or ""))
|
|
6245
|
+
elif kind == "brand":
|
|
6246
|
+
code, data, _ = doctor_run_json_command(
|
|
6247
|
+
cmd_brand,
|
|
6248
|
+
argparse.Namespace(
|
|
6249
|
+
name="山川茶事",
|
|
6250
|
+
industry="茶饮",
|
|
6251
|
+
palette="moss green,ivory",
|
|
6252
|
+
avoid=None,
|
|
6253
|
+
values=None,
|
|
6254
|
+
style=None,
|
|
6255
|
+
request="新品冷泡茶品牌主视觉",
|
|
6256
|
+
asset_type="poster",
|
|
6257
|
+
style_preset=None,
|
|
6258
|
+
text=["山川茶事"],
|
|
6259
|
+
strict_text=False,
|
|
6260
|
+
target="raw",
|
|
6261
|
+
json=True,
|
|
6262
|
+
),
|
|
6263
|
+
)
|
|
6264
|
+
if code != 0:
|
|
6265
|
+
doctor_add(findings, "error", "command.exit", f"brand 返回非零状态:{code}")
|
|
6266
|
+
compiled = data.get("compiled") or {}
|
|
6267
|
+
doctor_collect_quality(findings, "brand.compiled", compiled)
|
|
6268
|
+
prompt_blobs.extend([str(data.get("brand_prompt_block") or ""), str(compiled.get("prompt") or "")])
|
|
6269
|
+
labels = set((compiled.get("spec") or {}).get("required_text") or [])
|
|
6270
|
+
for text_item in case.get("expect_required_text") or []:
|
|
6271
|
+
if text_item not in labels:
|
|
6272
|
+
doctor_add(findings, "error", "command.required_text_missing", f"brand 缺少必显文字:{text_item}")
|
|
6273
|
+
elif kind == "character":
|
|
6274
|
+
code, data, _ = doctor_run_json_command(
|
|
6275
|
+
cmd_character,
|
|
6276
|
+
argparse.Namespace(
|
|
6277
|
+
name="Mira",
|
|
6278
|
+
description="short silver hair, amber eyes, explorer coat",
|
|
6279
|
+
scene=["standing beside a glowing archive machine"],
|
|
6280
|
+
style=None,
|
|
6281
|
+
outfit=None,
|
|
6282
|
+
palette=None,
|
|
6283
|
+
style_preset=None,
|
|
6284
|
+
target="raw",
|
|
6285
|
+
json=True,
|
|
6286
|
+
),
|
|
6287
|
+
)
|
|
6288
|
+
if code != 0:
|
|
6289
|
+
doctor_add(findings, "error", "command.exit", f"character 返回非零状态:{code}")
|
|
6290
|
+
reference = data.get("reference_sheet") or {}
|
|
6291
|
+
doctor_collect_quality(findings, "character.reference", reference)
|
|
6292
|
+
prompt_blobs.append(str(reference.get("prompt") or ""))
|
|
6293
|
+
for idx, scene in enumerate(data.get("scene_prompts") or [], start=1):
|
|
6294
|
+
doctor_collect_quality(findings, f"character.scene.{idx}", scene)
|
|
6295
|
+
prompt_blobs.append(str(scene.get("prompt") or ""))
|
|
6296
|
+
elif kind == "data-viz":
|
|
6297
|
+
with NamedTemporaryFile("w", suffix=".csv", encoding="utf-8", delete=False) as tmp:
|
|
6298
|
+
tmp.write("week,conversion\nW1,0.12\nW2,0.18\n")
|
|
6299
|
+
tmp_path = Path(tmp.name)
|
|
6300
|
+
cleanup_paths.append(tmp_path)
|
|
6301
|
+
code, data, _ = doctor_run_json_command(
|
|
6302
|
+
cmd_data_viz,
|
|
6303
|
+
argparse.Namespace(
|
|
6304
|
+
file=str(tmp_path),
|
|
6305
|
+
request="展示转化率趋势",
|
|
6306
|
+
title="转化率趋势",
|
|
6307
|
+
chart_type=None,
|
|
6308
|
+
strict_text=False,
|
|
6309
|
+
json=True,
|
|
6310
|
+
),
|
|
6311
|
+
)
|
|
6312
|
+
if code != 0:
|
|
6313
|
+
doctor_add(findings, "error", "command.exit", f"data-viz 返回非零状态:{code}")
|
|
6314
|
+
doctor_collect_quality(findings, "data-viz", data)
|
|
6315
|
+
prompt_blobs.append(str(data.get("prompt") or ""))
|
|
6316
|
+
chart_spec = data.get("chart_data_spec") or {}
|
|
6317
|
+
if case.get("expect_title") and chart_spec.get("title") != case["expect_title"]:
|
|
6318
|
+
doctor_add(findings, "error", "command.title", f"data-viz 标题错误:{chart_spec.get('title')}")
|
|
6319
|
+
for column in case.get("expect_columns") or []:
|
|
6320
|
+
if column not in (chart_spec.get("columns") or []):
|
|
6321
|
+
doctor_add(findings, "error", "command.column_missing", f"data-viz 缺少列:{column}")
|
|
6322
|
+
elif kind == "rewrite":
|
|
6323
|
+
code, data, _ = doctor_run_json_command(
|
|
6324
|
+
cmd_rewrite,
|
|
6325
|
+
argparse.Namespace(
|
|
6326
|
+
prompt_text=["做一个迪士尼风格的角色海报,好看高级"],
|
|
6327
|
+
file=None,
|
|
6328
|
+
asset_type="poster",
|
|
6329
|
+
style=None,
|
|
6330
|
+
style_preset=None,
|
|
6331
|
+
strict_text=False,
|
|
6332
|
+
text=[],
|
|
6333
|
+
target="raw",
|
|
6334
|
+
json=True,
|
|
6335
|
+
),
|
|
6336
|
+
)
|
|
6337
|
+
if code != 0:
|
|
6338
|
+
doctor_add(findings, "error", "command.exit", f"rewrite 返回非零状态:{code}")
|
|
6339
|
+
doctor_collect_quality(findings, "rewrite", data)
|
|
6340
|
+
prompt_blobs.append(str(data.get("prompt") or ""))
|
|
6341
|
+
safety = (data.get("spec") or {}).get("safety_rewrite") or []
|
|
6342
|
+
if case.get("expect_safety_rewrite") and not safety:
|
|
6343
|
+
doctor_add(findings, "error", "command.safety_rewrite", "rewrite 未触发风险引用改写")
|
|
6344
|
+
else:
|
|
6345
|
+
doctor_add(findings, "error", "command.kind", f"未知命令类型:{kind}")
|
|
6346
|
+
finally:
|
|
6347
|
+
for path in cleanup_paths:
|
|
6348
|
+
try:
|
|
6349
|
+
path.unlink()
|
|
6350
|
+
except OSError:
|
|
6351
|
+
pass
|
|
6352
|
+
|
|
6353
|
+
joined_prompt = "\n".join(prompt_blobs)
|
|
6354
|
+
for phrase in case.get("expect_prompt") or []:
|
|
6355
|
+
if phrase not in joined_prompt:
|
|
6356
|
+
doctor_add(findings, "error", "command.prompt_missing", f"{kind} 输出缺少片段:{phrase}")
|
|
6357
|
+
for phrase in case.get("forbid_prompt") or []:
|
|
6358
|
+
if phrase in joined_prompt:
|
|
6359
|
+
doctor_add(findings, "error", "command.forbidden_prompt", f"{kind} 输出出现不应出现的片段:{phrase}")
|
|
6360
|
+
return {
|
|
6361
|
+
"name": f"command-{case['id']}",
|
|
6362
|
+
"pass": not has_lint_error(findings),
|
|
6363
|
+
"kind": kind,
|
|
6364
|
+
"findings": findings,
|
|
6365
|
+
}
|
|
6366
|
+
|
|
6367
|
+
|
|
6025
6368
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
6026
6369
|
root = package_root()
|
|
6027
6370
|
checks = [doctor_check_package(root)]
|
|
@@ -6040,6 +6383,11 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
6040
6383
|
checks.append(doctor_check_workflow_case(case))
|
|
6041
6384
|
except Exception as exc:
|
|
6042
6385
|
checks.append({"name": f"workflow-{case.get('id', 'case')}", "pass": False, "findings": [{"severity": "error", "rule": "workflow.exception", "message": str(exc)}]})
|
|
6386
|
+
for case in DOCTOR_COMMAND_CASES:
|
|
6387
|
+
try:
|
|
6388
|
+
checks.append(doctor_check_command_case(case))
|
|
6389
|
+
except Exception as exc:
|
|
6390
|
+
checks.append({"name": f"command-{case.get('id', 'case')}", "pass": False, "findings": [{"severity": "error", "rule": "command.exception", "message": str(exc)}]})
|
|
6043
6391
|
passed = all(item["pass"] for item in checks)
|
|
6044
6392
|
summary = {"pass": passed, "version": COMPILER_VERSION, "checks": len(checks), "failed": len([item for item in checks if not item["pass"]])}
|
|
6045
6393
|
if args.json:
|