@qingflow-tech/qingflow-app-user-mcp 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,7 @@ from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_T
20
20
  from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
21
21
  from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
22
22
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
23
- from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
23
+ from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, workspace_icon_config
24
24
  from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
25
25
  from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
26
26
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
@@ -878,14 +878,21 @@ class AiBuilderFacade:
878
878
  "verification": {},
879
879
  }
880
880
 
881
- def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
881
+ def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
882
882
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
883
+ raw_items = listed.get("items") if isinstance(listed.get("items"), list) else []
884
+ items = [_publicize_package_list_item(item) for item in raw_items if isinstance(item, dict)]
885
+ normalized_query = str(query or "").strip()
886
+ if normalized_query:
887
+ filtered_items = [item for item in items if _package_list_item_matches_query(item, normalized_query)]
888
+ else:
889
+ filtered_items = items
883
890
  return {
884
891
  "status": "success",
885
892
  "error_code": None,
886
893
  "recoverable": False,
887
894
  "message": "listed packages",
888
- "normalized_args": {"trial_status": trial_status},
895
+ "normalized_args": {"trial_status": trial_status, "query": normalized_query},
889
896
  "missing_fields": [],
890
897
  "allowed_values": {},
891
898
  "details": {},
@@ -894,8 +901,12 @@ class AiBuilderFacade:
894
901
  "noop": False,
895
902
  "verification": {},
896
903
  "trial_status": trial_status,
897
- "items": listed.get("items") if isinstance(listed.get("items"), list) else [],
898
- "count": listed.get("count") or 0,
904
+ "query": normalized_query,
905
+ "items": filtered_items,
906
+ "count": len(filtered_items),
907
+ "matched_count": len(filtered_items),
908
+ "unfiltered_count": len(items),
909
+ "filter_mode": "local_packages",
899
910
  "source_shape": listed.get("source_shape"),
900
911
  "retried": bool(listed.get("retried", False)),
901
912
  }
@@ -2564,6 +2575,7 @@ class AiBuilderFacade:
2564
2575
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_buttons", error=api_error),
2565
2576
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
2566
2577
  ))
2578
+ app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
2567
2579
 
2568
2580
  existing_by_id = {
2569
2581
  button_id: item
@@ -2867,13 +2879,29 @@ class AiBuilderFacade:
2867
2879
  try:
2868
2880
  write_executed = True
2869
2881
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
2882
+ delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
2870
2883
  removed.append(
2871
2884
  {
2872
2885
  "index": op["index"],
2873
2886
  "operation": "remove",
2874
- "status": "success",
2887
+ "status": delete_readback.get("status") or "readback_pending",
2875
2888
  "button_id": button_id,
2876
2889
  "button_text": selector.button_text or (existing_by_id.get(button_id) or {}).get("button_text"),
2890
+ "delete_executed": True,
2891
+ "readback_status": delete_readback.get("readback_status"),
2892
+ "safe_to_retry_delete": False,
2893
+ **(
2894
+ {
2895
+ "error_code": delete_readback.get("error_code"),
2896
+ "message": delete_readback.get("message"),
2897
+ "request_id": delete_readback.get("request_id"),
2898
+ "backend_code": delete_readback.get("backend_code"),
2899
+ "http_status": delete_readback.get("http_status"),
2900
+ "transport_error": delete_readback.get("transport_error"),
2901
+ }
2902
+ if delete_readback.get("readback_status") != "deleted"
2903
+ else {}
2904
+ ),
2877
2905
  }
2878
2906
  )
2879
2907
  except (QingflowApiError, RuntimeError) as error:
@@ -2891,12 +2919,14 @@ class AiBuilderFacade:
2891
2919
  }
2892
2920
  )
2893
2921
 
2922
+ needs_button_list_readback = bool(created or updated or request.view_configs)
2894
2923
  readback_buttons: list[dict[str, Any]] = []
2895
2924
  readback_failed = False
2896
- try:
2897
- readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2898
- except (QingflowApiError, RuntimeError):
2899
- readback_failed = True
2925
+ if needs_button_list_readback:
2926
+ try:
2927
+ readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2928
+ except (QingflowApiError, RuntimeError):
2929
+ readback_failed = True
2900
2930
  readback_ids = {
2901
2931
  button_id
2902
2932
  for item in readback_buttons
@@ -2917,10 +2947,12 @@ class AiBuilderFacade:
2917
2947
  for item in removed
2918
2948
  if _coerce_positive_int(item.get("button_id")) is not None
2919
2949
  ]
2950
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
2951
+ remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
2920
2952
  verified = (
2921
2953
  not readback_failed
2922
2954
  and all(button_id in readback_ids for button_id in created_ids + updated_ids)
2923
- and all(button_id not in readback_ids for button_id in removed_ids)
2955
+ and removed_verified
2924
2956
  and not failed
2925
2957
  and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
2926
2958
  )
@@ -2979,6 +3011,13 @@ class AiBuilderFacade:
2979
3011
  else "custom button writes all failed or produced no confirmed result; application was not published",
2980
3012
  )
2981
3013
  )
3014
+ if remove_readback_pending:
3015
+ warnings.append(
3016
+ _warning(
3017
+ "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
3018
+ "custom button delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
3019
+ )
3020
+ )
2982
3021
  response = {
2983
3022
  "status": status,
2984
3023
  "error_code": error_code,
@@ -3008,11 +3047,14 @@ class AiBuilderFacade:
3008
3047
  "readback_loaded": not readback_failed,
3009
3048
  "created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
3010
3049
  "updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
3011
- "removed_verified": not readback_failed and all(button_id not in readback_ids for button_id in removed_ids),
3050
+ "removed_verified": removed_verified,
3051
+ "remove_readback_pending": remove_readback_pending,
3052
+ "removed_readback_results": deepcopy(removed),
3012
3053
  "view_button_bindings_verified": view_config_verified,
3013
3054
  },
3014
3055
  "verified": verified,
3015
3056
  "app_key": app_key,
3057
+ "app_name": app_name,
3016
3058
  "mode": "apply",
3017
3059
  "created": created,
3018
3060
  "updated": updated,
@@ -3372,30 +3414,35 @@ class AiBuilderFacade:
3372
3414
  return finalize(edit_context_error)
3373
3415
 
3374
3416
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
3417
+ delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
3418
+ verified = delete_readback.get("readback_status") == "deleted"
3375
3419
  return finalize(
3376
3420
  self._append_publish_result(
3377
3421
  profile=profile,
3378
3422
  app_key=app_key,
3379
3423
  publish=True,
3380
3424
  response={
3381
- "status": "success",
3382
- "error_code": None,
3383
- "recoverable": False,
3384
- "message": "deleted custom button",
3425
+ "status": "success" if verified else "partial_success",
3426
+ "error_code": None if verified else delete_readback.get("error_code") or "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
3427
+ "recoverable": not verified,
3428
+ "message": "deleted custom button" if verified else "custom button delete completed; readback pending",
3385
3429
  "normalized_args": normalized_args,
3386
3430
  "missing_fields": [],
3387
3431
  "allowed_values": {},
3388
3432
  "details": {},
3389
- "request_id": None,
3390
- "suggested_next_call": None,
3433
+ "request_id": delete_readback.get("request_id"),
3434
+ "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3391
3435
  "noop": False,
3392
- "warnings": [],
3393
- "verification": {"custom_button_deleted": True},
3394
- "verified": True,
3436
+ "warnings": [] if verified else [_warning("CUSTOM_BUTTON_DELETE_READBACK_PENDING", "custom button delete was sent, but deletion readback is not fully verified")],
3437
+ "verification": {"custom_button_deleted": verified, "delete_readback": delete_readback},
3438
+ "verified": verified,
3395
3439
  "app_key": app_key,
3396
3440
  "button_id": button_id,
3397
3441
  "edit_version_no": edit_version_no,
3398
- "deleted": True,
3442
+ "deleted": verified,
3443
+ "delete_executed": True,
3444
+ "readback_status": delete_readback.get("readback_status"),
3445
+ "safe_to_retry_delete": False,
3399
3446
  },
3400
3447
  )
3401
3448
  )
@@ -3663,6 +3710,7 @@ class AiBuilderFacade:
3663
3710
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
3664
3711
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3665
3712
  ))
3713
+ app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
3666
3714
 
3667
3715
  existing_by_id = _associated_resource_index(existing_resources)
3668
3716
  blocking_issues: list[dict[str, Any]] = []
@@ -3671,7 +3719,6 @@ class AiBuilderFacade:
3671
3719
  client_key_to_patch: dict[str, AssociatedResourceUpsertPatch] = {}
3672
3720
  client_key_to_id: dict[str, int] = {}
3673
3721
  used_client_keys: set[str] = set()
3674
- force_update_resource_ids: set[int] = set()
3675
3722
  resolved_associated_item_refs: dict[str, list[int]] = {}
3676
3723
 
3677
3724
  upsert_resources = list(request.upsert_resources)
@@ -3691,11 +3738,6 @@ class AiBuilderFacade:
3691
3738
  )
3692
3739
  )
3693
3740
  upsert_resources.extend(expanded_resources)
3694
- force_update_resource_ids.update(
3695
- item_id
3696
- for item_id in (_coerce_positive_int(patch.associated_item_id) for patch in expanded_resources)
3697
- if item_id is not None
3698
- )
3699
3741
  normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
3700
3742
  normalized_args["patch_results"] = patch_results
3701
3743
 
@@ -3733,14 +3775,9 @@ class AiBuilderFacade:
3733
3775
  touched_ids.add(associated_item_id)
3734
3776
  if client_key:
3735
3777
  client_key_to_id[client_key] = associated_item_id
3736
- operation = (
3737
- "update"
3738
- if associated_item_id in force_update_resource_ids
3739
- or _associated_resource_patch_has_match_config(patch)
3740
- else "unchanged"
3741
- if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
3742
- else "update"
3743
- )
3778
+ has_match_config = _associated_resource_patch_has_match_config(patch)
3779
+ identity_matches = _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
3780
+ operation = "update" if has_match_config or not identity_matches else "unchanged"
3744
3781
  upsert_ops.append({"operation": operation, "associated_item_id": associated_item_id, "patch": patch, "index": index})
3745
3782
  continue
