@josephyan/qingflow-app-builder-mcp 0.2.0-beta.39 → 0.2.0-beta.40

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.
@@ -29,9 +29,11 @@ from ..tools.solution_tools import SolutionTools
29
29
  from ..tools.view_tools import ViewTools
30
30
  from ..tools.workflow_tools import WorkflowTools
31
31
  from .models import (
32
+ AppChartsReadResponse,
32
33
  AppFieldsReadResponse,
33
34
  AppFlowReadResponse,
34
35
  AppLayoutReadResponse,
36
+ PortalReadSummaryResponse,
35
37
  AppReadSummaryResponse,
36
38
  AppViewsReadResponse,
37
39
  ChartApplyRequest,
@@ -1048,7 +1050,13 @@ class AiBuilderFacade:
1048
1050
  "request_id": None,
1049
1051
  "suggested_next_call": None,
1050
1052
  "noop": False,
1051
- "verification": {"app_exists": True},
1053
+ "warnings": _warnings_from_verification_hints(verification_hints),
1054
+ "verification": {
1055
+ "app_exists": True,
1056
+ "views_read_unavailable": views_unavailable,
1057
+ "workflow_read_unavailable": workflow_unavailable,
1058
+ },
1059
+ "verified": not views_unavailable and not workflow_unavailable,
1052
1060
  **response.model_dump(mode="json"),
1053
1061
  }
1054
1062
 
@@ -1092,7 +1100,9 @@ class AiBuilderFacade:
1092
1100
  "request_id": None,
1093
1101
  "suggested_next_call": None,
1094
1102
  "noop": False,
1103
+ "warnings": [],
1095
1104
  "verification": {"app_exists": True},
1105
+ "verified": True,
1096
1106
  **response.model_dump(mode="json"),
1097
1107
  }
1098
1108
 
@@ -1128,7 +1138,9 @@ class AiBuilderFacade:
1128
1138
  "request_id": None,
1129
1139
  "suggested_next_call": None,
1130
1140
  "noop": False,
1141
+ "warnings": _layout_read_warnings(response.unplaced_fields),
1131
1142
  "verification": {"app_exists": True},
1143
+ "verified": True,
1132
1144
  **response.model_dump(mode="json"),
1133
1145
  }
1134
1146
 
@@ -1160,7 +1172,9 @@ class AiBuilderFacade:
1160
1172
  "request_id": None,
1161
1173
  "suggested_next_call": None,
1162
1174
  "noop": False,
1175
+ "warnings": [],
1163
1176
  "verification": {"app_exists": True},
1177
+ "verified": True,
1164
1178
  **response.model_dump(mode="json"),
1165
1179
  }
1166
1180
 
@@ -1194,7 +1208,99 @@ class AiBuilderFacade:
1194
1208
  "request_id": None,
1195
1209
  "suggested_next_call": None,
1196
1210
  "noop": False,
