@josephyan/qingflow-app-builder-mcp 0.2.0-beta.98 → 0.2.0-beta.982

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.98
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.982
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.98 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.982 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.98",
3
+ "version": "0.2.0-beta.982",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
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.0b98"
7
+ version = "0.2.0b982"
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.0b97"
8
+ _FALLBACK_VERSION = "0.2.0b982"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1536,8 +1536,8 @@ class PortalApplyRequest(StrictModel):
1536
1536
  raise ValueError("package_tag_id is required when dash_key is empty")
1537
1537
  if not self.dash_key and not self.dash_name:
1538
1538
  raise ValueError("dash_name is required when creating a portal")
1539
- if not self.sections:
1540
- raise ValueError("portal apply requires a non-empty sections list")
1539
+ if not self.dash_key and not self.sections:
1540
+ raise ValueError("portal apply requires a non-empty sections list when creating a portal")
1541
1541
  if self.visibility is not None and self.auth is not None:
1542
1542
  raise ValueError("visibility and auth cannot be provided together")
1543
1543
  return self
@@ -1413,6 +1413,8 @@ class AiBuilderFacade:
1413
1413
  issues: list[dict[str, Any]] = []
1414
1414
  resolved: list[dict[str, Any]] = []
1415
1415
  seen_ids: set[int] = set()
