@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50-beta.1

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.
Files changed (64) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-cli/SKILL.md +92 -410
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  16. package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
  17. package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
  18. package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
  19. package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
  20. package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
  21. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  22. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  27. package/skills/qingflow-cli/reference/data/README.md +32 -0
  28. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  29. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  30. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  31. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  32. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  33. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  34. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  35. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  36. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  37. package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
  38. package/skills/qingflow-common/SKILL.md +10 -17
  39. package/src/qingflow_mcp/backend_client.py +142 -12
  40. package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
  41. package/src/qingflow_mcp/builder_facade/service.py +749 -6
  42. package/src/qingflow_mcp/builder_facade/workflow_spec.py +154 -2
  43. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  44. package/src/qingflow_mcp/cli/commands/auth.py +1 -1
  45. package/src/qingflow_mcp/cli/commands/builder.py +57 -41
  46. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  47. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  48. package/src/qingflow_mcp/cli/context.py +4 -1
  49. package/src/qingflow_mcp/cli/formatters.py +159 -4
  50. package/src/qingflow_mcp/cli/main.py +4 -1
  51. package/src/qingflow_mcp/config.py +60 -0
  52. package/src/qingflow_mcp/public_surface.py +9 -0
  53. package/src/qingflow_mcp/resource_uri.py +241 -0
  54. package/src/qingflow_mcp/response_trim.py +53 -5
  55. package/src/qingflow_mcp/server.py +2 -0
  56. package/src/qingflow_mcp/server_app_builder.py +57 -2
  57. package/src/qingflow_mcp/server_app_user.py +10 -3
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
  59. package/src/qingflow_mcp/tools/app_tools.py +52 -13
  60. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  61. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  62. package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
  63. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  64. package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