1211
+ "warnings": [_warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable")] if workflow_unavailable else [],
1197
1212
  "verification": {"app_exists": True, "workflow_read_unavailable": workflow_unavailable},
1213
+ "verified": not workflow_unavailable,
1214
+ **response.model_dump(mode="json"),
1215
+ }
1216
+
1217
+ def app_read_charts_summary(self, *, profile: str, app_key: str) -> JSONObject:
1218
+ try:
1219
+ app_result = self.app_resolve(profile=profile, app_key=app_key)
1220
+ if app_result.get("status") != "success":
1221
+ return app_result
1222
+ resolved_app_key = str(app_result.get("app_key") or app_key)
1223
+ items = self.charts.qingbi_report_list(profile=profile, app_key=resolved_app_key).get("items") or []
1224
+ except (QingflowApiError, RuntimeError) as error:
1225
+ api_error = _coerce_api_error(error)
1226
+ return _failed_from_api_error(
1227
+ "CHARTS_READ_FAILED",
1228
+ api_error,
1229
+ normalized_args={"app_key": app_key},
1230
+ details={"app_key": app_key},
1231
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1232
+ )
1233
+ charts = _summarize_charts(items)
1234
+ response = AppChartsReadResponse(
1235
+ app_key=resolved_app_key,
1236
+ charts=charts,
1237
+ chart_count=len(charts),
1238
+ )
1239
+ return {
1240
+ "status": "success",
1241
+ "error_code": None,
1242
+ "recoverable": False,
1243
+ "message": "read app charts summary",
1244
+ "normalized_args": {"app_key": resolved_app_key},
1245
+ "missing_fields": [],
1246
+ "allowed_values": {},
1247
+ "details": {},
1248
+ "request_id": None,
1249
+ "suggested_next_call": None,
1250
+ "noop": False,
1251
+ "warnings": [],
1252
+ "verification": {"app_exists": True},
1253
+ "verified": True,
1254
+ **response.model_dump(mode="json"),
1255
+ }
1256
+
1257
+ def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
1258
+ try:
1259
+ result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
1260
+ except (QingflowApiError, RuntimeError) as error:
1261
+ api_error = _coerce_api_error(error)
1262
+ return _failed_from_api_error(
1263
+ "PORTAL_READ_FAILED",
1264
+ api_error,
1265
+ normalized_args={"dash_key": dash_key, "being_draft": being_draft},
1266
+ details={"dash_key": dash_key, "being_draft": being_draft},
1267
+ suggested_next_call={"tool_name": "portal_read_summary", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
1268
+ )
1269
+ response = PortalReadSummaryResponse(
1270
+ dash_key=dash_key,
1271
+ being_draft=being_draft,
1272
+ dash_name=str(result.get("dashName") or "").strip() or None,
1273
+ package_tag_ids=[
1274
+ tag_id
1275
+ for tag_id in (
1276
+ _coerce_positive_int((item or {}).get("tagId"))
1277
+ for item in (result.get("tags") or [])
1278
+ if isinstance(item, dict)
1279
+ )
1280
+ if tag_id is not None
1281
+ ],
1282
+ dash_icon=str(result.get("dashIcon") or "").strip() or None,
1283
+ hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
1284
+ config_keys=sorted(str(key) for key in (result.get("config") or {}).keys()) if isinstance(result.get("config"), dict) else [],
1285
+ dash_global_config_keys=sorted(str(key) for key in (result.get("dashGlobalConfig") or {}).keys()) if isinstance(result.get("dashGlobalConfig"), dict) else [],
1286
+ section_count=len(result.get("components") or []) if isinstance(result.get("components"), list) else 0,
1287
+ sections=_summarize_portal_sections(result.get("components")),
1288
+ )
1289
+ return {
1290
+ "status": "success",
1291
+ "error_code": None,
1292
+ "recoverable": False,
1293
+ "message": "read portal summary",
1294
+ "normalized_args": {"dash_key": dash_key, "being_draft": being_draft},
1295
+ "missing_fields": [],
1296
+ "allowed_values": {},
1297
+ "details": {},
1298
+ "request_id": None,
1299
+ "suggested_next_call": None,
1300
+ "noop": False,
1301
+ "warnings": [],
1302
+ "verification": {"portal_exists": True, "being_draft": being_draft},
1303
+ "verified": True,
1198
1304
  **response.model_dump(mode="json"),
1199
1305
  }
1200
1306
 
@@ -2843,6 +2949,7 @@ class AiBuilderFacade:
2843
2949
  "request_id": None,
2844
2950
  "suggested_next_call": None,
2845
2951
  "noop": True,
2952
+ "warnings": [],
2846
2953
  "verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
2847
2954
  "app_key": app_key,
2848
2955
  "published": True,
@@ -2892,6 +2999,11 @@ class AiBuilderFacade:
2892
2999
  views = views or []
2893
3000
  views_ok = isinstance(views, list) and not views_unavailable
2894
3001
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
3002
+ warnings = _publish_verify_warnings(
3003
+ package_attached=package_attached,
3004
+ views_unavailable=views_unavailable,
3005
+ verified=verified,
3006
+ )
2895
3007
  return {
2896
3008
  "status": "success" if verified else "partial_success",
2897
3009
  "error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
@@ -2909,6 +3021,7 @@ class AiBuilderFacade:
2909
3021
  "arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
2910
3022
  },
2911
3023
  "noop": False,
3024
+ "warnings": warnings,
2912
3025
  "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
2913
3026
  "app_key": app_key,
2914
3027
  "published": bool(base.get("appPublishStatus") in {1, 2}),
@@ -2937,7 +3050,7 @@ class AiBuilderFacade:
2937
3050
  api_error,
2938
3051
  normalized_args=normalized_args,
2939
3052
  details={"app_key": app_key},
2940
- suggested_next_call={"tool_name": "chart_apply", "arguments": {"profile": profile, **normalized_args}},
3053
+ suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
2941
3054
  )
2942
3055
 
2943
3056
  field_lookup = _build_public_field_lookup(fields)
@@ -2951,11 +3064,14 @@ class AiBuilderFacade:
2951
3064
  for item in existing_chart_items
2952
3065
  if isinstance(item, dict) and _extract_chart_identifier(item)
2953
3066
  }
2954
- existing_by_name = {
2955
- str(item.get("chartName") or "").strip(): deepcopy(item)
2956
- for item in existing_chart_items
2957
- if isinstance(item, dict) and str(item.get("chartName") or "").strip()
2958
- }
3067
+ existing_by_name: dict[str, list[dict[str, Any]]] = {}
3068
+ for item in existing_chart_items:
3069
+ if not isinstance(item, dict):
3070
+ continue
3071
+ item_name = str(item.get("chartName") or "").strip()
3072
+ if not item_name:
3073
+ continue
3074
+ existing_by_name.setdefault(item_name, []).append(deepcopy(item))
2959
3075
 
2960
3076
  chart_results: list[dict[str, Any]] = []
2961
3077
  created_ids: list[str] = []
@@ -2968,15 +3084,24 @@ class AiBuilderFacade:
2968
3084
  existing = None
2969
3085
  if patch.chart_id:
2970
3086
  existing = existing_by_id.get(str(patch.chart_id))
3087
+ if existing is None:
3088
+ raise ValueError(f"chart_id '{patch.chart_id}' was not found under app '{app_key}'")
2971
3089
  if existing is None:
2972
- existing = existing_by_name.get(patch.name)
2973
- chart_id = _extract_chart_identifier(existing or {}) or str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
3090
+ name_matches = list(existing_by_name.get(patch.name) or [])
3091
+ if len(name_matches) > 1:
3092
+ raise ValueError(
3093
+ f"chart name '{patch.name}' is ambiguous under app '{app_key}'; supply chart_id to target an exact chart"
3094
+ )
3095
+ if name_matches:
3096
+ existing = deepcopy(name_matches[0])
3097
+ chart_id = _extract_chart_identifier(existing or {}) or str(patch.chart_id or "")
2974
3098
  existing_name = str((existing or {}).get("chartName") or "").strip()