1416
+ if not dept_ids and not dept_names:
1417
+ return {"department_entries": resolved, "issues": issues}
1416
1418
  listed = self.directory.directory_list_all_departments(
1417
1419
  profile=profile,
1418
1420
  parent_dept_id=None,
@@ -6706,6 +6708,7 @@ class AiBuilderFacade:
6706
6708
 
6707
6709
  for patch in request.upsert_charts:
6708
6710
  try:
6711
+ config_update_requested = _chart_patch_updates_chart_config(patch)
6709
6712
  chart_visible_auth = (
6710
6713
  self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
6711
6714
  if patch.visibility is not None
@@ -6809,18 +6812,17 @@ class AiBuilderFacade:
6809
6812
  existing_by_name.pop(old_name, None)
6810
6813
  existing_by_name.setdefault(patch.name, []).append(deepcopy(updated_chart))
6811
6814
 
6812
- config_payload = _build_public_chart_config_payload(
6813
- patch=patch,
6814
- app_key=app_key,
6815
- field_lookup=field_lookup,
6816
- qingbi_fields_by_id=qingbi_fields_by_id,
6817
- )
6818
- if isinstance(chart_visible_auth, dict):
6819
- raw_data_config = config_payload.get("rawDataConfigDTO")
6820
- if isinstance(raw_data_config, dict):
6821
- raw_data_config["authInfo"] = deepcopy(chart_visible_auth)
6822
- self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6823
- if existing is not None and chart_id not in updated_ids:
6815
+ config_updated = False
6816
+ if existing is None or config_update_requested:
6817
+ config_payload = _build_public_chart_config_payload(
6818
+ patch=patch,
6819
+ app_key=app_key,
6820
+ field_lookup=field_lookup,
6821
+ qingbi_fields_by_id=qingbi_fields_by_id,
6822
+ )
6823
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6824
+ config_updated = True
6825
+ if existing is not None and chart_id not in updated_ids and config_updated:
6824
6826
  updated_ids.append(chart_id)
6825
6827
  if patch.question_config:
6826
6828
  self._request_backend(
@@ -6836,6 +6838,8 @@ class AiBuilderFacade:
6836
6838
  path=f"/chart/{chart_id}/user/config",
6837
6839
  json_body=patch.user_config,
6838
6840
  )
6841
+ if existing is not None and chart_id not in updated_ids and (patch.question_config or patch.user_config):
6842
+ updated_ids.append(chart_id)
6839
6843
  chart_results.append(
6840
6844
  {
6841
6845
  "chart_id": chart_id,
@@ -6992,11 +6996,12 @@ class AiBuilderFacade:
6992
6996
  permission_outcomes: list[PermissionCheckOutcome] = []
6993
6997
  dash_key = str(request.dash_key or "").strip()
6994
6998
  creating = not dash_key
6999
+ sections_requested = creating or bool(request.sections)
6995
7000
  verify_dash_name = creating or request.dash_name is not None
6996
7001
  verify_dash_icon = bool(request.icon or request.color)
6997
7002
  verify_auth = request.visibility is not None or request.auth is not None
6998
- verify_hide_copyright = request.hide_copyright is not None
6999
- verify_dash_global_config = request.dash_global_config is not None
7003
+ verify_hide_copyright = request.hide_copyright is not None and sections_requested
7004
+ verify_dash_global_config = request.dash_global_config is not None and sections_requested
7000
7005
  verify_tags = creating or request.package_tag_id is not None
7001
7006
  requested_visibility = request.visibility
7002
7007
  if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
@@ -7073,6 +7078,25 @@ class AiBuilderFacade:
7073
7078
  if package_edit_outcome.block is not None:
7074
7079
  return package_edit_outcome.block
7075
7080
  permission_outcomes.append(package_edit_outcome)
7081
+ if not sections_requested:
7082
+ unsupported_base_only_keys: list[str] = []
7083
+ if request.hide_copyright is not None:
7084
+ unsupported_base_only_keys.append("hide_copyright")
7085
+ if request.dash_global_config is not None:
7086
+ unsupported_base_only_keys.append("dash_global_config")
7087
+ if request.config:
7088
+ unsupported_base_only_keys.append("config")
7089
+ if unsupported_base_only_keys:
7090
+ return _failed(
7091
+ "PORTAL_SECTIONS_REQUIRED",
7092
+ "editing a portal without sections only supports base-info updates",
7093
+ normalized_args=normalized_args,
7094
+ details={
7095
+ "unsupported_without_sections": unsupported_base_only_keys,
7096
+ "fix_hint": "Pass sections when changing layout or global portal config, or omit those keys for visibility/icon/package updates.",
7097
+ },
7098
+ suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7099
+ )
7076
7100
  try:
7077
7101
  if creating:
7078
7102
  create_payload = _build_public_portal_base_payload(
@@ -7098,7 +7122,6 @@ class AiBuilderFacade:
7098
7122
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7099
7123
  )
7100
7124
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
7101
- component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7102
7125
  update_payload = _build_public_portal_base_payload(
7103
7126
  dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
7104
7127
  package_tag_id=target_package_tag_id,
@@ -7110,8 +7133,10 @@ class AiBuilderFacade:
7110
7133
  config=request.config,
7111
7134
  base_payload=base_payload,
7112
7135
  )
7113
- update_payload["components"] = component_payload
7114
- self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7136
+ if sections_requested:
7137
+ component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7138
+ update_payload["components"] = component_payload
7139
+ self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7115
7140
  self.portals.portal_update_base_info(
7116
7141
  profile=profile,
7117
7142
  dash_key=dash_key,
@@ -7148,11 +7173,14 @@ class AiBuilderFacade:
7148
7173
  publish_failed = True
7149
7174
 
7150
7175
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
7151
- expected_count = len(request.sections)
7152
- draft_verified = isinstance(draft_components, list) and len(draft_components) == expected_count
7176
+ expected_count = len(request.sections) if sections_requested else None
7177
+ draft_verified = isinstance(draft_result, dict) and (
7178
+ expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
7179
+ )
7153
7180
  draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
7154
7181
  actual=draft_result,
7155
7182
  expected_payload=update_payload,
7183
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7156
7184
  expected_section_count=expected_count,
7157
7185
  requested_config_keys=set((request.config or {}).keys()),
7158
7186
  verify_dash_name=verify_dash_name,
@@ -7168,12 +7196,18 @@ class AiBuilderFacade:
7168
7196
  if request.publish:
7169
7197
  live_verified = (
7170
7198
  isinstance(live_result, dict)
7171
- and isinstance(live_result.get("components"), list)
7172
- and len(live_result.get("components")) == expected_count
7199
+ and (
7200
+ expected_count is None
7201
+ or (
7202
+ isinstance(live_result.get("components"), list)
7203
+ and len(live_result.get("components")) == expected_count
7204
+ )
7205
+ )
7173
7206
  )
7174
7207
  live_meta_verified, live_meta_mismatches = _verify_portal_readback(
7175
7208
  actual=live_result,
7176
7209
  expected_payload=update_payload,
7210
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7177
7211
  expected_section_count=expected_count,
7178
7212
  requested_config_keys=set((request.config or {}).keys()),
7179
7213
  verify_dash_name=verify_dash_name,
@@ -7207,7 +7241,15 @@ class AiBuilderFacade:
7207
7241
  "status": status,
7208
7242
  "error_code": error_code,
7209
7243
  "recoverable": not verified,
7210
- "message": "applied portal" if verified else "applied portal; draft/live verification pending",
7244
+ "message": (
7245
+ "updated portal base info"
7246
+ if verified and not sections_requested
7247
+ else "applied portal"
7248
+ if verified
7249
+ else "updated portal base info; draft/live verification pending"
7250
+ if not sections_requested
7251
+ else "applied portal; draft/live verification pending"
7252
+ ),
7211
7253
  "normalized_args": normalized_args,
7212
7254
  "missing_fields": [],
7213
7255
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
@@ -8733,6 +8775,11 @@ def _build_public_chart_config_payload(
8733
8775
  return payload
8734
8776
 
8735
8777
 
8778
+ def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
8779
+ explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
8780
+ return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
8781
+
8782
+
8736
8783
  def _build_public_portal_base_payload(
8737
8784
  *,
8738
8785
  dash_name: str,
@@ -8962,7 +9009,8 @@ def _verify_portal_readback(
8962
9009
  *,
8963
9010
  actual: Any,
8964
9011
  expected_payload: dict[str, Any],
8965
- expected_section_count: int,
9012
+ expected_visibility: dict[str, Any] | None,
9013
+ expected_section_count: int | None,
8966
9014
  requested_config_keys: set[str],
8967
9015
  verify_dash_name: bool,
8968
9016
  verify_dash_icon: bool,
@@ -8975,14 +9023,19 @@ def _verify_portal_readback(
8975
9023
  if not isinstance(actual, dict):
8976
9024
  return False, ["portal readback payload is unavailable"]
8977
9025
  components = actual.get("components")
8978
- if not isinstance(components, list) or len(components) != expected_section_count:
9026
+ if expected_section_count is not None and (not isinstance(components, list) or len(components) != expected_section_count):
8979
9027
  mismatches.append(f"components expected {expected_section_count}, got {len(components) if isinstance(components, list) else 'unavailable'}")
8980
9028
  if verify_dash_name and str(actual.get("dashName") or "").strip() != str(expected_payload.get("dashName") or "").strip():
8981
9029
  mismatches.append("dash_name")
8982
9030
  if verify_dash_icon and str(actual.get("dashIcon") or "") != str(expected_payload.get("dashIcon") or ""):
8983
9031
  mismatches.append("dash_icon")
8984
- if verify_auth and not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
8985
- mismatches.append("auth")
9032
+ if verify_auth:
9033
+ if expected_visibility is not None:
9034
+ actual_visibility = _public_visibility_from_member_auth(actual.get("auth"))
9035
+ if not _visibility_matches_expected(actual_visibility, expected_visibility):
9036
+ mismatches.append("auth")
9037
+ elif not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
9038
+ mismatches.append("auth")
8986
9039
  if verify_hide_copyright and bool(actual.get("hideCopyright", False)) != bool(expected_payload.get("hideCopyright", False)):
8987
9040
  mismatches.append("hide_copyright")
8988
9041
  if verify_dash_global_config and not _mapping_contains(actual.get("dashGlobalConfig") or {}, expected_payload.get("dashGlobalConfig") or {}):
@@ -9190,7 +9243,11 @@ def _visibility_matches_expected(actual: Any, expected: Any) -> bool:
9190
9243
  if expected_text and sorted_values(actual_group, text_key) != expected_text:
9191
9244
  return False
9192
9245
 
9193
- if "include_sub_departs" in expected_selectors and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs"):
9246
+ if (
9247
+ "include_sub_departs" in expected_selectors
9248
+ and expected_selectors.get("include_sub_departs") is not None
9249
+ and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs")
9250
+ ):
9194
9251
  return False
9195
9252
  return True
9196
9253
 
@@ -160,7 +160,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
160
160
  portal_apply.add_argument("--dash-name", default="")
161
161
  portal_apply.add_argument("--package-id", type=int)
162
162
  portal_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=True)
163
- portal_apply.add_argument("--sections-file", required=True)
163
+ portal_apply.add_argument("--sections-file")
164
164
  portal_apply.add_argument("--visibility-file")
165
165
  portal_apply.add_argument("--auth-file")
166
166
  portal_apply.add_argument("--icon")
@@ -513,13 +513,14 @@ def _handle_portal_apply(args: argparse.Namespace, context: CliContext) -> dict:
513
513
  "portal apply requires either --dash-key, or --package-id together with --dash-name.",
514
514
  fix_hint="Use `--dash-key` for an existing portal. For create mode, pass `--package-id --dash-name`.",
515
515
  )
516
+ sections = [] if not args.sections_file else require_list_arg(args.sections_file, option_name="--sections-file")
516
517
  return context.builder.portal_apply(
517
518
  profile=args.profile,
518
519
  dash_key=args.dash_key,
519
520
  dash_name=args.dash_name,
520
521
  package_id=args.package_id,
521
522
  publish=bool(args.publish),
522
- sections=require_list_arg(args.sections_file, option_name="--sections-file"),
523
+ sections=sections,
523
524
  visibility=load_object_arg(args.visibility_file, option_name="--visibility-file"),
524
525
  auth=load_object_arg(args.auth_file, option_name="--auth-file"),
525
526
  icon=args.icon,
@@ -177,10 +177,10 @@ def _format_task_list(result: dict[str, Any]) -> str:
177
177
  str(item.get("app_key") or ""),
178
178
  str(item.get("record_id") or ""),
179
179
  str(item.get("workflow_node_id") or ""),
180
- str(item.get("title") or item.get("task_name") or ""),
180
+ str(item.get("workflow_node_name") or ""),
181
181
  ]
182
182
  )
183
- output = _render_titled_table("Tasks", ["app_key", "record_id", "node_id", "title"], rows)
183
+ output = _render_titled_table("Tasks", ["app_key", "record_id", "node_id", "node_name"], rows)
184
184
  lines = output.rstrip("\n").split("\n")
185
185
  _append_warnings(lines, result.get("warnings"))
186
186
  return "\n".join(lines) + "\n"
@@ -193,11 +193,13 @@ def _format_task_get(result: dict[str, Any]) -> str:
193
193
  editable_fields = data.get("editable_fields") if isinstance(data.get("editable_fields"), list) else []
194
194
  available_actions = data.get("available_actions") if isinstance(data.get("available_actions"), list) else []
195
195
  extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
196
+ initiator = task.get("initiator") if isinstance(task.get("initiator"), dict) else {}
197
+ initiator_label = initiator.get("displayName") or initiator.get("email") or "-"
196
198
  lines = [
197
199
  f"Task: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
198
200
  f"Node: {task.get('workflow_node_name') or '-'}",
199
201
  f"App: {task.get('app_name') or '-'}",
200
- f"Initiator: {task.get('initiator') or '-'}",
202
+ f"Initiator: {initiator_label}",
201
203
  f"Apply Status: {record_summary.get('apply_status')}",
202
204
  f"Available Actions: {', '.join(str(item) for item in available_actions) or '-'}",
203
205
  f"Editable Fields: {len(editable_fields)}",
@@ -238,17 +240,22 @@ def _format_import_verify(result: dict[str, Any]) -> str:
238
240
  f"App Key: {result.get('app_key') or '-'}",
239
241
  f"File: {result.get('file_name') or result.get('file_path') or '-'}",
240
242
  f"Can Import: {result.get('can_import')}",
241
- f"Apply Rows: {result.get('apply_rows')}",
242
243
  f"Verification ID: {result.get('verification_id') or '-'}",
243
244
  ]
244
- issues = result.get("issues") if isinstance(result.get("issues"), list) else []
245
- if issues:
246
- lines.append("Issues:")
247
- for issue in issues:
248
- if isinstance(issue, dict):
249
- lines.append(f"- {issue.get('code') or 'ISSUE'}: {issue.get('message') or issue}")
250
- else:
251
- lines.append(f"- {issue}")
245
+ issue_summary = result.get("issue_summary") if isinstance(result.get("issue_summary"), dict) else {}
246
+ if issue_summary:
247
+ lines.append(
248
+ "Issues: "
249
+ f"total={issue_summary.get('total', 0)}, "
250
+ f"errors={issue_summary.get('errors', 0)}, "
251
+ f"warnings={issue_summary.get('warnings', 0)}"
252
+ )
253
+ sample = issue_summary.get("sample") if isinstance(issue_summary.get("sample"), list) else []
254
+ if sample:
255
+ lines.append("Issue Samples:")
256
+ for item in sample:
257
+ if isinstance(item, dict):
258
+ lines.append(f"- {item.get('code') or 'ISSUE'}: {item.get('message') or ''}".rstrip())
252
259
  _append_warnings(lines, result.get("warnings"))
253
260
  _append_verification(lines, result.get("verification"))
254
261
  return "\n".join(lines) + "\n"
@@ -259,8 +266,10 @@ def _format_import_status(result: dict[str, Any]) -> str:
259
266
  f"Status: {result.get('status') or '-'}",
260
267
  f"Import ID: {result.get('import_id') or '-'}",
261
268
  f"Process ID: {result.get('process_id_str') or '-'}",
262
- f"Success Rows: {result.get('success_rows') or 0}",
263
- f"Failed Rows: {result.get('failed_rows') or 0}",
269
+ f"Total Rows: {result.get('total') or 0}",
270
+ f"Finished Rows: {result.get('finished') or 0}",
271
+ f"Succeeded Rows: {result.get('succeeded') or 0}",
272
+ f"Failed Rows: {result.get('failed') or 0}",
264
273
  f"Progress: {result.get('progress') or '-'}",
265
274
  ]
266
275
  _append_warnings(lines, result.get("warnings"))
@@ -293,34 +293,175 @@ def _trim_file_upload_local(payload: JSONObject) -> None:
293
293
 
294
294
 
295
295
  def _trim_import_schema(payload: JSONObject) -> None:
296
- pass
296
+ columns: list[JSONObject] | None = None
297
+ if isinstance(payload.get("columns"), list):
298
+ columns = [item for item in payload.get("columns", []) if isinstance(item, dict)]
299
+ elif isinstance(payload.get("expected_columns"), list):
300
+ columns = [item for item in payload.get("expected_columns", []) if isinstance(item, dict)]
301
+ if columns is not None:
302
+ payload["columns"] = [_compact_import_column(item) for item in columns]
303
+ payload.pop("expected_columns", None)
304
+ payload.pop("schema_fingerprint", None)
305
+ payload.pop("import_capability", None)
306
+ payload.pop("request_route", None)
307
+ payload.pop("verification", None)
308
+
309
+ if _looks_like_import_verify(payload):
310
+ _trim_import_verify_payload(payload)
311
+ return
312
+ if "applied_repairs" in payload or "repaired_file_path" in payload:
313
+ _trim_import_repair_payload(payload)
314
+ return
315
+ if "template_url" in payload or "downloaded_to_path" in payload:
316
+ _trim_import_template_payload(payload)
317
+ return
318
+ if "import_id" in payload or "process_id_str" in payload:
319
+ _trim_import_status_payload(payload)
320
+ return
297
321
 
298
322
 
299
323
  def _trim_record_schema(payload: JSONObject) -> None:
300
324
  payload.pop("legacy_schema", None)
325
+ template_map = payload.get("payload_template")
326
+ if not isinstance(template_map, dict):
327
+ template_map = None
328
+
329
+ if "writable_fields" in payload:
330
+ writable_fields = payload.get("writable_fields")
331
+ payload.pop("writable_fields", None)
332
+ required_fields: list[JSONObject] = []
333
+ optional_fields: list[JSONObject] = []
334
+ if isinstance(writable_fields, list):
335
+ for item in writable_fields:
336
+ compact = _compact_schema_field(item, template_map=template_map)
337
+ if not compact:
338
+ continue
339
+ if compact.get("required") is True:
340
+ required_fields.append(compact)
341
+ else:
342
+ optional_fields.append(compact)
343
+ payload["required_fields"] = required_fields
344
+ payload["optional_fields"] = optional_fields
345
+
346
+ for key in ("required_fields", "optional_fields", "runtime_linked_required_fields", "fields", "ambiguous_fields"):
347
+ if key in payload:
348
+ payload[key] = _compact_schema_fields(payload.get(key), template_map=template_map)
349
+
350
+ for key in ("suggested_dimensions", "suggested_metrics", "suggested_time_fields"):
351
+ if isinstance(payload.get(key), list):
352
+ payload[key] = [
353
+ _pick(item, ("field_id", "title")) for item in payload.get(key) if isinstance(item, dict)
354
+ ]
355
+
356
+ for key in ("workflow_node", "view_resolution", "field_count"):
357
+ payload.pop(key, None)
301
358
 
302
359
 
303
360
  def _trim_record_write(payload: JSONObject) -> None:
361
+ payload.pop("verification", None)
304
362
  data = payload.get("data")
305
363
  if not isinstance(data, dict):
306
364
  return
365
+ data.pop("debug", None)
307
366
  data.pop("normalized_payload", None)
308
367
  data.pop("human_review", None)
309
368
  data.pop("action", None)
369
+ resource = _compact_record_resource(data.get("resource"))
370
+ if resource:
371
+ data["resource"] = resource
372
+ else:
373
+ data.pop("resource", None)
374
+ verification = data.get("verification")
375
+ if isinstance(verification, dict):
376
+ compact_verification = _pick(
377
+ verification,
378
+ (
379
+ "verified",
380
+ "verification_mode",
381
+ "field_level_verified",
382
+ ),
383
+ )
384
+ if compact_verification:
385
+ data["verification"] = compact_verification
386
+ else:
387
+ data.pop("verification", None)
388
+ for key in ("blockers", "field_errors", "confirmation_requests", "resolved_fields"):
389
+ value = data.get(key)
390
+ if value in (None, [], {}, ""):
391
+ data.pop(key, None)
310
392
 
311
393
 
312
394
  def _trim_record_get(payload: JSONObject) -> None:
313
- _keep_nested_keys(payload, ("data", "selection", "view"), allowed=("view_id", "name"))
314
- _drop_nested_keys(payload, ("data", "selection"), keys=("columns", "workflow_node_id"))
395
+ data = payload.get("data")
396
+ if not isinstance(data, dict):
397
+ return
398
+ compact: dict[str, Any] = {}
399
+ app_key = data.get("app_key")
400
+ if app_key:
401
+ compact["app_key"] = app_key
402
+ record_id = data.get("record_id")
403
+ if record_id not in (None, ""):
404
+ compact["record_id"] = str(record_id)
405
+ record = data.get("record")
406
+ if isinstance(record, dict):
407
+ compact["record"] = record
408
+ normalized_record = data.get("normalized_record")
409
+ if isinstance(normalized_record, dict):
410
+ compact["normalized_record"] = normalized_record
411
+ normalized_ambiguous_fields = data.get("normalized_ambiguous_fields")
412
+ if isinstance(normalized_ambiguous_fields, dict):
413
+ compact["normalized_ambiguous_fields"] = normalized_ambiguous_fields
414
+ payload["data"] = compact
315
415
 
316
416
 
317
417
  def _trim_record_list(payload: JSONObject) -> None:
318
- _keep_nested_keys(payload, ("data", "selection", "view"), allowed=("view_id", "name"))
319
- _drop_nested_keys(payload, ("data", "selection"), keys=("columns",))
418
+ data = payload.get("data")
419
+ if not isinstance(data, dict):
420
+ return
421
+ pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
422
+ returned_items = pagination.get("returned_items")
423
+ result_amount = pagination.get("result_amount")
424
+ limit = pagination.get("limit")
425
+ truncated = False
426
+ if isinstance(result_amount, int) and isinstance(returned_items, int):
427
+ truncated = result_amount > returned_items
428
+ compact_pagination = {
429
+ "loaded": True,
430
+ "page_size": limit,
431
+ "fetched_pages": 1,
432
+ "reported_total": result_amount,
433
+ "truncated": truncated,
434
+ }
435
+ selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
436
+ view = selection.get("view") if isinstance(selection.get("view"), dict) else {}
437
+ compact: dict[str, Any] = {
438
+ "app_key": data.get("app_key"),
439
+ "items": data.get("items") if isinstance(data.get("items"), list) else [],
440
+ "pagination": compact_pagination,
441
+ }
442
+ if view:
443
+ compact["view"] = _pick(view, ("view_id", "name"))
444
+ payload["data"] = compact
320
445
 
321
446
 
322
447
  def _trim_record_analyze(payload: JSONObject) -> None:
323
- _drop_deep_keys(payload, {"debug"})
448
+ summary: dict[str, Any] = {}
449
+ completeness = payload.get("completeness")
450
+ if isinstance(completeness, dict):
451
+ summary["completeness"] = completeness
452
+ presentation = payload.get("presentation")
453
+ if isinstance(presentation, dict):
454
+ summary["presentation"] = presentation
455
+ ranking = payload.get("ranking")
456
+ if isinstance(ranking, dict):
457
+ summary["ranking"] = ranking
458
+ error = payload.get("error")
459
+ if isinstance(error, dict):
460
+ summary["error"] = error
461
+ if summary:
462
+ payload["summary"] = summary
463
+ for key in ("query", "ranking", "ratios", "completeness", "presentation", "error", "debug"):
464
+ payload.pop(key, None)
324
465
 
325
466
 
326
467
  def _trim_code_block_schema(payload: JSONObject) -> None:
@@ -344,6 +485,222 @@ def _trim_task_context_detail(payload: JSONObject) -> None:
344
485
  _drop_deep_keys(payload, {"request_route", "output_profile"})
345
486
 
346
487
 
488
+ def _trim_record_delete(payload: JSONObject) -> None:
489
+ data = payload.get("data")
490
+ if not isinstance(data, dict):
491
+ return
492
+ resource = data.get("resource")
493
+ deleted_ids: list[str] = []
494
+ if isinstance(resource, dict):
495
+ raw_ids = resource.get("record_ids") or resource.get("apply_ids") or resource.get("applyIds")
496
+ if isinstance(raw_ids, list):
497
+ deleted_ids = [str(item) for item in raw_ids if item not in (None, "")]
498
+ data["deleted_ids"] = deleted_ids
499
+ data.setdefault("failed_ids", [])
500
+ for key in (
501
+ "resource",
502
+ "action",
503
+ "normalized_payload",
504
+ "human_review",
505
+ "verification",
506
+ "blockers",
507
+ "field_errors",
508
+ "confirmation_requests",
509
+ "resolved_fields",
510
+ "debug",
511
+ ):
512
+ data.pop(key, None)
513
+
514
+
515
+ def _compact_record_resource(resource: Any) -> dict[str, Any] | None:
516
+ if not isinstance(resource, dict):
517
+ return None
518
+ compact: dict[str, Any] = {}
519
+ if resource.get("type") not in (None, ""):
520
+ compact["type"] = resource.get("type")
521
+ app_key = resource.get("app_key") or resource.get("appKey")
522
+ if app_key not in (None, ""):
523
+ compact["app_key"] = app_key
524
+ record_id = resource.get("record_id")
525
+ if record_id not in (None, ""):
526
+ compact["record_id"] = str(record_id)
527
+ apply_id = resource.get("apply_id") or resource.get("applyId")
528
+ if apply_id not in (None, "") and "record_id" not in compact:
529
+ compact["record_id"] = str(apply_id)
530
+ record_ids = resource.get("record_ids")
531
+ if isinstance(record_ids, list):
532
+ compact["record_ids"] = [str(item) for item in record_ids if item not in (None, "")]
533
+ apply_ids = resource.get("apply_ids") or resource.get("applyIds")
534
+ if isinstance(apply_ids, list) and "record_ids" not in compact:
535
+ compact["record_ids"] = [str(item) for item in apply_ids if item not in (None, "")]
536
+ return compact or None
537
+
538
+
539
+ def _compact_schema_fields(items: Any, *, template_map: dict[str, Any] | None) -> list[JSONObject]:
540
+ if not isinstance(items, list):
541
+ return []
542
+ compacted: list[JSONObject] = []
543
+ for item in items:
544
+ compact = _compact_schema_field(item, template_map=template_map)
545
+ if compact:
546
+ compacted.append(compact)
547
+ return compacted
548
+
549
+
550
+ def _compact_schema_field(item: Any, *, template_map: dict[str, Any] | None) -> JSONObject | None:
551
+ if not isinstance(item, dict):
552
+ return None
553
+ compact: dict[str, Any] = {}
554
+ field_id = item.get("field_id")
555
+ if field_id not in (None, ""):
556
+ compact["field_id"] = field_id
557
+ title = item.get("title")
558
+ if title not in (None, ""):
559
+ compact["title"] = title
560
+ kind = item.get("kind") or item.get("write_kind")
561
+ if kind not in (None, ""):
562
+ compact["kind"] = kind
563
+ if "required" in item:
564
+ compact["required"] = bool(item.get("required"))
565
+ if template_map is not None and isinstance(title, str) and title in template_map:
566
+ compact["template"] = template_map.get(title)
567
+ candidate_hint = item.get("candidate_hint")
568
+ if isinstance(candidate_hint, dict):
569
+ compact["candidate_hint"] = candidate_hint
570
+ options = item.get("options")
571
+ if isinstance(options, list) and options:
572
+ compact["options"] = options
573
+ target_app_key = item.get("target_app_key")
574
+ if isinstance(target_app_key, str) and target_app_key:
575
+ compact["target_app_key"] = target_app_key
576
+ searchable_fields = item.get("searchable_fields")
577
+ if isinstance(searchable_fields, list) and searchable_fields:
578
+ compact["searchable_fields"] = searchable_fields
579
+ row_fields = item.get("row_fields")
580
+ if isinstance(row_fields, list) and row_fields:
581
+ compact["row_fields"] = _compact_schema_fields(row_fields, template_map=None)
582
+ return compact or None
583
+
584
+
585
+ def _compact_import_column(item: dict[str, Any]) -> dict[str, Any]:
586
+ compact: dict[str, Any] = {}
587
+ title = item.get("title")
588
+ if title not in (None, ""):
589
+ compact["title"] = title
590
+ kind = item.get("kind") or item.get("write_kind")
591
+ if kind not in (None, ""):
592
+ compact["kind"] = kind
593
+ compact["required"] = bool(item.get("required"))
594
+ options = item.get("options")
595
+ if isinstance(options, list) and options:
596
+ compact["options"] = options
597
+ if bool(item.get("accepts_natural_input")):
598
+ compact["accepts_natural_input"] = True
599
+ if bool(item.get("requires_upload")):
600
+ compact["requires_upload"] = True
601
+ target_app_key = item.get("target_app_key")
602
+ if isinstance(target_app_key, str) and target_app_key:
603
+ compact["target_app_key"] = target_app_key
604
+ target_app_name = item.get("target_app_name")
605
+ if isinstance(target_app_name, str) and target_app_name:
606
+ compact["target_app_name"] = target_app_name
607
+ searchable_fields = item.get("searchable_fields")
608
+ if isinstance(searchable_fields, list) and searchable_fields:
609
+ compact["searchable_fields"] = searchable_fields
610
+ return compact
611
+
612
+
613
+ def _looks_like_import_verify(payload: JSONObject) -> bool:
614
+ return "verification_id" in payload and "can_import" in payload
615
+
616
+
617
+ def _trim_import_verify_payload(payload: JSONObject) -> None:
618
+ issues = payload.get("issues") if isinstance(payload.get("issues"), list) else []
619
+ issue_summary = _summarize_import_issues(issues)
620
+ payload["issue_summary"] = issue_summary
621
+ columns = payload.get("columns")
622
+ if "expected_columns" not in payload and isinstance(columns, list):
623
+ payload["expected_columns"] = columns
624
+ file_name = payload.get("file_name")
625
+ if not file_name:
626
+ file_path = payload.get("file_path")
627
+ if isinstance(file_path, str) and file_path:
628
+ payload["file_name"] = file_path.split("/")[-1]
629
+ for key in ("apply_rows", "schema_fingerprint", "import_capability", "file_sha256", "verified_file_sha256", "file_format", "local_precheck_limited"):
630
+ payload.pop(key, None)
631
+
632
+
633
+ def _trim_import_repair_payload(payload: JSONObject) -> None:
634
+ payload["verification_id"] = payload.get("new_verification_id") or payload.get("verification_id")
635
+ post_repair_issues = payload.get("post_repair_issues")
636
+ if isinstance(post_repair_issues, list):
637
+ payload["post_repair_issue_summary"] = _summarize_import_issues(post_repair_issues)
638
+ for key in ("new_verification_id", "verification"):
639
+ payload.pop(key, None)
640
+
641
+
642
+ def _trim_import_template_payload(payload: JSONObject) -> None:
643
+ for key in ("schema_fingerprint", "verification"):
644
+ payload.pop(key, None)
645
+
646
+
647
+ def _trim_import_status_payload(payload: JSONObject) -> None:
648
+ total_rows = payload.get("total_rows")
649
+ success_rows = payload.get("success_rows")
650
+ failed_rows = payload.get("failed_rows")
651
+ payload["total"] = total_rows
652
+ if isinstance(success_rows, int) and isinstance(failed_rows, int):
653
+ payload["finished"] = success_rows + failed_rows
654
+ elif isinstance(success_rows, int):
655
+ payload["finished"] = success_rows
656
+ else:
657
+ payload["finished"] = None
658
+ payload["succeeded"] = success_rows
659
+ payload["failed"] = failed_rows
660
+ for key in (
661
+ "matched_by",
662
+ "source_file_name",
663
+ "total_rows",
664
+ "success_rows",
665
+ "failed_rows",
666
+ "error_file_urls",
667
+ "operate_time",
668
+ "operate_user",
669
+ "verification",
670
+ ):
671
+ payload.pop(key, None)
672
+
673
+
674
+ def _summarize_import_issues(issues: list[Any]) -> dict[str, Any]:
675
+ total = 0
676
+ error_count = 0
677
+ warning_count = 0
678
+ sample: list[dict[str, Any]] = []
679
+ for item in issues:
680
+ if not isinstance(item, dict):
681
+ continue
682
+ total += 1
683
+ severity = str(item.get("severity") or "").lower()
684
+ if severity == "error":
685
+ error_count += 1
686
+ if severity == "warning":
687
+ warning_count += 1
688
+ if len(sample) < 3:
689
+ sample.append(
690
+ {
691
+ "code": item.get("code"),
692
+ "message": item.get("message"),
693
+ "severity": item.get("severity"),
694
+ }
695
+ )
696
+ return {
697
+ "total": total,
698
+ "errors": error_count,
699
+ "warnings": warning_count,
700
+ "sample": sample,
701
+ }
702
+
703
+
347
704
  def _trim_directory(payload: JSONObject) -> None:
348
705
  pass
349
706
 
@@ -442,10 +799,10 @@ _register_policy(
442
799
  (
443
800
  "record_member_candidates",
444
801
  "record_department_candidates",
445
- "record_delete",
446
802
  ),
447
803
  _trim_builder_list_like,
448
804
  )
805
+ _register_policy((USER_DOMAIN,), ("record_delete",), _trim_record_delete)
449
806
  _register_policy(
450
807
  (BUILDER_DOMAIN,),
451
808
  (
@@ -38,7 +38,7 @@ def build_builder_server() -> FastMCP:
38
38
  "If creating or updating an app package may be appropriate, use package_apply with explicit user intent; otherwise use package_get and app_resolve to locate resources, "
39
39
  "app_get/app_get_fields/app_repair_code_blocks/app_get_layout/app_get_views/app_get_flow/app_get_charts/portal_list/portal_get/view_get/chart_get for configuration reads, "
40
40
  "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
41
- "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates are replace-only and publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
41
+ "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
42
42
  "For code_block fields with output bindings, always use qf_output assignment rather than const/let qf_output, and use app_repair_code_blocks when an existing form hangs because output-bound fields stay loading. "
43
43
  "Use package_apply to manage package metadata, visibility, grouping, and ordering, and app_publish_verify for explicit final publish verification. "
44
44
  "For workflow edits, keep the public builder surface on stable linear flows only: start/approve/fill/copy/webhook/end. Branch and condition nodes are intentionally disabled because the backend workflow route is not front-end stable for those node types. Declare node assignees and editable fields explicitly. "
@@ -1763,7 +1763,7 @@ class AiBuilderTools(ToolBase):
1763
1763
  dash_name: str = "",
1764
1764
  package_id: int | None = None,
1765
1765
  publish: bool = True,
1766
- sections: list[JSONObject],
1766
+ sections: list[JSONObject] | None = None,
1767
1767
  visibility: JSONObject | None = None,
1768
1768
  auth: JSONObject | None = None,
1769
1769
  icon: str | None = None,
@@ -3179,14 +3179,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3179
3179
  "chart.filter.operator": [member.value for member in ViewFilterOperator],
3180
3180
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3181
3181
  },
3182
- "execution_notes": [
3183
- "app_charts_apply is immediate-live and does not publish",
3184
- "chart matching precedence is chart_id first, then exact unique chart name",
3185
- "when chart names are not unique, supply chart_id instead of guessing by name",
3186
- "successful create results must return a real backend chart_id",
3187
- "upsert_charts[].visibility compiles to QingBI visibleAuth; omit it on updates to preserve the existing visibleAuth",
3188
- *_VISIBILITY_EXECUTION_NOTES,
3189
- ],
3182
+ "execution_notes": [
3183
+ "app_charts_apply is immediate-live and does not publish",
3184
+ "chart matching precedence is chart_id first, then exact unique chart name",
3185
+ "when chart names are not unique, supply chart_id instead of guessing by name",
3186
+ "successful create results must return a real backend chart_id",
3187
+ "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
3188
+ "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
3189
+ *_VISIBILITY_EXECUTION_NOTES,
3190
+ ],
3190
3191
  "minimal_example": {
3191
3192
  "profile": "default",
3192
3193
  "app_key": "APP_KEY",
@@ -3242,14 +3243,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3242
3243
  "dashStyleConfigBO": "dash_style_config",
3243
3244
  },
3244
3245
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
3245
- "execution_notes": [
3246
- "use exactly one resource mode",
3247
- "update mode: dash_key",
3248
- "create mode: package_id + dash_name",
3249
- "portal_apply uses replace semantics for sections",
3250
- "remove a section by omitting it from the new sections list",
3251
- "package_id is required when creating a new portal",
3252
- "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3246
+ "execution_notes": [
3247
+ "use exactly one resource mode",
3248
+ "update mode: dash_key",
3249
+ "create mode: package_id + dash_name",
3250
+ "portal_apply uses replace semantics for sections",
3251
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
3252
+ "remove a section by omitting it from the new sections list",
3253
+ "package_id is required when creating a new portal",
3254
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3253
3255
  "chart_ref resolves by chart_id first, then exact unique chart_name",
3254
3256
  "view_ref resolves by view_key first, then exact unique view_name",
3255
3257
  "position.pc/mobile is the canonical portal layout shape",
@@ -760,6 +760,8 @@ class ImportTools(ToolBase):
760
760
  error_code="CONFIG_ERROR",
761
761
  message="record_import_status_get accepts import_id or process_id_str, but not both at the same time",
762
762
  extra={
763
+ "import_id": normalized_import_id,
764
+ "process_id_str": normalized_process_id,
763
765
  "details": {
764
766
  "fix_hint": "Use only one of `import_id` or `process_id_str`. You may pass `app_key` as an optional routing hint for direct method compatibility.",
765
767
  }
@@ -770,6 +772,8 @@ class ImportTools(ToolBase):
770
772
  error_code="CONFIG_ERROR",
771
773
  message="record_import_status_get requires at least one selector: process_id_str, import_id, or app_key",
772
774
  extra={
775
+ "import_id": normalized_import_id,
776
+ "process_id_str": normalized_process_id,
773
777
  "details": {
774
778
  "fix_hint": "Use `process_id_str` or `import_id` for a known import, or use only `app_key` to inspect the latest import in that app.",
775
779
  }
@@ -783,6 +787,9 @@ class ImportTools(ToolBase):
783
787
  if local_job is None and normalized_process_id:
784
788
  matches = [item for item in self._job_store.list() if _normalize_optional_text(item.get("process_id_str")) == normalized_process_id]
785
789
  local_job = matches[0] if len(matches) == 1 else None
790
+ effective_process_id = normalized_process_id
791
+ if effective_process_id is None and isinstance(local_job, dict):
792
+ effective_process_id = _normalize_optional_text(local_job.get("process_id_str"))
786
793
  resolved_app_key = normalized_app_key
787
794
  if not resolved_app_key and isinstance(local_job, dict):
788
795
  resolved_app_key = str(local_job.get("app_key") or "").strip()
@@ -791,6 +798,8 @@ class ImportTools(ToolBase):
791
798
  error_code="CONFIG_ERROR",
792
799
  message="record_import_status_get could not determine app_key from the provided selector",
793
800
  extra={
801
+ "import_id": normalized_import_id,
802
+ "process_id_str": effective_process_id,
794
803
  "details": {
795
804
  "fix_hint": "Use the original `app_key`, or call import status with the latest-import mode: only `app_key`.",
796
805
  }
@@ -809,13 +818,18 @@ class ImportTools(ToolBase):
809
818
  matched_record, matched_by = _match_import_record(
810
819
  records,
811
820
  local_job=local_job,
812
- process_id_str=normalized_process_id,
821
+ import_id=normalized_import_id,
822
+ process_id_str=effective_process_id,
813
823
  )
814
824
  if matched_record is None:
815
825
  return self._failed_status_result(
816
826
  error_code="IMPORT_STATUS_AMBIGUOUS",
817
827
  message="could not uniquely resolve an import record from the provided identifiers",
818
- extra={"matched_by": matched_by},
828
+ extra={
829
+ "import_id": normalized_import_id,
830
+ "process_id_str": effective_process_id,
831
+ "matched_by": matched_by,
832
+ },
819
833
  )
820
834
  normalized_process = _normalize_optional_text(
821
835
  matched_record.get("processIdStr") or matched_record.get("processId") or matched_record.get("process_id_str")
@@ -2079,6 +2093,7 @@ def _match_import_record(
2079
2093
  records: list[JSONObject],
2080
2094
  *,
2081
2095
  local_job: dict[str, Any] | None,
2096
+ import_id: str | None,
2082
2097
  process_id_str: str | None,
2083
2098
  ) -> tuple[JSONObject | None, str | None]:
2084
2099
  if process_id_str:
@@ -2091,6 +2106,16 @@ def _match_import_record(
2091
2106
  return exact[0], "process_id_str"
2092
2107
  if len(exact) > 1:
2093
2108
  return None, "process_id_str"
2109
+ if import_id:
2110
+ exact = [
2111
+ item
2112
+ for item in records
2113
+ if import_id in _extract_import_record_ids(item)
2114
+ ]
2115
+ if len(exact) == 1:
2116
+ return exact[0], "import_id"
2117
+ if len(exact) > 1:
2118
+ return None, "import_id"
2094
2119
  if isinstance(local_job, dict):
2095
2120
  source_file_name = _normalize_optional_text(local_job.get("source_file_name"))
2096
2121
  started_at = _parse_utc(local_job.get("started_at"))
@@ -2121,6 +2146,15 @@ def _match_import_record(
2121
2146
  return None, None
2122
2147
 
2123
2148
 
2149
+ def _extract_import_record_ids(record: JSONObject) -> set[str]:
2150
+ identifiers: set[str] = set()
2151
+ for key in ("importId", "import_id", "dataImportId", "data_import_id"):
2152
+ normalized = _normalize_optional_text(record.get(key))
2153
+ if normalized:
2154
+ identifiers.add(normalized)
2155
+ return identifiers
2156
+
2157
+
2124
2158
  def _parse_utc(value: Any) -> datetime | None:
2125
2159
  text = _normalize_optional_text(value)
2126
2160
  if text is None:
@@ -1025,6 +1025,7 @@ class RecordTools(ToolBase):
1025
1025
  else:
1026
1026
  required = bool(required_override) if required_override is not None else bool(field.required)
1027
1027
  payload: JSONObject = {
1028
+ "field_id": field.que_id,
1028
1029
  "title": field.que_title,
1029
1030
  "kind": kind,
1030
1031
  "required": required,
@@ -1371,7 +1371,7 @@ class TaskContextTools(ToolBase):
1371
1371
  "record_id": task.get("record_id"),
1372
1372
  "workflow_node_id": task.get("workflow_node_id"),
1373
1373
  "workflow_node_name": task.get("workflow_node_name"),
1374
- "initiator": record.get("apply_user"),
1374
+ "initiator": self._compact_initiator(record.get("apply_user")),
1375
1375
  "actionable": task.get("actionable"),
1376
1376
  },
1377
1377
  "record_summary": {
@@ -1416,12 +1416,6 @@ class TaskContextTools(ToolBase):
1416
1416
  },
1417
1417
  },
1418
1418
  }
1419
- action_metadata = self._compact_task_action_metadata(capabilities)
1420
- if action_metadata:
1421
- compact["action_metadata"] = action_metadata
1422
- editable_metadata = self._compact_task_editable_metadata(update_schema)
1423
- if editable_metadata:
1424
- compact["editable_metadata"] = editable_metadata
1425
1419
  return compact
1426
1420
 
1427
1421
  def _compact_task_action_metadata(self, capabilities: dict[str, Any]) -> dict[str, Any]:
@@ -1449,6 +1443,18 @@ class TaskContextTools(ToolBase):
1449
1443
  metadata["warnings"] = warnings
1450
1444
  return metadata
1451
1445
 
1446
+ def _compact_initiator(self, payload: Any) -> dict[str, Any] | None:
1447
+ if not isinstance(payload, dict):
1448
+ return None
1449
+ compact = {
1450
+ "uid": payload.get("uid"),
1451
+ "displayName": payload.get("displayName") or payload.get("name") or payload.get("nickName"),
1452
+ "email": payload.get("email"),
1453
+ "mobile": payload.get("mobile"),
1454
+ "headImg": payload.get("headImg"),
1455
+ }
1456
+ return {key: value for key, value in compact.items() if value not in (None, "", [])} or None
1457
+
1452
1458
  def _task_app_name(self, detail: dict[str, Any], node_info: dict[str, Any]) -> Any:
1453
1459
  for source in (detail, node_info):
1454
1460
  for key in ("formTitle", "appName", "worksheetName", "appTitle"):
@@ -1550,12 +1556,6 @@ class TaskContextTools(ToolBase):
1550
1556
  app_key = raw.get("appKey") or raw.get("app_key")
1551
1557
  record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
1552
1558
  workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
1553
- apply_user = raw.get("applyUser")
1554
- if apply_user is None:
1555
- user_uid = raw.get("applyUserUid")
1556
- user_name = raw.get("applyUserName")
1557
- if user_uid is not None or user_name is not None:
1558
- apply_user = {"uid": user_uid, "name": user_name}
1559
1559
  return {
1560
1560
  "task_id": raw.get("id") or raw.get("taskId") or record_id,
1561
1561
  "app_key": app_key,
@@ -1563,8 +1563,6 @@ class TaskContextTools(ToolBase):
1563
1563
  "record_id": record_id,
1564
1564
  "workflow_node_id": workflow_node_id,
1565
1565
  "workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
1566
- "title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
1567
- "apply_user": apply_user,
1568
1566
  "apply_time": raw.get("applyTime") or raw.get("receiveTime"),
1569
1567
  "task_box": task_box,
1570
1568
  "flow_status": flow_status,