@qingflow-tech/qingflow-cli 1.1.44 → 1.1.45-beta.2
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 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +1 -1
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +6 -0
- package/src/qingflow_mcp/app_form_validator.py +131 -0
- package/src/qingflow_mcp/builder_facade/app_form.py +263 -50
- package/src/qingflow_mcp/cli/commands/builder.py +208 -1
- package/src/qingflow_mcp/public_surface.py +2 -0
- package/src/qingflow_mcp/response_trim.py +6 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +58 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.45-beta.2
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.45-beta.2 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
62
62
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.45-beta.2 qingflow-skills install --agent codex --scope user --copy
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qingflow-tech/qingflow-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.45-beta.2",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pyproject.toml
CHANGED
|
@@ -444,7 +444,7 @@ qingflow auth logout --forget-persisted
|
|
|
444
444
|
| **app / portal / view / chart** | `app list [--query 关键词]` → **`app get --app-key`**(`accessible_views` / `view_id`、`import_capability`)· `builder package list [--query 关键词]` → `builder package get --package-id` · `portal list` · `portal get --dash-key` · `view get --view-id`(只读视图元信息;`baseInfo` 或 `viewConfig` 只有 40002 / 40027 / 404 这类权限/缺失边界可用另一侧降级,非权限类后端错误不隐藏;解析 app 仅依赖同源 view/form 与可见应用树,不回退旧应用搜索)· `chart get --chart-id`(图表 `baseInfo` / 数据 / 配置分阶段读取;单个辅助阶段 40002 / 40027 / 404 只作为 warning,非权限类后端错误不隐藏) |
|
|
445
445
|
| **record** | `record schema insert`(新建记录 insert-ready schema,仅 `--app-key`)· **`browse`/`list`/`access`:`--app-key` + `--view-id`(缺一常 CONFIG)** · `record list --query ... [--query-field FIELD_ID] [--page-size N]` 用于候选定位/样本浏览 · `record get --record-id` 读详情页首屏上下文 · `record logs --record-id` 读完整数据/流程日志 JSONL · `record insert --items-file`(单条也是 items 一行)· `schema update --record-id [--view-id]` / **`update`**(单条 `--fields-file` **或** 批量 `--items-file`/`--dry-run`,二者勿混)· `record delete --app-key --record-id --view-id system:*` / `--record-ids-file` · `export start/direct --view-id` 仅用于明确导出文件 · **大 schema**:先 **[FIELD_DATA_TYPES](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md)** / **[RECORD_CREATE](./reference/record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md)** / **[RECORD_UPDATE](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)** / **[RECORD_DELETE](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md)**,再 `-h` |
|
|
446
446
|
| **import** | `record schema import?` · `import template` → `verify` → `repair?` → `start`(**`being-enter-auditing` 必填**)→ `status`(**三者 selector 只选一**);导入权限预检的 `baseInfo` 只有 40002 / 40027 / 404 可降级为 unknown,非权限错误不能当成无导入权或继续执行;**链路与本机目录**只靠本节不够 → **[RECORD_IMPORT](./reference/record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md)** |
|
|
447
|
-
| **builder** | **搭建/系统化配置**:先按 **[INDEX](./reference/00-INDEX.md)** / **[BUILDER](./reference/builder/README.md)** 选主路径,再读 `builder contract`。**完整系统/应用包**:`builder package apply --config-file` → 每个应用依次执行 `builder app-form schema`、更新时 `get --app-key APP_KEY --being-draft`、`validate --schema-version VERSION --file DECLARATION.json`、`apply --file DECLARATION.json`(固定版本、完整声明、记录返回的 `appKey`)→ 视图(按钮随视图 `action_buttons` 写)→ 门户(缺报表时写 `chart`)→ 发布/回读。旧 `schema apply` / `layout apply` 不在当前 CLI parser 中。超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或回读不完整时先读取 AppForm draft/published,再恢复,不得重复创建或创建 V2/测试/随机后缀绕过重名。**单应用**:新建和更新遵循同一 AppForm CLI 链路,再按需求配置流程/视图。更新动作归入对应资源文档:AppForm [30](./reference/builder/30-schema-fields.md)、视图 [50](./reference/builder/50-views.md)、报表 [60](./reference/builder/60-charts.md)、门户 [70](./reference/builder/70-portal.md)、关联资源/非主链路按钮维护 [80](./reference/builder/80-buttons-associated-resources.md)、流程 [90](./reference/builder/90-workflow.md)、发布 [99](./reference/builder/99-publish-verify.md)
|
|
447
|
+
| **builder** | **搭建/系统化配置**:先按 **[INDEX](./reference/00-INDEX.md)** / **[BUILDER](./reference/builder/README.md)** 选主路径,再读 `builder contract`。**完整系统/应用包**:`builder package apply --config-file` → 每个应用依次执行 `builder app-form schema`、更新时 `get --app-key APP_KEY --being-draft`、`validate --schema-version VERSION --file DECLARATION.json`、`apply --file DECLARATION.json`(固定版本、完整声明、记录返回的 `appKey`)→ 视图(按钮随视图 `action_buttons` 写)→ 门户(缺报表时写 `chart`)→ 发布/回读。旧 `schema apply` / `layout apply` 不在当前 CLI parser 中。超时、`partial_success`、`write_executed=true`、`safe_to_retry=false` 或回读不完整时先读取 AppForm draft/published,再恢复,不得重复创建或创建 V2/测试/随机后缀绕过重名。**单应用**:新建和更新遵循同一 AppForm CLI 链路,再按需求配置流程/视图。更新动作归入对应资源文档:AppForm [30](./reference/builder/30-schema-fields.md)、视图 [50](./reference/builder/50-views.md)、报表 [60](./reference/builder/60-charts.md)、门户 [70](./reference/builder/70-portal.md)、关联资源/非主链路按钮维护 [80](./reference/builder/80-buttons-associated-resources.md)、流程 [90](./reference/builder/90-workflow.md)、发布 [99](./reference/builder/99-publish-verify.md)。**AI 公式/代码块校验**:`builder app-form validate-formula --app-key KEY --formula '<公式>' --test-case '<{queId: value} JSON>'`(本地语法 + 后端求值,输出 `syntaxErrors` / `evaluation` / `evaluationError`)与 `builder app-form validate-code-block --app-key KEY --code '<代码>' --test-case '<[{keyQueId, ordinal?, values}] JSON 数组>'`(本地静态 + 后端真跑,输出 `staticErrors` / `runResult` / `runError`);两者是公式/代码块生成场景的真实校验命令,校验结论必须来自其真实执行,不得以本地模拟替代。代码内占位符 `qf_field.{字段名$$queId$$}` 与 `keyQueId` 均使用**十进制** queId(CLI 真跑前自动把十进制占位符编码为运行时形态,已编码段原样保留)。 |
|
|
448
448
|
| **task**(须落盘见上文) | `task list [--task-box][--flow-status][--app-key][--query] --page --page-size`(**枚举、`--query`、与 `record` 视图口径勿混**:见上文 SOP · **[TASK_CONTEXT](./reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md)**)· **`get`/`log`/`report`/`action`**:默认只传 `task list` 返回的 **`data.items[].task_id`**;`report` 另须必选 **`--report-id`**;`action` **无 `--dry-run`** |
|
|
449
449
|
|
|
450
450
|
**高发必败**:旧应用搜索入口 · **`record list`/`browse`/`access` 缺 `--view-id`** · **`task report` 缺 `--report-id`** · **`record update` 批量与单条参数混用**。
|
|
@@ -219,6 +219,8 @@ current CLI payload.
|
|
|
219
219
|
| `data_relation` | 数据关联/引用填充 |
|
|
220
220
|
| `subtable` | 子表(**`subfields[]`**) |
|
|
221
221
|
|
|
222
|
+
历史表单中的后端 `queType=1` 是“描述文字”布局控件,不属于上述新建字段类型。`app-form get` 为了保持完整声明可校验,会将它兼容输出为 `long_text`;`app-form apply` 会根据已有字段模板恢复原始 `queType=1` 和 `queDefaultValue`,所以修改其他字段时不会丢失描述文字。新建可填写多行文本仍使用 `long_text`,不要用它创建描述文字控件。
|
|
223
|
+
|
|
222
224
|
**`config.relationMode`**:仅 **`relation`** 使用,取值为 **`single`** 或 **`multiple`**。一个应用可以有多个 `relation` 字段;不要因为 relation 数量超过 1 就改成文本字段。
|
|
223
225
|
|
|
224
226
|
### 1.1 历史字段类型别名
|
|
@@ -246,6 +248,10 @@ current CLI payload.
|
|
|
246
248
|
| `q_linker` | **`config.qLinkerBinding`**:`inputs`、`request`、**`outputs[]` 每项须含 `targetField`**(能解析到本表单字段) | `request.url` 等须符合**租户/后端远程查询策略**;以字段类型 Schema 返回的配置为准 |
|
|
247
249
|
| `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;配置 outputs 时,代码必须显式写 **`qf_output = {...}`**,仅 `return {...}` 不会产生可回写结果,且每个 `outputs[].path` 必须从 `qf_output` 取值 | 以字段类型 Schema 返回的 camelCase 配置为准 |
|
|
248
250
|
|
|
251
|
+
部门字段的 `defaultDepartments` 也遵循同一约束:`custom` 模式必须包含至少一个部门。读取历史后端数据时,如果默认部门值存在但部门列表为空,AppForm 归一化会将其视为“未配置默认部门”并省略 `defaultDepartments`;不要在新声明中写 `departments: []`。
|
|
252
|
+
|
|
253
|
+
读取历史数据时,空的应用/段落/字段标题会按稳定 ID 生成兼容名称,空行和空筛选组会被忽略;选择项、子表列、序列号组件、Q-Linker 输出和代码源码等语义必需值无法安全推断时,读取会返回结构化 `UNSUPPORTED_FIELD_CONFIG`,不会伪造声明值。`validate/apply` 对新声明仍保持严格 Schema 约束。
|
|
254
|
+
|
|
249
255
|
别名:契约会把 `title`/`label` 映射到 **`name`** 等;字段类型自然别名见 §1.1。
|
|
250
256
|
|
|
251
257
|
### 2.1 `relation` 选择器形态
|
|
@@ -24,6 +24,8 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
|
|
|
24
24
|
}
|
|
25
25
|
_Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
|
|
26
26
|
_FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
27
|
+
#: 公式字段引用正则(#queId_<id>$,支持表格嵌套 <id>.<id>)
|
|
28
|
+
_FORMULA_FIELD_REF_RE = re.compile(r"#queId_([^$]+)\$")
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
class AppFormValidationError(ValueError):
|
|
@@ -460,3 +462,132 @@ def validate_declaration(
|
|
|
460
462
|
issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
|
|
461
463
|
raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
|
|
462
464
|
return {"valid": True, "schemaVersion": pinned, "apiVersion": api_version, "fieldTypes": list(types)}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def validate_formula_expression(
|
|
468
|
+
formula: str,
|
|
469
|
+
fields: list[dict[str, Any]] | None = None,
|
|
470
|
+
) -> list[dict[str, str]]:
|
|
471
|
+
"""校验单条公式表达式:函数名合法 + 字段引用存在性。不依赖整份 AppForm,不调后端。
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
formula: 公式文本,如 ``CONCAT(#queId_101$, ":", #queId_102$)``。
|
|
475
|
+
fields: 字段清单 ``[{queId, name}]``(兼容 ``que_id``/``id``);提供时校验字段引用存在性,
|
|
476
|
+
不提供时跳过字段引用校验。
|
|
477
|
+
Returns:
|
|
478
|
+
错误列表 ``[{pointer, message}]``,空列表表示本地语法通过。
|
|
479
|
+
"""
|
|
480
|
+
errors: list[dict[str, str]] = []
|
|
481
|
+
if not isinstance(formula, str) or not formula.strip():
|
|
482
|
+
errors.append({"pointer": "formula", "message": "formula is empty"})
|
|
483
|
+
return errors
|
|
484
|
+
|
|
485
|
+
# 函数名合法性(复用 AppForm formula Schema 的函数目录)
|
|
486
|
+
supported = _supported_formula_functions("v1alpha1")
|
|
487
|
+
unknown = sorted(
|
|
488
|
+
name for name in _formula_function_calls(formula) if name.upper() not in supported
|
|
489
|
+
)
|
|
490
|
+
for name in unknown:
|
|
491
|
+
errors.append({"pointer": "formula", "message": f"unsupported formula function '{name}'"})
|
|
492
|
+
|
|
493
|
+
# 字段引用存在性(#queId_<id>$,支持表格嵌套 <id>.<id>,按叶子 id 与原始 id 双重匹配)
|
|
494
|
+
if fields:
|
|
495
|
+
known_ids = {
|
|
496
|
+
str(identifier)
|
|
497
|
+
for field in fields
|
|
498
|
+
if isinstance(field, dict)
|
|
499
|
+
for identifier in (
|
|
500
|
+
field.get("queId"),
|
|
501
|
+
field.get("que_id"),
|
|
502
|
+
field.get("id"),
|
|
503
|
+
)
|
|
504
|
+
if identifier is not None and str(identifier) != ""
|
|
505
|
+
}
|
|
506
|
+
for match in _FORMULA_FIELD_REF_RE.finditer(formula):
|
|
507
|
+
raw_id = match.group(1)
|
|
508
|
+
leaf_id = raw_id.split(".")[-1]
|
|
509
|
+
if leaf_id not in known_ids and raw_id not in known_ids:
|
|
510
|
+
errors.append(
|
|
511
|
+
{
|
|
512
|
+
"pointer": "formula",
|
|
513
|
+
"message": f"field reference '{match.group(0)}' refers to unknown queId {raw_id}",
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
return errors
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def validate_code_block_static(code: str) -> list[dict[str, str]]:
|
|
520
|
+
"""校验代码块静态结构:qf_output 赋值 + 基本语法。不调后端。
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
code: 代码文本。
|
|
524
|
+
Returns:
|
|
525
|
+
错误列表 ``[{pointer, message}]``,空列表表示本地静态校验通过。
|
|
526
|
+
"""
|
|
527
|
+
errors: list[dict[str, str]] = []
|
|
528
|
+
if not isinstance(code, str) or not code.strip():
|
|
529
|
+
errors.append({"pointer": "code", "message": "code is empty"})
|
|
530
|
+
return errors
|
|
531
|
+
|
|
532
|
+
# qf_output 必须直接赋值(不能用 const/let 声明);仅当代码涉及 outputs 绑定时才提示
|
|
533
|
+
has_qf_output = bool(re.search(r"(^|[^A-Za-z0-9_$])qf_output\s*=(?!=)", code))
|
|
534
|
+
has_outputs_binding = "outputs" in code
|
|
535
|
+
if not has_qf_output and has_outputs_binding:
|
|
536
|
+
errors.append(
|
|
537
|
+
{
|
|
538
|
+
"pointer": "code",
|
|
539
|
+
"message": "code declares outputs but does not assign qf_output; "
|
|
540
|
+
"use 'qf_output = {...}' (not const/let)",
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
return errors
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
# 代码块字段占位符编码
|
|
548
|
+
#
|
|
549
|
+
# 后端 codeBlockTest 运行时通过 CustomPushContentConverter.getQueId 解码代码里的
|
|
550
|
+
# ``qf_field.{label$$<encoded>$$}`` 段(私有混淆编码,非十进制),再按解码出的十进制
|
|
551
|
+
# queId 匹配 --test-case 的 keyQueId 填值;匹配不上会静默跳过(占位符原样留跑)。
|
|
552
|
+
# AI 生成的代码按十进制 queId 书写(与 skill/编辑器上下文一致),本模块在真跑前把
|
|
553
|
+
# 纯数字的 ``$$<digits>$$`` 段编码为运行时形态——与后端采纳链路(encryptQueId)同一
|
|
554
|
+
# 编码、同一"纯数字才编码"规则,已编码(含字母)的段原样保留,避免双重编码。
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
_ENC_KEY = 1000013
|
|
558
|
+
_INT_TO_HEX = "0123456789ABCDEF"
|
|
559
|
+
_DECIMAL_PLACEHOLDER = re.compile(r"\$\$(-?\d+)\$\$")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def encode_que_id(que_id: int) -> str:
|
|
563
|
+
"""Java ``CustomPushContentConverter.encryptQueId`` 的逐行为等价移植。"""
|
|
564
|
+
have_prefix = que_id < 0
|
|
565
|
+
abs_id = abs(que_id)
|
|
566
|
+
ran = abs_id * 17
|
|
567
|
+
inp = str(abs_id)
|
|
568
|
+
seeds: list[int] = []
|
|
569
|
+
salt_sum = 0
|
|
570
|
+
for _ in range(max(0, 4 - len(inp))):
|
|
571
|
+
tmp = (ran % 20) + 71
|
|
572
|
+
ran = ((ran + 13) * 17) % _ENC_KEY
|
|
573
|
+
salt_sum += tmp
|
|
574
|
+
seeds.append(tmp)
|
|
575
|
+
result = [(salt_sum + i + int(ch, 16)) % 15 for i, ch in enumerate(inp)]
|
|
576
|
+
for seed in seeds:
|
|
577
|
+
pos = (ran + seed) % len(result)
|
|
578
|
+
ran = ((ran + 13) * 17) % _ENC_KEY
|
|
579
|
+
result.insert(pos, seed)
|
|
580
|
+
encoded = "".join(chr(value) if value > 15 else _INT_TO_HEX[value] for value in result)
|
|
581
|
+
return ("-" if have_prefix else "") + encoded
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def encode_code_block_field_ids(code: str) -> str:
|
|
585
|
+
"""把代码里的十进制 queId 占位符段编码为后端运行时形态。
|
|
586
|
+
|
|
587
|
+
仅处理纯数字(可带负号)的 ``$$...$$`` 段;混有字母的段视为已编码,原样保留。
|
|
588
|
+
"""
|
|
589
|
+
if not isinstance(code, str):
|
|
590
|
+
return code
|
|
591
|
+
return _DECIMAL_PLACEHOLDER.sub(
|
|
592
|
+
lambda match: f"$${encode_que_id(int(match.group(1)))}$$", code
|
|
593
|
+
)
|
|
@@ -31,6 +31,10 @@ from .service import (
|
|
|
31
31
|
|
|
32
32
|
API_VERSION = "builder.qingflow.com/v1alpha1"
|
|
33
33
|
QUE_TYPE_TO_CANONICAL = {
|
|
34
|
+
# Historical description-text controls are read as long_text for the
|
|
35
|
+
# public declaration shape; the compiler restores queType=1 from the
|
|
36
|
+
# existing backend template when writing them back.
|
|
37
|
+
1: "long_text",
|
|
34
38
|
2: "text",
|
|
35
39
|
3: "long_text",
|
|
36
40
|
5: "member",
|
|
@@ -227,23 +231,33 @@ def _canonical_member_scope(scope: Any, scope_type: Any, *, department: bool = F
|
|
|
227
231
|
}
|
|
228
232
|
if department:
|
|
229
233
|
result["departments"] = [
|
|
230
|
-
_without_none(
|
|
234
|
+
_without_none(
|
|
235
|
+
{
|
|
236
|
+
"deptId": _positive_int(item.get("deptId") or item.get("id")),
|
|
237
|
+
"deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
|
|
238
|
+
}
|
|
239
|
+
)
|
|
231
240
|
for item in _scope_items(raw, "depart")
|
|
232
241
|
if _positive_int(item.get("deptId") or item.get("id")) is not None
|
|
233
242
|
]
|
|
234
243
|
else:
|
|
235
244
|
result["members"] = [
|
|
236
|
-
_without_none({"uid": _positive_int(item.get("uid")), "nickName": item.get("nickName")})
|
|
245
|
+
_without_none({"uid": _positive_int(item.get("uid")), "nickName": _compat_label(item.get("nickName"), "Member", item.get("uid"))})
|
|
237
246
|
for item in _scope_items(raw, "member")
|
|
238
247
|
if _positive_int(item.get("uid")) is not None
|
|
239
248
|
]
|
|
240
249
|
result["departments"] = [
|
|
241
|
-
_without_none(
|
|
250
|
+
_without_none(
|
|
251
|
+
{
|
|
252
|
+
"deptId": _positive_int(item.get("deptId")),
|
|
253
|
+
"deptName": _compat_label(item.get("deptName"), "Department", item.get("deptId")),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
242
256
|
for item in _scope_items(raw, "depart")
|
|
243
257
|
if _positive_int(item.get("deptId")) is not None
|
|
244
258
|
]
|
|
245
259
|
result["roles"] = [
|
|
246
|
-
_without_none({"roleId": _positive_int(item.get("roleId")), "roleName": item.get("roleName")})
|
|
260
|
+
_without_none({"roleId": _positive_int(item.get("roleId")), "roleName": _compat_label(item.get("roleName"), "Role", item.get("roleId"))})
|
|
247
261
|
for item in _scope_items(raw, "role")
|
|
248
262
|
if _positive_int(item.get("roleId")) is not None
|
|
249
263
|
]
|
|
@@ -281,11 +295,12 @@ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
|
|
|
281
295
|
return None
|
|
282
296
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
283
297
|
displayed_id = _positive_int(relation.get("displayedQueId") or displayed.get("queId"))
|
|
284
|
-
|
|
298
|
+
target_app_key = str(displayed.get("appKey") or "")
|
|
299
|
+
if displayed_id is None or not target_app_key.strip():
|
|
285
300
|
return None
|
|
286
301
|
result: JSONObject = {
|
|
287
302
|
"displayedField": _selector(displayed_id, displayed.get("queTitle")),
|
|
288
|
-
"targetAppKey":
|
|
303
|
+
"targetAppKey": target_app_key,
|
|
289
304
|
"targetAppTitle": displayed.get("appTitle"),
|
|
290
305
|
"displayedFieldType": displayed.get("queType"),
|
|
291
306
|
"matchRuleType": relation.get("matchRuleType"),
|
|
@@ -334,6 +349,22 @@ def _selector(que_id: Any, name: Any) -> JSONObject:
|
|
|
334
349
|
return result
|
|
335
350
|
|
|
336
351
|
|
|
352
|
+
def _compat_label(value: Any, kind: str, identity: Any) -> str:
|
|
353
|
+
"""Give legacy records with an empty display label a stable, non-empty name."""
|
|
354
|
+
label = str(value or "")
|
|
355
|
+
if label.strip():
|
|
356
|
+
return label
|
|
357
|
+
normalized_identity = str(identity or "").strip()
|
|
358
|
+
return f"{kind} {normalized_identity}" if normalized_identity else f"Unnamed {kind}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _required_text(value: Any, *, message: str, details: JSONObject | None = None) -> str:
|
|
362
|
+
text = str(value or "")
|
|
363
|
+
if not text.strip():
|
|
364
|
+
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", message, "NORMALIZE", details=details)
|
|
365
|
+
return text
|
|
366
|
+
|
|
367
|
+
|
|
337
368
|
def _require_selector(que_id: Any, name: Any, *, message: str, details: JSONObject | None = None) -> JSONObject:
|
|
338
369
|
result = _selector(que_id, name)
|
|
339
370
|
if not result:
|
|
@@ -620,21 +651,40 @@ class AppFormNormalizer:
|
|
|
620
651
|
section_id = _positive_int(section.get("queId")) or _positive_int(section.get("sectionId"))
|
|
621
652
|
if section_id is None:
|
|
622
653
|
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
|
|
623
|
-
rows = [
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
654
|
+
rows = []
|
|
655
|
+
for inner in section.get("innerQuestions") or []:
|
|
656
|
+
if not isinstance(inner, list) or not inner:
|
|
657
|
+
continue
|
|
658
|
+
fields = [
|
|
659
|
+
self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
660
|
+
for question in inner
|
|
661
|
+
if isinstance(question, dict)
|
|
662
|
+
]
|
|
663
|
+
if fields:
|
|
664
|
+
rows.append({"fields": fields})
|
|
628
665
|
if not rows:
|
|
629
666
|
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
|
|
630
|
-
body.append(
|
|
667
|
+
body.append(
|
|
668
|
+
{
|
|
669
|
+
"kind": "section",
|
|
670
|
+
"sectionId": str(section_id),
|
|
671
|
+
"title": _compat_label(section.get("queTitle"), "Section", section_id),
|
|
672
|
+
"rows": rows,
|
|
673
|
+
}
|
|
674
|
+
)
|
|
631
675
|
else:
|
|
632
|
-
|
|
676
|
+
fields = [
|
|
677
|
+
self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
678
|
+
for question in row
|
|
679
|
+
if isinstance(question, dict)
|
|
680
|
+
]
|
|
681
|
+
if fields:
|
|
682
|
+
body.append({"kind": "row", "fields": fields})
|
|
633
683
|
resource = {
|
|
634
684
|
"apiVersion": context.api_version,
|
|
635
685
|
"appKey": app_key,
|
|
636
686
|
"spec": {
|
|
637
|
-
"appName":
|
|
687
|
+
"appName": _compat_label(form.get("formTitle"), "App", app_key),
|
|
638
688
|
"appDescription": form.get("formDesc") or None,
|
|
639
689
|
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
|
|
640
690
|
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
|
|
@@ -676,18 +726,27 @@ class AppFormNormalizer:
|
|
|
676
726
|
details={"queId": que_id, "queType": que_type},
|
|
677
727
|
)
|
|
678
728
|
field: JSONObject = {
|
|
679
|
-
"name":
|
|
729
|
+
"name": _compat_label(question.get("queTitle"), "Field", que_id),
|
|
680
730
|
"questionType": field_type,
|
|
681
731
|
"beingRequired": bool(question.get("required")),
|
|
682
732
|
"questionDescription": question.get("queHint") or None,
|
|
683
733
|
}
|
|
684
734
|
default_type = _positive_int(question.get("queDefaultType"))
|
|
685
|
-
|
|
735
|
+
raw_default_value = question.get("queDefaultValue")
|
|
736
|
+
decoded_default_value = (
|
|
737
|
+
_decode_formula_default(raw_default_value)
|
|
738
|
+
if isinstance(raw_default_value, (str, int, float, bool))
|
|
739
|
+
else raw_default_value
|
|
740
|
+
)
|
|
741
|
+
empty_formula_default = default_type == 3 and (
|
|
742
|
+
decoded_default_value is None
|
|
743
|
+
or (isinstance(decoded_default_value, str) and not decoded_default_value.strip())
|
|
744
|
+
)
|
|
745
|
+
if default_type is not None and not empty_formula_default and (
|
|
686
746
|
question.get("queDefaultValue") is not None
|
|
687
747
|
or (default_type != 1 and field_type != "relation")
|
|
688
748
|
):
|
|
689
749
|
field["defaultType"] = default_type
|
|
690
|
-
raw_default_value = question.get("queDefaultValue")
|
|
691
750
|
if isinstance(raw_default_value, (dict, list)) and field_type not in {"address", "attachment", "relation", "subtable"}:
|
|
692
751
|
raise AppFormFailure(
|
|
693
752
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
@@ -695,9 +754,14 @@ class AppFormNormalizer:
|
|
|
695
754
|
"NORMALIZE",
|
|
696
755
|
details={"queId": que_id, "type": field_type},
|
|
697
756
|
)
|
|
698
|
-
if
|
|
699
|
-
|
|
700
|
-
|
|
757
|
+
if (
|
|
758
|
+
"queDefaultValue" in question
|
|
759
|
+
and raw_default_value is not None
|
|
760
|
+
and not isinstance(raw_default_value, (dict, list))
|
|
761
|
+
and not empty_formula_default
|
|
762
|
+
and not (default_type == 2 and isinstance(decoded_default_value, str) and not decoded_default_value.strip())
|
|
763
|
+
):
|
|
764
|
+
field["defaultValue"] = decoded_default_value
|
|
701
765
|
if que_id is not None:
|
|
702
766
|
field["queId"] = que_id
|
|
703
767
|
width = _positive_int(question.get("queWidth"))
|
|
@@ -735,9 +799,20 @@ class AppFormNormalizer:
|
|
|
735
799
|
if config:
|
|
736
800
|
field["config"] = config
|
|
737
801
|
elif field_type in CHOICE_TYPES:
|
|
738
|
-
options = [
|
|
802
|
+
options = [
|
|
803
|
+
str(item.get("optValue"))
|
|
804
|
+
for item in question.get("options") or []
|
|
805
|
+
if isinstance(item, dict) and item.get("optValue") is not None and str(item.get("optValue")).strip()
|
|
806
|
+
]
|
|
739
807
|
if not options and field_type == "single_choice":
|
|
740
808
|
options = ["是", "否"]
|
|
809
|
+
if not options:
|
|
810
|
+
raise AppFormFailure(
|
|
811
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
812
|
+
"choice field has no options",
|
|
813
|
+
"NORMALIZE",
|
|
814
|
+
details={"queId": que_id, "type": field_type, "path": "/config/options"},
|
|
815
|
+
)
|
|
741
816
|
field["config"] = _without_none({
|
|
742
817
|
"options": options,
|
|
743
818
|
"minOpts": _canonical_unlimited(question.get("minOpts")),
|
|
@@ -745,7 +820,14 @@ class AppFormNormalizer:
|
|
|
745
820
|
"optDirection": question.get("optDirection") if question.get("optDirection") not in {None, 0} else None,
|
|
746
821
|
"optSelectMode": question.get("optSelectMode"),
|
|
747
822
|
"beingShowColor": question.get("beingShowColor"),
|
|
748
|
-
"defaultOptions": [
|
|
823
|
+
"defaultOptions": [
|
|
824
|
+
str(item.get("optValue"))
|
|
825
|
+
for item in question.get("options") or []
|
|
826
|
+
if isinstance(item, dict)
|
|
827
|
+
and item.get("beingDefault") is True
|
|
828
|
+
and item.get("optValue") is not None
|
|
829
|
+
and str(item.get("optValue")).strip()
|
|
830
|
+
],
|
|
749
831
|
})
|
|
750
832
|
if not field["config"].get("defaultOptions"):
|
|
751
833
|
field["config"].pop("defaultOptions", None)
|
|
@@ -766,7 +848,12 @@ class AppFormNormalizer:
|
|
|
766
848
|
scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
|
|
767
849
|
departments = scope.get("depart") if isinstance(scope.get("depart"), list) else []
|
|
768
850
|
canonical_departments = [
|
|
769
|
-
_without_none(
|
|
851
|
+
_without_none(
|
|
852
|
+
{
|
|
853
|
+
"deptId": _positive_int(item.get("deptId") or item.get("id")),
|
|
854
|
+
"deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
|
|
855
|
+
}
|
|
856
|
+
)
|
|
770
857
|
for item in departments
|
|
771
858
|
if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
|
|
772
859
|
]
|
|
@@ -791,7 +878,10 @@ class AppFormNormalizer:
|
|
|
791
878
|
else:
|
|
792
879
|
field["config"] = {"departmentScope": {"mode": "all", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
|
|
793
880
|
if question.get("deptDftValue") is not None:
|
|
794
|
-
|
|
881
|
+
default_scope = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
|
|
882
|
+
# Older backend responses may include an empty default-value container when no default department is set.
|
|
883
|
+
if default_scope.get("departments"):
|
|
884
|
+
field["config"]["defaultDepartments"] = default_scope
|
|
795
885
|
relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
|
|
796
886
|
scope_relation = _canonical_scope_relation(relation)
|
|
797
887
|
if scope_relation is not None:
|
|
@@ -800,9 +890,14 @@ class AppFormNormalizer:
|
|
|
800
890
|
if question.get(key) is not None:
|
|
801
891
|
field["config"][key] = question[key]
|
|
802
892
|
elif field_type == "attachment":
|
|
893
|
+
file_types = question.get("fileType")
|
|
894
|
+
if isinstance(file_types, list):
|
|
895
|
+
file_types = [item for item in file_types if str(item or "").strip()]
|
|
896
|
+
elif isinstance(file_types, str) and not file_types.strip():
|
|
897
|
+
file_types = None
|
|
803
898
|
field["config"] = _without_none({
|
|
804
899
|
"fileSize": question.get("fileSize"),
|
|
805
|
-
"fileType":
|
|
900
|
+
"fileType": file_types,
|
|
806
901
|
"fileBeingCaptureOnly": question.get("fileBeingCaptureOnly"),
|
|
807
902
|
"imageCompress": question.get("imageCompress"),
|
|
808
903
|
"pluginStatus": question.get("pluginStatus"),
|
|
@@ -826,14 +921,22 @@ class AppFormNormalizer:
|
|
|
826
921
|
display_id = _positive_int(reference.get("referQueId"))
|
|
827
922
|
display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
|
|
828
923
|
visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
|
|
924
|
+
target_app_key = str(reference.get("referAppKey") or "")
|
|
925
|
+
if not target_app_key.strip():
|
|
926
|
+
raise AppFormFailure(
|
|
927
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
928
|
+
"relation target app cannot be normalized",
|
|
929
|
+
"NORMALIZE",
|
|
930
|
+
details={"queId": que_id, "path": "/config/targetAppKey"},
|
|
931
|
+
)
|
|
829
932
|
relation_config: JSONObject = {
|
|
830
|
-
"targetAppKey":
|
|
933
|
+
"targetAppKey": target_app_key,
|
|
831
934
|
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
832
935
|
"displayField": _require_selector(
|
|
833
936
|
display_id,
|
|
834
937
|
(display or {}).get("queTitle"),
|
|
835
938
|
message="relation display field cannot be normalized",
|
|
836
|
-
details={"queId": que_id},
|
|
939
|
+
details={"queId": que_id, "path": "/config/displayField"},
|
|
837
940
|
),
|
|
838
941
|
"visibleFields": [
|
|
839
942
|
_require_selector(
|
|
@@ -849,7 +952,8 @@ class AppFormNormalizer:
|
|
|
849
952
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
850
953
|
}
|
|
851
954
|
if supports_reference_default_formula:
|
|
852
|
-
|
|
955
|
+
formula = reference.get("defaultValueFormula")
|
|
956
|
+
relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
|
|
853
957
|
field["config"] = _without_none(relation_config)
|
|
854
958
|
elif field_type == "q_linker":
|
|
855
959
|
field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
|
|
@@ -858,34 +962,54 @@ class AppFormNormalizer:
|
|
|
858
962
|
elif field_type == "data_relation":
|
|
859
963
|
field["config"] = self._data_relation_config(question, relations)
|
|
860
964
|
elif field_type == "subtable":
|
|
861
|
-
|
|
965
|
+
subfields = [
|
|
862
966
|
self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
863
967
|
for item in question.get("subQuestions") or []
|
|
864
968
|
if isinstance(item, dict)
|
|
865
969
|
]
|
|
970
|
+
if not subfields:
|
|
971
|
+
raise AppFormFailure(
|
|
972
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
973
|
+
"subtable has no subfields",
|
|
974
|
+
"NORMALIZE",
|
|
975
|
+
details={"queId": que_id, "path": "/subfields"},
|
|
976
|
+
)
|
|
977
|
+
field["subfields"] = subfields
|
|
866
978
|
for subfield in field["subfields"]:
|
|
867
979
|
subfield.pop("width", None)
|
|
980
|
+
row_height = question.get("tableRowHeight")
|
|
981
|
+
if isinstance(row_height, str) and not row_height.strip():
|
|
982
|
+
row_height = None
|
|
868
983
|
field["config"] = _without_none({
|
|
869
984
|
"tableRowsLimit": question.get("tableRowsLimit"),
|
|
870
985
|
"tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
|
|
871
986
|
"tableNoRepeatQuestion": [_selector(item, field_names.get(_positive_int(item))) for item in question.get("tableNoRepeatQuestion") or [] if _positive_int(item) is not None],
|
|
872
|
-
"tableRowHeight":
|
|
987
|
+
"tableRowHeight": row_height,
|
|
873
988
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
|
|
874
989
|
})
|
|
875
990
|
return field
|
|
876
991
|
|
|
877
992
|
@staticmethod
|
|
878
993
|
def _attachments(value: Any) -> list[JSONObject]:
|
|
994
|
+
def attachment_text(item: JSONObject, *keys: str) -> Any:
|
|
995
|
+
for key in keys:
|
|
996
|
+
candidate = item.get(key)
|
|
997
|
+
if candidate:
|
|
998
|
+
return candidate
|
|
999
|
+
return None
|
|
1000
|
+
|
|
879
1001
|
result = [
|
|
880
1002
|
_without_none(
|
|
881
1003
|
{
|
|
882
1004
|
"attachId": _positive_int(item.get("attachId") or item.get("id")),
|
|
883
|
-
"fileName": item
|
|
884
|
-
"fileUrl": item
|
|
1005
|
+
"fileName": attachment_text(item, "fileName", "name"),
|
|
1006
|
+
"fileUrl": attachment_text(item, "fileUrl", "url"),
|
|
885
1007
|
}
|
|
886
1008
|
)
|
|
887
1009
|
for item in value or []
|
|
888
|
-
if isinstance(item, dict)
|
|
1010
|
+
if isinstance(item, dict)
|
|
1011
|
+
and str(attachment_text(item, "fileName", "name") or "").strip()
|
|
1012
|
+
and str(attachment_text(item, "fileUrl", "url") or "").strip()
|
|
889
1013
|
]
|
|
890
1014
|
return sorted(result, key=lambda item: (item.get("attachId", 0), item["fileName"], item["fileUrl"]))
|
|
891
1015
|
|
|
@@ -907,12 +1031,28 @@ class AppFormNormalizer:
|
|
|
907
1031
|
elif kind == "submit_datetime":
|
|
908
1032
|
component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
|
|
909
1033
|
elif kind == "fixed_text":
|
|
910
|
-
component["value"] =
|
|
1034
|
+
component["value"] = _required_text(
|
|
1035
|
+
item.get("customTitle"),
|
|
1036
|
+
message="custom serial number fixed text is empty",
|
|
1037
|
+
details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/value"},
|
|
1038
|
+
)
|
|
911
1039
|
else:
|
|
912
|
-
component["field"] =
|
|
1040
|
+
component["field"] = _require_selector(
|
|
1041
|
+
item.get("queId"),
|
|
1042
|
+
item.get("queTitle"),
|
|
1043
|
+
message="custom serial number field cannot be normalized",
|
|
1044
|
+
details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/field"},
|
|
1045
|
+
)
|
|
913
1046
|
if item.get("dateFormat") is not None:
|
|
914
1047
|
component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
|
|
915
1048
|
components.append(component)
|
|
1049
|
+
if not components:
|
|
1050
|
+
raise AppFormFailure(
|
|
1051
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
1052
|
+
"custom serial number has no components",
|
|
1053
|
+
"NORMALIZE",
|
|
1054
|
+
details={"path": "/spec/serialNumber/components"},
|
|
1055
|
+
)
|
|
916
1056
|
return {"mode": "custom", "components": components}
|
|
917
1057
|
|
|
918
1058
|
@staticmethod
|
|
@@ -926,9 +1066,11 @@ class AppFormNormalizer:
|
|
|
926
1066
|
for inner in item.get("innerQuestions") or []:
|
|
927
1067
|
for nested in inner if isinstance(inner, list) else []:
|
|
928
1068
|
if isinstance(nested, dict) and _positive_int(nested.get("queId")):
|
|
929
|
-
|
|
1069
|
+
nested_id = _positive_int(nested.get("queId")) or 0
|
|
1070
|
+
result[nested_id] = _compat_label(nested.get("queTitle"), "Field", nested_id)
|
|
930
1071
|
elif _positive_int(item.get("queId")):
|
|
931
|
-
|
|
1072
|
+
item_id = _positive_int(item.get("queId")) or 0
|
|
1073
|
+
result[item_id] = _compat_label(item.get("queTitle"), "Field", item_id)
|
|
932
1074
|
return result
|
|
933
1075
|
|
|
934
1076
|
@staticmethod
|
|
@@ -939,7 +1081,16 @@ class AppFormNormalizer:
|
|
|
939
1081
|
for alias in config.get("resultFormatPath") or []:
|
|
940
1082
|
if not isinstance(alias, dict):
|
|
941
1083
|
continue
|
|
942
|
-
name =
|
|
1084
|
+
name = _required_text(
|
|
1085
|
+
alias.get("aliasName"),
|
|
1086
|
+
message="Q-Linker output alias is empty",
|
|
1087
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/outputs/alias"},
|
|
1088
|
+
)
|
|
1089
|
+
path = _required_text(
|
|
1090
|
+
alias.get("aliasPath"),
|
|
1091
|
+
message="Q-Linker output path is empty",
|
|
1092
|
+
details={"queId": source_id, "alias": name, "path": "/config/qLinkerBinding/outputs/path"},
|
|
1093
|
+
)
|
|
943
1094
|
relation = next(
|
|
944
1095
|
(
|
|
945
1096
|
item
|
|
@@ -953,7 +1104,7 @@ class AppFormNormalizer:
|
|
|
953
1104
|
_without_none(
|
|
954
1105
|
{
|
|
955
1106
|
"alias": name,
|
|
956
|
-
"path":
|
|
1107
|
+
"path": path,
|
|
957
1108
|
"aliasId": _positive_int(alias.get("aliasId")) or _positive_int((relation.get("aliasConfig") or {}).get("aliasId")),
|
|
958
1109
|
"targetField": _require_selector(
|
|
959
1110
|
relation.get("queId"),
|
|
@@ -973,15 +1124,30 @@ class AppFormNormalizer:
|
|
|
973
1124
|
continue
|
|
974
1125
|
if _positive_int(item.get("type")) == 2:
|
|
975
1126
|
input_que_id = _positive_int(item.get("value"))
|
|
1127
|
+
input_key = _required_text(
|
|
1128
|
+
item.get("key"),
|
|
1129
|
+
message="Q-Linker input key is empty",
|
|
1130
|
+
details={"queId": source_id, "source": source, "path": "/config/qLinkerBinding/inputs/key"},
|
|
1131
|
+
)
|
|
976
1132
|
inputs.append(
|
|
977
1133
|
{
|
|
978
|
-
"field":
|
|
979
|
-
|
|
1134
|
+
"field": _require_selector(
|
|
1135
|
+
input_que_id,
|
|
1136
|
+
field_names.get(input_que_id or -1),
|
|
1137
|
+
message="Q-Linker input field cannot be normalized",
|
|
1138
|
+
details={"queId": source_id, "key": input_key},
|
|
1139
|
+
),
|
|
1140
|
+
"key": input_key,
|
|
980
1141
|
"source": source,
|
|
981
1142
|
}
|
|
982
1143
|
)
|
|
983
1144
|
else:
|
|
984
|
-
|
|
1145
|
+
static_key = _required_text(
|
|
1146
|
+
item.get("key"),
|
|
1147
|
+
message="Q-Linker request key is empty",
|
|
1148
|
+
details={"queId": source_id, "source": source, "path": f"/config/qLinkerBinding/request/{source}/key"},
|
|
1149
|
+
)
|
|
1150
|
+
static[key].append({"key": static_key, "value": item.get("value")})
|
|
985
1151
|
request_values = {
|
|
986
1152
|
"url": config.get("url"),
|
|
987
1153
|
"jsonValue": config.get("jsonValue"),
|
|
@@ -1013,9 +1179,14 @@ class AppFormNormalizer:
|
|
|
1013
1179
|
|
|
1014
1180
|
request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
|
|
1015
1181
|
inputs.extend(json_path_inputs.values())
|
|
1182
|
+
request_url = _required_text(
|
|
1183
|
+
request_values["url"],
|
|
1184
|
+
message="Q-Linker request URL is empty",
|
|
1185
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
|
|
1186
|
+
)
|
|
1016
1187
|
request = _without_none(
|
|
1017
1188
|
{
|
|
1018
|
-
"url":
|
|
1189
|
+
"url": request_url,
|
|
1019
1190
|
"method": config.get("method") or "GET",
|
|
1020
1191
|
**static,
|
|
1021
1192
|
"bodyType": {1: "url_encoded", 2: "json", 3: "xml"}.get(_positive_int(config.get("bodyType")) or 1, "url_encoded"),
|
|
@@ -1029,6 +1200,13 @@ class AppFormNormalizer:
|
|
|
1029
1200
|
"hideOnForm": bool(config.get("beingHideOnForm")),
|
|
1030
1201
|
}
|
|
1031
1202
|
)
|
|
1203
|
+
if not outputs:
|
|
1204
|
+
raise AppFormFailure(
|
|
1205
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
1206
|
+
"Q-Linker has no output mapping",
|
|
1207
|
+
"NORMALIZE",
|
|
1208
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/outputs"},
|
|
1209
|
+
)
|
|
1032
1210
|
return {"inputs": inputs, "request": request, "outputs": outputs}
|
|
1033
1211
|
|
|
1034
1212
|
@staticmethod
|
|
@@ -1039,7 +1217,16 @@ class AppFormNormalizer:
|
|
|
1039
1217
|
for alias in config.get("resultAliasPath") or []:
|
|
1040
1218
|
if not isinstance(alias, dict):
|
|
1041
1219
|
continue
|
|
1042
|
-
name =
|
|
1220
|
+
name = _required_text(
|
|
1221
|
+
alias.get("aliasName"),
|
|
1222
|
+
message="code block output alias is empty",
|
|
1223
|
+
details={"queId": source_id, "path": "/config/codeBlockBinding/outputs/alias"},
|
|
1224
|
+
)
|
|
1225
|
+
path = _required_text(
|
|
1226
|
+
alias.get("aliasPath"),
|
|
1227
|
+
message="code block output path is empty",
|
|
1228
|
+
details={"queId": source_id, "alias": name, "path": "/config/codeBlockBinding/outputs/path"},
|
|
1229
|
+
)
|
|
1043
1230
|
relation = next(
|
|
1044
1231
|
(
|
|
1045
1232
|
item
|
|
@@ -1051,7 +1238,7 @@ class AppFormNormalizer:
|
|
|
1051
1238
|
)
|
|
1052
1239
|
output = {
|
|
1053
1240
|
"alias": name,
|
|
1054
|
-
"path":
|
|
1241
|
+
"path": path,
|
|
1055
1242
|
"targetField": _require_selector(
|
|
1056
1243
|
relation.get("queId"),
|
|
1057
1244
|
relation.get("queTitle") or field_names.get(_positive_int(relation.get("queId")) or -1),
|
|
@@ -1069,11 +1256,29 @@ class AppFormNormalizer:
|
|
|
1069
1256
|
line = lines.pop(0).strip()
|
|
1070
1257
|
var = line[6:].split("=", 1)[0].strip()
|
|
1071
1258
|
name = line.split("qf_field.{", 1)[1].split("$$", 1)[0]
|
|
1072
|
-
|
|
1259
|
+
input_name = _required_text(
|
|
1260
|
+
name,
|
|
1261
|
+
message="code block input field is empty",
|
|
1262
|
+
details={"queId": source_id, "path": "/config/codeBlockBinding/inputs/field"},
|
|
1263
|
+
)
|
|
1264
|
+
inputs.append(
|
|
1265
|
+
{
|
|
1266
|
+
"field": {"name": input_name},
|
|
1267
|
+
"var": var,
|
|
1268
|
+
}
|
|
1269
|
+
)
|
|
1270
|
+
code = "\n".join(lines).lstrip("\n")
|
|
1271
|
+
if not code.strip():
|
|
1272
|
+
raise AppFormFailure(
|
|
1273
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
1274
|
+
"code block has no code",
|
|
1275
|
+
"NORMALIZE",
|
|
1276
|
+
details={"queId": source_id, "path": "/config/codeBlockBinding/code"},
|
|
1277
|
+
)
|
|
1073
1278
|
return _without_none(
|
|
1074
1279
|
{
|
|
1075
1280
|
"inputs": inputs,
|
|
1076
|
-
"code":
|
|
1281
|
+
"code": code,
|
|
1077
1282
|
"autoTrigger": question.get("autoTrigger"),
|
|
1078
1283
|
"customButtonTextEnabled": question.get("customBtnTextStatus"),
|
|
1079
1284
|
"customButtonText": question.get("customBtnText"),
|
|
@@ -1092,7 +1297,8 @@ class AppFormNormalizer:
|
|
|
1092
1297
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
1093
1298
|
alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
|
|
1094
1299
|
if relation_type == 1:
|
|
1095
|
-
|
|
1300
|
+
source_app_key = str(displayed.get("appKey") or "")
|
|
1301
|
+
if not source_app_key.strip():
|
|
1096
1302
|
raise AppFormFailure(
|
|
1097
1303
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
1098
1304
|
"data_relation source app cannot be normalized",
|
|
@@ -1101,7 +1307,7 @@ class AppFormNormalizer:
|
|
|
1101
1307
|
)
|
|
1102
1308
|
source = {
|
|
1103
1309
|
"kind": "app",
|
|
1104
|
-
"appKey":
|
|
1310
|
+
"appKey": source_app_key,
|
|
1105
1311
|
"field": _require_selector(
|
|
1106
1312
|
relation.get("displayedQueId"),
|
|
1107
1313
|
displayed.get("queTitle"),
|
|
@@ -1143,7 +1349,9 @@ class AppFormNormalizer:
|
|
|
1143
1349
|
for group in relation.get("matchRules") or []:
|
|
1144
1350
|
if not isinstance(group, list):
|
|
1145
1351
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation match rules are malformed", "NORMALIZE", details={"queId": que_id})
|
|
1146
|
-
|
|
1352
|
+
conditions = [AppFormNormalizer._data_relation_condition(item, que_id=que_id) for item in group if isinstance(item, dict)]
|
|
1353
|
+
if conditions:
|
|
1354
|
+
groups.append(conditions)
|
|
1147
1355
|
result["filters"] = {"mode": "simple", "groups": groups}
|
|
1148
1356
|
sort = relation.get("sortConfig") if isinstance(relation.get("sortConfig"), dict) else None
|
|
1149
1357
|
if sort is not None:
|
|
@@ -2173,6 +2381,11 @@ class AppFormCompiler:
|
|
|
2173
2381
|
"queHint": field.get("questionDescription") or "",
|
|
2174
2382
|
}
|
|
2175
2383
|
)
|
|
2384
|
+
if _positive_int((template or {}).get("queType")) == 1 and field_type == "long_text":
|
|
2385
|
+
# queType=1 is a non-data description control. Keep its backend
|
|
2386
|
+
# type and template-only settings while allowing the declaration
|
|
2387
|
+
# to carry its stable identity and default content.
|
|
2388
|
+
question["queType"] = 1
|
|
2176
2389
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
2177
2390
|
if field_type in {"text", "long_text", "link", "phone", "email"}:
|
|
2178
2391
|
for key in ("minWordCount", "maxWordCount", "noRepeat", "canScan", "scanType"):
|
|
@@ -2,9 +2,16 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from ..context import CliContext
|
|
7
|
-
from ...app_form_validator import
|
|
8
|
+
from ...app_form_validator import (
|
|
9
|
+
AppFormValidationError,
|
|
10
|
+
encode_code_block_field_ids,
|
|
11
|
+
validate_code_block_static,
|
|
12
|
+
validate_declaration,
|
|
13
|
+
validate_formula_expression,
|
|
14
|
+
)
|
|
8
15
|
from ..json_io import load_json_value
|
|
9
16
|
from .common import load_list_arg, load_object_arg, parse_bool_text, raise_config_error, require_list_arg
|
|
10
17
|
|
|
@@ -123,6 +130,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
123
130
|
app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
|
|
124
131
|
app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
|
|
125
132
|
|
|
133
|
+
app_form_validate_formula = app_form_subparsers.add_parser(
|
|
134
|
+
"validate-formula", help="校验公式表达式(本地语法 + 后端求值)"
|
|
135
|
+
)
|
|
136
|
+
app_form_validate_formula.add_argument("--app-key", required=True, help="目标应用 key")
|
|
137
|
+
app_form_validate_formula.add_argument("--formula", required=True, help="公式文本")
|
|
138
|
+
app_form_validate_formula.add_argument(
|
|
139
|
+
"--test-case",
|
|
140
|
+
required=True,
|
|
141
|
+
help="测试用例 JSON 文件路径,- 表示 stdin;格式 {queId: value}",
|
|
142
|
+
)
|
|
143
|
+
app_form_validate_formula.add_argument(
|
|
144
|
+
"--fields",
|
|
145
|
+
required=False,
|
|
146
|
+
help="字段清单 JSON 文件路径(用于本地字段引用校验);缺省时从后端拉取",
|
|
147
|
+
)
|
|
148
|
+
app_form_validate_formula.set_defaults(
|
|
149
|
+
handler=_handle_app_form_validate_formula, format_hint="builder_summary", force_json_output=True
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
app_form_validate_code_block = app_form_subparsers.add_parser(
|
|
153
|
+
"validate-code-block", help="校验代码块(本地静态 + 后端真跑)"
|
|
154
|
+
)
|
|
155
|
+
app_form_validate_code_block.add_argument("--app-key", required=True, help="目标应用 key")
|
|
156
|
+
app_form_validate_code_block.add_argument(
|
|
157
|
+
"--code", required=True, help="代码文本(或 @文件路径读取)"
|
|
158
|
+
)
|
|
159
|
+
app_form_validate_code_block.add_argument(
|
|
160
|
+
"--test-case",
|
|
161
|
+
required=True,
|
|
162
|
+
help="测试用例 JSON 文件路径,- 表示 stdin;格式 [{keyQueId, ordinal?, values}]",
|
|
163
|
+
)
|
|
164
|
+
app_form_validate_code_block.set_defaults(
|
|
165
|
+
handler=_handle_app_form_validate_code_block, format_hint="builder_summary", force_json_output=True
|
|
166
|
+
)
|
|
167
|
+
|
|
126
168
|
app = builder_subparsers.add_parser("app", help="应用")
|
|
127
169
|
app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
|
|
128
170
|
|
|
@@ -390,6 +432,171 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
|
|
|
390
432
|
return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
|
|
391
433
|
|
|
392
434
|
|
|
435
|
+
def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliContext) -> dict:
|
|
436
|
+
formula = args.formula
|
|
437
|
+
test_case = load_json_value(
|
|
438
|
+
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
439
|
+
)
|
|
440
|
+
if not isinstance(test_case, dict):
|
|
441
|
+
raise_config_error(
|
|
442
|
+
"--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# ① 本地语法校验(函数名 + 字段引用存在性)
|
|
446
|
+
fields = _load_fields_for_validation(args, context)
|
|
447
|
+
syntax_errors = validate_formula_expression(formula, fields)
|
|
448
|
+
|
|
449
|
+
# ② 后端求值校验(语法有错时不浪费后端调用)
|
|
450
|
+
evaluation = None
|
|
451
|
+
evaluation_error = None
|
|
452
|
+
if not syntax_errors:
|
|
453
|
+
result = context.builder.app_form_evaluate_formula(
|
|
454
|
+
profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
|
|
455
|
+
)
|
|
456
|
+
evaluation = result.get("evaluation")
|
|
457
|
+
evaluation_error = result.get("evaluationError")
|
|
458
|
+
|
|
459
|
+
ok = bool(
|
|
460
|
+
not syntax_errors
|
|
461
|
+
and isinstance(evaluation, dict)
|
|
462
|
+
and evaluation.get("valid") is True
|
|
463
|
+
)
|
|
464
|
+
return {
|
|
465
|
+
"category": "validation",
|
|
466
|
+
"status": "success" if ok else "failed",
|
|
467
|
+
"ok": ok,
|
|
468
|
+
"syntaxErrors": syntax_errors,
|
|
469
|
+
"evaluation": evaluation,
|
|
470
|
+
"evaluationError": evaluation_error,
|
|
471
|
+
"message": "formula is valid" if ok else "formula validation failed",
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
|
|
476
|
+
code = _read_code_arg(args.code)
|
|
477
|
+
# 后端运行时按 encryptQueId 的编码形态解码代码里的 $$id$$ 段再匹配 test-case 填值;
|
|
478
|
+
# AI 生成的代码是十进制 queId,先编码归一化(含字母的已编码段原样保留,防双重编码)。
|
|
479
|
+
code = encode_code_block_field_ids(code)
|
|
480
|
+
test_case = load_json_value(
|
|
481
|
+
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
482
|
+
)
|
|
483
|
+
if not isinstance(test_case, list):
|
|
484
|
+
raise_config_error(
|
|
485
|
+
"--test-case must be a JSON array [{keyQueId, ordinal?, values}]",
|
|
486
|
+
error_code="INVALID_TEST_CASE",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# ① 本地静态校验(qf_output 赋值 + 基本语法)
|
|
490
|
+
static_errors = validate_code_block_static(code)
|
|
491
|
+
|
|
492
|
+
# ② 后端真跑校验(静态有错时不浪费后端调用)
|
|
493
|
+
key_que_values = _normalize_code_block_test_case(test_case)
|
|
494
|
+
run_result = None
|
|
495
|
+
run_error = None
|
|
496
|
+
if not static_errors:
|
|
497
|
+
result = context.builder.app_form_run_code_block(
|
|
498
|
+
profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
|
|
499
|
+
)
|
|
500
|
+
run_result = result.get("runResult")
|
|
501
|
+
run_error = result.get("runError")
|
|
502
|
+
|
|
503
|
+
ok = not static_errors and run_result is not None
|
|
504
|
+
return {
|
|
505
|
+
"category": "validation",
|
|
506
|
+
"status": "success" if ok else "failed",
|
|
507
|
+
"ok": ok,
|
|
508
|
+
"staticErrors": static_errors,
|
|
509
|
+
"runResult": run_result,
|
|
510
|
+
"runError": run_error,
|
|
511
|
+
"message": "code block is valid" if ok else "code block validation failed",
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
|
|
516
|
+
"""加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
|
|
517
|
+
|
|
518
|
+
后端拉取失败时返回 None(跳过字段引用校验,不阻断流程)。
|
|
519
|
+
"""
|
|
520
|
+
fields_path = getattr(args, "fields", None)
|
|
521
|
+
if fields_path:
|
|
522
|
+
fields = load_json_value(
|
|
523
|
+
fields_path, option_name="--fields", max_bytes=2 * 1024 * 1024, max_depth=64
|
|
524
|
+
)
|
|
525
|
+
if not isinstance(fields, list):
|
|
526
|
+
raise_config_error(
|
|
527
|
+
"--fields must be a JSON array of field objects", error_code="INVALID_FIELDS"
|
|
528
|
+
)
|
|
529
|
+
return fields
|
|
530
|
+
fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
|
|
531
|
+
if not isinstance(fetched, dict) or fetched.get("status") != "success":
|
|
532
|
+
return None
|
|
533
|
+
raw_fields = fetched.get("fields")
|
|
534
|
+
if not isinstance(raw_fields, list):
|
|
535
|
+
return None
|
|
536
|
+
return [
|
|
537
|
+
{"queId": field.get("que_id"), "name": field.get("name")}
|
|
538
|
+
for field in raw_fields
|
|
539
|
+
if isinstance(field, dict)
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _read_code_arg(code_arg: str) -> str:
|
|
544
|
+
"""读取 --code 参数:@ 前缀读文件内容,否则当文本。"""
|
|
545
|
+
if not isinstance(code_arg, str) or not code_arg:
|
|
546
|
+
raise_config_error("--code is required", error_code="INVALID_CODE")
|
|
547
|
+
if code_arg.startswith("@"):
|
|
548
|
+
path = code_arg[1:]
|
|
549
|
+
try:
|
|
550
|
+
return Path(path).expanduser().read_text(encoding="utf-8")
|
|
551
|
+
except OSError as exc:
|
|
552
|
+
raise_config_error(
|
|
553
|
+
f"failed to read --code from '{path}': {exc}", error_code="INVALID_CODE"
|
|
554
|
+
)
|
|
555
|
+
return code_arg
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _normalize_code_block_test_case(test_case: list) -> list[dict]:
|
|
559
|
+
"""将 --test-case 规范化为后端 OpenMatchQueValue 结构 [{keyQueId, ordinal?, values}]。"""
|
|
560
|
+
normalized: list[dict] = []
|
|
561
|
+
for item in test_case:
|
|
562
|
+
if not isinstance(item, dict):
|
|
563
|
+
raise_config_error(
|
|
564
|
+
"--test-case items must be JSON objects {keyQueId, ordinal?, values}",
|
|
565
|
+
error_code="INVALID_TEST_CASE",
|
|
566
|
+
)
|
|
567
|
+
raw_que_id = item.get("keyQueId", item.get("key_que_id"))
|
|
568
|
+
if raw_que_id is None:
|
|
569
|
+
raise_config_error(
|
|
570
|
+
"--test-case items require keyQueId", error_code="INVALID_TEST_CASE"
|
|
571
|
+
)
|
|
572
|
+
try:
|
|
573
|
+
key_que_id = int(raw_que_id)
|
|
574
|
+
except (TypeError, ValueError):
|
|
575
|
+
raise_config_error(
|
|
576
|
+
f"--test-case keyQueId must be an integer, got {raw_que_id!r}",
|
|
577
|
+
error_code="INVALID_TEST_CASE",
|
|
578
|
+
)
|
|
579
|
+
entry: dict = {"keyQueId": key_que_id}
|
|
580
|
+
if item.get("ordinal") is not None:
|
|
581
|
+
try:
|
|
582
|
+
entry["ordinal"] = int(item.get("ordinal"))
|
|
583
|
+
except (TypeError, ValueError):
|
|
584
|
+
raise_config_error(
|
|
585
|
+
f"--test-case ordinal must be an integer, got {item.get('ordinal')!r}",
|
|
586
|
+
error_code="INVALID_TEST_CASE",
|
|
587
|
+
)
|
|
588
|
+
values = item.get("values", [])
|
|
589
|
+
if not isinstance(values, list):
|
|
590
|
+
raise_config_error(
|
|
591
|
+
"--test-case item values must be a JSON array", error_code="INVALID_TEST_CASE"
|
|
592
|
+
)
|
|
593
|
+
entry["values"] = [str(value) for value in values]
|
|
594
|
+
if item.get("tableValues") is not None:
|
|
595
|
+
entry["tableValues"] = item.get("tableValues")
|
|
596
|
+
normalized.append(entry)
|
|
597
|
+
return normalized
|
|
598
|
+
|
|
599
|
+
|
|
393
600
|
def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
|
|
394
601
|
return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
|
|
395
602
|
|
|
@@ -144,6 +144,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
144
144
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
145
145
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
|
|
146
146
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
|
|
147
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
|
|
148
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
|
|
147
149
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
|
|
148
150
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
|
|
149
151
|
PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -51,6 +51,10 @@ COMMON_ERROR_DROP_TOP = {
|
|
|
51
51
|
|
|
52
52
|
SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
|
|
53
53
|
|
|
54
|
+
#: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
|
|
55
|
+
#: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
|
|
56
|
+
_KEEP_OK_TOOLS = {"builder:app_form_validate_formula", "builder:app_form_validate_code_block"}
|
|
57
|
+
|
|
54
58
|
|
|
55
59
|
def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict[str, Any]:
|
|
56
60
|
if not isinstance(payload, dict):
|
|
@@ -85,6 +89,8 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
|
|
|
85
89
|
drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
|
|
86
90
|
if tool_name in {"user:record_insert", "user:record_update", "user:record_delete"} and payload.get("ok") is False:
|
|
87
91
|
drop_keys = drop_keys - {"ok"}
|
|
92
|
+
if tool_name in _KEEP_OK_TOOLS:
|
|
93
|
+
drop_keys = drop_keys - {"ok"}
|
|
88
94
|
if tool_name == "builder:app_form_apply" and payload.get("mustReadCurrentStateBeforeRepair") is True:
|
|
89
95
|
drop_keys = drop_keys - {"suggested_next_call"}
|
|
90
96
|
_drop_top_keys(trimmed, drop_keys)
|
|
@@ -852,6 +852,64 @@ class AiBuilderTools(ToolBase):
|
|
|
852
852
|
"""Reconcile one complete declarative AppForm target."""
|
|
853
853
|
return self._app_form.apply(profile=profile, spec=spec)
|
|
854
854
|
|
|
855
|
+
def app_form_evaluate_formula(
|
|
856
|
+
self,
|
|
857
|
+
*,
|
|
858
|
+
profile: str,
|
|
859
|
+
app_key: str,
|
|
860
|
+
formula: str,
|
|
861
|
+
test_case: dict[int, Any],
|
|
862
|
+
) -> JSONObject:
|
|
863
|
+
"""调用后端 POST /ai/coding/formula/validate 对公式求值。
|
|
864
|
+
|
|
865
|
+
本地语法校验由 CLI handler 负责;本方法只做后端求值,捕获后端错误为 evaluationError。
|
|
866
|
+
鉴权/会话错误(未登录/未选工作区)按既有约定向上抛出 QingflowApiError。
|
|
867
|
+
"""
|
|
868
|
+
_session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
|
|
869
|
+
evaluation_result: Any = None
|
|
870
|
+
evaluation_error: str | None = None
|
|
871
|
+
try:
|
|
872
|
+
evaluation_result = self.backend.request(
|
|
873
|
+
"POST",
|
|
874
|
+
context,
|
|
875
|
+
"/ai/coding/formula/validate",
|
|
876
|
+
json_body={"appKey": app_key, "formula": formula, "testCase": test_case},
|
|
877
|
+
)
|
|
878
|
+
except QingflowApiError as exc:
|
|
879
|
+
evaluation_error = exc.message
|
|
880
|
+
return {"evaluation": evaluation_result, "evaluationError": evaluation_error}
|
|
881
|
+
|
|
882
|
+
def app_form_run_code_block(
|
|
883
|
+
self,
|
|
884
|
+
*,
|
|
885
|
+
profile: str,
|
|
886
|
+
app_key: str,
|
|
887
|
+
code: str,
|
|
888
|
+
key_que_values: list[JSONObject],
|
|
889
|
+
) -> JSONObject:
|
|
890
|
+
"""调用后端 POST /data/{appKey}/codeBlock/detection 真跑代码块。
|
|
891
|
+
|
|
892
|
+
本地静态校验由 CLI handler 负责;本方法只做后端真跑,捕获后端错误为 runError。
|
|
893
|
+
``key_que_values`` 需符合后端 OpenMatchQueValue 结构 ``{keyQueId, ordinal?, values}``。
|
|
894
|
+
|
|
895
|
+
传入的 ``code`` 是原始 JS(未经前端字段公式加密),必须声明 ``unEncrypted=True``;
|
|
896
|
+
否则后端 ``DataSvcImpl.codeBlockTest`` 会走 ``FormulaDecoder.extractFormula``
|
|
897
|
+
对原始 JS 解密并抛 ``QFRuntimeException``,导致后端真跑对所有真实代码必然失败。
|
|
898
|
+
"""
|
|
899
|
+
_session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
|
|
900
|
+
run_result: Any = None
|
|
901
|
+
run_error: str | None = None
|
|
902
|
+
try:
|
|
903
|
+
run_result = self.backend.request(
|
|
904
|
+
"POST",
|
|
905
|
+
context,
|
|
906
|
+
f"/data/{app_key}/codeBlock/detection",
|
|
907
|
+
json_body={"code": code, "keyQueValues": key_que_values, "unEncrypted": True},
|
|
908
|
+
)
|
|
909
|
+
except QingflowApiError as exc:
|
|
910
|
+
run_error = exc.message
|
|
911
|
+
return {"runResult": run_result, "runError": run_error}
|
|
912
|
+
|
|
855
913
|
def app_delete(self, *, profile: str, app_key: str) -> JSONObject:
|
|
856
914
|
"""Delete an application explicitly."""
|
|
857
915
|
return self._app_form.delete(profile=profile, app_key=app_key)
|