2975
3099
  existing_type = _normalize_backend_chart_type((existing or {}).get("chartType"))
2976
3100
  target_type = _map_public_chart_type_to_backend(patch.chart_type)
2977
3101
  if existing is None:
3102
+ temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
2978
3103
  create_payload = {
2979
- "chartId": chart_id,
3104
+ "chartId": temp_chart_id,
2980
3105
  "chartName": patch.name,
2981
3106
  "chartType": target_type,
2982
3107
  "dataSourceType": "qingflow",
@@ -2992,15 +3117,30 @@ class AiBuilderFacade:
2992
3117
  created_chart_id = _extract_chart_identifier(create_result or {})
2993
3118
  if not created_chart_id:
2994
3119
  refreshed_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
2995
- refreshed_existing = _find_chart_by_name(
3120
+ refreshed_matches = _find_charts_by_name(
2996
3121
  refreshed_items,
2997
3122
  chart_name=patch.name,
2998
3123
  chart_type=target_type,
2999
3124
  )
3000
- created_chart_id = _extract_chart_identifier(refreshed_existing or {})
3001
- if created_chart_id:
3002
- chart_id = created_chart_id
3125
+ if len(refreshed_matches) == 1:
3126
+ created_chart_id = _extract_chart_identifier(refreshed_matches[0])
3127
+ elif len(refreshed_matches) > 1:
3128
+ raise ValueError(
3129
+ f"created chart '{patch.name}' could not be uniquely resolved from readback; supply chart_id on the next update"
3130
+ )
3131
+ if not created_chart_id:
3132
+ raise ValueError(
3133
+ f"created chart '{patch.name}' did not return a real chart_id and could not be confirmed from readback"
3134
+ )
3135
+ chart_id = created_chart_id
3003
3136
  created_ids.append(chart_id)
3137
+ created_chart = {
3138
+ "chartId": chart_id,
3139
+ "chartName": patch.name,
3140
+ "chartType": target_type,
3141
+ }
3142
+ existing_by_id[chart_id] = deepcopy(created_chart)
3143
+ existing_by_name.setdefault(patch.name, []).append(deepcopy(created_chart))
3004
3144
  elif existing_name != patch.name or existing_type != target_type:
3005
3145
  self.charts.qingbi_report_update_base(
3006
3146
  profile=profile,
@@ -3008,6 +3148,19 @@ class AiBuilderFacade:
3008
3148
  payload={"chartName": patch.name, "chartType": target_type},
3009
3149
  )
3010
3150
  updated_ids.append(chart_id)
3151
+ updated_chart = deepcopy(existing or {})
3152
+ updated_chart["chartId"] = chart_id
3153
+ updated_chart["chartName"] = patch.name
3154
+ updated_chart["chartType"] = target_type
3155
+ existing_by_id[chart_id] = deepcopy(updated_chart)
3156
+ old_name = existing_name
3157
+ if old_name and old_name in existing_by_name:
3158
+ existing_by_name[old_name] = [
3159
+ item for item in existing_by_name[old_name] if _extract_chart_identifier(item) != chart_id
3160
+ ]
3161
+ if not existing_by_name[old_name]:
3162
+ existing_by_name.pop(old_name, None)
3163
+ existing_by_name.setdefault(patch.name, []).append(deepcopy(updated_chart))
3011
3164
 
3012
3165
  config_payload = _build_public_chart_config_payload(
3013
3166
  patch=patch,
@@ -3127,37 +3280,54 @@ class AiBuilderFacade:
3127
3280
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
3128
3281
  "details": {"per_chart_results": chart_results},
3129
3282
  "request_id": failed_items[0].get("request_id"),
3130
- "suggested_next_call": {"tool_name": "chart_apply", "arguments": {"profile": profile, **normalized_args}},
3283
+ "suggested_next_call": {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
3131
3284
  "backend_code": failed_items[0].get("backend_code"),
3132
3285
  "http_status": failed_items[0].get("http_status"),
3133
3286
  "noop": noop,
3287
+ "warnings": _chart_apply_warnings(
3288
+ failed_items=failed_items,
3289
+ readback_unavailable=readback_unavailable,
3290
+ verified=False if failed_items else verified,
3291
+ ),
3134
3292
  "verification": {"charts_verified": False if failed_items else verified, "readback_unavailable": readback_unavailable},
3135
3293
  "app_key": app_key,
3136
3294
  "chart_results": chart_results,
3137
3295
  "verified": False if failed_items else verified,
3138
3296
  }
3297
+ result_verified = verified or noop
3139
3298
  return {
3140
- "status": "success" if verified or noop else "partial_success",
3141
- "error_code": None if verified or noop else "CHART_READBACK_PENDING",
3142
- "recoverable": not (verified or noop),
3143
- "message": "applied chart operations" if verified or noop else "applied chart operations; readback pending",
3299
+ "status": "success" if result_verified else "partial_success",
3300
+ "error_code": None if result_verified else "CHART_READBACK_PENDING",
3301
+ "recoverable": not result_verified,
3302
+ "message": "no chart changes requested" if noop else ("applied chart operations" if verified else "applied chart operations; readback pending"),
3144
3303
  "normalized_args": normalized_args,
3145
3304
  "missing_fields": [],
3146
3305
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
3147
3306
  "details": {},
3148
3307
  "request_id": None,
3149
- "suggested_next_call": None if verified or noop else {"tool_name": "chart_apply", "arguments": {"profile": profile, **normalized_args}},
3308
+ "suggested_next_call": None if result_verified else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
3150
3309
  "noop": noop,
3151
- "verification": {"charts_verified": verified, "readback_unavailable": readback_unavailable},
3310
+ "warnings": _chart_apply_warnings(
3311
+ failed_items=[],
3312
+ readback_unavailable=False if noop else readback_unavailable,
3313
+ verified=result_verified,
3314
+ ),
3315
+ "verification": {"charts_verified": result_verified, "readback_unavailable": False if noop else readback_unavailable},
3152
3316
  "app_key": app_key,
3153
3317
  "chart_results": chart_results,
3154
- "verified": verified or noop,
3318
+ "verified": result_verified,
3155
3319
  }
3156
3320
 
3157
3321
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
3158
3322
  normalized_args = request.model_dump(mode="json")
3159
3323
  dash_key = str(request.dash_key or "").strip()
3160
3324
  creating = not dash_key
3325
+ verify_dash_name = creating or request.dash_name is not None
3326
+ verify_dash_icon = bool(request.icon or request.color)
3327
+ verify_auth = request.auth is not None
3328
+ verify_hide_copyright = request.hide_copyright is not None
3329
+ verify_dash_global_config = request.dash_global_config is not None
3330
+ verify_tags = creating or request.package_tag_id is not None
3161
3331
  try:
3162
3332
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") if dash_key else {}
3163
3333
  except (QingflowApiError, RuntimeError) as error:
@@ -3258,16 +3428,59 @@ class AiBuilderFacade:
3258
3428
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
3259
3429
  expected_count = len(request.sections)
3260
3430
  draft_verified = isinstance(draft_components, list) and len(draft_components) == expected_count
3261
- live_verified = not request.publish or (
3262
- isinstance(live_result, dict)
3263
- and isinstance(live_result.get("components"), list)
3264
- and len(live_result.get("components")) == expected_count
3431
+ draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
3432
+ actual=draft_result,
3433
+ expected_payload=update_payload,
3434
+ expected_section_count=expected_count,
3435
+ requested_config_keys=set((request.config or {}).keys()),
3436
+ verify_dash_name=verify_dash_name,
3437
+ verify_dash_icon=verify_dash_icon,
3438
+ verify_auth=verify_auth,
3439
+ verify_hide_copyright=verify_hide_copyright,
3440
+ verify_dash_global_config=verify_dash_global_config,
3441
+ verify_tags=verify_tags,
3442
+ )
3443
+ live_verified: bool | None = None
3444
+ live_meta_verified: bool | None = None
3445
+ live_meta_mismatches: list[str] = []
3446
+ if request.publish:
3447
+ live_verified = (
3448
+ isinstance(live_result, dict)
3449
+ and isinstance(live_result.get("components"), list)
3450
+ and len(live_result.get("components")) == expected_count
3451
+ )
3452
+ live_meta_verified, live_meta_mismatches = _verify_portal_readback(
3453
+ actual=live_result,
3454
+ expected_payload=update_payload,
3455
+ expected_section_count=expected_count,
3456
+ requested_config_keys=set((request.config or {}).keys()),
3457
+ verify_dash_name=verify_dash_name,
3458
+ verify_dash_icon=verify_dash_icon,
3459
+ verify_auth=verify_auth,
3460
+ verify_hide_copyright=verify_hide_copyright,
3461
+ verify_dash_global_config=verify_dash_global_config,
3462
+ verify_tags=verify_tags,
3463
+ )
3464
+ verified = (
3465
+ draft_verified
3466
+ and draft_meta_verified
3467
+ and (published if request.publish else True)
3468
+ and (live_verified if request.publish else True)
3469
+ and (live_meta_verified if request.publish else True)
3470
+ and not publish_failed
3265
3471
  )
3266
- verified = draft_verified and live_verified and (published if request.publish else True) and not publish_failed
3267
3472
  status = "success" if verified else "partial_success"
3268
3473
  error_code = None if verified else "PORTAL_READBACK_PENDING"
3269
3474
  if publish_failed:
3270
3475
  error_code = "PORTAL_PUBLISH_FAILED"
3476
+ warnings = _portal_apply_warnings(
3477
+ publish_failed=publish_failed,
3478
+ draft_verified=draft_verified,
3479
+ draft_meta_verified=draft_meta_verified,
3480
+ live_verified=live_verified,
3481
+ live_meta_verified=live_meta_verified,
3482
+ publish_requested=request.publish,
3483
+ )
3271
3484
  return {
3272
3485
  "status": status,
3273
3486
  "error_code": error_code,
@@ -3276,13 +3489,21 @@ class AiBuilderFacade:
3276
3489
  "normalized_args": normalized_args,
3277
3490
  "missing_fields": [],
3278
3491
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
3279
- "details": {},
3492
+ "details": {
3493
+ "verification_mismatches": {
3494
+ "draft": draft_meta_mismatches,
3495
+ "live": live_meta_mismatches,
3496
+ }
3497
+ },
3280
3498
  "request_id": None,
3281
3499
  "suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
3282
3500
  "noop": False,
3501
+ "warnings": warnings,
3283
3502
  "verification": {
3284
3503
  "draft_verified": draft_verified,
3504
+ "draft_metadata_verified": draft_meta_verified,
3285
3505
  "live_verified": live_verified,
3506
+ "live_metadata_verified": live_meta_verified,
3286
3507
  "published": published,
3287
3508
  "publish_failed": publish_failed,
3288
3509
  },
@@ -3321,9 +3542,11 @@ class AiBuilderFacade:
3321
3542
  "request_id": None,
3322
3543
  "suggested_next_call": None,
3323
3544
  "noop": False,
3545
+ "warnings": [],
3324
3546
  "verification": {"published": True},
3325
3547
  "app_key": app_key,
3326
3548
  "published": True,
3549
+ "verified": True,
3327
3550
  }
3328
3551
 
3329
3552
  def _resolve_form_edit_version(self, *, profile: str, app_key: str, current_schema: dict[str, Any]) -> int:
@@ -3338,6 +3561,10 @@ class AiBuilderFacade:
3338
3561
  if not isinstance(verification, dict):
3339
3562
  verification = {}
3340
3563
  response["verification"] = verification
3564
+ warnings = response.get("warnings")
3565
+ if not isinstance(warnings, list):
3566
+ warnings = []
3567
+ response["warnings"] = warnings
3341
3568
  if bool(response.get("noop")):
3342
3569
  response["publish_requested"] = False
3343
3570
  response["publish_skipped"] = bool(publish)
@@ -3354,6 +3581,7 @@ class AiBuilderFacade:
3354
3581
  response["published"] = bool(publish_result.get("published"))
3355
3582
  verification["published"] = bool(publish_result.get("published"))
3356
3583
  if publish_result.get("status") == "failed":
3584
+ warnings.append(_warning("PUBLISH_FAILED", "publish step failed after apply succeeded"))
3357
3585
  response["status"] = "partial_success"
3358
3586
  response["error_code"] = response.get("error_code") or publish_result.get("error_code")
3359
3587
  response["recoverable"] = True
@@ -3766,7 +3994,9 @@ def _failed(
3766
3994
  "backend_code": backend_code,
3767
3995
  "http_status": http_status,
3768
3996
  "noop": False,
3997
+ "warnings": [],
3769
3998
  "verification": {},
3999
+ "verified": False,
3770
4000
  }
3771
4001
 
3772
4002
 
@@ -3838,6 +4068,10 @@ def _from_stage_failure(stage: JSONObject, *, fallback_tool: str) -> JSONObject:
3838
4068
  "request_id": stage.get("request_id"),
3839
4069
  "backend_code": stage.get("backend_code"),
3840
4070
  "http_status": stage.get("http_status"),
4071
+ "noop": False,
4072
+ "warnings": [],
4073
+ "verification": {},
4074
+ "verified": False,
3841
4075
  }
3842
4076
 
3843
4077
 
@@ -4310,12 +4544,12 @@ def _normalize_backend_chart_type(value: Any) -> str:
4310
4544
  return by_code.get(raw, raw.lower())
4311
4545
 
4312
4546
 
4313
- def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None = None) -> dict[str, Any] | None:
4547
+ def _find_charts_by_name(items: Any, *, chart_name: str, chart_type: str | None = None) -> list[dict[str, Any]]:
4314
4548
  target_name = str(chart_name or "").strip()
4315
4549
  target_type = _normalize_backend_chart_type(chart_type)
4316
4550
  candidates: list[dict[str, Any]] = []
4317
4551
  if not isinstance(items, list) or not target_name:
4318
- return None
4552
+ return []
4319
4553
  for item in items:
4320
4554
  if not isinstance(item, dict):
4321
4555
  continue
@@ -4325,7 +4559,12 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
4325
4559
  item_type = _normalize_backend_chart_type(item.get("chartType"))
4326
4560
  if target_type and item_type and item_type != target_type:
4327
4561
  continue
4328
- candidates.append(item)
4562
+ candidates.append(deepcopy(item))
4563
+ return candidates
4564
+
4565
+
4566
+ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None = None) -> dict[str, Any] | None:
4567
+ candidates = _find_charts_by_name(items, chart_name=chart_name, chart_type=chart_type)
4329
4568
  if not candidates:
4330
4569
  return None
4331
4570
  return deepcopy(candidates[-1])
@@ -4402,15 +4641,25 @@ def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: An
4402
4641
  chart_id = str(getattr(ref, "chart_id", "") or "").strip()
4403
4642
  chart_name = str(getattr(ref, "chart_name", "") or "").strip()
4404
4643
  items = charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
4405
- for item in items:
4406
- if not isinstance(item, dict):
4407
- continue
4408
- item_id = _extract_chart_identifier(item)
4409
- item_name = str(item.get("chartName") or "").strip()
4410
- if chart_id and item_id == chart_id:
4411
- return {"chart_id": item_id, "chart_name": item_name, "app_key": app_key}
4412
- if not chart_id and chart_name and item_name == chart_name:
4413
- return {"chart_id": item_id, "chart_name": item_name, "app_key": app_key}
4644
+ if chart_id:
4645
+ for item in items:
4646
+ if not isinstance(item, dict):
4647
+ continue
4648
+ item_id = _extract_chart_identifier(item)
4649
+ item_name = str(item.get("chartName") or "").strip()
4650
+ if item_id == chart_id:
4651
+ return {"chart_id": item_id, "chart_name": item_name, "app_key": app_key}
4652
+ raise ValueError(f"chart ref chart_id '{chart_id}' could not be resolved under app '{app_key}'")
4653
+ matches = _find_charts_by_name(items, chart_name=chart_name)
4654
+ if len(matches) > 1:
4655
+ raise ValueError(f"chart ref chart_name '{chart_name}' is ambiguous under app '{app_key}'; supply chart_id")
4656
+ if len(matches) == 1:
4657
+ item = matches[0]
4658
+ return {
4659
+ "chart_id": _extract_chart_identifier(item),
4660
+ "chart_name": str(item.get("chartName") or "").strip(),
4661
+ "app_key": app_key,
4662
+ }
4414
4663
  raise ValueError(f"chart ref could not be resolved under app '{app_key}'")