3746
3783
  matches = [
@@ -3997,7 +4034,16 @@ class AiBuilderFacade:
3997
4034
  try:
3998
4035
  write_executed = True
3999
4036
  self._associated_resource_delete(profile=profile, app_key=app_key, associated_item_id=item_id)
4000
- removed.append({"associated_item_id": item_id, "status": "success"})
4037
+ removed.append(
4038
+ {
4039
+ "associated_item_id": item_id,
4040
+ "operation": "remove",
4041
+ "status": "readback_pending",
4042
+ "delete_executed": True,
4043
+ "readback_status": "unavailable",
4044
+ "safe_to_retry_delete": False,
4045
+ }
4046
+ )
4001
4047
  except (QingflowApiError, RuntimeError) as error:
4002
4048
  api_error = _coerce_api_error(error)
4003
4049
  failed.append({"operation": "remove", "associated_item_id": item_id, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
@@ -4011,13 +4057,39 @@ class AiBuilderFacade:
4011
4057
  api_error = _coerce_api_error(error)
4012
4058
  failed.append({"operation": "reorder", "associated_item_ids": reorder_ids, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_REORDER_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4013
4059
 
4014
- try:
4015
- resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4016
- except (QingflowApiError, RuntimeError):
4017
- resources_after = []
4060
+ resources_after: list[dict[str, Any]] = []
4061
+ resources_after_loaded = False
4062
+ resources_after_readback_failed = False
4063
+ if request.view_configs:
4064
+ try:
4065
+ resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4066
+ resources_after_loaded = True
4067
+ except (QingflowApiError, RuntimeError):
4068
+ resources_after = []
4069
+ resources_after_readback_failed = True
4018
4070
 
4019
4071
  for index, view_config in enumerate(request.view_configs):
4020
4072
  resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
4073
+ view_name = str(view_config.view_key or "").strip()
4074
+ missing_ref_ids = [
4075
+ str(ref or "").strip()
4076
+ for ref in view_config.associated_item_refs
4077
+ if str(ref or "").strip() and str(ref or "").strip() not in client_key_to_id
4078
+ ]
4079
+ if missing_ref_ids:
4080
+ failed.append(
4081
+ {
4082
+ "operation": "view_config",
4083
+ "index": index,
4084
+ "view_key": view_config.view_key,
4085
+ "view_name": view_name,
4086
+ "status": "failed",
4087
+ "error_code": "ASSOCIATED_RESOURCE_REF_UNRESOLVED",
4088
+ "missing_refs": missing_ref_ids,
4089
+ "message": "view_config references associated resources that were not successfully created or resolved; skipped view config write to avoid clearing existing associations",
4090
+ }
4091
+ )
4092
+ continue
4021
4093
  selected_ids = [
4022
4094
  item_id
4023
4095
  for item_id in (
@@ -4034,19 +4106,21 @@ class AiBuilderFacade:
4034
4106
  available_resources=resources_after,
4035
4107
  )
4036
4108
  if issues:
4037
- failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "issues": issues})
4109
+ failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "issues": issues})
4038
4110
  continue
4039
4111
  try:
4040
4112
  write_executed = True
4041
4113
  self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
4042
4114
  try:
4043
4115
  config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
4116
+ view_name = _extract_view_name(config if isinstance(config, dict) else {}) or view_name
4044
4117
  actual_config = _extract_view_associated_resources_config(config if isinstance(config, dict) else {}, available_resources=resources_after)
4045
4118
  verified_config = expected_config is not None and _associated_resources_config_matches(expected_config, actual_config)
4046
4119
  if not verified_config and expected_config is not None:
4047
4120
  self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
4048
4121
  refreshed_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4049
4122
  refreshed_config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
4123
+ view_name = _extract_view_name(refreshed_config if isinstance(refreshed_config, dict) else {}) or view_name
4050
4124
  actual_config = _extract_view_associated_resources_config(
4051
4125
  refreshed_config if isinstance(refreshed_config, dict) else {},
4052
4126
  available_resources=refreshed_resources,
@@ -4055,26 +4129,35 @@ class AiBuilderFacade:
4055
4129
  except (QingflowApiError, RuntimeError):
4056
4130
  actual_config = {}
4057
4131
  verified_config = False
4058
- view_config_results.append({"index": index, "view_key": view_config.view_key, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
4132
+ view_config_results.append({"index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
4059
4133
  except (QingflowApiError, RuntimeError) as error:
4060
4134
  api_error = _coerce_api_error(error)
4061
- failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4135
+ failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4062
4136
 
4063
- final_resources: list[dict[str, Any]] = []
4064
- readback_failed = False
4065
- try:
4066
- final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4067
- except (QingflowApiError, RuntimeError):
4068
- readback_failed = True
4137
+ final_resources: list[dict[str, Any]] = resources_after if resources_after_loaded else []
4138
+ readback_failed = resources_after_readback_failed
4139
+ if not resources_after_loaded and not resources_after_readback_failed:
4140
+ try:
4141
+ final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4142
+ except (QingflowApiError, RuntimeError):
4143
+ readback_failed = True
4069
4144
  final_by_id = _associated_resource_index(final_resources)
4145
+ if removed:
4146
+ removed = self._verify_associated_resources_deleted_by_pool(
4147
+ deleted_items=removed,
4148
+ resources=final_resources,
4149
+ readback_failed=readback_failed,
4150
+ )
4070
4151
  created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
4071
4152
  updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
4072
4153
  unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
4073
4154
  removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
4155
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
4156
+ remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
4074
4157
  pool_verified = (
4075
4158
  not readback_failed
4076
4159
  and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
4077
- and all(item_id not in final_by_id for item_id in removed_ids)
4160
+ and removed_verified
4078
4161
  and not failed
4079
4162
  and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
4080
4163
  )
@@ -4109,6 +4192,13 @@ class AiBuilderFacade:
4109
4192
  else "associated resource writes all failed or produced no confirmed result; application was not published",
4110
4193
  )
4111
4194
  )
4195
+ if remove_readback_pending:
4196
+ warnings.append(
4197
+ _warning(
4198
+ "ASSOCIATED_RESOURCE_DELETE_READBACK_PENDING",
4199
+ "associated resource delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
4200
+ )
4201
+ )
4112
4202
  response = {
4113
4203
  "status": status,
4114
4204
  "error_code": error_code,
@@ -4131,9 +4221,17 @@ class AiBuilderFacade:
4131
4221
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4132
4222
  "noop": False,
4133
4223
  "warnings": warnings,
4134
- "verification": {"associated_resources_verified": pool_verified, "associated_resource_view_configs_verified": view_configs_verified, "readback_loaded": not readback_failed},
4224
+ "verification": {
4225
+ "associated_resources_verified": pool_verified,
4226
+ "associated_resource_view_configs_verified": view_configs_verified,
4227
+ "readback_loaded": not readback_failed,
4228
+ "removed_verified": removed_verified,
4229
+ "remove_readback_pending": remove_readback_pending,
4230
+ "removed_readback_results": deepcopy(removed),
4231
+ },
4135
4232
  "verified": verified,
4136
4233
  "app_key": app_key,
4234
+ "app_name": app_name,
4137
4235
  "mode": "apply",
4138
4236
  "created": created,
4139
4237
  "updated": updated,
@@ -4612,10 +4710,21 @@ class AiBuilderFacade:
4612
4710
  "associated_resources": len(associated_resources),
4613
4711
  "custom_buttons": len(custom_buttons),
4614
4712
  }
4713
+ app_name = str(
4714
+ base_result.get("formTitle")
4715
+ or base_result.get("title")
4716
+ or base_result.get("appName")
4717
+ or base_result.get("name")
4718
+ or app_key
4719
+ ).strip() or app_key
4720
+ app_icon = str(base_result.get("appIcon") or "").strip() or None
4615
4721
  response = AppReadSummaryResponse(
4616
4722
  app_key=app_key,
4617
- title=base_result.get("formTitle"),
4618
- app_icon=str(base_result.get("appIcon") or "").strip() or None,
4723
+ app_name=app_name,
4724
+ name=app_name,
4725
+ title=app_name,
4726
+ app_icon=app_icon,
4727
+ icon_config=workspace_icon_config(app_icon),
4619
4728
  visibility=_public_visibility_from_member_auth(base_result.get("auth")),
4620
4729
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
4621
4730
  publish_status=base_result.get("appPublishStatus"),
@@ -4999,10 +5108,32 @@ class AiBuilderFacade:
4999
5108
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
5000
5109
  )
5001
5110
  parsed = state["parsed"]
5111
+ warnings: list[dict[str, Any]] = []
5112
+ field_lookup = _build_public_field_lookup(cast(list[dict[str, Any]], parsed["fields"]))
5113
+ chart_fields: list[dict[str, Any]] = []
5114
+ try:
5115
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
5116
+ chart_fields = _compact_public_chart_fields_read(
5117
+ app_key=app_key,
5118
+ qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
5119
+ field_lookup=field_lookup,
5120
+ )
5121
+ except (QingflowApiError, RuntimeError) as error:
5122
+ api_error = _coerce_api_error(error)
5123
+ warnings.append(
5124
+ _warning(
5125
+ "QINGBI_FIELDS_READ_FAILED",
5126
+ "form fields were read, but QingBI chart fields could not be read; chart configuration should use chart_fields when available",
5127
+ backend_code=api_error.backend_code,
5128
+ request_id=api_error.request_id,
5129
+ )
5130
+ )
5002
5131
  response = AppFieldsReadResponse(
5003
5132
  app_key=app_key,
5004
5133
  fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
5005
5134
  field_count=len(parsed["fields"]),
5135
+ chart_fields=chart_fields,
5136
+ chart_field_count=len(chart_fields),
5006
5137
  form_settings=_form_settings_from_schema(state["schema"], parsed["fields"]),
5007
5138
  )
5008
5139
  return {
@@ -5017,7 +5148,7 @@ class AiBuilderFacade:
5017
5148
  "request_id": None,
5018
5149
  "suggested_next_call": None,
5019
5150
  "noop": False,
5020
- "warnings": [],
5151
+ "warnings": warnings,
5021
5152
  "verification": {"app_exists": True},
5022
5153
  "verified": True,
5023
5154
  **response.model_dump(mode="json"),
@@ -5441,7 +5572,7 @@ class AiBuilderFacade:
5441
5572
  for index, patch, config in semantic_patches:
5442
5573
  config_payload = config.model_dump(mode="json", exclude_none=True)
5443
5574
  reason_base = f"upsert_buttons[{index}].trigger_add_data_config"
5444
- if config.que_relation:
5575
+ if "que_relation" in config.model_fields_set:
5445
5576
  issues.append(
5446
5577
  {
5447
5578
  "error_code": "MIXED_CUSTOM_BUTTON_MAPPING_MODES",
@@ -5745,11 +5876,12 @@ class AiBuilderFacade:
5745
5876
  )
5746
5877
  return {"verified": False, "write_executed": False, "write_succeeded": False, "view_configs": results, "failed": failed, "warnings": warnings}
5747
5878
 
5748
- view_keys = {
5749
- _extract_view_key(view)
5879
+ existing_views_by_key = {
5880
+ _extract_view_key(view): view
5750
5881
  for view in (existing_views if isinstance(existing_views, list) else [])
5751
5882
  if isinstance(view, dict) and _extract_view_key(view)
5752
5883
  }
5884
+ view_keys = set(existing_views_by_key)
5753
5885
  button_inventory: dict[int, dict[str, Any]] = {}
5754
5886
  for item in [*existing_buttons, *readback_buttons]:
5755
5887
  if not isinstance(item, dict):
@@ -5781,6 +5913,7 @@ class AiBuilderFacade:
5781
5913
  try:
5782
5914
  current_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
5783
5915
  current_config = current_response.get("result") if isinstance(current_response.get("result"), dict) else {}
5916
+ view_name = _extract_view_name(current_config) or _extract_view_name(existing_views_by_key.get(view_key) or {}) or view_key
5784
5917
  except (QingflowApiError, RuntimeError) as error:
5785
5918
  api_error = _coerce_api_error(error)
5786
5919
  issue = {
@@ -5789,6 +5922,7 @@ class AiBuilderFacade:
5789
5922
  "status": "failed",
5790
5923
  "error_code": "VIEW_CONFIG_READ_FAILED",
5791
5924
  "view_key": view_key,
5925
+ "view_name": _extract_view_name(existing_views_by_key.get(view_key) or {}) or view_key,
5792
5926
  "message": api_error.message,
5793
5927
  "transport_error": _transport_error_payload(api_error),
5794
5928
  }
@@ -5879,6 +6013,7 @@ class AiBuilderFacade:
5879
6013
  "status": "failed",
5880
6014
  "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5881
6015
  "view_key": view_key,
6016
+ "view_name": view_name,
5882
6017
  "message": "backend rejected inside/list-button placement; header/detail placements were retried without inside buttons",
5883
6018
  "backend_message": api_error.message,
5884
6019
  "transport_error": _transport_error_payload(api_error),
@@ -5891,9 +6026,10 @@ class AiBuilderFacade:
5891
6026
  "index": config_index,
5892
6027
  "operation": "view_config",
5893
6028
  "status": "failed",
5894
- "error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
5895
- "view_key": view_key,
5896
- "message": fallback_api_error.message,
6029
+ "error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
6030
+ "view_key": view_key,
6031
+ "view_name": view_name,
6032
+ "message": fallback_api_error.message,
5897
6033
  "transport_error": _transport_error_payload(fallback_api_error),
5898
6034
  "initial_error": _transport_error_payload(api_error),
5899
6035
  }
@@ -5908,6 +6044,7 @@ class AiBuilderFacade:
5908
6044
  "status": "failed",
5909
6045
  "error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
5910
6046
  "view_key": view_key,
6047
+ "view_name": view_name,
5911
6048
  "message": "backend rejected inside/list-button placement",
5912
6049
  "backend_message": api_error.message,
5913
6050
  "transport_error": _transport_error_payload(api_error),
@@ -5924,6 +6061,7 @@ class AiBuilderFacade:
5924
6061
  "status": "failed",
5925
6062
  "error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
5926
6063
  "view_key": view_key,
6064
+ "view_name": view_name,
5927
6065
  "message": api_error.message,
5928
6066
  "transport_error": _transport_error_payload(api_error),
5929
6067
  }
@@ -5953,6 +6091,7 @@ class AiBuilderFacade:
5953
6091
  "operation": "view_config",
5954
6092
  "status": "success" if verified else ("partial_success" if unsupported_list_issue is not None else "unverified"),
5955
6093
  "view_key": view_key,
6094
+ "view_name": view_name,
5956
6095
  "mode": "replace" if replace_existing else "merge",
5957
6096
  "buttons_configured": len(new_dtos),
5958
6097
  "view_buttons_verified": verified,
@@ -5975,6 +6114,7 @@ class AiBuilderFacade:
5975
6114
  "operation": "view_config",
5976
6115
  "status": "unverified",
5977
6116
  "view_key": view_key,
6117
+ "view_name": view_name,
5978
6118
  "mode": "replace" if replace_existing else "merge",
5979
6119
  "buttons_configured": len(new_dtos),
5980
6120
  "view_buttons_verified": None,
@@ -6001,6 +6141,7 @@ class AiBuilderFacade:
6001
6141
  details={"dash_key": dash_key, "being_draft": being_draft},
6002
6142
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
6003
6143
  )
6144
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
6004
6145
  response = PortalGetResponse(
6005
6146
  dash_key=dash_key,
6006
6147
  being_draft=being_draft,
@@ -6014,7 +6155,8 @@ class AiBuilderFacade:
6014
6155
  )
6015
6156
  if tag_id is not None
6016
6157
  ],
6017
- dash_icon=str(result.get("dashIcon") or "").strip() or None,
6158
+ dash_icon=dash_icon,
6159
+ icon_config=workspace_icon_config(dash_icon),
6018
6160
  hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
6019
6161
  visibility=_public_visibility_from_member_auth(result.get("auth")),
6020
6162
  auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
@@ -6053,6 +6195,7 @@ class AiBuilderFacade:
6053
6195
  details={"dash_key": dash_key, "being_draft": being_draft},
6054
6196
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
6055
6197
  )
6198
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
6056
6199
  response = PortalReadSummaryResponse(
6057
6200
  dash_key=dash_key,
6058
6201
  being_draft=being_draft,
@@ -6066,7 +6209,8 @@ class AiBuilderFacade:
6066
6209
  )
6067
6210
  if tag_id is not None
6068
6211
  ],
6069
- dash_icon=str(result.get("dashIcon") or "").strip() or None,
6212
+ dash_icon=dash_icon,
6213
+ icon_config=workspace_icon_config(dash_icon),
6070
6214
  hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
6071
6215
  config_keys=sorted(str(key) for key in (result.get("config") or {}).keys()) if isinstance(result.get("config"), dict) else [],
6072
6216
  dash_global_config_keys=sorted(str(key) for key in (result.get("dashGlobalConfig") or {}).keys()) if isinstance(result.get("dashGlobalConfig"), dict) else [],
@@ -7055,6 +7199,7 @@ class AiBuilderFacade:
7055
7199
  },
7056
7200
  "app_key": target.app_key,
7057
7201
  "app_icon": str(resolved.get("app_icon") or visual_result.get("app_icon") or "").strip() or None,
7202
+ "app_name": str(visual_result.get("app_name_after") or target.app_name),
7058
7203
  "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
7059
7204
  "app_name_after": str(visual_result.get("app_name_after") or target.app_name),
7060
7205
  "app_base_updated": bool(visual_result.get("updated")),
@@ -7295,7 +7440,8 @@ class AiBuilderFacade:
7295
7440
  actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
7296
7441
  actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
7297
7442
  expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
7298
- app_base_verified = actual_app_name == effective_app_name and actual_app_icon == expected_app_icon
7443
+ app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
7444
+ app_base_verified = actual_app_name == effective_app_name and app_icon_verified
7299
7445
  verified = app_base_verified and relation_target_metadata_verified
7300
7446
  response = {
7301
7447
  "status": "success" if verified else "partial_success",
@@ -7319,6 +7465,7 @@ class AiBuilderFacade:
7319
7465
  },
7320
7466
  "app_key": target.app_key,
7321
7467
  "app_icon": str(visual_result.get("app_icon") or "").strip() or None,
7468
+ "app_name": effective_app_name,
7322
7469
  "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
7323
7470
  "app_name_after": effective_app_name,
7324
7471
  "app_base_updated": bool(visual_result.get("updated")),
@@ -7516,6 +7663,7 @@ class AiBuilderFacade:
7516
7663
  },
7517
7664
  "app_key": target.app_key,
7518
7665
  "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
7666
+ "app_name": effective_app_name,
7519
7667
  "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
7520
7668
  "app_name_after": effective_app_name,
7521
7669
  "app_base_updated": bool(visual_result.get("updated")),
@@ -7525,6 +7673,13 @@ class AiBuilderFacade:
7525
7673
  "updated": updated,
7526
7674
  "removed": removed,
7527
7675
  },
7676
+ "field_diff_details": _schema_field_diff_details(
7677
+ added=added,
7678
+ updated=updated,
7679
+ removed=removed,
7680
+ before_fields=original_fields,
7681
+ after_fields=current_fields,
7682
+ ),
7528
7683
  "verified": False,
7529
7684
  "tag_ids_after": [],
7530
7685
  "package_attached": None,
@@ -7544,6 +7699,13 @@ class AiBuilderFacade:
7544
7699
  try:
7545
7700
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
7546
7701
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
7702
+ response["field_diff_details"] = _schema_field_diff_details(
7703
+ added=added,
7704
+ updated=updated,
7705
+ removed=removed,
7706
+ before_fields=original_fields,
7707
+ after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
7708
+ )
7547
7709
  verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
7548
7710
  data_display_verification = _verify_data_display_readback(
7549
7711
  form_settings=verified.get("form_settings"),
@@ -7575,7 +7737,8 @@ class AiBuilderFacade:
7575
7737
  actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
7576
7738
  actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
7577
7739
  expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
7578
- app_base_verified = actual_app_name == effective_app_name and actual_app_icon == expected_app_icon
7740
+ app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
7741
+ app_base_verified = actual_app_name == effective_app_name and app_icon_verified
7579
7742
  except (QingflowApiError, RuntimeError) as error:
7580
7743
  base_error = _coerce_api_error(error)
7581
7744
  if verification_error is None:
@@ -7669,6 +7832,7 @@ class AiBuilderFacade:
7669
7832
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
7670
7833
  suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
7671
7834
  ))
