@josephyan/qingflow-cli 0.2.0-beta.985 → 0.2.0-beta.987

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 (44) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +70 -11
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/qingflow_mcp/__init__.py +1 -1
  6. package/src/qingflow_mcp/builder_facade/service.py +376 -19
  7. package/src/qingflow_mcp/cli/commands/auth.py +14 -43
  8. package/src/qingflow_mcp/cli/commands/workspace.py +8 -5
  9. package/src/qingflow_mcp/cli/formatters.py +19 -22
  10. package/src/qingflow_mcp/config.py +39 -0
  11. package/src/qingflow_mcp/errors.py +2 -2
  12. package/src/qingflow_mcp/public_surface.py +4 -6
  13. package/src/qingflow_mcp/response_trim.py +1 -8
  14. package/src/qingflow_mcp/server.py +1 -1
  15. package/src/qingflow_mcp/server_app_builder.py +4 -28
  16. package/src/qingflow_mcp/server_app_user.py +4 -28
  17. package/src/qingflow_mcp/session_store.py +31 -5
  18. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  19. package/src/qingflow_mcp/solution/executor.py +2 -2
  20. package/src/qingflow_mcp/tools/ai_builder_tools.py +117 -1
  21. package/src/qingflow_mcp/tools/app_tools.py +51 -1
  22. package/src/qingflow_mcp/tools/approval_tools.py +82 -1
  23. package/src/qingflow_mcp/tools/auth_tools.py +306 -288
  24. package/src/qingflow_mcp/tools/base.py +204 -4
  25. package/src/qingflow_mcp/tools/code_block_tools.py +21 -0
  26. package/src/qingflow_mcp/tools/custom_button_tools.py +24 -1
  27. package/src/qingflow_mcp/tools/directory_tools.py +28 -1
  28. package/src/qingflow_mcp/tools/feedback_tools.py +8 -0
  29. package/src/qingflow_mcp/tools/file_tools.py +25 -1
  30. package/src/qingflow_mcp/tools/import_tools.py +40 -1
  31. package/src/qingflow_mcp/tools/navigation_tools.py +34 -1
  32. package/src/qingflow_mcp/tools/package_tools.py +37 -1
  33. package/src/qingflow_mcp/tools/portal_tools.py +28 -1
  34. package/src/qingflow_mcp/tools/qingbi_report_tools.py +38 -1
  35. package/src/qingflow_mcp/tools/record_tools.py +255 -2
  36. package/src/qingflow_mcp/tools/repository_dev_tools.py +21 -2
  37. package/src/qingflow_mcp/tools/resource_read_tools.py +23 -1
  38. package/src/qingflow_mcp/tools/role_tools.py +19 -1
  39. package/src/qingflow_mcp/tools/solution_tools.py +56 -1
  40. package/src/qingflow_mcp/tools/task_context_tools.py +72 -1
  41. package/src/qingflow_mcp/tools/task_tools.py +49 -3
  42. package/src/qingflow_mcp/tools/view_tools.py +56 -1
  43. package/src/qingflow_mcp/tools/workflow_tools.py +65 -1
  44. package/src/qingflow_mcp/tools/workspace_tools.py +100 -217
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-cli@0.2.0-beta.985
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.987
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.985 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.987 qingflow
13
13
  ```
14
14
 
15
15
  Environment:
@@ -6,6 +6,20 @@
6
6
  2. 记录/待办优先的 `qingflow-app-user-mcp`
7
7
  3. 精简 builder 的 `qingflow-app-builder-mcp`
8
8
 
9
+ ## 本地鉴权推荐方案
10
+
11
+ 本地模式现在推荐优先使用 `credential` 建立会话,而不是直接注入 `token`。
12
+
13
+ 推荐链路:
14
+
15
+ 1. createClaw 或其它本地宿主为当前实例保存 `credential`
16
+ 2. 本地 MCP 调用 `auth_use_credential`
17
+ 3. MCP 用该 `credential` 请求 apaas `/mcp/auth/context`
18
+ 4. 解析并保存 `token / wsId / qfVersion / uid`
19
+ 5. 业务工具直接使用这份上下文
20
+
21
+ `auth_use_credential` 是本地唯一鉴权主路径。
22
+
9
23
  ## npm 安装器适用场景
10
24
 
11
25
  适合这类本地 agent / gateway:
@@ -63,17 +77,17 @@ npm run pack:npm
63
77
  会生成:
64
78
 
65
79
  ```bash