4415
4664
 
4416
4665
 
@@ -4419,18 +4668,98 @@ def _resolve_view_reference(*, facade: AiBuilderFacade, profile: str, ref: Any)
4419
4668
  view_key = str(getattr(ref, "view_key", "") or "").strip()
4420
4669
  view_name = str(getattr(ref, "view_name", "") or "").strip()
4421
4670
  views, _ = facade._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
4422
- for item in views or []:
4423
- if not isinstance(item, dict):
4424
- continue
4425
- item_key = _extract_view_key(item)
4426
- item_name = _extract_view_name(item)
4427
- if view_key and item_key == view_key:
4428
- return {"view_key": item_key, "view_name": item_name, "app_key": app_key, "view_type": _normalize_portal_view_type(item)}
4429
- if not view_key and view_name and item_name == view_name:
4430
- return {"view_key": item_key, "view_name": item_name, "app_key": app_key, "view_type": _normalize_portal_view_type(item)}
4671
+ if view_key:
4672
+ for item in views or []:
4673
+ if not isinstance(item, dict):
4674
+ continue
4675
+ item_key = _extract_view_key(item)
4676
+ item_name = _extract_view_name(item)
4677
+ if item_key == view_key:
4678
+ return {"view_key": item_key, "view_name": item_name, "app_key": app_key, "view_type": _normalize_portal_view_type(item)}
4679
+ raise ValueError(f"view ref view_key '{view_key}' could not be resolved under app '{app_key}'")
4680
+ matched_items = [
4681
+ item
4682
+ for item in (views or [])
4683
+ if isinstance(item, dict) and _extract_view_name(item) == view_name
4684
+ ]
4685
+ if len(matched_items) > 1:
4686
+ raise ValueError(f"view ref view_name '{view_name}' is ambiguous under app '{app_key}'; supply view_key")
4687
+ if len(matched_items) == 1:
4688
+ item = matched_items[0]
4689
+ return {
4690
+ "view_key": _extract_view_key(item),
4691
+ "view_name": _extract_view_name(item),
4692
+ "app_key": app_key,
4693
+ "view_type": _normalize_portal_view_type(item),
4694
+ }
4431
4695
  raise ValueError(f"view ref could not be resolved under app '{app_key}'")