7835
+ app_name = str(schema_result.get("formTitle") or schema_result.get("title") or schema_result.get("appName") or app_key).strip() or app_key
7672
7836
  parsed = _parse_schema(schema_result)
7673
7837
  current_fields = parsed["fields"]
7674
7838
  requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
@@ -7751,6 +7915,7 @@ class AiBuilderFacade:
7751
7915
  "warnings": [],
7752
7916
  "verification": {"layout_verified": True, "layout_summary_verified": True},
7753
7917
  "app_key": app_key,
7918
+ "app_name": app_name,
7754
7919
  "layout_diff": {
7755
7920
  "mode": mode.value,
7756
7921
  "replaced": mode == LayoutApplyMode.replace,
@@ -7839,6 +8004,7 @@ class AiBuilderFacade:
7839
8004
  "warnings": [],
7840
8005
  "verification": {"layout_verified": False, "layout_summary_verified": False, "layout_read_unavailable": True},
7841
8006
  "app_key": app_key,
8007
+ "app_name": app_name,
7842
8008
  "layout_diff": {
7843
8009
  "mode": mode.value,
7844
8010
  "replaced": mode == LayoutApplyMode.replace,
@@ -7895,6 +8061,7 @@ class AiBuilderFacade:
7895
8061
  "warnings": warnings,
7896
8062
  "verification": {"layout_verified": layout_verified, "layout_summary_verified": layout_summary_verified},
7897
8063
  "app_key": app_key,
8064
+ "app_name": app_name,
7898
8065
  "layout_diff": {
7899
8066
  "mode": mode.value,
7900
8067
  "replaced": mode == LayoutApplyMode.replace,
@@ -7957,6 +8124,7 @@ class AiBuilderFacade:
7957
8124
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
7958
8125
  suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
7959
8126
  ))
8127
+ app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
7960
8128
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
7961
8129
  current_fields = _parse_schema(schema)["fields"]
7962
8130
  normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
@@ -8124,6 +8292,7 @@ class AiBuilderFacade:
8124
8292
  "workflow_read_unavailable": verified_nodes_unavailable,
8125
8293
  },
8126
8294
  "app_key": app_key,
8295
+ "app_name": app_name,
8127
8296
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
8128
8297
  "verified": workflow_verified,
8129
8298
  }
@@ -8199,6 +8368,7 @@ class AiBuilderFacade:
8199
8368
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
8200
8369
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8201
8370
  ))
8371
+ app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip() or None
8202
8372
  existing_views = existing_views or []
8203
8373
  existing_by_key: dict[str, dict[str, Any]] = {}
8204
8374
  existing_by_name: dict[str, list[dict[str, Any]]] = {}
@@ -8306,9 +8476,15 @@ class AiBuilderFacade:
8306
8476
  )
8307
8477
  )
8308
8478
  removed: list[str] = []
8479
+ removed_keys: set[str] = set()
8309
8480
  view_results: list[dict[str, Any]] = []
8310
- for name in remove_views:
8311
- matches = existing_by_name.get(name, [])
8481
+ failed_views: list[dict[str, Any]] = []
8482
+ for selector in remove_views:
8483
+ selector_text = str(selector or "").strip()
8484
+ if not selector_text:
8485
+ continue
8486
+ key_match = existing_by_key.get(selector_text)
8487
+ matches = [key_match] if isinstance(key_match, dict) else existing_by_name.get(selector_text, [])
8312
8488
  if len(matches) > 1:
8313
8489
  return _failed(
8314
8490
  "AMBIGUOUS_VIEW",
@@ -8316,7 +8492,7 @@ class AiBuilderFacade:
8316
8492
  normalized_args=normalized_args,
8317
8493
  details={
8318
8494
  "app_key": app_key,
8319
- "view_name": name,
8495
+ "view_name": selector_text,
8320
8496
  "matches": [
8321
8497
  {"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
8322
8498
  for view in matches
@@ -8324,21 +8500,79 @@ class AiBuilderFacade:
8324
8500
  },
8325
8501
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8326
8502
  )
8503
+ if not matches:
8504
+ failed_view = {
8505
+ "name": selector_text,
8506
+ "view_key": selector_text,
8507
+ "type": None,
8508
+ "status": "failed",
8509
+ "error_code": "VIEW_NOT_FOUND",
8510
+ "message": "remove_views item did not match an existing view name or view_key",
8511
+ }
8512
+ failed_views.append(failed_view)
8513
+ view_results.append(deepcopy(failed_view))
8514
+ continue
8327
8515
  if len(matches) == 1:
8328
8516
  key = _extract_view_key(matches[0])
8329
- self.views.view_delete(profile=profile, viewgraph_key=key)
8330
- removed.append(name)
8331
- existing_by_name.pop(name, None)
8332
- existing_by_key.pop(key, None)
8333
- view_results.append({"name": name, "view_key": key, "type": None, "status": "removed"})
8517
+ removed_name = _extract_view_name(matches[0]) or selector_text
8518
+ try:
8519
+ self.views.view_delete(profile=profile, viewgraph_key=key)
8520
+ delete_readback = self._verify_view_deleted_by_key(profile=profile, view_key=key)
8521
+ removed.append(removed_name)
8522
+ if key:
8523
+ removed_keys.add(key)
8524
+ existing_by_key.pop(key, None)
8525
+ existing_by_name.pop(removed_name, None)
8526
+ view_results.append(
8527
+ {
8528
+ "name": removed_name,
8529
+ "view_key": key,
8530
+ "type": None,
8531
+ "status": delete_readback.get("status") or "readback_pending",
8532
+ "operation": "delete",
8533
+ "delete_executed": True,
8534
+ "readback_status": delete_readback.get("readback_status"),
8535
+ "safe_to_retry_delete": False,
8536
+ **(
8537
+ {
8538
+ "error_code": delete_readback.get("error_code"),
8539
+ "message": delete_readback.get("message"),
8540
+ "request_id": delete_readback.get("request_id"),
8541
+ "backend_code": delete_readback.get("backend_code"),
8542
+ "http_status": delete_readback.get("http_status"),
8543
+ "transport_error": delete_readback.get("transport_error"),
8544
+ }
8545
+ if delete_readback.get("readback_status") != "deleted"
8546
+ else {}
8547
+ ),
8548
+ }
8549
+ )
8550
+ except (QingflowApiError, RuntimeError) as error:
8551
+ api_error = _coerce_api_error(error)
8552
+ failed_view = {
8553
+ "name": removed_name,
8554
+ "view_key": key,
8555
+ "type": None,
8556
+ "status": "failed",
8557
+ "operation": "delete",
8558
+ "delete_executed": False,
8559
+ "safe_to_retry_delete": True,
8560
+ "error_code": "VIEW_DELETE_FAILED",
8561
+ "message": _public_error_message("VIEW_APPLY_FAILED", api_error),
8562
+ "request_id": api_error.request_id,
8563
+ "backend_code": api_error.backend_code,
8564
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
8565
+ }
8566
+ failed_views.append(failed_view)
8567
+ view_results.append(deepcopy(failed_view))
8334
8568
  created: list[str] = []
8335
8569
  updated: list[str] = []
8336
- failed_views: list[dict[str, Any]] = []
8337
8570
  existing_view_list = [
8338
8571
  view
8339
8572
  for view in (existing_views if isinstance(existing_views, list) else [])
8340
8573
  if isinstance(view, dict)
8341
- and _extract_view_name(view) not in remove_views
8574
+ and _extract_view_name(view) not in removed
8575
+ and _extract_view_key(view) not in removed_keys
8342
8576
  ]
8343
8577
  for ordinal, patch in enumerate(upsert_views, start=1):
8344
8578
  apply_columns = _resolve_view_visible_field_names(patch)
@@ -8551,6 +8785,11 @@ class AiBuilderFacade:
8551
8785
  if not existing_key and query_condition_payload_for_apply is None:
8552
8786
  query_condition_payload_for_apply = _empty_view_query_conditions_payload()
8553
8787
  expected_query_conditions_for_verify = _normalize_view_query_conditions_for_compare(query_condition_payload_for_apply)
8788
+ if not existing_key and associated_resources_payload_for_apply is None:
8789
+ associated_resources_payload_for_apply, _, _ = _build_view_associated_resources_payload(
8790
+ associated_resources={"visible": True, "limit_type": "all", "associated_item_ids": []},
8791
+ available_resources=associated_resources,
8792
+ )
8554
8793
  try:
8555
8794
  view_auth_override = (
8556
8795
  self._compile_visibility_to_member_auth(profile=profile, visibility=patch.visibility)
@@ -8865,17 +9104,21 @@ class AiBuilderFacade:
8865
9104
  failed_views.append(failure_entry)
8866
9105
  view_results.append(failure_entry)
8867
9106
  continue
8868
- try:
8869
- verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
8870
- except (QingflowApiError, RuntimeError) as error:
8871
- api_error = _coerce_api_error(error)
8872
- return finalize(_failed_from_api_error(
8873
- "VIEWS_READ_FAILED",
8874
- api_error,
8875
- normalized_args=normalized_args,
8876
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
8877
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8878
- ))
9107
+ needs_view_list_readback = bool(created or updated)
9108
+ verified_view_result: list[dict[str, Any]] | None = []
9109
+ verified_views_unavailable = False
9110
+ if needs_view_list_readback:
9111
+ try:
9112
+ verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
9113
+ except (QingflowApiError, RuntimeError) as error:
9114
+ api_error = _coerce_api_error(error)
9115
+ return finalize(_failed_from_api_error(
9116
+ "VIEWS_READ_FAILED",
9117
+ api_error,
9118
+ normalized_args=normalized_args,
9119
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
9120
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9121
+ ))
8879
9122
  verified_names = {
8880
9123
  _extract_view_name(item)
8881
9124
  for item in (verified_view_result or [])
@@ -9160,9 +9403,30 @@ class AiBuilderFacade:
9160
9403
  verification_by_view.append(
9161
9404
  {
9162
9405
  "name": name,
9406
+ "view_key": item.get("view_key"),
9163
9407
  "type": item.get("type"),
9164
9408
  "status": "removed",
9165
- "present_in_readback": None if verified_views_unavailable else name not in verified_names,
9409
+ "present_in_readback": False,
9410
+ "removed_verified": True,
9411
+ "delete_executed": bool(item.get("delete_executed")),
9412
+ "readback_status": item.get("readback_status") or "deleted",
9413
+ "safe_to_retry_delete": False,
9414
+ }
9415
+ )
9416
+ elif status == "readback_pending" and item.get("operation") == "delete":
9417
+ readback_status = str(item.get("readback_status") or "unavailable")
9418
+ verification_by_view.append(
9419
+ {
9420
+ "name": name,
9421
+ "view_key": item.get("view_key"),
9422
+ "type": item.get("type"),
9423
+ "status": "readback_pending",
9424
+ "present_in_readback": True if readback_status == "still_exists" else None,
9425
+ "removed_verified": False,
9426
+ "delete_executed": bool(item.get("delete_executed")),
9427
+ "readback_status": readback_status,
9428
+ "safe_to_retry_delete": False,
9429
+ "error_code": item.get("error_code"),
9166
9430
  }
9167
9431
  )
9168
9432
  else:
@@ -9175,15 +9439,22 @@ class AiBuilderFacade:
9175
9439
  "error_code": item.get("error_code"),
9176
9440
  }
9177
9441
  )
9442
+ removed_delete_results = [
9443
+ item
9444
+ for item in view_results
9445
+ if item.get("operation") == "delete" and bool(item.get("delete_executed"))
9446
+ ]
9447
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed_delete_results)
9448
+ delete_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed_delete_results)
9178
9449
  verified = (
9179
9450
  (not verified_views_unavailable)
9180
9451
  and all(name in verified_names for name in created + updated)
9181
- and all(name not in verified_names for name in removed)
9452
+ and removed_verified
9182
9453
  )
9183
9454
  view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
9184
9455
  view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
9185
9456
  view_associated_resources_verified = verified and not associated_resource_readback_pending and not associated_resource_mismatches
9186
- view_buttons_verified = verified and not button_readback_pending and not button_mismatches
9457
+ view_buttons_verified = verified and not button_readback_pending and not button_mismatches and not custom_button_readback_pending
9187
9458
  noop = not created and not updated and not removed
9188
9459
  if failed_views:
9189
9460
  successful_changes = bool(created or updated or removed)
@@ -9252,6 +9523,7 @@ class AiBuilderFacade:
9252
9523
  "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
9253
9524
  },
9254
9525
  "app_key": app_key,
9526
+ "app_name": app_name,
9255
9527
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
9256
9528
  "verified": verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified,
9257
9529
  }
@@ -9272,6 +9544,13 @@ class AiBuilderFacade:
9272
9544
  "system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
9273
9545
  )
9274
9546
  )
9547
+ if delete_readback_pending:
9548
+ warnings.append(
9549
+ _warning(
9550
+ "VIEW_DELETE_READBACK_PENDING",
9551
+ "view delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
9552
+ )
9553
+ )
9275
9554
  all_verified = verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified
9276
9555
  response = {
9277
9556
  "status": "success" if all_verified else "partial_success",
@@ -9319,11 +9598,13 @@ class AiBuilderFacade:
9319
9598
  "query_condition_readback_pending": query_condition_readback_pending,
9320
9599
  "associated_resource_readback_pending": associated_resource_readback_pending,
9321
9600
  "button_readback_pending": button_readback_pending,
9601
+ "delete_readback_pending": delete_readback_pending,
9322
9602
  "custom_button_readback_pending": custom_button_readback_pending,
9323
9603
  "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
9324
9604
  "by_view": verification_by_view,
9325
9605
  },
9326
9606
  "app_key": app_key,
9607
+ "app_name": app_name,
9327
9608
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
9328
9609
  "verified": all_verified,
9329
9610
  }
@@ -9349,6 +9630,7 @@ class AiBuilderFacade:
9349
9630
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9350
9631
  )
9351
9632
  tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
9633
+ app_name_before = str(base_before.get("formTitle") or base_before.get("title") or base_before.get("appName") or "").strip() or None
9352
9634
  already_published = bool(base_before.get("appPublishStatus") in {1, 2})
9353
9635
  package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
9354
9636
  try:
@@ -9379,6 +9661,7 @@ class AiBuilderFacade:
9379
9661
  "warnings": [],
9380
9662
  "verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
9381
9663
  "app_key": app_key,
9664
+ "app_name": app_name_before,
9382
9665
  "published": True,
9383
9666
  "package_attached": package_already_attached,
9384
9667
  "tag_ids_after": tag_ids_before,
@@ -9411,6 +9694,7 @@ class AiBuilderFacade:
9411
9694
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9412
9695
  )
9413
9696
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
9697
+ app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
9414
9698
  package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
9415
9699
  try:
9416
9700
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
@@ -9451,6 +9735,7 @@ class AiBuilderFacade:
9451
9735
  "warnings": warnings,
9452
9736
  "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
9453
9737
  "app_key": app_key,
9738
+ "app_name": app_name_after,
9454
9739
  "published": bool(base.get("appPublishStatus") in {1, 2}),