@@ -0,0 +1,569 @@
1
+ """Offline WorkflowSpec validation (product module).
2
+
3
+ 从 ``skills/qingflow-workflow-builder/scripts/validate_flow_spec.py`` 提炼为产品模块,
4
+ skill 脚本改为薄壳引用本模块,防止双源漂移。
5
+
6
+ 校验口径(镜像后端 SpecGraphValidator 与评测结构检查 ``_structural_issues``):
7
+
8
+ - JSON Schema 校验:schema 由调用方动态拉取(``/api/workflow/spec/schema``),
9
+ ``NodeAttrsDTO`` 与 ``WorkflowNodeDTO.attrs`` 的 oneOf 多态判别放宽为 anyOf
10
+ (判别字段缺失导致误匹配,类型专属校验由自定义 check 完成);
11
+ - 必要字段、单 applicant、>1 节点须非空边;
12
+ - gateway 条件 / approval / filling / automation / cc / 孤立节点 /
13
+ 入口出边与 sync 深度检查(与 skill 现有实现一致);
14
+ - **不做 DAG 无环**:驳回边 / 回退边是合法业务语义(eval r2 复盘已固化),
15
+ 检测到环时仅产出 warning,不算 issue。
16
+
17
+ 红线:本模块是纯函数集合,不做任何网络调用;唯一网络调用(schema 拉取)
18
+ 由 facade ``flow_validate`` 完成,绝无 POST / 写。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections import deque
24
+ from copy import deepcopy
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ import jsonschema
29
+
30
+
31
+ VALID_RESPONSIBLE_TYPES = {
32
+ "user",
33
+ "dept",
34
+ "role",
35
+ "applicant",
36
+ "leader",
37
+ "formEmail",
38
+ "formMember",
39
+ "formDept",
40
+ "nodeLeader",
41
+ }
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class FlowValidationResult:
46
+ """离线校验结果:issues 非空即失败,warnings 不影响结论。"""
47
+
48
+ issues: list[str] = field(default_factory=list)
49
+ warnings: list[str] = field(default_factory=list)
50
+ node_count: int = 0
51
+ edge_count: int = 0
52
+
53
+ @property
54
+ def ok(self) -> bool:
55
+ return not self.issues
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # 预处理
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def strip_nulls(obj: Any) -> Any:
64
+ """递归删除对象中的 null 值字段,但保留必要字段(如 name)并给默认值。"""
65
+ if isinstance(obj, dict):
66
+ result: dict[str, Any] = {}
67
+ for key, value in obj.items():
68
+ if value is None:
69
+ if key == "name":
70
+ # name 是必要字段,使用 id 作为默认名称
71
+ result[key] = obj.get("id", "unnamed")
72
+ # role 等可选字段为 null 时直接跳过
73
+ continue
74
+ result[key] = strip_nulls(value)
75
+ return result
76
+ if isinstance(obj, list):
77
+ return [strip_nulls(item) for item in obj]
78
+ return obj
79
+
80
+
81
+ def normalize_auto_judges(spec: dict[str, Any]) -> dict[str, Any]:
82
+ """后端 get 返回的 autoJudges 可能是 [{"rules": [...]}] 格式。
83
+
84
+ 将其规范化为 OR-of-AND 二维数组 [[{...}]] 格式。
85
+ """
86
+ edges = spec.get("edges", {})
87
+ edge_list = edges.get("edges", []) if isinstance(edges, dict) else edges
88
+ if isinstance(edge_list, list):
89
+ for edge in edge_list:
90
+ condition = edge.get("condition") if isinstance(edge, dict) else None
91
+ if not isinstance(condition, dict):
92
+ continue
93
+ judges = condition.get("autoJudges")
94
+ if not judges or not isinstance(judges, list):
95
+ continue
96
+ normalized: list[Any] = []
97
+ for item in judges:
98
+ if isinstance(item, dict) and "rules" in item:
99
+ # 格式: {"rules": [...]} → 展开为 [...]
100
+ normalized.append(item["rules"])
101
+ elif isinstance(item, list):
102
+ normalized.append(item)
103
+ if normalized:
104
+ condition["autoJudges"] = normalized
105
+ return spec
106
+
107
+
108
+ def inject_attrs_type(spec: dict[str, Any]) -> dict[str, Any]:
109
+ """Schema 中 attrs 的多态判别依赖 type 字段,但实际数据中 type 在父节点。
110
+
111
+ 为满足 schema 校验,将 node.type 注入到 node.attrs.type 中(仅校验用,不改原数据)。
112
+ """
113
+ mutated = deepcopy(spec)
114
+ for node in mutated.get("nodes", []) or []:
115
+ if isinstance(node, dict) and isinstance(node.get("attrs"), dict) and "type" not in node["attrs"]:
116
+ node["attrs"]["type"] = node.get("type", "")
117
+ return mutated
118
+
119
+
120
+ def normalize_spec(spec: dict[str, Any]) -> tuple[list[Any], list[Any]]:
121
+ """提取 (nodes, edges):edges 可能是 {"edges": [...]} 容器或直接数组。"""
122
+ edges = spec.get("edges")
123
+ if isinstance(edges, dict) and isinstance(edges.get("edges"), list):
124
+ return list(spec.get("nodes") or []), list(edges["edges"])
125
+ if isinstance(edges, list):
126
+ return list(spec.get("nodes") or []), edges
127
+ return list(spec.get("nodes") or []), []
128
+
129
+
130
+ def relax_schema(schema: dict[str, Any]) -> dict[str, Any]:
131
+ """放宽 NodeAttrsDTO / WorkflowNodeDTO.attrs 的 oneOf → anyOf。
132
+
133
+ 官方 schema 的 oneOf 多态判别过于严格(GatewayAttrsDTO 和 ApplicantAttrsDTO
134
+ 缺少 required 导致误匹配),类型专属校验由自定义 check 函数完成。
135
+ """
136
+ relaxed = deepcopy(schema)
137
+ defs = relaxed.get("$defs")
138
+ if isinstance(defs, dict):
139
+ node_attrs = defs.get("NodeAttrsDTO")
140
+ if isinstance(node_attrs, dict) and "oneOf" in node_attrs:
141
+ node_attrs["anyOf"] = node_attrs.pop("oneOf")
142
+ workflow_node = defs.get("WorkflowNodeDTO")
143
+ if isinstance(workflow_node, dict):
144
+ properties = workflow_node.get("properties")
145
+ attrs = properties.get("attrs") if isinstance(properties, dict) else None
146
+ if isinstance(attrs, dict) and "oneOf" in attrs:
147
+ attrs["anyOf"] = attrs.pop("oneOf")
148
+ return relaxed
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # 校验检查
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ def schema_issues(spec: dict[str, Any], schema: dict[str, Any]) -> list[str]:
157
+ """JSON Schema 校验(先注入 attrs.type 以匹配多态判别)。"""
158
+ validator = jsonschema.Draft202012Validator(relax_schema(schema))
159
+ errors = sorted(validator.iter_errors(spec), key=lambda error: error.path)
160
+ return [
161
+ f"[Schema] {'.'.join(str(part) for part in error.path) if error.path else '(root)'}: {error.message}"
162
+ for error in errors
163
+ ]
164
+
165
+
166
+ def check_required_fields(nodes: list[Any], edges: list[Any]) -> list[str]:
167
+ issues: list[str] = []
168
+ for node in nodes:
169
+ if not isinstance(node, dict):
170
+ issues.append(f"nodes[] 含非对象项: {node!r}")
171
+ continue
172
+ for field_name in ("id", "type", "name", "attrs"):
173
+ if field_name not in node:
174
+ issues.append(f"节点缺少必要字段 {field_name}: {node.get('id', '?')}")
175
+ for edge in edges:
176
+ if not isinstance(edge, dict):
177
+ issues.append(f"edges[] 含非对象项: {edge!r}")
178
+ continue
179
+ for field_name in ("from", "to"):
180
+ if field_name not in edge:
181
+ issues.append(f"边缺少必要字段 {field_name}: {edge}")
182
+ return issues
183
+
184
+
185
+ def check_nonempty_graph(nodes: list[Any], edges: list[Any]) -> list[str]:
186
+ """>1 节点的图必须有非空边(镜像后端 SpecGraphValidator / eval 结构口径)。"""
187
+ if len(nodes) > 1 and not edges:
188
+ return [f"[结构] 节点数 {len(nodes)} > 1 但 edges 为空:多节点流程必须显式声明边"]
189
+ return []
190
+
191
+
192
+ def check_cycle_warnings(nodes: list[Any], edges: list[Any]) -> list[str]:
193
+ """环仅作为 warning(驳回边 / 回退边是合法业务语义,不作为错误)。"""
194
+ node_ids = {str(node.get("id")) for node in nodes if isinstance(node, dict) and node.get("id") is not None}
195
+ adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
196
+ for edge in edges:
197
+ if not isinstance(edge, dict):
198
+ continue
199
+ source = str(edge.get("from"))
200
+ target = str(edge.get("to"))
201
+ if source in node_ids and target in node_ids:
202
+ adjacency[source].append(target)
203
+ indegree = {node_id: 0 for node_id in node_ids}
204
+ for edge in edges:
205
+ if isinstance(edge, dict) and str(edge.get("to")) in node_ids:
206
+ indegree[str(edge.get("to"))] += 1
207
+ queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
208
+ visited = 0
209
+ while queue:
210
+ current = queue.popleft()
211
+ visited += 1
212
+ for target in adjacency[current]:
213
+ indegree[target] -= 1
214
+ if indegree[target] == 0:
215
+ queue.append(target)
216
+ if visited != len(node_ids):
217
+ return ["检测到环路:可能由回退边(如审批驳回、gateway 回退)引起,通常是合法的业务需求"]
218
+ return []
219
+
220
+
221
+ def check_applicant(nodes: list[Any]) -> list[str]:
222
+ """检查有且仅有一个 applicant 节点。"""
223
+ applicants = [node for node in nodes if isinstance(node, dict) and node.get("type") == "applicant"]
224
+ if not applicants:
225
+ return ["缺少 applicant 节点:工作流必须包含一个申请人节点作为入口"]
226
+ if len(applicants) > 1:
227
+ ids = [str(applicant.get("id")) for applicant in applicants]
228
+ return [f"存在多个 applicant 节点: {ids},只允许一个"]
229
+ return []
230
+
231
+
232
+ def check_gateway_conditions(nodes: list[Any], edges: list[Any]) -> list[str]:
233
+ """检查 gateway 出边条件。"""
234
+ issues: list[str] = []
235
+ gateway_ids = {str(node.get("id")) for node in nodes if isinstance(node, dict) and node.get("type") == "gateway"}
236
+ nodes_by_id = {
237
+ str(node.get("id")): node
238
+ for node in nodes
239
+ if isinstance(node, dict) and node.get("id") is not None
240
+ }
241
+
242
+ for gateway_id in sorted(gateway_ids):
243
+ out_edges = [edge for edge in edges if isinstance(edge, dict) and str(edge.get("from")) == gateway_id]
244
+ gateway_node = nodes_by_id.get(gateway_id) or {}
245
+ attrs = gateway_node.get("attrs") if isinstance(gateway_node.get("attrs"), dict) else {}
246
+ gateway_mode = str(attrs.get("mode") or "")
247
+ # join gateway 允许 0 或 1 条出边(流程终点或汇合后继续)
248
+ if gateway_mode != "join" and len(out_edges) < 2:
249
+ issues.append(f"Gateway {gateway_id} 出边数={len(out_edges)},需要至少 2 条出边")
250
+ continue
251
+
252
+ for edge in out_edges:
253
+ condition = edge.get("condition")
254
+ if not isinstance(condition, dict):
255
+ continue
256
+ if str(condition.get("kind") or "") != "rules":
257
+ continue
258
+ target = str(edge.get("to"))
259
+ auto_judges = condition.get("autoJudges")
260
+ if not isinstance(auto_judges, list) or not auto_judges:
261
+ issues.append(f"Gateway {gateway_id} → {target} 边 kind=rules 但 autoJudges 为空")
262
+ continue
263
+ for group_index, group in enumerate(auto_judges):
264
+ if not isinstance(group, list):
265
+ issues.append(f"Gateway {gateway_id} → {target} autoJudges[{group_index}] 不是数组(应为 OR 组)")
266
+ continue
267
+ for rule_index, rule in enumerate(group):
268
+ if not isinstance(rule, dict):
269
+ issues.append(f"Gateway {gateway_id} → {target} autoJudges[{group_index}][{rule_index}] 不是对象")
270
+ continue
271
+ if "fieldId" not in rule or "judgeType" not in rule:
272
+ issues.append(
273
+ f"Gateway {gateway_id} → {target} autoJudges[{group_index}][{rule_index}] 缺少 fieldId 或 judgeType"
274
+ )
275
+ return issues
276
+
277
+
278
+ def _check_responsible_item(item: Any, node_label: str) -> list[str]:
279
+ issues: list[str] = []
280
+ if not isinstance(item, dict):
281
+ issues.append(f"{node_label} responsible 中包含非对象项")
282
+ return issues
283
+ if "type" not in item:
284
+ issues.append(f"{node_label} responsible 项缺少 type")
285
+ return issues
286
+ responsible_type = item.get("type")
287
+ if responsible_type not in VALID_RESPONSIBLE_TYPES:
288
+ issues.append(
289
+ f"{node_label} responsible type='{responsible_type}' 不合法,可选: {sorted(VALID_RESPONSIBLE_TYPES)}"
290
+ )
291
+ return issues
292
+ if responsible_type == "user" and "uid" not in item:
293
+ issues.append(f"{node_label} responsible type='user' 缺少 uid(整数)")
294
+ if responsible_type == "dept" and "deptId" not in item:
295
+ issues.append(f"{node_label} responsible type='dept' 缺少 deptId(整数)")
296
+ if responsible_type == "role" and "roleId" not in item:
297
+ issues.append(f"{node_label} responsible type='role' 缺少 roleId(整数)")
298
+ if responsible_type in ("leader", "formEmail", "formMember", "formDept") and "queId" not in item:
299
+ issues.append(f"{node_label} responsible type='{responsible_type}' 缺少 queId(整数)")
300
+ return issues
301
+
302
+
303
+ def _check_responsible_array(attrs: dict[str, Any], node_label: str) -> list[str]:
304
+ issues: list[str] = []
305
+ responsible = attrs.get("responsible")
306
+ if responsible is None:
307
+ return issues # 外层已报缺字段
308
+ if not isinstance(responsible, list):
309
+ issues.append(f"{node_label} responsible 必须是数组,当前为 {type(responsible).__name__}")
310
+ return issues
311
+ if not responsible:
312
+ issues.append(f"{node_label} responsible 数组至少包含 1 个元素")
313
+ return issues
314
+ for item in responsible:
315
+ issues.extend(_check_responsible_item(item, node_label))
316
+ return issues
317
+
318
+
319
+ def check_approval_nodes(nodes: list[Any]) -> list[str]:
320
+ """检查审批节点必要字段。"""
321
+ issues: list[str] = []
322
+ for node in nodes:
323
+ if not isinstance(node, dict) or node.get("type") != "approval":
324
+ continue
325
+ attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
326
+ node_label = f"审批节点 {node.get('id')} ({node.get('name', '')})"
327
+ if "responsible" not in attrs:
328
+ issues.append(f"{node_label} 缺少 responsible(审批人)")
329
+ else:
330
+ issues.extend(_check_responsible_array(attrs, node_label))
331
+ if "approveType" not in attrs:
332
+ issues.append(f"{node_label} 缺少 approveType")
333
+ if "auditUserType" not in attrs:
334
+ issues.append(f"{node_label} 缺少 auditUserType")
335
+ return issues
336
+
337
+
338
+ def check_filling_nodes(nodes: list[Any]) -> list[str]:
339
+ """检查填写节点必要字段。"""
340
+ issues: list[str] = []
341
+ for node in nodes:
342
+ if not isinstance(node, dict) or node.get("type") != "filling":
343
+ continue
344
+ attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
345
+ node_label = f"填写节点 {node.get('id')} ({node.get('name', '')})"
346
+ if "responsible" not in attrs:
347
+ issues.append(f"{node_label} 缺少 responsible(处理人)")
348
+ else:
349
+ issues.extend(_check_responsible_array(attrs, node_label))
350
+ return issues
351
+
352
+
353
+ def check_automation_nodes(nodes: list[Any]) -> list[str]:
354
+ """检查自动化节点配置。"""
355
+ issues: list[str] = []
356
+ for node in nodes:
357
+ if not isinstance(node, dict) or node.get("type") != "automation":
358
+ continue
359
+ attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
360
+ node_label = f"自动化节点 {node.get('id')} ({node.get('name', '')})"
361
+ sub_type = str(attrs.get("subType") or "")
362
+ if not sub_type:
363
+ issues.append(f"{node_label} 缺少 subType")
364
+ continue
365
+ if sub_type == "update":
366
+ if "appKey" not in attrs:
367
+ issues.append(f"{node_label} update 缺少 appKey")
368
+ if "targetFilterRules" not in attrs:
369
+ issues.append(f"{node_label} update 缺少 targetFilterRules")
370
+ if "fieldMappings" not in attrs:
371
+ issues.append(f"{node_label} update 缺少 fieldMappings")
372
+ elif sub_type == "add":
373
+ if "appKey" not in attrs:
374
+ issues.append(f"{node_label} add 缺少 appKey")
375
+ if "fieldMappings" not in attrs:
376
+ issues.append(f"{node_label} add 缺少 fieldMappings")
377
+ elif sub_type == "send_email":
378
+ if "mailConfig" not in attrs:
379
+ issues.append(f"{node_label} send_email 缺少 mailConfig")
380
+ if "mailReceivers" not in attrs:
381
+ issues.append(f"{node_label} send_email 缺少 mailReceivers")
382
+ elif sub_type == "http_request":
383
+ if "remoteRequestConfig" not in attrs:
384
+ issues.append(f"{node_label} http_request 缺少 remoteRequestConfig")
385
+ elif sub_type == "ai_":
386
+ if "aiConfig" not in attrs:
387
+ issues.append(f"{node_label} AI 节点缺少 aiConfig")
388
+ return issues
389
+
390
+
391
+ def check_cc_nodes(nodes: list[Any]) -> list[str]:
392
+ """检查抄送节点必要字段。"""
393
+ issues: list[str] = []
394
+ for node in nodes:
395
+ if not isinstance(node, dict) or node.get("type") != "cc":
396
+ continue
397
+ attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
398
+ if "receivers" not in attrs:
399
+ issues.append(f"抄送节点 {node.get('id')} 缺少 receivers")
400
+ return issues
401
+
402
+
403
+ def check_orphan_nodes(nodes: list[Any], edges: list[Any]) -> list[str]:
404
+ """检查孤立节点(无入边且非 applicant);applicant 节点不应有入边。"""
405
+ has_incoming = {str(edge.get("to")) for edge in edges if isinstance(edge, dict)}
406
+ applicants = {
407
+ str(node.get("id"))
408
+ for node in nodes
409
+ if isinstance(node, dict) and node.get("type") == "applicant"
410
+ }
411
+ issues: list[str] = []
412
+ for node in nodes:
413
+ if not isinstance(node, dict):
414
+ continue
415
+ node_id = str(node.get("id"))
416
+ if node_id in applicants:
417
+ if node_id in has_incoming:
418
+ issues.append(f"申请人节点 {node_id} 不应有入边")
419
+ continue
420
+ if node_id not in has_incoming:
421
+ issues.append(f"节点 {node_id} ({node.get('name', '')}) 缺少入边(孤立节点)")
422
+ return issues
423
+
424
+
425
+ def check_routing_entry_and_sync(nodes: list[Any], edges: list[Any]) -> list[str]:
426
+ """检查入口出边和需要继续流转节点的 sync 标记。"""
427
+ outgoing: dict[str, list[str]] = {}
428
+ for edge in edges:
429
+ if isinstance(edge, dict):
430
+ outgoing.setdefault(str(edge.get("from")), []).append(str(edge.get("to")))
431
+ issues: list[str] = []
432
+ for node in nodes:
433
+ if not isinstance(node, dict):
434
+ continue
435
+ node_id = str(node.get("id"))
436
+ node_type = node.get("type")
437
+ node_label = f"节点 {node_id} ({node.get('name', '')})"
438
+ has_outgoing = bool(outgoing.get(node_id))
439
+ if node_type == "applicant" and not has_outgoing:
440
+ issues.append(f"申请人入口节点 {node_id} 没有出边,提交后流程不会进入下一节点")
441
+ if has_outgoing and node.get("sync") is not True:
442
+ issues.append(f"{node_label} 存在出边但未显式 sync=true,后端可能停止继续路由")
443
+ return issues
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # 更新模式:最小修改原则(skill --previous 检查)
448
+ # ---------------------------------------------------------------------------
449
+
450
+
451
+ def _minimal_spec_edges(spec: dict[str, Any]) -> list[tuple[Any, Any]]:
452
+ edges = spec.get("edges")
453
+ if isinstance(edges, dict) and isinstance(edges.get("edges"), list):
454
+ return [(edge.get("from"), edge.get("to")) for edge in edges["edges"] if isinstance(edge, dict)]
455
+ if isinstance(edges, list):
456
+ return [(edge.get("from"), edge.get("to")) for edge in edges if isinstance(edge, dict)]
457
+ return []
458
+
459
+
460
+ def _node_identity(node: dict[str, Any]) -> str:
461
+ import json
462
+
463
+ return json.dumps(
464
+ {"type": node.get("type", ""), "name": node.get("name", ""), "attrs": node.get("attrs", {})},
465
+ sort_keys=True,
466
+ ensure_ascii=False,
467
+ )
468
+
469
+
470
+ def check_minimal_modification(
471
+ old_spec: dict[str, Any],
472
+ new_spec: dict[str, Any],
473
+ ) -> tuple[list[str], list[str]]:
474
+ """更新模式下检查最小修改原则:ID 稳定性、无冗余删建、修改范围合理。"""
475
+ issues: list[str] = []
476
+ warnings: list[str] = []
477
+
478
+ old_nodes = [node for node in (old_spec.get("nodes") or []) if isinstance(node, dict)]
479
+ new_nodes = [node for node in (new_spec.get("nodes") or []) if isinstance(node, dict)]
480
+ old_by_id = {str(node.get("id")): node for node in old_nodes}
481
+ new_by_id = {str(node.get("id")): node for node in new_nodes}
482
+ old_ids = set(old_by_id)
483
+ new_ids = set(new_by_id)
484
+ deleted_ids = old_ids - new_ids
485
+ added_ids = new_ids - old_ids
486
+
487
+ # 1. 节点 ID 变更(内容相同但 ID 不同 —— 可能是不必要的重建)
488
+ old_identities: dict[str, list[str]] = {}
489
+ for node in old_nodes:
490
+ old_identities.setdefault(_node_identity(node), []).append(str(node.get("id")))
491
+ for node in new_nodes:
492
+ node_id = str(node.get("id"))
493
+ if node_id in added_ids:
494
+ identity = _node_identity(node)
495
+ if old_identities.get(identity):
496
+ old_id = old_identities[identity][0]
497
+ if old_id in deleted_ids:
498
+ issues.append(
499
+ f"[最小修改] 节点 {old_id} → {node_id} ({node.get('name', '')}):"
500
+ f"内容未变但 ID 已变更,可能导致后端不支持配置丢失"
501
+ )
502
+
503
+ # 2. 类型变更(type 不同视为破坏性修改)
504
+ for node_id in old_ids & new_ids:
505
+ if old_by_id[node_id].get("type") != new_by_id[node_id].get("type"):
506
+ warnings.append(
507
+ f"[最小修改] 节点 {node_id} type 从 {old_by_id[node_id].get('type')} 变为 "
508
+ f"{new_by_id[node_id].get('type')},可能丢失原类型专属配置"
509
+ )
510
+
511
+ # 3. 边删建(from/to 相同但 condition 不同 → 应该是修改而非删建)
512
+ old_edge_keys = set(_minimal_spec_edges(old_spec))
513
+ new_edge_keys = set(_minimal_spec_edges(new_spec))
514
+ deleted_edges = old_edge_keys - new_edge_keys
515
+ added_edges = new_edge_keys - old_edge_keys
516
+ if deleted_edges:
517
+ warnings.append(f"[最小修改] 删除了 {len(deleted_edges)} 条边,请确认是否为业务需要")
518
+ if added_edges:
519
+ warnings.append(f"[最小修改] 新增了 {len(added_edges)} 条边,请确认是否按预期新增")
520
+
521
+ # 4. 节点删除
522
+ if deleted_ids:
523
+ warnings.append(f"[最小修改] 删除了 {len(deleted_ids)} 个节点: {sorted(deleted_ids)},请确认是否为业务需要")
524
+
525
+ return issues, warnings
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # 汇总入口
530
+ # ---------------------------------------------------------------------------
531
+
532
+
533
+ def validate_spec(spec: dict[str, Any], schema: dict[str, Any]) -> FlowValidationResult:
534
+ """对单个 WorkflowSpec 执行全量离线校验。
535
+
536
+ ``schema`` 须为 ``/api/workflow/spec/schema`` 的动态拉取结果(relaxed 由本函数内部处理)。
537
+ """
538
+ result = FlowValidationResult()
539
+ if not isinstance(spec, dict):
540
+ result.issues.append("[结构] spec 必须是 JSON 对象(WorkflowSpecDTO)")
541
+ return result
542
+
543
+ # 预处理:null 值清理 + autoJudges 格式规范化(不改调用方原数据)
544
+ normalized = normalize_auto_judges(strip_nulls(deepcopy(spec)))
545
+ nodes, edges = normalize_spec(normalized)
546
+ result.node_count = len(nodes)
547
+ result.edge_count = len(edges)
548
+
549
+ result.issues.extend(schema_issues(inject_attrs_type(normalized), schema))
550
+
551
+ if not nodes:
552
+ result.issues.append("[结构] spec 中未找到 nodes 数组")
553
+ if not edges:
554
+ # 单节点(applicant-only)流程允许无边;多节点必须显式声明边。
555
+ result.issues.extend(check_nonempty_graph(nodes, edges))
556
+
557
+ if nodes:
558
+ result.issues.extend(check_required_fields(nodes, edges))
559
+ result.issues.extend(check_applicant(nodes))
560
+ result.issues.extend(check_gateway_conditions(nodes, edges))
561
+ result.issues.extend(check_approval_nodes(nodes))
562
+ result.issues.extend(check_filling_nodes(nodes))
563
+ result.issues.extend(check_automation_nodes(nodes))
564
+ result.issues.extend(check_cc_nodes(nodes))
565
+ result.issues.extend(check_orphan_nodes(nodes, edges))
566
+ result.issues.extend(check_routing_entry_and_sync(nodes, edges))
567
+ if nodes and edges:
568
+ result.warnings.extend(check_cycle_warnings(nodes, edges))
569
+ return result