66
- dist/npm/josephyan-qingflow-cli-<version>.tgz
67
- dist/npm/josephyan-qingflow-app-user-mcp-<version>.tgz
68
- dist/npm/josephyan-qingflow-app-builder-mcp-<version>.tgz
80
+ dist/npm/qingflow-tech-qingflow-cli-<version>.tgz
81
+ dist/npm/qingflow-tech-qingflow-app-user-mcp-<version>.tgz
82
+ dist/npm/qingflow-tech-qingflow-app-builder-mcp-<version>.tgz
69
83
  ```
70
84
 
71
85
  然后在目标机器安装:
72
86
 
73
87
  ```bash
74
- npm install /absolute/path/to/dist/npm/josephyan-qingflow-cli-<version>.tgz
75
- npm install /absolute/path/to/dist/npm/josephyan-qingflow-app-user-mcp-<version>.tgz
76
- npm install /absolute/path/to/dist/npm/josephyan-qingflow-app-builder-mcp-<version>.tgz
88
+ npm install /absolute/path/to/dist/npm/qingflow-tech-qingflow-cli-<version>.tgz
89
+ npm install /absolute/path/to/dist/npm/qingflow-tech-qingflow-app-user-mcp-<version>.tgz
90
+ npm install /absolute/path/to/dist/npm/qingflow-tech-qingflow-app-builder-mcp-<version>.tgz
77
91
  ```
78
92
 
79
93
  安装时会自动:
@@ -136,7 +150,10 @@ qingflow-app-builder-mcp
136
150
  ],
137
151
  "env": {
138
152
  "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
139
- "QINGFLOW_MCP_HOME": "/absolute/path/to/.qingflow-mcp"
153
+ "QINGFLOW_MCP_HOME": "/absolute/path/to/.qingflow-mcp",
154
+ "QINGFLOW_MCP_CREDIT_METER_ENABLED": "true",
155
+ "QINGFLOW_MCP_CREDIT_APAAS_BASE_URL": "https://apaas.internal.example.com",
156
+ "QINGFLOW_MCP_CREDIT_APAAS_PATH": "/user/credit/usage"
140
157
  }
141
158
  }
142
159
  }
@@ -153,7 +170,10 @@ qingflow-app-builder-mcp
153
170
  "args": [],
154
171
  "env": {
155
172
  "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
156
- "QINGFLOW_MCP_HOME": "/absolute/path/to/.qingflow-mcp"
173
+ "QINGFLOW_MCP_HOME": "/absolute/path/to/.qingflow-mcp",
174
+ "QINGFLOW_MCP_CREDIT_METER_ENABLED": "true",
175
+ "QINGFLOW_MCP_CREDIT_APAAS_BASE_URL": "https://apaas.internal.example.com",
176
+ "QINGFLOW_MCP_CREDIT_APAAS_PATH": "/user/credit/usage"
157
177
  }
158
178
  }
159
179
  }
@@ -170,7 +190,10 @@ qingflow-app-builder-mcp
170
190
  "args": [],
171
191
  "env": {
172
192
  "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
173
- "QINGFLOW_MCP_HOME": "/absolute/path/to/.qingflow-mcp"
193
+ "QINGFLOW_MCP_HOME": "/absolute/path/to/.qingflow-mcp",
194
+ "QINGFLOW_MCP_CREDIT_METER_ENABLED": "true",
195
+ "QINGFLOW_MCP_CREDIT_APAAS_BASE_URL": "https://apaas.internal.example.com",
196
+ "QINGFLOW_MCP_CREDIT_APAAS_PATH": "/user/credit/usage"
174
197
  }
175
198
  }
176
199
  }
@@ -191,7 +214,10 @@ qingflow-app-builder-mcp
191
214
  "@josephyan/qingflow-app-user-mcp"
192
215
  ],
193
216
  "env": {
194
- "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api"
217
+ "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
218
+ "QINGFLOW_MCP_CREDIT_METER_ENABLED": "true",
219
+ "QINGFLOW_MCP_CREDIT_APAAS_BASE_URL": "https://apaas.internal.example.com",
220
+ "QINGFLOW_MCP_CREDIT_APAAS_PATH": "/user/credit/usage"
195
221
  }
196
222
  },
197
223
  "qingflow-builder": {
@@ -201,7 +227,10 @@ qingflow-app-builder-mcp
201
227
  "@josephyan/qingflow-app-builder-mcp"
202
228
  ],
203
229
  "env": {
204
- "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api"
230
+ "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
231
+ "QINGFLOW_MCP_CREDIT_METER_ENABLED": "true",
232
+ "QINGFLOW_MCP_CREDIT_APAAS_BASE_URL": "https://apaas.internal.example.com",
233
+ "QINGFLOW_MCP_CREDIT_APAAS_PATH": "/user/credit/usage"
205
234
  }
206
235
  }
207
236
  }
@@ -212,6 +241,7 @@ qingflow-app-builder-mcp
212
241
  - 源码目录 `npm install` 不会把命令加到全局 PATH;这种模式请用 `node ./npm/bin/qingflow.mjs`、`node ./npm/bin/qingflow-app-user-mcp.mjs` 或 `node ./npm/bin/qingflow-app-builder-mcp.mjs`
213
242
  - `npx` 方式适合临时安装或容器化本地 agent
214
243
  - 全局安装方式更适合长期固定使用的本机开发环境
244
+ - 计费接口使用当前登录会话的 `token` 与 `wsId` 请求头,可通过 `QINGFLOW_MCP_CREDIT_APAAS_BASE_URL/PATH` 覆盖调用记录接口地址
215
245
 
216
246
  ## 排障
217
247
 
@@ -242,3 +272,32 @@ npm install
242
272
  4. 再启动 MCP 客户端
243
273
 
244
274
  现在 stdio MCP 入口会拒绝在启动瞬间“边启动边重建 Python 运行时”,因为安装日志一旦写进 stdout,就会破坏 MCP 握手并表现成 `Transport closed`。如果运行时缺失或版本不一致,入口会直接报错并提示重装,而不是静默自修复。
275
+
276
+ ## createClaw 本地接入示例
277
+
278
+ 如果 createClaw 已经为当前本地实例保存了 `credential`,推荐在首次建链时调用:
279
+
280
+ ```bash
281
+ qingflow auth use-credential \
282
+ --base-url https://qingflow.com/api \
283
+ --credential-stdin
284
+ ```
285
+
286
+ 然后把 `credential` 写到 stdin。
287
+
288
+ 等价 MCP 工具调用参数:
289
+
290
+ ```json
291
+ {
292
+ "profile": "default",
293
+ "base_url": "https://qingflow.com/api",
294
+ "credential": "1602853_277941",
295
+ "persist": false
296
+ }
297
+ ```
298
+
299
+ 说明:
300
+
301
+ - 本地会把解析后的 `token` 和原始 `credential` 写入 profile 文件,用于后续 CLI 命令恢复会话
302
+ - `persist=true` 时,本地还会优先把解析后的 `token` 和原始 `credential` 同步写入系统 keychain
303
+ - 当前工作区以 `/mcp/auth/context` 返回的 `wsId` 为准,不再通过本地 MCP 显式切换
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-cli",
3
- "version": "0.2.0-beta.985",
3
+ "version": "0.2.0-beta.987",
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b985"
7
+ version = "0.2.0b987"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b985"
8
+ _FALLBACK_VERSION = "0.2.0b987"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -7708,7 +7708,7 @@ class AiBuilderFacade:
7708
7708
  "request_route": request_route,
7709
7709
  },
7710
7710
  suggested_next_call=(
7711
- {"tool_name": "workspace_select", "arguments": {"profile": profile, "ws_id": request_route.get("ws_id")}}
7711
+ {"tool_name": "auth_use_credential", "arguments": {"profile": profile}}
7712
7712
  if request_route.get("ws_id")
7713
7713
  else None
7714
7714
  ),
@@ -9811,6 +9811,15 @@ def _apply_relation_target_selection(
9811
9811
  config["refer_field_types"] = [item.get("type") for item in normalized_visible]
9812
9812
  config["auth_field_ids"] = [item.get("field_id") or item.get("name") for item in normalized_visible]
9813
9813
  config["auth_field_que_ids"] = [_coerce_positive_int(item.get("que_id")) or 0 for item in normalized_visible]
9814
+ config["refer_auth_ques"] = [
9815
+ {
9816
+ "queId": _coerce_positive_int(item.get("que_id")) or 0,
9817
+ "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH,
9818
+ "_field_id": item.get("field_id") or item.get("name"),
9819
+ }
9820
+ for item in normalized_visible
9821
+ if (_coerce_positive_int(item.get("que_id")) or 0) > 0
9822
+ ]
9814
9823
  config["field_name_show"] = bool(field.get("field_name_show", True))
9815
9824
  field["target_field_id"] = display_field.get("field_id") or display_field.get("name")
9816
9825
  field["target_field_que_id"] = _coerce_positive_int(display_field.get("que_id")) or 0
@@ -10071,17 +10080,32 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
10071
10080
  field["target_app_key"] = reference.get("referAppKey")
10072
10081
  field["relation_mode"] = _relation_mode_from_optional_data_num(reference.get("optionalDataNum"))
10073
10082
  refer_questions = reference.get("referQuestions") if isinstance(reference.get("referQuestions"), list) else []
10083
+ refer_auth_questions = reference.get("referAuthQues") if isinstance(reference.get("referAuthQues"), list) else []
10084
+ refer_auth_by_que_id: dict[int, int] = {}
10085
+ for raw_item in refer_auth_questions:
10086
+ if not isinstance(raw_item, dict):
10087
+ continue
10088
+ que_id = _coerce_nonnegative_int(raw_item.get("queId"))
10089
+ que_auth = _coerce_nonnegative_int(raw_item.get("queAuth"))
10090
+ if que_id is None or que_auth is None or que_id in refer_auth_by_que_id:
10091
+ continue
10092
+ refer_auth_by_que_id[que_id] = que_auth
10074
10093
  visible_fields: list[dict[str, Any]] = []
10075
10094
  display_field_que_id = _coerce_nonnegative_int(reference.get("referQueId"))
10076
10095
  display_field_name: str | None = None
10077
10096
  for item in refer_questions:
10078
10097
  if not isinstance(item, dict):
10079
10098
  continue
10099
+ que_id = _coerce_nonnegative_int(item.get("queId"))
10100
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
10101
+ if que_auth is None and que_id is not None:
10102
+ que_auth = refer_auth_by_que_id.get(que_id)
10080
10103
  selector = {
10081
- "que_id": _coerce_nonnegative_int(item.get("queId")),
10104
+ "que_id": que_id,
10082
10105
  "name": str(item.get("queTitle") or "").strip() or None,
10083
10106
  }
10084
- visible_fields.append(selector)
10107
+ if que_auth != _REFERENCE_FIELD_HIDDEN_AUTH:
10108
+ visible_fields.append(selector)
10085
10109
  if display_field_que_id is not None and selector["que_id"] == display_field_que_id:
10086
10110
  display_field_name = selector["name"]
10087
10111
  if display_field_name is None and visible_fields:
@@ -13275,6 +13299,333 @@ def _normalize_reference_auth_question_for_save(value: Any) -> dict[str, Any] |
13275
13299
  return payload
13276
13300
 
13277
13301
 
13302
+ def _dedupe_reference_auth_questions(auth_questions: list[dict[str, Any]]) -> list[dict[str, Any]]:
13303
+ deduped: list[dict[str, Any]] = []
13304
+ seen_que_ids: set[int] = set()
13305
+ for item in auth_questions:
13306
+ normalized_item = _normalize_reference_auth_question_for_save(item)
13307
+ if normalized_item is None:
13308
+ continue
13309
+ que_id = _coerce_any_int(normalized_item.get("queId"))
13310
+ if que_id is None or que_id in seen_que_ids:
13311
+ continue
13312
+ seen_que_ids.add(que_id)
13313
+ deduped.append(normalized_item)
13314
+ return deduped
13315
+
13316
+
13317
+ _REFERENCE_FIELD_HIDDEN_AUTH = 2
13318
+ _REFERENCE_FIELD_VISIBLE_AUTH = 3
13319
+
13320
+
13321
+ def _synthesize_reference_auth_questions_for_save(
13322
+ *,
13323
+ source: dict[str, Any],
13324
+ field: dict[str, Any],
13325
+ ) -> list[dict[str, Any]]:
13326
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
13327
+ synthesized: list[dict[str, Any]] = []
13328
+
13329
+ if isinstance(config.get("refer_auth_ques"), list):
13330
+ synthesized.extend(cast(list[dict[str, Any]], config.get("refer_auth_ques") or []))
13331
+ if synthesized:
13332
+ return _dedupe_reference_auth_questions(synthesized)
13333
+
13334
+ refer_question_ids_by_name: dict[str, int] = {}
13335
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13336
+ if not isinstance(raw_item, dict):
13337
+ continue
13338
+ que_id = _coerce_any_int(raw_item.get("queId"))
13339
+ name = str(raw_item.get("queTitle") or "").strip()
13340
+ if que_id is None or not name or name in refer_question_ids_by_name:
13341
+ continue
13342
+ refer_question_ids_by_name[name] = que_id
13343
+
13344
+ visible_fields = cast(list[dict[str, Any]], field.get("visible_fields") or [])
13345
+ for item in visible_fields:
13346
+ if not isinstance(item, dict):
13347
+ continue
13348
+ que_id = _coerce_any_int(item.get("que_id"))
13349
+ if que_id is None:
13350
+ name = str(item.get("name") or "").strip()
13351
+ que_id = refer_question_ids_by_name.get(name)
13352
+ if que_id is None:
13353
+ continue
13354
+ synthesized.append({"queId": que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13355
+ if synthesized:
13356
+ return _dedupe_reference_auth_questions(synthesized)
13357
+
13358
+ auth_field_que_ids = cast(list[Any], config.get("auth_field_que_ids") or [])
13359
+ for raw_que_id in auth_field_que_ids:
13360
+ que_id = _coerce_any_int(raw_que_id)
13361
+ if que_id is None:
13362
+ continue
13363
+ synthesized.append({"queId": que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13364
+ if synthesized:
13365
+ return _dedupe_reference_auth_questions(synthesized)
13366
+
13367
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13368
+ if not isinstance(raw_item, dict):
13369
+ continue
13370
+ que_id = _coerce_any_int(raw_item.get("queId"))
13371
+ if que_id is None:
13372
+ continue
13373
+ synthesized.append(
13374
+ {
13375
+ "queId": que_id,
13376
+ "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH,
13377
+ }
13378
+ )
13379
+ if synthesized:
13380
+ return _dedupe_reference_auth_questions(synthesized)
13381
+
13382
+ fallback_que_id = _coerce_any_int(field.get("target_field_que_id"))
13383
+ if fallback_que_id is not None:
13384
+ synthesized.append({"queId": fallback_que_id, "queAuth": _REFERENCE_FIELD_VISIBLE_AUTH})
13385
+ return _dedupe_reference_auth_questions(synthesized)
13386
+
13387
+
13388
+ def _reference_question_auth_overrides_for_save(
13389
+ *,
13390
+ source: dict[str, Any],
13391
+ field: dict[str, Any],
13392
+ ) -> dict[int, int]:
13393
+ overrides: dict[int, int] = {}
13394
+ visible_que_ids: set[int] = set()
13395
+
13396
+ for item in cast(list[Any], field.get("visible_fields") or []):
13397
+ if not isinstance(item, dict):
13398
+ continue
13399
+ que_id = _coerce_any_int(item.get("que_id"))
13400
+ if que_id is not None:
13401
+ visible_que_ids.add(que_id)
13402
+
13403
+ if not visible_que_ids:
13404
+ refer_auth_ques = _synthesize_reference_auth_questions_for_save(source=source, field=field)
13405
+ for item in refer_auth_ques:
13406
+ que_id = _coerce_any_int(item.get("queId"))
13407
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13408
+ if que_id is None or que_auth is None:
13409
+ continue
13410
+ overrides[que_id] = que_auth
13411
+ if overrides:
13412
+ return overrides
13413
+
13414
+ for raw_item in cast(list[Any], source.get("referQuestions") or []):
13415
+ if not isinstance(raw_item, dict):
13416
+ continue
13417
+ que_id = _coerce_any_int(raw_item.get("queId"))
13418
+ if que_id is None:
13419
+ continue
13420
+ overrides[que_id] = (
13421
+ _REFERENCE_FIELD_VISIBLE_AUTH if que_id in visible_que_ids else _REFERENCE_FIELD_HIDDEN_AUTH
13422
+ )
13423
+ return overrides
13424
+
13425
+
13426
+ def _reference_question_matches_visible_selector(question: dict[str, Any], selector: dict[str, Any]) -> bool:
13427
+ question_que_id = _coerce_any_int(question.get("queId"))
13428
+ selector_que_id = _coerce_any_int(selector.get("que_id"))
13429
+ if question_que_id is not None and selector_que_id is not None and question_que_id == selector_que_id:
13430
+ return True
13431
+ question_name = str(question.get("queTitle") or "").strip()
13432
+ selector_name = str(selector.get("name") or "").strip()
13433
+ return bool(question_name and selector_name and question_name == selector_name)
13434
+
13435
+
13436
+ def _build_reference_question_from_visible_selector(
13437
+ selector: dict[str, Any],
13438
+ *,
13439
+ ordinal: int,
13440
+ ) -> dict[str, Any] | None:
13441
+ return _normalize_reference_question_for_save(
13442
+ {
13443
+ "queId": _coerce_any_int(selector.get("que_id")),
13444
+ "queTitle": str(selector.get("name") or "").strip() or None,
13445
+ "queType": str(selector.get("type") or "2"),
13446
+ "ordinal": ordinal,
13447
+ },
13448
+ ordinal=ordinal,
13449
+ )
13450
+
13451
+
13452
+ def _canonicalize_reference_questions_for_save(
13453
+ *,
13454
+ source: dict[str, Any],
13455
+ field: dict[str, Any],
13456
+ ) -> list[dict[str, Any]]:
13457
+ normalized_source_questions = [
13458
+ item
13459
+ for item in (
13460
+ _normalize_reference_question_for_save(raw_item, ordinal=index)
13461
+ for index, raw_item in enumerate(cast(list[Any], source.get("referQuestions") or []), start=1)
13462
+ )
13463
+ if item is not None
13464
+ ]
13465
+
13466
+ display_field = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
13467
+ visible_fields = [item for item in cast(list[Any], field.get("visible_fields") or []) if isinstance(item, dict)]
13468
+ ordered_visible_selectors: list[dict[str, Any]] = []
13469
+ if display_field is not None:
13470
+ ordered_visible_selectors.append(display_field)
13471
+ for item in visible_fields:
13472
+ if any(_relation_target_field_matches(existing, item) for existing in ordered_visible_selectors):
13473
+ continue
13474
+ ordered_visible_selectors.append(item)
13475
+
13476
+ if not ordered_visible_selectors:
13477
+ return normalized_source_questions
13478
+
13479
+ canonical_questions: list[dict[str, Any]] = []
13480
+ used_source_indexes: set[int] = set()
13481
+
13482
+ for ordinal, selector in enumerate(ordered_visible_selectors, start=1):
13483
+ matched_index: int | None = None
13484
+ matched_item: dict[str, Any] | None = None
13485
+ for index, item in enumerate(normalized_source_questions):
13486
+ if index in used_source_indexes:
13487
+ continue
13488
+ if _reference_question_matches_visible_selector(item, selector):
13489
+ matched_index = index
13490
+ matched_item = deepcopy(item)
13491
+ break
13492
+ if matched_item is None:
13493
+ matched_item = _build_reference_question_from_visible_selector(selector, ordinal=ordinal)
13494
+ if matched_item is None:
13495
+ continue
13496
+ matched_item["ordinal"] = ordinal
13497
+ canonical_questions.append(matched_item)
13498
+ if matched_index is not None:
13499
+ used_source_indexes.add(matched_index)
13500
+
13501
+ next_ordinal = len(canonical_questions) + 1
13502
+ for index, item in enumerate(normalized_source_questions):
13503
+ if index in used_source_indexes:
13504
+ continue
13505
+ remaining_item = deepcopy(item)
13506
+ remaining_item["ordinal"] = next_ordinal
13507
+ next_ordinal += 1
13508
+ canonical_questions.append(remaining_item)
13509
+
13510
+ return canonical_questions
13511
+
13512
+
13513
+ def _canonicalize_reference_auth_questions_for_save(
13514
+ *,
13515
+ source: dict[str, Any],
13516
+ refer_questions: list[dict[str, Any]],
13517
+ ) -> list[dict[str, Any]]:
13518
+ source_auth_questions = [
13519
+ item
13520
+ for item in (
13521
+ _normalize_reference_auth_question_for_save(raw_item)
13522
+ for raw_item in cast(list[Any], source.get("referAuthQues") or [])
13523
+ )
13524
+ if item is not None
13525
+ ]
13526
+ source_auth_by_que_id: dict[int, dict[str, Any]] = {}
13527
+ for item in source_auth_questions:
13528
+ que_id = _coerce_any_int(item.get("queId"))
13529
+ if que_id is None or que_id in source_auth_by_que_id:
13530
+ continue
13531
+ source_auth_by_que_id[que_id] = item
13532
+
13533
+ auth_questions: list[dict[str, Any]] = []
13534
+ for item in refer_questions:
13535
+ que_id = _coerce_any_int(item.get("queId"))
13536
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13537
+ if que_id is None or que_auth is None:
13538
+ continue
13539
+ payload = deepcopy(source_auth_by_que_id.get(que_id) or {"queId": que_id})
13540
+ payload["queId"] = que_id
13541
+ payload["queAuth"] = que_auth
13542
+ auth_questions.append(payload)
13543
+ return _dedupe_reference_auth_questions(auth_questions)
13544
+
13545
+
13546
+ def _enforce_reference_config_consistency_for_save(
13547
+ payload: dict[str, Any],
13548
+ *,
13549
+ field: dict[str, Any],
13550
+ ) -> dict[str, Any]:
13551
+ refer_questions = [
13552
+ item
13553
+ for item in (
13554
+ _normalize_reference_question_for_save(raw_item, ordinal=index)
13555
+ for index, raw_item in enumerate(cast(list[Any], payload.get("referQuestions") or []), start=1)
13556
+ )
13557
+ if item is not None
13558
+ ]
13559
+ if not refer_questions:
13560
+ return payload
13561
+
13562
+ refer_auth_ques = _dedupe_reference_auth_questions(
13563
+ [
13564
+ item
13565
+ for item in (
13566
+ _normalize_reference_auth_question_for_save(raw_item)
13567
+ for raw_item in cast(list[Any], payload.get("referAuthQues") or [])
13568
+ )
13569
+ if item is not None
13570
+ ]
13571
+ )
13572
+ refer_auth_by_que_id: dict[int, int] = {}
13573
+ for item in refer_auth_ques:
13574
+ que_id = _coerce_any_int(item.get("queId"))
13575
+ que_auth = _coerce_nonnegative_int(item.get("queAuth"))
13576
+ if que_id is None or que_auth is None or que_id in refer_auth_by_que_id:
13577
+ continue
13578
+ refer_auth_by_que_id[que_id] = que_auth
13579
+
13580
+ display_field_que_id = _coerce_any_int(payload.get("referQueId"))
13581
+ if display_field_que_id is None:
13582
+ display_field_que_id = _coerce_any_int(field.get("target_field_que_id"))
13583
+ if display_field_que_id is not None:
13584
+ payload["referQueId"] = display_field_que_id
13585
+
13586
+ if display_field_que_id is not None and not any(
13587
+ _coerce_any_int(item.get("queId")) == display_field_que_id for item in refer_questions
13588
+ ):
13589
+ display_selector = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
13590
+ display_question = (
13591
+ _build_reference_question_from_visible_selector(display_selector, ordinal=1)
13592
+ if display_selector is not None
13593
+ else None
13594
+ )
13595
+ if display_question is not None:
13596
+ display_question["queId"] = display_field_que_id
13597
+ display_question["queAuth"] = _REFERENCE_FIELD_VISIBLE_AUTH
13598
+ refer_questions = [display_question, *refer_questions]
13599
+
13600
+ if display_field_que_id is not None:
13601
+ display_questions = [
13602
+ item for item in refer_questions if _coerce_any_int(item.get("queId")) == display_field_que_id
13603
+ ]
13604
+ trailing_questions = [
13605
+ item for item in refer_questions if _coerce_any_int(item.get("queId")) != display_field_que_id
13606
+ ]
13607
+ refer_questions = [*display_questions, *trailing_questions]
13608
+
13609
+ for ordinal, item in enumerate(refer_questions, start=1):
13610
+ que_id = _coerce_any_int(item.get("queId"))
13611
+ if que_id is None:
13612
+ continue
13613
+ item["ordinal"] = ordinal
13614
+ item["queAuth"] = refer_auth_by_que_id.get(
13615
+ que_id,
13616
+ _coerce_nonnegative_int(item.get("queAuth")) or _REFERENCE_FIELD_VISIBLE_AUTH,
13617
+ )
13618
+ if display_field_que_id is not None and que_id == display_field_que_id:
13619
+ item["queAuth"] = _REFERENCE_FIELD_VISIBLE_AUTH
13620
+
13621
+ payload["referQuestions"] = refer_questions
13622
+ payload["referAuthQues"] = _canonicalize_reference_auth_questions_for_save(
13623
+ source={"referAuthQues": refer_auth_ques},
13624
+ refer_questions=refer_questions,
13625
+ )
13626
+ return payload
13627
+
13628
+
13278
13629
  def _normalize_reference_config_for_save(
13279
13630
  reference: Any,
13280
13631
  *,
@@ -13289,11 +13640,13 @@ def _normalize_reference_config_for_save(
13289
13640
  if field.get("field_name_show") is not None:
13290
13641
  payload["fieldNameShow"] = bool(field.get("field_name_show"))
13291
13642
 
13292
- refer_questions: list[dict[str, Any]] = []
13293
- for index, raw_item in enumerate(cast(list[Any], source.get("referQuestions") or []), start=1):
13294
- normalized_item = _normalize_reference_question_for_save(raw_item, ordinal=index)
13295
- if normalized_item is not None:
13296
- refer_questions.append(normalized_item)
13643
+ refer_question_auth_overrides = _reference_question_auth_overrides_for_save(source=source, field=field)
13644
+ refer_questions = _canonicalize_reference_questions_for_save(source=source, field=field)
13645
+ for index, normalized_item in enumerate(refer_questions, start=1):
13646
+ que_id = _coerce_any_int(normalized_item.get("queId"))
13647
+ if que_id is not None and que_id in refer_question_auth_overrides:
13648
+ normalized_item["queAuth"] = refer_question_auth_overrides[que_id]
13649
+ normalized_item["ordinal"] = index
13297
13650
  if refer_questions or "referQuestions" in source:
13298
13651
  payload["referQuestions"] = refer_questions
13299
13652
 
@@ -13308,18 +13661,13 @@ def _normalize_reference_config_for_save(
13308
13661
  if refer_fill_rules or "referFillRules" in source:
13309
13662
  payload["referFillRules"] = refer_fill_rules
13310
13663
 
13311
- refer_auth_ques = [
13312
- item
13313
- for item in (
13314
- _normalize_reference_auth_question_for_save(raw_item)
13315
- for raw_item in cast(list[Any], source.get("referAuthQues") or [])
13316
- )
13317
- if item is not None
13318
- ]
13664
+ refer_auth_ques = _canonicalize_reference_auth_questions_for_save(source=source, refer_questions=refer_questions)
13665
+ if not refer_auth_ques:
13666
+ refer_auth_ques = _synthesize_reference_auth_questions_for_save(source=source, field=field)
13319
13667
  if refer_auth_ques or "referAuthQues" in source:
13320
13668
  payload["referAuthQues"] = refer_auth_ques
13321
13669
 
13322
- return payload
13670
+ return _enforce_reference_config_consistency_for_save(payload, field=field)
13323
13671
 
13324
13672
 
13325
13673
  def _normalize_relation_question_for_save(question: dict[str, Any], *, field: dict[str, Any]) -> dict[str, Any]:
@@ -13550,9 +13898,16 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
13550
13898
  preserved_reference["referAppKey"] = field.get("target_app_key")
13551
13899
  question["referenceConfig"] = preserved_reference
13552
13900
  else:
13901
+ existing_reference = (
13902
+ deepcopy(relation_question_template.get("referenceConfig"))
13903
+ if relation_question_template is not None and isinstance(relation_question_template.get("referenceConfig"), dict)
13904
+ else deepcopy(question.get("referenceConfig"))
13905
+ if isinstance(question.get("referenceConfig"), dict)
13906
+ else {}
13907
+ )
13553
13908
  reference = (
13554
- deepcopy(built_question.get("referenceConfig"))
13555
- if relation_config_explicit and isinstance(built_question.get("referenceConfig"), dict)
13909
+ existing_reference
13910
+ if relation_config_explicit
13556
13911
  else deepcopy(question.get("referenceConfig"))
13557
13912
  if isinstance(question.get("referenceConfig"), dict)
13558
13913
  else {}
@@ -13573,6 +13928,8 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
13573
13928
  "fieldNameShow",
13574
13929
  "_targetFieldId",
13575
13930
  ):
13931
+ if relation_config_explicit and key in {"referQuestions", "referAuthQues"}:
13932
+ continue
13576
13933
  if key in built_reference:
13577
13934
  reference[key] = deepcopy(built_reference[key])
13578
13935
  reference["referAppKey"] = field.get("target_app_key")