9455
9740
  "package_attached": package_attached,
9456
9741
  "tag_ids_after": tag_ids_after,
@@ -9578,6 +9863,159 @@ class AiBuilderFacade:
9578
9863
  )
9579
9864
  return expanded, issues, results
9580
9865
 
9866
+ def _verify_view_deleted_by_key(self, *, profile: str, view_key: str) -> JSONObject:
9867
+ try:
9868
+ self.views.view_get_config(profile=profile, viewgraph_key=view_key)
9869
+ except (QingflowApiError, RuntimeError) as error:
9870
+ api_error = _coerce_api_error(error)
9871
+ if _delete_readback_is_not_found(api_error):
9872
+ return {
9873
+ "view_key": view_key,
9874
+ "operation": "delete",
9875
+ "status": "removed",
9876
+ "delete_executed": True,
9877
+ "readback_status": "deleted",
9878
+ "safe_to_retry_delete": False,
9879
+ }
9880
+ return {
9881
+ "view_key": view_key,
9882
+ "operation": "delete",
9883
+ "status": "readback_pending",
9884
+ "delete_executed": True,
9885
+ "readback_status": "unavailable",
9886
+ "safe_to_retry_delete": False,
9887
+ "error_code": "VIEW_DELETE_READBACK_UNAVAILABLE",
9888
+ "message": "delete request completed, but view existence could not be verified by view_key readback",
9889
+ "request_id": api_error.request_id,
9890
+ "backend_code": api_error.backend_code,
9891
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
9892
+ "transport_error": _transport_error_payload(api_error),
9893
+ }
9894
+ return {
9895
+ "view_key": view_key,
9896
+ "operation": "delete",
9897
+ "status": "readback_pending",
9898
+ "delete_executed": True,
9899
+ "readback_status": "still_exists",
9900
+ "safe_to_retry_delete": False,
9901
+ "error_code": "VIEW_DELETE_READBACK_STILL_EXISTS",
9902
+ "message": "delete request completed, but the view still exists during view_key readback",
9903
+ }
9904
+
9905
+ def _verify_custom_button_deleted_by_id(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
9906
+ try:
9907
+ self.buttons.custom_button_get(profile=profile, app_key=app_key, button_id=button_id, being_draft=True, include_raw=False)
9908
+ except (QingflowApiError, RuntimeError) as error:
9909
+ api_error = _coerce_api_error(error)
9910
+ if _delete_readback_is_not_found(api_error):
9911
+ return {
9912
+ "button_id": button_id,
9913
+ "operation": "delete",
9914
+ "status": "removed",
9915
+ "delete_executed": True,
9916
+ "readback_status": "deleted",
9917
+ "safe_to_retry_delete": False,
9918
+ }
9919
+ return {
9920
+ "button_id": button_id,
9921
+ "operation": "delete",
9922
+ "status": "readback_pending",
9923
+ "delete_executed": True,
9924
+ "readback_status": "unavailable",
9925
+ "safe_to_retry_delete": False,
9926
+ "error_code": "CUSTOM_BUTTON_DELETE_READBACK_UNAVAILABLE",
9927
+ "message": "delete request completed, but custom button existence could not be verified by button_id readback",
9928
+ "request_id": api_error.request_id,
9929
+ "backend_code": api_error.backend_code,
9930
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
9931
+ "transport_error": _transport_error_payload(api_error),
9932
+ }
9933
+ return {
9934
+ "button_id": button_id,
9935
+ "operation": "delete",
9936
+ "status": "readback_pending",
9937
+ "delete_executed": True,
9938
+ "readback_status": "still_exists",
9939
+ "safe_to_retry_delete": False,
9940
+ "error_code": "CUSTOM_BUTTON_DELETE_READBACK_STILL_EXISTS",
9941
+ "message": "delete request completed, but the custom button still exists during button_id readback",
9942
+ }
9943
+
9944
+ def _verify_associated_resources_deleted_by_pool(
9945
+ self,
9946
+ *,
9947
+ deleted_items: list[JSONObject],
9948
+ resources: list[dict[str, Any]],
9949
+ readback_failed: bool,
9950
+ ) -> list[JSONObject]:
9951
+ existing_by_id = _associated_resource_index(resources) if not readback_failed else {}
9952
+ verified_items: list[JSONObject] = []
9953
+ for item in deleted_items:
9954
+ associated_item_id = _coerce_positive_int(item.get("associated_item_id"))
9955
+ verified = deepcopy(item)
9956
+ verified["operation"] = "remove"
9957
+ verified["delete_executed"] = True
9958
+ verified["safe_to_retry_delete"] = False
9959
+ if associated_item_id is None or readback_failed:
9960
+ verified["status"] = "readback_pending"
9961
+ verified["readback_status"] = "unavailable"
9962
+ verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_UNAVAILABLE"
9963
+ verified["message"] = "delete request completed, but associated resource pool readback is unavailable"
9964
+ elif associated_item_id in existing_by_id:
9965
+ verified["status"] = "readback_pending"
9966
+ verified["readback_status"] = "still_exists"
9967
+ verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_STILL_EXISTS"
9968
+ verified["message"] = "delete request completed, but the associated resource still exists in pool readback"
9969
+ else:
9970
+ verified["status"] = "removed"
9971
+ verified["readback_status"] = "deleted"
9972
+ verified.pop("error_code", None)
9973
+ verified.pop("message", None)
9974
+ verified_items.append(verified)
9975
+ return verified_items
9976
+
9977
+ def _verify_chart_deleted_by_id(self, *, profile: str, chart_id: str) -> JSONObject:
9978
+ base_result: dict[str, Any] | None = None
9979
+ try:
9980
+ raw = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
9981
+ base_result = raw if isinstance(raw, dict) else {}
9982
+ except (QingflowApiError, RuntimeError) as error:
9983
+ api_error = _coerce_api_error(error)
9984
+ if _chart_delete_readback_is_not_found(api_error):
9985
+ return {
9986
+ "chart_id": chart_id,
9987
+ "operation": "delete",
9988
+ "status": "removed",
9989
+ "delete_executed": True,
9990
+ "readback_status": "deleted",
9991
+ "safe_to_retry_delete": False,
9992
+ }
9993
+ return {
9994
+ "chart_id": chart_id,
9995
+ "operation": "delete",
9996
+ "status": "readback_pending",
9997
+ "delete_executed": True,
9998
+ "readback_status": "unavailable",
9999
+ "safe_to_retry_delete": False,
10000
+ "error_code": "CHART_DELETE_READBACK_UNAVAILABLE",
10001
+ "message": "delete request completed, but chart existence could not be verified by chart_id readback",
10002
+ "request_id": api_error.request_id,
10003
+ "backend_code": api_error.backend_code,
10004
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
10005
+ "transport_error": _transport_error_payload(api_error),
10006
+ }
10007
+ return {
10008
+ "chart_id": chart_id,
10009
+ "operation": "delete",
10010
+ "status": "readback_pending",
10011
+ "delete_executed": True,
10012
+ "readback_status": "still_exists",
10013
+ "safe_to_retry_delete": False,
10014
+ "error_code": "CHART_DELETE_READBACK_STILL_EXISTS",
10015
+ "message": "delete request completed, but the chart still exists during chart_id readback",
10016
+ "readback_name": base_result.get("chartName") or base_result.get("name") if isinstance(base_result, dict) else None,
10017
+ }
10018
+
9581
10019
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
9582
10020
  normalized_args = request.model_dump(mode="json")
9583
10021
  permission_outcomes: list[PermissionCheckOutcome] = []
@@ -9588,6 +10026,7 @@ class AiBuilderFacade:
9588
10026
  if resolved_outcome is not None:
9589
10027
  permission_outcomes.append(resolved_outcome)
9590
10028
  app_key = str(app_result.get("app_key") or request.app_key)
10029
+ app_name = str(app_result.get("app_name") or "").strip() or None
9591
10030
  permission_outcome = self._guard_app_permission(
9592
10031
  profile=profile,
9593
10032
  app_key=app_key,
@@ -9601,21 +10040,27 @@ class AiBuilderFacade:
9601
10040
  def finalize(response: JSONObject) -> JSONObject:
9602
10041
  return _apply_permission_outcomes(response, *permission_outcomes)
9603
10042
 
9604
- try:
9605
- schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
9606
- parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
9607
- fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
9608
- qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
9609
- existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9610
- except (QingflowApiError, RuntimeError) as error:
9611
- api_error = _coerce_api_error(error)
9612
- return finalize(_failed_from_api_error(
9613
- "CHART_APPLY_FAILED",
9614
- api_error,
9615
- normalized_args=normalized_args,
9616
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
9617
- suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
9618
- ))
10043
+ fields: list[dict[str, Any]] = []
10044
+ qingbi_fields: list[Any] = []
10045
+ existing_chart_items: list[Any] = []
10046
+ existing_chart_list_source: str | None = None
10047
+ needs_chart_inventory = bool(request.upsert_charts or request.patch_charts or request.reorder_chart_ids)
10048
+ if needs_chart_inventory:
10049
+ try:
10050
+ schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
10051
+ parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
10052
+ fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
10053
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
10054
+ existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
10055
+ except (QingflowApiError, RuntimeError) as error:
10056
+ api_error = _coerce_api_error(error)
10057
+ return finalize(_failed_from_api_error(
10058
+ "CHART_APPLY_FAILED",
10059
+ api_error,
10060
+ normalized_args=normalized_args,
10061
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
10062
+ suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10063
+ ))
9619
10064
 
9620
10065
  field_lookup = _build_public_field_lookup(fields)
9621
10066
  qingbi_fields_by_id = {
@@ -9623,6 +10068,11 @@ class AiBuilderFacade:
9623
10068
  for item in qingbi_fields
9624
10069
  if isinstance(item, dict) and item.get("fieldId")
9625
10070
  }
10071
+ chart_field_lookup = _build_qingbi_chart_field_lookup(
10072
+ app_key=app_key,
10073
+ qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
10074
+ field_lookup=field_lookup,
10075
+ )
9626
10076
  existing_by_id = {
9627
10077
  _extract_chart_identifier(item): deepcopy(item)
9628
10078
  for item in existing_chart_items
@@ -9665,8 +10115,12 @@ class AiBuilderFacade:
9665
10115
  updated_ids: list[str] = []
9666
10116
  removed_ids: list[str] = []
9667
10117
  failed_items: list[dict[str, Any]] = []
10118
+ delete_readback_issues: list[dict[str, Any]] = []
9668
10119
 
9669
10120
  for patch in upsert_charts:
10121
+ chart_id = ""
10122
+ target_type = ""
10123
+ config_payload: dict[str, Any] | None = None
9670
10124
  try:
9671
10125
  dataset_source = _chart_patch_dataset_source_type(patch)
9672
10126
  if dataset_source:
@@ -9705,6 +10159,14 @@ class AiBuilderFacade:
9705
10159
  f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
9706
10160
  "Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
9707
10161
  )
10162
+ if existing is None or config_update_requested:
10163
+ config_payload = _build_public_chart_config_payload(
10164
+ patch=patch,
10165
+ app_key=app_key,
10166
+ field_lookup=field_lookup,
10167
+ chart_field_lookup=chart_field_lookup,
10168
+ qingbi_fields_by_id=qingbi_fields_by_id,
10169
+ )
9708
10170
  if existing is None:
9709
10171
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
9710
10172
  create_payload = {
@@ -9788,13 +10250,7 @@ class AiBuilderFacade:
9788
10250
 
9789
10251
  config_updated = False
9790
10252
  if existing is None or config_update_requested:
9791
- config_payload = _build_public_chart_config_payload(
9792
- patch=patch,
9793
- app_key=app_key,
9794
- field_lookup=field_lookup,
9795
- qingbi_fields_by_id=qingbi_fields_by_id,
9796
- )
9797
- self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
10253
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload or {})
9798
10254
  config_updated = True
9799
10255
  if existing is not None and chart_id not in updated_ids and config_updated:
9800
10256
  updated_ids.append(chart_id)
@@ -9824,11 +10280,21 @@ class AiBuilderFacade:
9824
10280
  )
9825
10281
  except (QingflowApiError, RuntimeError, ValueError, VisibilityResolutionError) as error:
9826
10282
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
10283
+ diagnostics = (
10284
+ error.diagnostics
10285
+ if isinstance(error, ChartRuleViolation)
10286
+ else _explain_chart_backend_validation_error(api_error=api_error, chart_type=target_type or patch.chart_type.value, payload=config_payload)
10287
+ if api_error is not None
10288
+ else None
10289
+ )
9827
10290
  failure = {
9828
- "chart_id": str(locals().get("chart_id") or patch.chart_id or ""),
10291
+ "chart_id": str(chart_id or patch.chart_id or ""),
9829
10292
  "name": patch.name,
10293
+ "chart_type": patch.chart_type.value,
9830
10294
  "status": "failed",
9831
- "message": str(error),
10295
+ "error_code": diagnostics.get("rule_code") if isinstance(diagnostics, dict) else "CHART_APPLY_FAILED",
10296
+ "message": str(diagnostics.get("message") if isinstance(diagnostics, dict) and diagnostics.get("message") else error),
10297
+ "diagnostics": diagnostics,
9832
10298
  "request_id": api_error.request_id if api_error else None,
9833
10299
  "backend_code": api_error.backend_code if api_error else None,
9834
10300
  "http_status": None if api_error is None or api_error.http_status == 404 else api_error.http_status,
@@ -9840,12 +10306,18 @@ class AiBuilderFacade:
9840
10306
  try:
9841
10307
  self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
9842
10308
  removed_ids.append(chart_id)
9843
- chart_results.append({"chart_id": chart_id, "status": "removed"})
10309
+ delete_result = self._verify_chart_deleted_by_id(profile=profile, chart_id=chart_id)
10310
+ if delete_result.get("readback_status") != "deleted":
10311
+ delete_readback_issues.append(delete_result)
10312
+ chart_results.append(delete_result)
9844
10313
  except (QingflowApiError, RuntimeError) as error:
9845
10314
  api_error = _coerce_api_error(error)
9846
10315
  failure = {
9847
10316
  "chart_id": chart_id,
10317
+ "operation": "delete",
9848
10318
  "status": "failed",
10319
+ "delete_executed": False,
10320
+ "safe_to_retry_delete": True,
9849
10321
  "message": _public_error_message("CHART_APPLY_FAILED", api_error),
9850
10322
  "request_id": api_error.request_id,
9851
10323
  "backend_code": api_error.backend_code,
@@ -9880,30 +10352,37 @@ class AiBuilderFacade:
9880
10352
  chart_results.append(failure)
9881
10353
 
9882
10354
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
9883
- try:
9884
- readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9885
- readback_ids = {
9886
- _extract_chart_identifier(item)
9887
- for item in readback_items
9888
- if isinstance(item, dict) and _extract_chart_identifier(item)
9889
- }
9890
- verified = (
9891
- all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
9892
- and all(chart_id not in readback_ids for chart_id in removed_ids)
9893
- )
9894
- if request.reorder_chart_ids:
9895
- ordered_readback = [
10355
+ needs_list_readback = bool(created_ids or updated_ids or reordered)
10356
+ delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
10357
+ deletes_verified = not delete_readback_issues
10358
+ readback_unavailable = False
10359
+ readback_list_source: str | None = existing_chart_list_source
10360
+ if needs_list_readback:
10361
+ try:
10362
+ readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
10363
+ readback_ids = {
9896
10364
  _extract_chart_identifier(item)
9897
10365
  for item in readback_items
9898
10366
  if isinstance(item, dict) and _extract_chart_identifier(item)
9899
- ]
9900
- requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
9901
- verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
9902
- readback_unavailable = False
9903
- except (QingflowApiError, RuntimeError):
9904
- verified = False
9905
- readback_unavailable = True
9906
- readback_list_source = None
10367
+ }
10368
+ verified = all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
10369
+ if request.reorder_chart_ids:
10370
+ ordered_readback = [
10371
+ _extract_chart_identifier(item)
10372
+ for item in readback_items
10373
+ if isinstance(item, dict) and _extract_chart_identifier(item)
10374
+ ]
10375
+ requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
10376
+ verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
10377
+ readback_unavailable = False
10378
+ except (QingflowApiError, RuntimeError):
10379
+ verified = False
10380
+ readback_unavailable = True
10381
+ readback_list_source = None
10382
+ else:
10383
+ verified = True
10384
+ verified = verified and deletes_verified
10385
+ any_readback_unavailable = readback_unavailable or delete_readback_unavailable
9907
10386
 
9908
10387
  if failed_items:
9909
10388
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
@@ -9925,42 +10404,56 @@ class AiBuilderFacade:
9925
10404
  failed_items=failed_items,
9926
10405
  readback_unavailable=readback_unavailable,
9927
10406
  verified=False if failed_items else verified,
10407
+ delete_readback_issues=delete_readback_issues,
9928
10408
  ),
9929
10409
  "verification": {
9930
10410
  "charts_verified": False if failed_items else verified,
9931
- "readback_unavailable": readback_unavailable,
9932
- "chart_order_verified": False if request.reorder_chart_ids else (readback_list_source == "sorted"),
10411
+ "readback_unavailable": any_readback_unavailable,
10412
+ "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
10413
+ "chart_order_verified": False if request.reorder_chart_ids else True,
9933
10414
  "chart_list_source": readback_list_source or existing_chart_list_source,
9934
10415
  },
9935
10416
  "app_key": app_key,
10417
+ "app_name": app_name,
9936
10418
  "chart_results": chart_results,
9937
10419
  "verified": False if failed_items else verified,
9938
10420
  })