4432
4696
 
4433
4697
 
4698
+ def _verify_portal_readback(
4699
+ *,
4700
+ actual: Any,
4701
+ expected_payload: dict[str, Any],
4702
+ expected_section_count: int,
4703
+ requested_config_keys: set[str],
4704
+ verify_dash_name: bool,
4705
+ verify_dash_icon: bool,
4706
+ verify_auth: bool,
4707
+ verify_hide_copyright: bool,
4708
+ verify_dash_global_config: bool,
4709
+ verify_tags: bool,
4710
+ ) -> tuple[bool, list[str]]:
4711
+ mismatches: list[str] = []
4712
+ if not isinstance(actual, dict):
4713
+ return False, ["portal readback payload is unavailable"]
4714
+ components = actual.get("components")
4715
+ if not isinstance(components, list) or len(components) != expected_section_count:
4716
+ mismatches.append(f"components expected {expected_section_count}, got {len(components) if isinstance(components, list) else 'unavailable'}")
4717
+ if verify_dash_name and str(actual.get("dashName") or "").strip() != str(expected_payload.get("dashName") or "").strip():
4718
+ mismatches.append("dash_name")
4719
+ if verify_dash_icon and str(actual.get("dashIcon") or "") != str(expected_payload.get("dashIcon") or ""):
4720
+ mismatches.append("dash_icon")
4721
+ if verify_auth and not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
4722
+ mismatches.append("auth")
4723
+ if verify_hide_copyright and bool(actual.get("hideCopyright", False)) != bool(expected_payload.get("hideCopyright", False)):
4724
+ mismatches.append("hide_copyright")
4725
+ if verify_dash_global_config and not _mapping_contains(actual.get("dashGlobalConfig") or {}, expected_payload.get("dashGlobalConfig") or {}):
4726
+ mismatches.append("dash_global_config")
4727
+ if verify_tags:
4728
+ expected_tags = [
4729
+ _coerce_positive_int((item or {}).get("tagId"))
4730
+ for item in (expected_payload.get("tags") or [])
4731
+ if isinstance(item, dict)
4732
+ ]
4733
+ actual_tags = [
4734
+ _coerce_positive_int((item or {}).get("tagId"))
4735
+ for item in (actual.get("tags") or [])
4736
+ if isinstance(item, dict)
4737
+ ]
4738
+ if [item for item in expected_tags if item is not None] != [item for item in actual_tags if item is not None]:
4739
+ mismatches.append("tags")
4740
+ for key in sorted(requested_config_keys):
4741
+ if deepcopy(actual.get(key)) != deepcopy(expected_payload.get(key)):
4742
+ mismatches.append(f"config.{key}")
4743
+ return not mismatches, mismatches
4744
+
4745
+
4746
+ def _mapping_contains(actual: Any, expected: Any) -> bool:
4747
+ if isinstance(expected, dict):
4748
+ if not isinstance(actual, dict):
4749
+ return False
4750
+ for key, expected_value in expected.items():
4751
+ if key not in actual:
4752
+ return False
4753
+ if not _mapping_contains(actual.get(key), expected_value):
4754
+ return False
4755
+ return True
4756
+ if isinstance(expected, list):
4757
+ if not isinstance(actual, list) or len(actual) < len(expected):
4758
+ return False
4759
+ return all(_mapping_contains(actual[index], expected[index]) for index in range(len(expected)))
4760
+ return deepcopy(actual) == deepcopy(expected)
4761
+
4762
+
4434
4763
  def _normalize_portal_view_type(view: dict[str, Any]) -> str:
4435
4764
  raw = str(view.get("viewgraphType") or view.get("type") or "").strip()
4436
4765
  mapping = {
@@ -4895,6 +5224,70 @@ def _build_verification_hints(
4895
5224
  return hints
4896
5225
 
4897
5226
 
5227
+ def _warning(code: str, message: str, **extra: Any) -> dict[str, Any]:
5228
+ warning = {"code": code, "message": message}
5229
+ warning.update({key: value for key, value in extra.items() if value is not None})
5230
+ return warning
5231
+
5232
+
5233
+ def _warnings_from_verification_hints(hints: list[str]) -> list[dict[str, Any]]:
5234
+ mapping = {
5235
+ "package attachment not verified": _warning("PACKAGE_ATTACHMENT_UNVERIFIED", "package attachment is not verified"),
5236
+ "layout has unplaced fields": _warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields"),
5237
+ "no public views detected": _warning("NO_PUBLIC_VIEWS", "no public views were detected"),
5238
+ "views_read_unavailable": _warning("VIEWS_READ_UNAVAILABLE", "views summary readback is unavailable"),
5239
+ "workflow_read_unavailable": _warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable"),
5240
+ }
5241
+ return [deepcopy(mapping[hint]) for hint in hints if hint in mapping]
5242
+
5243
+
5244
+ def _layout_read_warnings(unplaced_fields: list[str]) -> list[dict[str, Any]]:
5245
+ if not unplaced_fields:
5246
+ return []
5247
+ return [_warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields", fields=unplaced_fields)]
5248
+
5249
+
5250
+ def _publish_verify_warnings(*, package_attached: bool | None, views_unavailable: bool, verified: bool) -> list[dict[str, Any]]:
5251
+ warnings: list[dict[str, Any]] = []
5252
+ if package_attached is False:
5253
+ warnings.append(_warning("PACKAGE_NOT_ATTACHED", "target package is not attached after publish verification"))
5254
+ if views_unavailable:
5255
+ warnings.append(_warning("VIEWS_READBACK_PENDING", "views readback is unavailable during publish verification"))
5256
+ if not verified and not warnings:
5257
+ warnings.append(_warning("PUBLISH_VERIFY_INCOMPLETE", "publish verification is incomplete"))
5258
+ return warnings
5259
+
5260
+
5261
+ def _chart_apply_warnings(*, failed_items: list[dict[str, Any]], readback_unavailable: bool, verified: bool) -> list[dict[str, Any]]:
5262
+ warnings: list[dict[str, Any]] = []
5263
+ if failed_items:
5264
+ warnings.append(_warning("CHART_OPERATION_FAILED", "one or more chart operations failed", failed_count=len(failed_items)))
5265
+ if readback_unavailable:
5266
+ warnings.append(_warning("CHART_READBACK_PENDING", "chart readback is unavailable after apply"))
5267
+ elif not verified and not failed_items:
5268
+ warnings.append(_warning("CHART_VERIFICATION_INCOMPLETE", "chart apply completed but verification is incomplete"))
5269
+ return warnings
5270
+
5271
+
5272
+ def _portal_apply_warnings(
5273
+ *,
5274
+ publish_failed: bool,
5275
+ draft_verified: bool,
5276
+ draft_meta_verified: bool,
5277
+ live_verified: bool | None,
5278
+ live_meta_verified: bool | None,
5279
+ publish_requested: bool,
5280
+ ) -> list[dict[str, Any]]:
5281
+ warnings: list[dict[str, Any]] = []
5282
+ if publish_failed:
5283
+ warnings.append(_warning("PORTAL_PUBLISH_FAILED", "portal publish failed after draft update"))
5284
+ if not draft_verified or not draft_meta_verified:
5285
+ warnings.append(_warning("PORTAL_DRAFT_VERIFICATION_INCOMPLETE", "portal draft verification is incomplete"))
5286
+ if publish_requested and (live_verified is False or live_meta_verified is False):
5287
+ warnings.append(_warning("PORTAL_LIVE_VERIFICATION_INCOMPLETE", "portal live verification is incomplete"))
5288
+ return warnings
5289
+
5290
+
4898
5291
  def _build_layout_preset_sections(*, preset: LayoutPreset, field_names: list[str]) -> list[dict[str, Any]]:
4899
5292
  ordered = [name for name in field_names if name]
4900
5293
  if preset == LayoutPreset.single_section:
@@ -5281,6 +5674,108 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
5281
5674
  return items
5282
5675
 
5283
5676
 
5677
+ def _summarize_charts(result: Any) -> list[dict[str, Any]]:
5678
+ if not isinstance(result, list):
5679
+ return []
5680
+ items: list[dict[str, Any]] = []
5681
+ for index, chart in enumerate(result):
5682
+ if not isinstance(chart, dict):
5683
+ continue
5684
+ chart_id = _extract_chart_identifier(chart)
5685
+ name = str(chart.get("chartName") or "").strip()
5686
+ chart_type = _normalize_backend_chart_type(chart.get("chartType"))
5687
+ if not any((chart_id, name, chart_type)):
5688
+ continue
5689
+ items.append(
5690
+ {
5691
+ "chart_id": chart_id or None,
5692
+ "name": name or None,
5693
+ "chart_type": chart_type or None,
5694
+ "order": index,
5695
+ }
5696
+ )
5697
+ return items
5698
+
5699
+
5700
+ def _summarize_portal_sections(components: Any) -> list[dict[str, Any]]:
5701
+ if not isinstance(components, list):
5702
+ return []
5703
+ items: list[dict[str, Any]] = []
5704
+ for index, component in enumerate(components):
5705
+ if not isinstance(component, dict):
5706
+ continue
5707
+ source_type = _normalize_portal_component_source_type(component.get("type"))
5708
+ title = _extract_portal_component_title(component, source_type=source_type)
5709
+ summary: dict[str, Any] = {
5710
+ "order": index,
5711
+ "source_type": source_type,
5712
+ "title": title,
5713
+ }
5714
+ position = component.get("position")
5715
+ if isinstance(position, dict):
5716
+ summary["position"] = deepcopy(position)
5717
+ if source_type == "chart":
5718
+ chart_config = component.get("chartConfig") if isinstance(component.get("chartConfig"), dict) else {}
5719
+ summary["chart_ref"] = {
5720
+ "chart_id": str(chart_config.get("biChartId") or "").strip() or None,
5721
+ "chart_name": str(chart_config.get("chartComponentTitle") or title or "").strip() or None,
5722
+ }
5723
+ elif source_type == "view":
5724
+ view_config = component.get("viewgraphConfig") if isinstance(component.get("viewgraphConfig"), dict) else {}
5725
+ summary["view_ref"] = {
5726
+ "app_key": str(view_config.get("appKey") or "").strip() or None,
5727
+ "view_key": str(view_config.get("viewgraphKey") or "").strip() or None,
5728
+ "view_name": str(view_config.get("viewgraphName") or title or "").strip() or None,
5729
+ }
5730
+ items.append(summary)
5731
+ return items
5732
+
5733
+
5734
+ def _normalize_portal_component_source_type(value: Any) -> str:
5735
+ raw = str(value or "").strip()
5736
+ mapping = {
5737
+ "2": "grid",
5738
+ "4": "link",
5739
+ "5": "text",
5740
+ "6": "filter",
5741
+ "9": "chart",
5742
+ "10": "view",
5743
+ "grid": "grid",
5744
+ "link": "link",
5745
+ "text": "text",
5746
+ "filter": "filter",
5747
+ "chart": "chart",
5748
+ "view": "view",
5749
+ }
5750
+ return mapping.get(raw, raw.lower() or "unknown")
5751
+
5752
+
5753
+ def _extract_portal_component_title(component: dict[str, Any], *, source_type: str) -> str | None:
5754
+ config_key_map = {
5755
+ "grid": "gridConfig",
5756
+ "link": "linkConfig",
5757
+ "text": "textConfig",
5758
+ "filter": "filterConfig",
5759
+ "chart": "chartConfig",
5760
+ "view": "viewgraphConfig",
5761
+ }
5762
+ config_key = config_key_map.get(source_type, "")
5763
+ config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
5764
+ title_candidates = {
5765
+ "grid": ["gridTitle"],
5766
+ "link": ["title", "linkTitle"],
5767
+ "text": ["title", "textTitle"],
5768
+ "filter": ["title", "filterTitle"],
5769
+ "chart": ["chartComponentTitle", "componentTitle", "title"],
5770
+ "view": ["componentTitle", "viewgraphName", "title"],
5771
+ }
5772
+ for key in title_candidates.get(source_type, []):
5773
+ title = str(config.get(key) or "").strip()
5774
+ if title:
5775
+ return title
5776
+ return None
5777
+
5778
+
5284
5779
  def _entity_spec_from_app(*, base_info: dict[str, Any], schema: dict[str, Any], views: Any) -> dict[str, Any]:
5285
5780
  parsed = _parse_schema(schema)
5286
5781
  fields = []