9939
10421
  result_verified = verified or noop
10422
+ pending_delete = bool(delete_readback_issues)
10423
+ pending_error_code = "CHART_DELETE_READBACK_PENDING" if pending_delete and not readback_unavailable else "CHART_READBACK_PENDING"
10424
+ pending_message = "applied chart operations; delete readback pending" if pending_delete else "applied chart operations; readback pending"
10425
+ pending_suggestion = (
10426
+ {"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": str(delete_readback_issues[0].get("chart_id") or "CHART_ID")}}
10427
+ if pending_delete
10428
+ else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}}
10429
+ )
9940
10430
  return finalize({
9941
10431
  "status": "success" if result_verified else "partial_success",
9942
- "error_code": None if result_verified else "CHART_READBACK_PENDING",
10432
+ "error_code": None if result_verified else pending_error_code,
9943
10433
  "recoverable": not result_verified,
9944
- "message": "no chart changes requested" if noop else ("applied chart operations" if verified else "applied chart operations; readback pending"),
10434
+ "message": "no chart changes requested" if noop else ("applied chart operations" if verified else pending_message),
9945
10435
  "normalized_args": normalized_args,
9946
10436
  "missing_fields": [],
9947
10437
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
9948
10438
  "details": {},
9949
10439
  "request_id": None,
9950
- "suggested_next_call": None if result_verified else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10440
+ "suggested_next_call": None if result_verified else pending_suggestion,
9951
10441
  "noop": noop,
9952
10442
  "warnings": _chart_apply_warnings(
9953
10443
  failed_items=[],
9954
10444
  readback_unavailable=False if noop else readback_unavailable,
9955
10445
  verified=result_verified,
10446
+ delete_readback_issues=delete_readback_issues,
9956
10447
  ),
9957
10448
  "verification": {
9958
10449
  "charts_verified": result_verified,
9959
- "readback_unavailable": False if noop else readback_unavailable,
9960
- "chart_order_verified": True if noop and not request.reorder_chart_ids else (readback_list_source == "sorted" and result_verified if request.reorder_chart_ids else readback_list_source == "sorted"),
10450
+ "readback_unavailable": False if noop else any_readback_unavailable,
10451
+ "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
10452
+ "chart_order_verified": (readback_list_source == "sorted" and result_verified) if request.reorder_chart_ids else True,
9961
10453
  "chart_list_source": existing_chart_list_source if noop else readback_list_source,
9962
10454
  },
9963
10455
  "app_key": app_key,
10456
+ "app_name": app_name,
9964
10457
  "chart_results": chart_results,
9965
10458
  "verified": result_verified,
9966
10459
  })
@@ -10072,6 +10565,7 @@ class AiBuilderFacade:
10072
10565
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10073
10566
  )
10074
10567
  try:
10568
+ layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
10075
10569
  if creating:
10076
10570
  create_payload = _build_public_portal_base_payload(
10077
10571
  dash_name=request.dash_name or "未命名门户",
@@ -10108,7 +10602,12 @@ class AiBuilderFacade:
10108
10602
  base_payload=base_payload,
10109
10603
  )
10110
10604
  if sections_requested:
10111
- component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
10605
+ component_payload = self._build_portal_components_from_sections(
10606
+ profile=profile,
10607
+ sections=request.sections,
10608
+ layout_preset=request.layout_preset,
10609
+ )
10610
+ layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload)
10112
10611
  update_payload["components"] = component_payload
10113
10612
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
10114
10613
  self.portals.portal_update_base_info(
@@ -10211,6 +10710,7 @@ class AiBuilderFacade:
10211
10710
  live_meta_verified=live_meta_verified,
10212
10711
  publish_requested=request.publish,
10213
10712
  )
10713
+ warnings.extend(_portal_layout_warning_items(layout_diagnostics))
10214
10714
  return finalize({
10215
10715
  "status": status,
10216
10716
  "error_code": error_code,
@@ -10237,6 +10737,7 @@ class AiBuilderFacade:
10237
10737
  "suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10238
10738
  "noop": False,
10239
10739
  "warnings": warnings,
10740
+ "layout_diagnostics": layout_diagnostics,
10240
10741
  "verification": {
10241
10742
  "draft_verified": draft_verified,
10242
10743
  "draft_metadata_verified": draft_meta_verified,
@@ -10246,6 +10747,9 @@ class AiBuilderFacade:
10246
10747
  "publish_failed": publish_failed,
10247
10748
  },
10248
10749
  "dash_key": dash_key,
10750
+ "dash_name": update_payload.get("dashName"),
10751
+ "package_id": target_package_tag_id,
10752
+ "created": creating,
10249
10753
  "published": published,
10250
10754
  "verified": verified,
10251
10755
  "draft_result": draft_result,
@@ -10338,6 +10842,49 @@ class AiBuilderFacade:
10338
10842
  verification["published"] = False
10339
10843
  return response
10340
10844
  publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=edit_version_no)
10845
+ if publish_result.get("status") == "failed" and publish_result.get("error_code") == "APP_EDIT_LOCKED":
10846
+ details = response.get("details")
10847
+ if not isinstance(details, dict):
10848
+ details = {}
10849
+ response["details"] = details
10850
+ suggested = publish_result.get("suggested_next_call")
10851
+ release_args = suggested.get("arguments") if isinstance(suggested, dict) else {}
10852
+ if isinstance(release_args, dict):
10853
+ release_result = self.app_release_edit_lock_if_mine(
10854
+ profile=profile,
10855
+ app_key=app_key,
10856
+ lock_owner_email=str(release_args.get("lock_owner_email") or ""),
10857
+ lock_owner_name=str(release_args.get("lock_owner_name") or ""),
10858
+ )
10859
+ details["edit_lock_release_result"] = release_result
10860
+ if release_result.get("status") == "success":
10861
+ retry_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=None)
10862
+ details["publish_retry_after_edit_lock_release"] = retry_result
10863
+ publish_result = retry_result
10864
+ response["retried_after_edit_lock_release"] = True
10865
+ response["edit_lock_released"] = True
10866
+ elif release_result.get("error_code") == "EDIT_LOCK_OWNER_UNKNOWN" and edit_version_no is not None:
10867
+ try:
10868
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
10869
+ release_result = {
10870
+ "status": "success",
10871
+ "error_code": None,
10872
+ "recoverable": False,
10873
+ "message": "released current apply edit context before publish retry",
10874
+ "details": {"app_key": app_key, "edit_version_no": edit_version_no, "release_strategy": "current_apply_edit_context"},
10875
+ "verification": {"released": True},
10876
+ "app_key": app_key,
10877
+ "verified": True,
10878
+ }
10879
+ details["edit_lock_release_result"] = release_result
10880
+ retry_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=None)
10881
+ details["publish_retry_after_edit_lock_release"] = retry_result
10882
+ publish_result = retry_result
10883
+ response["retried_after_edit_lock_release"] = True
10884
+ response["edit_lock_released"] = True
10885
+ except (QingflowApiError, RuntimeError) as error:
10886
+ api_error = _coerce_api_error(error)
10887
+ details["current_apply_edit_context_release_error"] = _transport_error_payload(api_error)
10341
10888
  response["publish_result"] = publish_result
10342
10889
  response["published"] = bool(publish_result.get("published"))
10343
10890
  verification["published"] = bool(publish_result.get("published"))
@@ -10362,6 +10909,14 @@ class AiBuilderFacade:
10362
10909
  "schema_source": schema_source,
10363
10910
  }
10364
10911
 
10912
+ def _read_app_name_for_builder_output(self, *, profile: str, app_key: str) -> str | None:
10913
+ try:
10914
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
10915
+ except (QingflowApiError, RuntimeError):
10916
+ return None
10917
+ app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip()
10918
+ return app_name or None
10919
+
10365
10920
  def _sync_system_view_apply_config(
10366
10921
  self,
10367
10922
  *,
@@ -10793,6 +11348,7 @@ class AiBuilderFacade:
10793
11348
  *,
10794
11349
  profile: str,
10795
11350
  sections: list[PortalSectionPatch],
11351
+ layout_preset: str | None = None,
10796
11352
  ) -> list[dict[str, Any]]:
10797
11353
  resolved_components: list[dict[str, Any]] = []
10798
11354
  pc_x = 0
@@ -10809,9 +11365,15 @@ class AiBuilderFacade:
10809
11365
  pc_y=pc_y,
10810
11366
  pc_row_height=pc_row_height,
10811
11367
  mobile_y=mobile_y,
11368
+ layout_preset=layout_preset,
10812
11369
  )
10813
11370
  else:
10814
- position_payload = _portal_position_payload(section.position)
11371
+ position_payload = _portal_position_payload(section.position, inferred_mobile_y=mobile_y)
11372
+ mobile = position_payload.get("mobile") if isinstance(position_payload.get("mobile"), dict) else {}
11373
+ mobile_y = max(
11374
+ mobile_y,
11375
+ int(mobile.get("y") or 0) + int(mobile.get("rows") or 0),
11376
+ )
10815
11377
  dash_style = deepcopy(section.dash_style_config) if isinstance(section.dash_style_config, dict) else None
10816
11378
  component: dict[str, Any]
10817
11379
  if section.source_type == "chart":
@@ -12210,6 +12772,28 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
12210
12772
  return mapping.get(error_code, "requested builder resource is unavailable in the current route")
12211
12773
 
12212
12774
 
12775
+ def _chart_delete_readback_is_not_found(error: QingflowApiError) -> bool:
12776
+ return _delete_readback_is_not_found(error)
12777
+
12778
+
12779
+ def _delete_readback_is_not_found(error: QingflowApiError) -> bool:
12780
+ backend_code = int(error.backend_code or 0) if str(error.backend_code or "").isdigit() else error.backend_code
12781
+ if error.http_status == 404 or backend_code in {404, 40038, 81007}:
12782
+ return True
12783
+ message = str(error.message or "").lower()
12784
+ return any(
12785
+ marker in message
12786
+ for marker in (
12787
+ "object not exist",
12788
+ "not found",
12789
+ "not exist",
12790
+ "does not exist",
12791
+ "不存在",
12792
+ "未找到",
12793
+ )
12794
+ )
12795
+
12796
+
12213
12797
  def _extract_edit_lock_owner(message: str) -> JSONObject:
12214
12798
  text = str(message or "").strip()
12215
12799
  if not text:
@@ -12217,6 +12801,8 @@ def _extract_edit_lock_owner(message: str) -> JSONObject:
12217
12801
  patterns = [
12218
12802
  r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
12219
12803
  r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
12804
+ r"active editor\s+(?P<email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
12805
+ r"(?P<email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
12220
12806
  ]
12221
12807
  for pattern in patterns:
12222
12808
  match = re.search(pattern, text)
@@ -12512,18 +13098,20 @@ def _build_public_dimension_fields(
12512
13098
  *,
12513
13099
  app_key: str,
12514
13100
  field_lookup: dict[str, dict[str, Any]],
13101
+ chart_field_lookup: dict[str, Any],
12515
13102
  qingbi_fields_by_id: dict[str, dict[str, Any]],
13103
+ chart_type: str = "chart",
12516
13104
  ) -> list[dict[str, Any]]:
12517
13105
  dimensions: list[dict[str, Any]] = []
12518
13106
  for selector in selectors:
12519
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12520
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12521
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
13107
+ qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="dimension")
13108
+ field_id = _chart_field_id(qingbi_field)
13109
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12522
13110
  dimensions.append(
12523
13111
  {
12524
13112
  "fieldId": field_id,
12525
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12526
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
13113
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13114
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12527
13115
  "orderType": "default",
12528
13116
  "alignType": "left",
12529
13117
  "dateFormat": "yyyy-MM-dd",
@@ -12565,27 +13153,540 @@ def _default_public_total_metric() -> dict[str, Any]:
12565
13153
  }
12566
13154
 
12567
13155
 
13156
+ _QINGBI_TOTAL_FIELD_ID = ":-100"
13157
+ _QINGBI_DECIMAL_FIELD_TYPES = {"decimal", "number", "numeric", "amount", "integer", "int", "long", "double", "float"}
13158
+
13159
+
13160
+ class ChartRuleViolation(ValueError):
13161
+ def __init__(self, diagnostics: dict[str, Any]) -> None:
13162
+ self.diagnostics = diagnostics
13163
+ super().__init__(str(diagnostics.get("message") or diagnostics.get("next_action") or "chart rule violation"))
13164
+
13165
+
13166
+ def _chart_field_id(field: dict[str, Any]) -> str:
13167
+ return str(field.get("fieldId") or field.get("field_id") or "").strip()
13168
+
13169
+
13170
+ def _chart_field_name(field: dict[str, Any]) -> str | None:
13171
+ name = str(field.get("fieldName") or field.get("field_name") or "").strip()
13172
+ return name or None
13173
+
13174
+
13175
+ def _chart_fields(payload: dict[str, Any], key: str) -> list[dict[str, Any]]:
13176
+ value = payload.get(key)
13177
+ if not isinstance(value, list):
13178
+ return []
13179
+ return [item for item in value if isinstance(item, dict)]
13180
+
13181
+
13182
+ def _chart_field_summary(field: dict[str, Any]) -> dict[str, Any]:
13183
+ return _compact_dict(
13184
+ {
13185
+ "field_id": _chart_field_id(field),
13186
+ "field_name": _chart_field_name(field),
13187
+ "field_type": field.get("fieldType") or field.get("field_type"),
13188
+ "field_source": field.get("fieldSource") or field.get("field_source"),
13189
+ "bi_formula_type": field.get("biFormulaType") or field.get("bi_formula_type"),
13190
+ "aggre_field_id": field.get("aggreFieldId") or field.get("aggre_field_id"),
13191
+ }
13192
+ )
13193
+
13194
+
13195
+ def _chart_duplicate_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
13196
+ seen: dict[str, dict[str, Any]] = {}
13197
+ duplicates: dict[str, dict[str, Any]] = {}
13198
+ for field in fields:
13199
+ field_id = _chart_field_id(field)
13200
+ if not field_id:
13201
+ continue
13202
+ if field_id in seen:
13203
+ duplicates[field_id] = _chart_field_summary(field)
13204
+ else:
13205
+ seen[field_id] = field
13206
+ return list(duplicates.values())
13207
+
13208
+
13209
+ def _chart_rule_diagnostics(
13210
+ *,
13211
+ rule_code: str,
13212
+ chart_type: str,
13213
+ message: str,
13214
+ expected: str,
13215
+ actual: dict[str, Any],
13216
+ next_action: str,
13217
+ offending_fields: list[dict[str, Any]] | None = None,
13218
+ ) -> dict[str, Any]:
13219
+ return _compact_dict(
13220
+ {
13221
+ "rule_code": rule_code,
13222
+ "chart_type": chart_type,
13223
+ "message": message,
13224
+ "expected": expected,
13225
+ "actual": actual,
13226
+ "offending_fields": offending_fields or [],
13227
+ "next_action": next_action,
13228
+ }
13229
+ )
13230
+
13231
+
13232
+ def _raise_chart_rule(
13233
+ *,
13234
+ rule_code: str,
13235
+ chart_type: str,
13236
+ message: str,
13237
+ expected: str,
13238
+ actual: dict[str, Any],
13239
+ next_action: str,
13240
+ offending_fields: list[dict[str, Any]] | None = None,
13241
+ ) -> None:
13242
+ raise ChartRuleViolation(
13243
+ _chart_rule_diagnostics(
13244
+ rule_code=rule_code,
13245
+ chart_type=chart_type,
13246
+ message=message,
13247
+ expected=expected,
13248
+ actual=actual,
13249
+ next_action=next_action,
13250
+ offending_fields=offending_fields,
13251
+ )
13252
+ )
13253
+
13254
+
13255
+ _QINGBI_TOTAL_FIELD_ALIASES = {_QINGBI_TOTAL_FIELD_ID, "数据总量", "data_total", "total", "count"}
13256
+
13257
+
13258
+ def _qingbi_field_que_id(*, app_key: str, field_id: Any) -> int | None:
13259
+ raw = str(field_id or "").strip()
13260
+ if not raw or raw == _QINGBI_TOTAL_FIELD_ID:
13261
+ return None
13262
+ if raw.startswith("field_"):
13263
+ return _coerce_positive_int(raw.removeprefix("field_"))
13264
+ if raw.startswith(f"{app_key}:"):
13265
+ return _coerce_positive_int(raw.split(":", 1)[1])
13266
+ if ":" in raw:
13267
+ return _coerce_positive_int(raw.rsplit(":", 1)[1])
13268
+ return _coerce_positive_int(raw)
13269
+
13270
+
13271
+ def _dedupe_qingbi_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
13272
+ deduped: list[dict[str, Any]] = []
13273
+ seen: set[str] = set()
13274
+ for field in fields:
13275
+ field_id = _chart_field_id(field)
13276
+ key = field_id or json.dumps(field, sort_keys=True, ensure_ascii=False, default=str)
13277
+ if key in seen:
13278
+ continue
13279
+ seen.add(key)
13280
+ deduped.append(field)
13281
+ return deduped
13282
+
13283
+
13284
+ def _build_qingbi_chart_field_lookup(
13285
+ *,
13286
+ app_key: str,
13287
+ qingbi_fields: list[dict[str, Any]],
13288
+ field_lookup: dict[str, dict[str, Any]],
13289
+ ) -> dict[str, Any]:
13290
+ by_selector: dict[str, list[dict[str, Any]]] = {}
13291
+ form_by_que_id = field_lookup.get("by_que_id") or {}
13292
+
13293
+ def add_selector(key: Any, field: dict[str, Any]) -> None:
13294
+ normalized = str(key or "").strip()
13295
+ if not normalized:
13296
+ return
13297
+ by_selector.setdefault(normalized, []).append(field)
13298
+ lower = normalized.lower()
13299
+ if lower != normalized:
13300
+ by_selector.setdefault(lower, []).append(field)
13301
+
13302
+ for raw_field in qingbi_fields:
13303
+ if not isinstance(raw_field, dict):
13304
+ continue
13305
+ field_id = _chart_field_id(raw_field)
13306
+ if not field_id:
13307
+ continue
13308
+ field = deepcopy(raw_field)
13309
+ que_id = _qingbi_field_que_id(app_key=app_key, field_id=field_id)
13310
+ form_field = form_by_que_id.get(que_id) if que_id is not None else None
13311
+ if isinstance(form_field, dict):
13312
+ field["_public_form_field"] = deepcopy(form_field)
13313
+ if not _chart_field_name(field) and isinstance(form_field, dict) and form_field.get("name"):
13314
+ field["fieldName"] = form_field.get("name")
13315
+
13316
+ add_selector(field_id, field)
13317
+ if que_id is not None:
13318
+ add_selector(que_id, field)
13319
+ add_selector(f"field_{que_id}", field)
13320
+ if isinstance(form_field, dict):
13321
+ add_selector(form_field.get("field_id"), field)
13322
+ title = _chart_field_name(field)
13323
+ if title:
13324
+ add_selector(title, field)
13325
+ return {"by_selector": by_selector}
13326
+
13327
+
13328
+ def _compact_public_chart_fields_read(
13329
+ *,
13330
+ app_key: str,
13331
+ qingbi_fields: list[dict[str, Any]],
13332
+ field_lookup: dict[str, dict[str, Any]],
13333
+ ) -> list[dict[str, Any]]:
13334
+ form_by_que_id = field_lookup.get("by_que_id") or {}
13335
+ compact_fields: list[dict[str, Any]] = []
13336
+ seen: set[str] = set()
13337
+ for field in qingbi_fields:
13338
+ if not isinstance(field, dict):
13339
+ continue
13340
+ bi_field_id = _chart_field_id(field)
13341
+ if not bi_field_id or bi_field_id in seen:
13342
+ continue
13343
+ seen.add(bi_field_id)
13344
+ que_id = _qingbi_field_que_id(app_key=app_key, field_id=bi_field_id)
13345
+ form_field = form_by_que_id.get(que_id) if que_id is not None else None
13346
+ public_field_id = (
13347
+ str(form_field.get("field_id"))
13348
+ if isinstance(form_field, dict) and form_field.get("field_id")
13349
+ else f"field_{que_id}"
13350
+ if que_id is not None
13351
+ else bi_field_id
13352
+ )
13353
+ title = _chart_field_name(field) or (
13354
+ str(form_field.get("name")) if isinstance(form_field, dict) and form_field.get("name") else bi_field_id
13355
+ )
13356
+ compact_fields.append(
13357
+ _compact_dict(
13358
+ {
13359
+ "field_id": public_field_id,
13360
+ "que_id": que_id,
13361
+ "bi_field_id": bi_field_id,
13362
+ "title": title,
13363
+ "field_type": field.get("fieldType") or field.get("field_type"),
13364
+ "system_field": bool(que_id is not None and not isinstance(form_field, dict)),
13365
+ "available_for_charts": True,
13366
+ }
13367
+ )
13368
+ )
13369
+ return compact_fields
13370
+
13371
+
13372
+ def _chart_field_candidates(
13373
+ selector: Any,
13374
+ *,
13375
+ chart_field_lookup: dict[str, Any],
13376
+ ) -> list[dict[str, Any]]:
13377
+ raw = str(selector or "").strip()
13378
+ if not raw:
13379
+ return []
13380
+ by_selector = chart_field_lookup.get("by_selector") if isinstance(chart_field_lookup.get("by_selector"), dict) else {}
13381
+ return _dedupe_qingbi_fields(list(by_selector.get(raw) or by_selector.get(raw.lower()) or []))
13382
+
13383
+
13384
+ def _resolve_qingbi_chart_field(
13385
+ selector: Any,
13386
+ *,
13387
+ chart_field_lookup: dict[str, Any],
13388
+ chart_type: str,
13389
+ role: str,
13390
+ ) -> dict[str, Any]:
13391
+ raw = str(selector or "").strip()
13392
+ if not raw:
13393
+ _raise_chart_rule(
13394
+ rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
13395
+ chart_type=chart_type,
13396
+ message="chart field selector cannot be empty",
13397
+ expected="use a field from app_get_fields.chart_fields",
13398
+ actual={"selector": raw, "role": role},
13399
+ next_action="Call app_get_fields and choose a field from chart_fields for chart dimensions, metrics, filters, or query conditions.",
13400
+ )
13401
+ if raw in _QINGBI_TOTAL_FIELD_ALIASES or raw.lower() in _QINGBI_TOTAL_FIELD_ALIASES:
13402
+ if role == "metric":
13403
+ return _default_public_total_metric()
13404
+ _raise_chart_rule(
13405
+ rule_code="CHART_TOTAL_FIELD_NOT_ALLOWED",
13406
+ chart_type=chart_type,
13407
+ message="数据总量 is only valid as a metric field, not as a dimension/filter/query field",
13408
+ expected="use 数据总量 only in indicator_field_ids or omit metrics for count-style charts",
13409
+ actual={"selector": raw, "role": role},
13410
+ next_action="Choose a real QingBI field from app_get_fields.chart_fields for dimensions, filters, and query conditions.",
13411
+ offending_fields=[{"field_id": _QINGBI_TOTAL_FIELD_ID, "field_name": "数据总量"}],
13412
+ )
13413
+ candidates = _chart_field_candidates(raw, chart_field_lookup=chart_field_lookup)
13414
+ if not candidates:
13415
+ _raise_chart_rule(
13416
+ rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
13417
+ chart_type=chart_type,
13418
+ message=f"field '{raw}' was not found in QingBI datasource fields for this app",
13419
+ expected="chart fields must come from app_get_fields.chart_fields, not record schema or form-only fields",
13420
+ actual={"selector": raw, "role": role},
13421
+ next_action="Call app_get_fields and choose a field from chart_fields; if the system field is absent there, QingBI cannot use it for this report.",
13422
+ )
13423
+ if len(candidates) > 1:
13424
+ _raise_chart_rule(
13425
+ rule_code="CHART_FIELD_AMBIGUOUS",
13426
+ chart_type=chart_type,
13427
+ message=f"field '{raw}' matched multiple QingBI datasource fields",
13428
+ expected="use an unambiguous field selector such as bi_field_id or field_<queId>",
13429
+ actual={"selector": raw, "role": role, "candidate_count": len(candidates)},
13430
+ next_action="Use one of the returned candidate bi_field_id values or field_<queId> selectors.",
13431
+ offending_fields=[_chart_field_summary(item) for item in candidates],
13432
+ )
13433
+ return deepcopy(candidates[0])
13434
+
13435
+
13436
+ def _check_chart_slot_duplicates(*, chart_type: str, payload: dict[str, Any], slot_names: list[str]) -> None:
13437
+ for slot_name in slot_names:
13438
+ duplicates = _chart_duplicate_fields(_chart_fields(payload, slot_name))
13439
+ if duplicates:
13440
+ _raise_chart_rule(
13441
+ rule_code="CHART_FIELD_ID_REPEAT",
13442
+ chart_type=chart_type,
13443
+ message=f"{chart_type} chart has duplicate field ids in {slot_name}",
13444
+ expected=f"{slot_name} must not contain duplicated fieldId values",
13445
+ actual={"slot": slot_name, "duplicate_count": len(duplicates)},
13446
+ offending_fields=duplicates,
13447
+ next_action="Use different fields for this slot, or remove the duplicated field before retrying.",
13448
+ )
13449
+
13450
+
13451
+ def _histogram_metric_issue(metric: dict[str, Any]) -> dict[str, Any] | None:
13452
+ field_id = _chart_field_id(metric)
13453
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
13454
+ return {
13455
+ "rule_code": "HISTOGRAM_DEFAULT_TOTAL_METRIC_UNSUPPORTED",
13456
+ "message": "histogram cannot use 数据总量 as its metric",
13457
+ "next_action": "Pass one explicit numeric field in indicator_field_ids and set config.aggregate such as sum/avg.",
13458
+ }
13459
+ field_type = str(metric.get("fieldType") or metric.get("field_type") or "").strip().lower()
13460
+ if field_type not in _QINGBI_DECIMAL_FIELD_TYPES:
13461
+ return {
13462
+ "rule_code": "HISTOGRAM_METRIC_FIELD_TYPE_UNSUPPORTED",
13463
+ "message": "histogram metric must be a numeric field",
13464
+ "next_action": "Choose one number/amount field as indicator_field_ids for histogram.",
13465
+ }
13466
+ field_source = str(metric.get("fieldSource") or metric.get("field_source") or "").strip().lower()
13467
+ bi_formula_type = str(metric.get("biFormulaType") or metric.get("bi_formula_type") or "").strip().lower()
13468
+ aggre_field_id = str(metric.get("aggreFieldId") or metric.get("aggre_field_id") or "").strip()
13469
+ if field_source == "formula" and (bi_formula_type in {"chart_agg", "agg"} or aggre_field_id):
13470
+ return {
13471
+ "rule_code": "HISTOGRAM_AGG_FORMULA_METRIC_UNSUPPORTED",
13472
+ "message": "histogram metric cannot be an aggregate formula field",
13473
+ "next_action": "Choose a plain numeric field, not an aggregate formula field.",
13474
+ }
13475
+ return None
13476
+
13477
+
13478
+ def _validate_public_chart_payload_rules(payload: dict[str, Any]) -> None:
13479
+ chart_type = str(payload.get("chartType") or "").strip().lower()
13480
+ dimensions = _chart_fields(payload, "selectedDimensions")
13481
+ metrics = _chart_fields(payload, "selectedMetrics")
13482
+ _check_chart_slot_duplicates(
13483
+ chart_type=chart_type,
13484
+ payload=payload,
13485
+ slot_names=[
13486
+ "selectedDimensions",
13487
+ "selectedMetrics",
13488
+ "xDimensions",
13489
+ "yDimensions",
13490
+ "xMetrics",
13491
+ "yMetrics",
13492
+ "leftMetrics",
13493
+ "rightMetrics",
13494
+ ],
13495
+ )
13496
+
13497
+ if chart_type == "gauge":
13498
+ if dimensions:
13499
+ _raise_chart_rule(
13500
+ rule_code="GAUGE_DIMENSION_NOT_ALLOWED",
13501
+ chart_type=chart_type,
13502
+ message="gauge chart must not have dimensions",
13503
+ expected="0 dimensions",
13504
+ actual={"dimension_count": len(dimensions)},
13505
+ offending_fields=[_chart_field_summary(field) for field in dimensions],
13506
+ next_action="Remove dimension_field_ids for gauge. The CLI clears public dimensions, but custom selectedDimensions in config must also be removed.",
13507
+ )
13508
+ if len(metrics) != 2:
13509
+ _raise_chart_rule(
13510
+ rule_code="GAUGE_METRIC_COUNT_INVALID",
13511
+ chart_type=chart_type,
13512
+ message="gauge chart requires exactly two metrics",
13513
+ expected="exactly 2 non-duplicated metrics; one real metric plus 数据总量 is allowed",
13514
+ actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
13515
+ offending_fields=[_chart_field_summary(field) for field in metrics],
13516
+ next_action="Pass two different indicator_field_ids, or pass one explicit real numeric metric so the CLI can pair it with 数据总量.",
13517
+ )
13518
+ elif chart_type == "histogram":
13519
+ if len(dimensions) > 1:
13520
+ _raise_chart_rule(
13521
+ rule_code="HISTOGRAM_DIMENSION_COUNT_INVALID",
13522
+ chart_type=chart_type,
13523
+ message="histogram chart supports at most one dimension",
13524
+ expected="0 or 1 dimension",
13525
+ actual={"dimension_count": len(dimensions)},
13526
+ offending_fields=[_chart_field_summary(field) for field in dimensions],
13527
+ next_action="Keep at most one dimension_field_ids value for histogram.",
13528
+ )
13529
+ if len(metrics) != 1:
13530
+ _raise_chart_rule(
13531
+ rule_code="HISTOGRAM_METRIC_COUNT_INVALID",
13532
+ chart_type=chart_type,
13533
+ message="histogram chart requires exactly one explicit metric",
13534
+ expected="exactly 1 plain numeric metric",
13535
+ actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
13536
+ offending_fields=[_chart_field_summary(field) for field in metrics],
13537
+ next_action="Pass exactly one numeric field in indicator_field_ids; histogram cannot rely on the default count metric.",
13538
+ )
13539
+ issue = _histogram_metric_issue(metrics[0])
13540
+ if issue:
13541
+ _raise_chart_rule(
13542
+ rule_code=str(issue["rule_code"]),
13543
+ chart_type=chart_type,
13544
+ message=str(issue["message"]),
13545
+ expected="one plain decimal metric; not 数据总量 and not aggregate formula",
13546
+ actual={"metric": _chart_field_summary(metrics[0])},
13547
+ offending_fields=[_chart_field_summary(metrics[0])],
13548
+ next_action=str(issue["next_action"]),
13549
+ )
13550
+ elif chart_type == "heatmap":
13551
+ if len(dimensions) != 2 or len(metrics) != 1:
13552
+ _raise_chart_rule(
13553
+ rule_code="HEATMAP_FIELD_COUNT_INVALID",
13554
+ chart_type=chart_type,
13555
+ message="heatmap chart requires two dimensions and one metric",
13556
+ expected="2 dimensions and 1 metric",
13557
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13558
+ next_action="Pass exactly two dimension_field_ids and one indicator_field_ids value for heatmap.",
13559
+ )
13560
+ elif chart_type == "waterfall":
13561
+ if len(dimensions) != 1 or len(metrics) != 1:
13562
+ _raise_chart_rule(
13563
+ rule_code="WATERFALL_FIELD_COUNT_INVALID",
13564
+ chart_type=chart_type,
13565
+ message="waterfall chart requires one dimension and one metric",
13566
+ expected="1 dimension and 1 metric",
13567
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13568
+ next_action="Pass exactly one dimension_field_ids value and one indicator_field_ids value for waterfall.",
13569
+ )
13570
+ elif chart_type == "treemap":
13571
+ if len(dimensions) < 1 or len(dimensions) > 2 or len(metrics) != 1:
13572
+ _raise_chart_rule(
13573
+ rule_code="TREEMAP_FIELD_COUNT_INVALID",
13574
+ chart_type=chart_type,
13575
+ message="treemap chart requires one or two dimensions and one metric",
13576
+ expected="1-2 dimensions and 1 metric",
13577
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13578
+ next_action="Pass one or two dimension_field_ids values and exactly one indicator_field_ids value for treemap.",
13579
+ )
13580
+ elif chart_type == "map":
13581
+ if len(dimensions) != 1 or len(metrics) != 1:
13582
+ _raise_chart_rule(
13583
+ rule_code="MAP_FIELD_COUNT_INVALID",
13584
+ chart_type=chart_type,
13585
+ message="map chart requires one dimension and one metric",
13586
+ expected="1 dimension and 1 metric",
13587
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
13588
+ next_action="Pass exactly one location/address dimension and one metric for map.",
13589
+ )
13590
+ elif chart_type == "scatter":
13591
+ x_metrics = _chart_fields(payload, "xMetrics")
13592
+ y_metrics = _chart_fields(payload, "yMetrics")
13593
+ if not dimensions or len(x_metrics) != 1 or len(y_metrics) != 1:
13594
+ _raise_chart_rule(
13595
+ rule_code="SCATTER_FIELD_COUNT_INVALID",
13596
+ chart_type=chart_type,
13597
+ message="scatter chart requires at least one dimension, one x metric, and one y metric",
13598
+ expected=">=1 dimensions, exactly 1 x metric and 1 y metric",
13599
+ actual={"dimension_count": len(dimensions), "x_metric_count": len(x_metrics), "y_metric_count": len(y_metrics)},
13600
+ next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for scatter.",
13601
+ )
13602
+ elif chart_type == "dualaxes":
13603
+ left_metrics = _chart_fields(payload, "leftMetrics")
13604
+ right_metrics = _chart_fields(payload, "rightMetrics")
13605
+ if not dimensions or (not left_metrics and not right_metrics):
13606
+ _raise_chart_rule(
13607
+ rule_code="DUALAXES_FIELD_COUNT_INVALID",
13608
+ chart_type=chart_type,
13609
+ message="dualaxes chart requires at least one dimension and at least one metric axis",
13610
+ expected=">=1 dimensions and at least one left/right metric",
13611
+ actual={"dimension_count": len(dimensions), "left_metric_count": len(left_metrics), "right_metric_count": len(right_metrics)},
13612
+ next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for dualaxes.",
13613
+ )
13614
+
13615
+
13616
+ def _explain_chart_backend_validation_error(
13617
+ *,
13618
+ api_error: QingflowApiError,
13619
+ chart_type: str,
13620
+ payload: dict[str, Any] | None,
13621
+ ) -> dict[str, Any] | None:
13622
+ backend_code = api_error.backend_code
13623
+ if backend_code not in {81002, 81005}:
13624
+ return None
13625
+ chart_type = str(chart_type or (payload or {}).get("chartType") or "").strip().lower()
13626
+ if isinstance(payload, dict):
13627
+ try:
13628
+ _validate_public_chart_payload_rules(payload)
13629
+ except ChartRuleViolation as violation:
13630
+ return violation.diagnostics
13631
+ if backend_code == 81005:
13632
+ duplicate_fields: list[dict[str, Any]] = []
13633
+ if isinstance(payload, dict):
13634
+ for slot_name in [
13635
+ "selectedDimensions",
13636
+ "selectedMetrics",
13637
+ "xDimensions",
13638
+ "yDimensions",
13639
+ "xMetrics",
13640
+ "yMetrics",
13641
+ "leftMetrics",
13642
+ "rightMetrics",
13643
+ ]:
13644
+ duplicate_fields.extend(_chart_duplicate_fields(_chart_fields(payload, slot_name)))
13645
+ return _chart_rule_diagnostics(
13646
+ rule_code="CHART_FIELD_ID_REPEAT",
13647
+ chart_type=chart_type,
13648
+ message="QingBI rejected the chart because one field id is repeated in a chart slot",
13649
+ expected="field ids must be unique within each dimension/metric slot",
13650
+ actual={"backend_code": backend_code},
13651
+ offending_fields=duplicate_fields,
13652
+ next_action="Remove duplicated fields or pass two different explicit metrics before retrying.",
13653
+ )
13654
+ return _chart_rule_diagnostics(
13655
+ rule_code="WRONG_METRIC_COUNT_OR_TYPE",
13656
+ chart_type=chart_type,
13657
+ message="QingBI rejected the chart because metric count or metric type does not satisfy this chart type",
13658
+ expected="use the chart-type metric count/type rules from builder charts documentation",
13659
+ actual={"backend_code": backend_code},
13660
+ next_action="Check indicator_field_ids count and field types; for histogram use exactly one plain numeric metric, and for gauge use two non-duplicated metrics.",
13661
+ )
13662
+
13663
+
12568
13664
  def _build_public_metric_fields(
12569
13665
  selectors: list[str],
12570
13666
  *,
12571
13667
  app_key: str,
12572
13668
  field_lookup: dict[str, dict[str, Any]],
13669
+ chart_field_lookup: dict[str, Any],
12573
13670
  qingbi_fields_by_id: dict[str, dict[str, Any]],
12574
13671
  aggregate: str,
13672
+ chart_type: str = "chart",
12575
13673
  ) -> list[dict[str, Any]]:
12576
13674
  normalized_aggregate = str(aggregate or "count").strip().lower()
12577
13675
  if normalized_aggregate == "count" or not selectors:
12578
13676
  return [_default_public_total_metric()]
12579
13677
  metrics: list[dict[str, Any]] = []
12580
13678
  for selector in selectors:
12581
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12582
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12583
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
13679
+ qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
13680
+ field_id = _chart_field_id(qingbi_field)
13681
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
13682
+ metrics.append(qingbi_field)
13683
+ continue
13684
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12584
13685
  metrics.append(
12585
13686
  {
12586
13687
  "fieldId": field_id,
12587
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12588
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
13688
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13689
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12589
13690
  "orderType": "default",
12590
13691
  "alignType": "left",
12591
13692
  "dateFormat": "yyyy-MM-dd",
@@ -12602,6 +13703,8 @@ def _build_public_metric_fields(
12602
13703
  "supId": qingbi_field.get("supId"),
12603
13704
  "beingTable": bool(qingbi_field.get("beingTable", False)),
12604
13705
  "returnType": qingbi_field.get("returnType"),
13706
+ "biFormulaType": qingbi_field.get("biFormulaType"),
13707
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
12605
13708
  }
12606
13709
  )
12607
13710
  return metrics or [_default_public_total_metric()]
@@ -12631,7 +13734,9 @@ def _build_public_chart_filter_matrix(
12631
13734
  *,
12632
13735
  app_key: str,
12633
13736
  field_lookup: dict[str, dict[str, Any]],
13737
+ chart_field_lookup: dict[str, Any],
12634
13738
  qingbi_fields_by_id: dict[str, dict[str, Any]],
13739
+ chart_type: str = "chart",
12635
13740
  ) -> list[list[dict[str, Any]]]:
12636
13741
  if not rules:
12637
13742
  return []
@@ -12647,16 +13752,21 @@ def _build_public_chart_filter_matrix(
12647
13752
  ViewFilterOperator.not_empty.value: 16,
12648
13753
  }
12649
13754
  for rule in rules:
12650
- field = _resolve_public_field(getattr(rule, "field_name", None), field_lookup=field_lookup)
12651
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12652
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
13755
+ qingbi_field = _resolve_qingbi_chart_field(
13756
+ getattr(rule, "field_name", None),
13757
+ chart_field_lookup=chart_field_lookup,
13758
+ chart_type=chart_type,
13759
+ role="filter",
13760
+ )
13761
+ field_id = _chart_field_id(qingbi_field)
13762
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12653
13763
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
12654
13764
  values = list(getattr(rule, "values", []) or [])
12655
13765
  group.append(
12656
13766
  {
12657
13767
  "fieldId": field_id,
12658
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12659
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
13768
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13769
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12660
13770
  "judgeType": judge_map.get(operator, 0),
12661
13771
  "judgeValues": values,
12662
13772
  "matchType": 1,
@@ -12670,6 +13780,7 @@ def _build_public_chart_config_payload(
12670
13780
  patch: ChartUpsertPatch,
12671
13781
  app_key: str,
12672
13782
  field_lookup: dict[str, dict[str, Any]],
13783
+ chart_field_lookup: dict[str, Any],
12673
13784
  qingbi_fields_by_id: dict[str, dict[str, Any]],
12674
13785
  ) -> dict[str, Any]:
12675
13786
  config = deepcopy(patch.config)
@@ -12688,12 +13799,19 @@ def _build_public_chart_config_payload(
12688
13799
  patch.filters,
12689
13800
  app_key=app_key,
12690
13801
  field_lookup=field_lookup,
13802
+ chart_field_lookup=chart_field_lookup,
12691
13803
  qingbi_fields_by_id=qingbi_fields_by_id,
13804
+ chart_type=patch.chart_type.value,
12692
13805
  )
12693
13806
  query_condition_field_ids = []
12694
13807
  for selector in list(config.pop("query_condition_field_ids", []) or []):
12695
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12696
- query_condition_field_ids.append(_bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id))
13808
+ field = _resolve_qingbi_chart_field(
13809
+ selector,
13810
+ chart_field_lookup=chart_field_lookup,
13811
+ chart_type=patch.chart_type.value,
13812
+ role="query_condition",
13813
+ )
13814
+ query_condition_field_ids.append(_chart_field_id(field))
12697
13815
  backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
12698
13816
  if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
12699
13817
  raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
@@ -12701,14 +13819,18 @@ def _build_public_chart_config_payload(
12701
13819
  patch.dimension_field_ids,
12702
13820
  app_key=app_key,
12703
13821
  field_lookup=field_lookup,
13822
+ chart_field_lookup=chart_field_lookup,
12704
13823
  qingbi_fields_by_id=qingbi_fields_by_id,
13824
+ chart_type=patch.chart_type.value,
12705
13825
  )
12706
13826
  selected_metrics = _build_public_metric_fields(
12707
13827
  patch.indicator_field_ids,
12708
13828
  app_key=app_key,
12709
13829
  field_lookup=field_lookup,
13830
+ chart_field_lookup=chart_field_lookup,
12710
13831
  qingbi_fields_by_id=qingbi_fields_by_id,
12711
13832
  aggregate=aggregate,
13833
+ chart_type=patch.chart_type.value,
12712
13834
  )
12713
13835
  payload: dict[str, Any] = {
12714
13836
  "chartName": patch.name,
@@ -12762,6 +13884,7 @@ def _build_public_chart_config_payload(
12762
13884
  if key in config:
12763
13885
  payload[key] = deepcopy(config.pop(key))
12764
13886
  payload.update(config)
13887
+ _validate_public_chart_payload_rules(payload)
12765
13888
  return payload
12766
13889
 
12767
13890
 
@@ -12937,7 +14060,9 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
12937
14060
  return deepcopy(candidates[-1])
12938
14061
 
12939
14062
 
12940
- def _portal_position_payload(position: Any) -> dict[str, Any]:
14063
+ def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> dict[str, Any]:
14064
+ mobile_provided = bool(getattr(position, "mobile_provided", False))
14065
+ mobile_rows = int(getattr(position, "mobile_h", 8)) if mobile_provided else int(getattr(position, "pc_h", 8))
12941
14066
  return {
12942
14067
  "pc": {
12943
14068
  "x": int(getattr(position, "pc_x", 0)),
@@ -12946,10 +14071,10 @@ def _portal_position_payload(position: Any) -> dict[str, Any]:
12946
14071
  "rows": int(getattr(position, "pc_h", 8)),
12947
14072
  },
12948
14073
  "mobile": {
12949
- "x": int(getattr(position, "mobile_x", 0)),
12950
- "y": int(getattr(position, "mobile_y", 0)),
12951
- "cols": int(getattr(position, "mobile_w", 12)),
12952
- "rows": int(getattr(position, "mobile_h", 8)),
14074
+ "x": int(getattr(position, "mobile_x", 0)) if mobile_provided else 0,
14075
+ "y": int(getattr(position, "mobile_y", 0)) if mobile_provided else int(inferred_mobile_y),
14076
+ "cols": int(getattr(position, "mobile_w", 6)) if mobile_provided else 6,
14077
+ "rows": mobile_rows,
12953
14078
  },
12954
14079
  }
12955
14080
 
@@ -12961,6 +14086,7 @@ def _portal_component_position_public(
12961
14086
  pc_y: int,
12962
14087
  pc_row_height: int,
12963
14088
  mobile_y: int,
14089
+ layout_preset: str | None = None,
12964
14090
  ) -> tuple[dict[str, Any], int, int, int, int]:
12965
14091
  source_name = str(source_type or "").lower()
12966
14092
  if source_name == "filter":
@@ -12979,8 +14105,11 @@ def _portal_component_position_public(
12979
14105
  cols = 12
12980
14106
  rows = 2
12981
14107
  else:
12982
- cols = 8
12983
- rows = 4
14108
+ if layout_preset == "dashboard_2col":
14109
+ cols = 12
14110
+ else:
14111
+ cols = 8
14112
+ rows = 6
12984
14113
  if cols == 24:
12985
14114
  if pc_x != 0:
12986
14115
  pc_y += pc_row_height
@@ -13003,6 +14132,73 @@ def _portal_component_position_public(
13003
14132
  return position, next_pc_x, next_pc_y, next_row_height, mobile_y + rows
13004
14133
 
13005
14134
 
14135
+ def _empty_portal_layout_diagnostics() -> dict[str, Any]:
14136
+ return {
14137
+ "pc_grid_columns": 24,
14138
+ "mobile_grid_columns": 6,
14139
+ "section_count": 0,
14140
+ "explicit_position_count": 0,
14141
+ "max_pc_right": None,
14142
+ "safe_for_display": True,
14143
+ "warnings": [],
14144
+ }
14145
+
14146
+
14147
+ def _portal_layout_diagnostics(sections: list[PortalSectionPatch], components: list[dict[str, Any]]) -> dict[str, Any]:
14148
+ diagnostics = _empty_portal_layout_diagnostics()
14149
+ diagnostics["section_count"] = len(sections)
14150
+ explicit_count = sum(1 for section in sections if section.position is not None)
14151
+ diagnostics["explicit_position_count"] = explicit_count
14152
+ pc_positions: list[dict[str, Any]] = []
14153
+ warnings: list[dict[str, Any]] = []
14154
+ for index, component in enumerate(components):
14155
+ if not isinstance(component, dict):
14156
+ continue
14157
+ position = component.get("position") if isinstance(component.get("position"), dict) else {}
14158
+ pc = position.get("pc") if isinstance(position.get("pc"), dict) else {}
14159
+ if pc:
14160
+ pc_positions.append(pc)
14161
+ section = sections[index] if index < len(sections) else None
14162
+ source_type = str(getattr(section, "source_type", "") or "").lower() if section is not None else ""
14163
+ title = str(getattr(section, "title", "") or "").strip() if section is not None else None
14164
+ cols = int(pc.get("cols") or 0)
14165
+ rows = int(pc.get("rows") or 0)
14166
+ if source_type == "chart" and (cols < 8 or rows < 5):
14167
+ warnings.append(_warning(
14168
+ "PORTAL_CHART_CARD_TOO_SMALL",
14169
+ "chart portal card is too small; use at least pc.cols >= 8 and pc.rows >= 5, preferably rows >= 6",
14170
+ section_index=index,
14171
+ title=title,
14172
+ pc=deepcopy(pc),
14173
+ ))
14174
+ if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
14175
+ warnings.append(_warning(
14176
+ "PORTAL_MOBILE_POSITION_MISSING",
14177
+ "pc position was provided without mobile position; mobile layout was generated with 6-column grid",
14178
+ section_index=index,
14179
+ title=title,
14180
+ ))
14181
+ max_right = None
14182
+ if pc_positions:
14183
+ max_right = max(int(position.get("x") or 0) + int(position.get("cols") or 0) for position in pc_positions)
14184
+ diagnostics["max_pc_right"] = max_right
14185
+ if len(pc_positions) > 1 and max_right is not None and max_right <= 12:
14186
+ warnings.append(_warning(
14187
+ "PORTAL_LAYOUT_HALF_WIDTH",
14188
+ "portal components only occupy the left half of the 24-column pc grid; this looks like a 12-column layout was used",
14189
+ max_pc_right=max_right,
14190
+ fix_hint="Use x=0/12 with cols=12 for two columns, x=0/8/16 with cols=8 for three columns, or omit position/use layout_preset.",
14191
+ ))
14192
+ diagnostics["warnings"] = warnings
14193
+ diagnostics["safe_for_display"] = not any(item.get("code") in {"PORTAL_LAYOUT_HALF_WIDTH", "PORTAL_CHART_CARD_TOO_SMALL"} for item in warnings)
14194
+ return diagnostics
14195
+
14196
+
14197
+ def _portal_layout_warning_items(layout_diagnostics: dict[str, Any]) -> list[dict[str, Any]]:
14198
+ warnings = layout_diagnostics.get("warnings") if isinstance(layout_diagnostics, dict) else None
14199
+ return [deepcopy(item) for item in warnings if isinstance(item, dict)] if isinstance(warnings, list) else []
14200
+
14201
+
13006
14202
  def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: Any) -> dict[str, Any]:
13007
14203
  app_key = str(getattr(ref, "app_key", "") or "").strip()
13008
14204
  chart_id = str(getattr(ref, "chart_id", "") or "").strip()
@@ -14193,6 +15389,38 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
14193
15389
  return parsed
14194
15390
 
14195
15391
 
15392
+ def _schema_field_identity(field: dict[str, Any] | None, *, fallback_name: str | None = None) -> dict[str, Any]:
15393
+ field = field or {}
15394
+ name = str(field.get("name") or fallback_name or "").strip() or None
15395
+ field_id = str(field.get("field_id") or "").strip() or None
15396
+ que_id = _coerce_positive_int(field.get("que_id") or field.get("queId"))
15397
+ return {
15398
+ "name": name,
15399
+ "field_id": field_id,
15400
+ "que_id": que_id,
15401
+ }
15402
+
15403
+
15404
+ def _schema_field_diff_details(
15405
+ *,
15406
+ added: list[str],
15407
+ updated: list[str],
15408
+ removed: list[str],
15409
+ before_fields: list[dict[str, Any]],
15410
+ after_fields: list[dict[str, Any]],
15411
+ ) -> dict[str, list[dict[str, Any]]]:
15412
+ before_by_name = {str(field.get("name") or ""): field for field in before_fields if str(field.get("name") or "").strip()}
15413
+ after_by_name = {str(field.get("name") or ""): field for field in after_fields if str(field.get("name") or "").strip()}
15414
+ return {
15415
+ "added": [_schema_field_identity(after_by_name.get(name), fallback_name=name) for name in added],
15416
+ "updated": [
15417
+ _schema_field_identity(after_by_name.get(name) or before_by_name.get(name), fallback_name=name)
15418
+ for name in updated
15419
+ ],
15420
+ "removed": [_schema_field_identity(before_by_name.get(name), fallback_name=name) for name in removed],
15421
+ }
15422
+
15423
+
14196
15424
  def _resolve_layout_sections_to_names(
14197
15425
  requested_sections: list[dict[str, Any]],
14198
15426
  fields: list[dict[str, Any]],
@@ -16584,13 +17812,38 @@ def _publish_verify_warnings(*, package_attached: bool | None, views_unavailable
16584
17812
  return warnings
16585
17813
 
16586
17814
 
16587
- def _chart_apply_warnings(*, failed_items: list[dict[str, Any]], readback_unavailable: bool, verified: bool) -> list[dict[str, Any]]:
17815
+ def _chart_apply_warnings(
17816
+ *,
17817
+ failed_items: list[dict[str, Any]],
17818
+ readback_unavailable: bool,
17819
+ verified: bool,
17820
+ delete_readback_issues: list[dict[str, Any]] | None = None,
17821
+ ) -> list[dict[str, Any]]:
16588
17822
  warnings: list[dict[str, Any]] = []
17823
+ delete_readback_issues = delete_readback_issues or []
16589
17824
  if failed_items:
16590
17825
  warnings.append(_warning("CHART_OPERATION_FAILED", "one or more chart operations failed", failed_count=len(failed_items)))
17826
+ still_exists = [item for item in delete_readback_issues if item.get("readback_status") == "still_exists"]
17827
+ unavailable = [item for item in delete_readback_issues if item.get("readback_status") == "unavailable"]
17828
+ if still_exists:
17829
+ warnings.append(
17830
+ _warning(
17831
+ "CHART_DELETE_READBACK_STILL_EXISTS",
17832
+ "one or more delete requests completed, but chart_id readback still found the chart",
17833
+ chart_ids=[item.get("chart_id") for item in still_exists if item.get("chart_id")],
17834
+ )
17835
+ )
17836
+ if unavailable:
17837
+ warnings.append(
17838
+ _warning(
17839
+ "CHART_DELETE_READBACK_UNAVAILABLE",
17840
+ "one or more delete requests completed, but chart_id readback was unavailable",
17841
+ chart_ids=[item.get("chart_id") for item in unavailable if item.get("chart_id")],
17842
+ )
17843
+ )
16591
17844
  if readback_unavailable:
16592
17845
  warnings.append(_warning("CHART_READBACK_PENDING", "chart readback is unavailable after apply"))
16593
- elif not verified and not failed_items:
17846
+ elif not verified and not failed_items and not delete_readback_issues:
16594
17847
  warnings.append(_warning("CHART_VERIFICATION_INCOMPLETE", "chart apply completed but verification is incomplete"))
16595
17848
  return warnings
16596
17849
 
@@ -17089,6 +18342,56 @@ def _package_resource_signature(items: Any, *, public: bool) -> tuple[tuple[str,
17089
18342
  return tuple(sorted(_flatten_package_resource_identities(items, public=public)))
17090
18343
 
17091
18344
 
18345
+ def _publicize_package_list_item(item: dict[str, Any]) -> JSONObject:
18346
+ package_id = _coerce_positive_int(item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId"))
18347
+ raw_package_id = package_id if package_id is not None else item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId")
18348
+ package_name = str(item.get("package_name") or item.get("packageName") or item.get("tag_name") or item.get("tagName") or "").strip()
18349
+ raw_items = item.get("tagItems") if isinstance(item.get("tagItems"), list) else None
18350
+ item_count = None
18351
+ raw_item_count = item.get("item_count") if "item_count" in item else item.get("itemCount")
18352
+ try:
18353
+ if raw_item_count is not None:
18354
+ coerced_count = int(raw_item_count)
18355
+ if coerced_count >= 0:
18356
+ item_count = coerced_count
18357
+ except (TypeError, ValueError):
18358
+ item_count = None
18359
+ if item_count is None and raw_items is not None:
18360
+ item_count = len(raw_items)
18361
+ tag_icon = item.get("tag_icon") if "tag_icon" in item else item.get("tagIcon")
18362
+ return {
18363
+ "package_id": raw_package_id,
18364
+ "package_name": package_name,
18365
+ "tag_id": raw_package_id,
18366
+ "tag_name": package_name,
18367
+ "publish_status": item.get("publish_status") if "publish_status" in item else item.get("publishStatus"),
18368
+ "being_trial": item.get("being_trial") if "being_trial" in item else item.get("beingTrial"),
18369
+ "item_count": item_count,
18370
+ "item_preview": deepcopy(item.get("item_preview") if "item_preview" in item else item.get("itemPreview") or []),
18371
+ "tag_icon": tag_icon,
18372
+ "icon_config": workspace_icon_config(str(tag_icon).strip() if tag_icon not in (None, "") else None),
18373
+ "permissions": {
18374
+ "can_add_app": item.get("can_add_app") if "can_add_app" in item else item.get("addAppStatus"),
18375
+ "can_edit_app": item.get("can_edit_app") if "can_edit_app" in item else item.get("editAppStatus"),
18376
+ "can_delete_app": item.get("can_delete_app") if "can_delete_app" in item else item.get("delAppStatus"),
18377
+ "can_edit_package": item.get("can_edit_package") if "can_edit_package" in item else item.get("editTagStatus"),
18378
+ },
18379
+ }
18380
+
18381
+
18382
+ def _package_list_item_matches_query(item: dict[str, Any], query: str) -> bool:
18383
+ needle = str(query or "").strip().casefold()
18384
+ if not needle:
18385
+ return True
18386
+ haystacks = (
18387
+ item.get("package_id"),
18388
+ item.get("tag_id"),
18389
+ item.get("package_name"),
18390
+ item.get("tag_name"),
18391
+ )
18392
+ return any(needle in str(value or "").casefold() for value in haystacks)
18393
+
18394
+
17092
18395
  def _backend_package_items_from_public_items(items: list[dict[str, Any]], group_ids_by_path: dict[tuple[int, ...], int], *, path: tuple[int, ...] = ()) -> list[JSONObject]:
17093
18396
  backend_items: list[JSONObject] = []
17094
18397
  for index, item in enumerate(items):
@@ -19360,7 +20663,7 @@ def _compare_view_button_summaries(
19360
20663
  expected_without_pending = [item for index, item in enumerate(expected) if index not in pending_indexes]
19361
20664
  if _sorted_view_button_compare_signatures(actual) == _sorted_view_button_compare_signatures(expected_without_pending):
19362
20665
  return {
19363
- "verified": True,
20666
+ "verified": False,
19364
20667
  "custom_button_readback_pending": True,
19365
20668
  "pending_custom_buttons": deepcopy(pending_custom_buttons),
19366
20669
  }
@@ -19371,7 +20674,7 @@ def _compare_view_button_summaries(
19371
20674
  and _sorted_view_button_compare_signatures(actual_other) == _sorted_view_button_compare_signatures(expected_other)
19372
20675
  )
19373
20676
  return {
19374
- "verified": custom_button_readback_pending,
20677
+ "verified": False,
19375
20678
  "custom_button_readback_pending": custom_button_readback_pending,
19376
20679
  "pending_custom_buttons": deepcopy(expected_custom) if custom_button_readback_pending else [],
19377
20680
  }
@@ -19601,6 +20904,7 @@ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
19601
20904
  "dash_key": dash_key or None,
19602
20905
  "dash_name": dash_name or None,
19603
20906
  "dash_icon": dash_icon,
20907
+ "icon_config": workspace_icon_config(dash_icon),
19604
20908
  "package_tag_ids": package_tag_ids,
19605
20909
  }
19606
20910
  )