@qingflow-tech/qingflow-app-user-mcp 1.0.40 → 1.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +2 -4
  2. package/docs/local-agent-install.md +4 -4
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-app-user/SKILL.md +5 -3
  6. package/skills/qingflow-mcp-setup/SKILL.md +2 -0
  7. package/skills/qingflow-record-analysis/SKILL.md +3 -1
  8. package/skills/qingflow-record-delete/SKILL.md +2 -0
  9. package/skills/qingflow-record-import/SKILL.md +29 -0
  10. package/skills/qingflow-record-insert/SKILL.md +24 -1
  11. package/skills/qingflow-record-update/SKILL.md +3 -0
  12. package/skills/qingflow-task-ops/SKILL.md +2 -0
  13. package/src/qingflow_mcp/builder_facade/models.py +183 -0
  14. package/src/qingflow_mcp/builder_facade/service.py +823 -75
  15. package/src/qingflow_mcp/cli/commands/builder.py +80 -6
  16. package/src/qingflow_mcp/cli/formatters.py +1 -0
  17. package/src/qingflow_mcp/cli/main.py +2 -0
  18. package/src/qingflow_mcp/response_trim.py +6 -4
  19. package/src/qingflow_mcp/tools/ai_builder_tools.py +388 -17
  20. package/src/qingflow_mcp/tools/record_tools.py +28 -2
  21. package/skills/qingflow-app-builder/SKILL.md +0 -280
  22. package/skills/qingflow-app-builder/agents/openai.yaml +0 -4
  23. package/skills/qingflow-app-builder/references/create-app.md +0 -160
  24. package/skills/qingflow-app-builder/references/environments.md +0 -63
  25. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +0 -123
  26. package/skills/qingflow-app-builder/references/gotchas.md +0 -107
  27. package/skills/qingflow-app-builder/references/match-rules.md +0 -129
  28. package/skills/qingflow-app-builder/references/public-surface-sync.md +0 -75
  29. package/skills/qingflow-app-builder/references/solution-playbooks.md +0 -52
  30. package/skills/qingflow-app-builder/references/tool-selection.md +0 -106
  31. package/skills/qingflow-app-builder/references/update-flow.md +0 -158
  32. package/skills/qingflow-app-builder/references/update-layout.md +0 -68
  33. package/skills/qingflow-app-builder/references/update-schema.md +0 -75
  34. package/skills/qingflow-app-builder/references/update-views.md +0 -286
  35. package/skills/qingflow-app-builder-code-integrations/SKILL.md +0 -137
  36. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +0 -4
  37. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +0 -66
  38. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +0 -77
@@ -48,6 +48,7 @@ from .models import (
48
48
  AssociatedResourceUpsertPatch,
49
49
  AssociatedResourceViewConfigPatch,
50
50
  ChartApplyRequest,
51
+ ChartMetricPatch,
51
52
  ChartPartialPatch,
52
53
  ChartUpsertPatch,
53
54
  CustomButtonsApplyRequest,
@@ -649,6 +650,42 @@ class AiBuilderFacade:
649
650
  normalized_args=normalized_args,
650
651
  )
651
652
  if layout_result.get("status") not in {"success", "partial_success"}:
653
+ prior_package_write_executed = bool(
654
+ created
655
+ or (
656
+ metadata_requested
657
+ and isinstance(update_result, dict)
658
+ and update_result.get("status") in {"success", "partial_success"}
659
+ and not bool(update_result.get("noop"))
660
+ )
661
+ )
662
+ if prior_package_write_executed:
663
+ layout_details = layout_result.get("details") if isinstance(layout_result.get("details"), dict) else {}
664
+ partial = _post_write_readback_pending_result(
665
+ error_code=str(layout_result.get("error_code") or "PACKAGE_APPLY_PARTIAL"),
666
+ message="created or updated package, but package layout apply failed; read package before retrying",
667
+ normalized_args=normalized_args,
668
+ details={
669
+ "package_id": effective_package_id,
670
+ "layout_error_code": layout_result.get("error_code"),
671
+ "layout_result": layout_result,
672
+ **(
673
+ {"layout_write_error": layout_details.get("write_error")}
674
+ if isinstance(layout_details.get("write_error"), dict)
675
+ else {}
676
+ ),
677
+ },
678
+ suggested_next_call={
679
+ "tool_name": "package_get",
680
+ "arguments": {"profile": profile, "package_id": effective_package_id},
681
+ },
682
+ request_id=layout_result.get("request_id") if isinstance(layout_result.get("request_id"), str) else None,
683
+ backend_code=layout_result.get("backend_code"),
684
+ http_status=layout_result.get("http_status") if isinstance(layout_result.get("http_status"), int) else None,
685
+ )
686
+ partial["package_id"] = effective_package_id
687
+ partial["layout_failed"] = True
688
+ return _apply_permission_outcomes(partial, *permission_outcomes)
652
689
  return _apply_permission_outcomes(layout_result, *permission_outcomes)
653
690
 
654
691
  write_executed = bool(
@@ -7185,6 +7222,8 @@ class AiBuilderFacade:
7185
7222
  base=deepcopy(base) if isinstance(base, dict) else {},
7186
7223
  visibility=_public_visibility_from_chart_visible_auth(base.get("visibleAuth")),
7187
7224
  filters=_public_chart_filter_groups_from_qingbi_config(config) if isinstance(config, dict) else [],
7225
+ group_by=_public_chart_group_by_from_qingbi_config(config) if isinstance(config, dict) else [],
7226
+ metrics=_public_chart_metrics_from_qingbi_config(config) if isinstance(config, dict) else [],
7188
7227
  config=deepcopy(config) if isinstance(config, dict) else {},
7189
7228
  )
7190
7229
  return {
@@ -7901,6 +7940,10 @@ class AiBuilderFacade:
7901
7940
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
7902
7941
  resolved["normalized_args"] = normalized_args
7903
7942
  return finalize(resolved)
7943
+ if resolved.get("status") == "partial_success" and not str(resolved.get("app_key") or "").strip():
7944
+ if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
7945
+ resolved["normalized_args"] = normalized_args
7946
+ return finalize(resolved)
7904
7947
  resolved_outcome = _permission_outcome_from_result(resolved)
7905
7948
  if resolved_outcome is not None:
7906
7949
  permission_outcomes.append(resolved_outcome)
@@ -7958,27 +8001,35 @@ class AiBuilderFacade:
7958
8001
  "request_id": None,
7959
8002
  }
7960
8003
  if bool(resolved.get("created")) and not requested_field_changes:
7961
- return finalize({
7962
- "status": "success",
7963
- "error_code": None,
7964
- "recoverable": False,
7965
- "message": "created app shell",
8004
+ shell_readback_pending = (
8005
+ resolved.get("status") == "partial_success"
8006
+ or bool((resolved.get("verification") if isinstance(resolved.get("verification"), dict) else {}).get("readback_unavailable"))
8007
+ or str(resolved.get("next_action") or "") == "readback_before_retry"
8008
+ )
8009
+ shell_verification = {
8010
+ "fields_verified": not shell_readback_pending,
8011
+ "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
8012
+ "relation_field_limit_verified": True,
8013
+ "app_visuals_verified": not shell_readback_pending,
8014
+ "app_base_verified": not shell_readback_pending,
8015
+ "publish_skipped": True,
8016
+ }
8017
+ if isinstance(resolved.get("verification"), dict):
8018
+ shell_verification.update(deepcopy(resolved.get("verification") or {}))
8019
+ created_shell_response = {
8020
+ "status": "partial_success" if shell_readback_pending else "success",
8021
+ "error_code": resolved.get("error_code") if shell_readback_pending else None,
8022
+ "recoverable": bool(shell_readback_pending),
8023
+ "message": str(resolved.get("message") or "created app shell") if shell_readback_pending else "created app shell",
7966
8024
  "normalized_args": normalized_args,
7967
8025
  "missing_fields": [],
7968
8026
  "allowed_values": {"field_types": [item.value for item in PublicFieldType]},
7969
8027
  "details": {"publish_skipped": True},
7970
- "request_id": None,
8028
+ "request_id": resolved.get("request_id"),
7971
8029
  "suggested_next_call": None,
7972
8030
  "noop": False,
7973
- "warnings": [],
7974
- "verification": {
7975
- "fields_verified": True,
7976
- "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7977
- "relation_field_limit_verified": True,
7978
- "app_visuals_verified": True,
7979
- "app_base_verified": True,
7980
- "publish_skipped": True,
7981
- },
8031
+ "warnings": deepcopy(resolved.get("warnings") or []),
8032
+ "verification": shell_verification,
7982
8033
  "app_key": target.app_key,
7983
8034
  "app_icon": str(resolved.get("app_icon") or visual_result.get("app_icon") or "").strip() or None,
7984
8035
  "app_name": str(visual_result.get("app_name_after") or target.app_name),
@@ -7989,19 +8040,28 @@ class AiBuilderFacade:
7989
8040
  "field_diff": {"added": [], "updated": [], "removed": []},
7990
8041
  "verified": True,
7991
8042
  "write_executed": True,
7992
- "write_succeeded": True,
8043
+ "write_succeeded": not shell_readback_pending or bool(resolved.get("write_succeeded", True)),
7993
8044
  "safe_to_retry": False,
7994
8045
  "tag_ids_after": list(target.tag_ids),
7995
8046
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7996
8047
  "publish_requested": False,
7997
8048
  "published": False,
7998
- })
8049
+ }
8050
+ if shell_readback_pending:
8051
+ created_shell_response["write_may_have_succeeded"] = True
8052
+ created_shell_response["next_action"] = "readback_before_retry"
8053
+ created_shell_response["suggested_next_call"] = resolved.get("suggested_next_call") or {
8054
+ "tool_name": "app_get",
8055
+ "arguments": {"profile": profile, "app_key": target.app_key},
8056
+ }
8057
+ return finalize(created_shell_response)
7999
8058
  schema_readback_delayed = False
8059
+ schema_readback_delayed_error: JSONObject | None = None
8000
8060
  try:
8001
8061
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
8002
8062
  except (QingflowApiError, RuntimeError) as error:
8003
8063
  api_error = _coerce_api_error(error)
8004
- if not bool(resolved.get("created")) or api_error.http_status != 404:
8064
+ if not bool(resolved.get("created")):
8005
8065
  return finalize(_failed_from_api_error(
8006
8066
  "SCHEMA_READBACK_FAILED",
8007
8067
  api_error,
@@ -8013,6 +8073,10 @@ class AiBuilderFacade:
8013
8073
  schema_result = _empty_schema_result(effective_app_name)
8014
8074
  _schema_source = "synthetic_new_app"
8015
8075
  schema_readback_delayed = True
8076
+ schema_readback_delayed_error = {
8077
+ "message": api_error.message,
8078
+ **_transport_error_payload(api_error),
8079
+ }
8016
8080
  parsed = _parse_schema(schema_result)
8017
8081
  current_fields = parsed["fields"]
8018
8082
  original_fields = deepcopy(current_fields)
@@ -8344,6 +8408,47 @@ class AiBuilderFacade:
8344
8408
  http_status=None if api_error.http_status == 404 else api_error.http_status,
8345
8409
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
8346
8410
  )
8411
+ if _is_uncertain_write_transport_error(api_error):
8412
+ uncertain = _post_write_may_have_succeeded_result(
8413
+ error_code="SCHEMA_WRITE_RESULT_UNCERTAIN",
8414
+ message="schema write request did not return a final result; read the app schema before retrying",
8415
+ normalized_args=normalized_args,
8416
+ details={
8417
+ "app_key": target.app_key,
8418
+ "app_name": effective_app_name,
8419
+ "created": bool(resolved.get("created")),
8420
+ "field_diff": {"added": added, "updated": updated, "removed": removed},
8421
+ "transport_error": _transport_error_payload(api_error),
8422
+ },
8423
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
8424
+ request_id=api_error.request_id,
8425
+ backend_code=api_error.backend_code,
8426
+ http_status=api_error.http_status,
8427
+ )
8428
+ uncertain.update(
8429
+ {
8430
+ "app_key": target.app_key,
8431
+ "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
8432
+ "app_name": effective_app_name,
8433
+ "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
8434
+ "app_name_after": effective_app_name,
8435
+ "app_base_updated": bool(visual_result.get("updated")),
8436
+ "created": bool(resolved.get("created")),
8437
+ "field_diff": {"added": added, "updated": updated, "removed": removed},
8438
+ "field_diff_details": _schema_field_diff_details(
8439
+ added=added,
8440
+ updated=updated,
8441
+ removed=removed,
8442
+ before_fields=original_fields,
8443
+ after_fields=current_fields,
8444
+ ),
8445
+ "tag_ids_after": list(target.tag_ids),
8446
+ "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
8447
+ "publish_requested": False,
8448
+ "published": False,
8449
+ }
8450
+ )
8451
+ return finalize(uncertain)
8347
8452
  return _failed_from_api_error(
8348
8453
  "SCHEMA_APPLY_FAILED",
8349
8454
  api_error,
@@ -8508,6 +8613,8 @@ class AiBuilderFacade:
8508
8613
  response["normalized_code_block_fields"] = normalized_code_block_fields
8509
8614
  if schema_readback_delayed:
8510
8615
  response["verification"]["schema_readback_delayed"] = True
8616
+ if schema_readback_delayed_error is not None:
8617
+ response["details"]["schema_readback_delayed_error"] = schema_readback_delayed_error
8511
8618
  response = _apply_permission_outcomes(response, relation_permission_outcome)
8512
8619
  response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
8513
8620
  verification_ok = False
@@ -8517,14 +8624,29 @@ class AiBuilderFacade:
8517
8624
  try:
8518
8625
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
8519
8626
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
8627
+ verified_fields = cast(list[dict[str, Any]], verified["schema"]["fields"])
8520
8628
  response["field_diff_details"] = _schema_field_diff_details(
8521
8629
  added=added,
8522
8630
  updated=updated,
8523
8631
  removed=removed,
8524
8632
  before_fields=original_fields,
8525
- after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
8633
+ after_fields=verified_fields,
8526
8634
  )
8527
8635
  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)
8636
+ relation_readback_matrix = _schema_relation_readback_matrix(
8637
+ expected_fields=current_fields,
8638
+ verified_fields=verified_fields,
8639
+ changed_field_names=set(added + updated),
8640
+ degraded_expectations=relation_degraded_expectations,
8641
+ )
8642
+ if relation_readback_matrix:
8643
+ relation_matrix_verified = all(bool(item.get("readback_verified")) for item in relation_readback_matrix)
8644
+ response["details"]["relation_readback_matrix"] = relation_readback_matrix
8645
+ response["verification"]["relation_readback_matrix_verified"] = relation_matrix_verified
8646
+ verification_ok = verification_ok and relation_matrix_verified
8647
+ relation_repair_plan = _schema_relation_repair_plan(relation_readback_matrix)
8648
+ if relation_repair_plan:
8649
+ response["details"]["relation_repair_plan"] = relation_repair_plan
8528
8650
  data_display_verification = _verify_data_display_readback(
8529
8651
  form_settings=verified.get("form_settings"),
8530
8652
  selection=data_display_selection,
@@ -8591,12 +8713,18 @@ class AiBuilderFacade:
8591
8713
  response["recoverable"] = True
8592
8714
  response["error_code"] = response.get("error_code") or "APP_BASE_READBACK_PENDING"
8593
8715
  response["message"] = f"{response.get('message') or 'apply succeeded'}; app base readback pending"
8716
+ response["write_may_have_succeeded"] = True
8717
+ response["next_action"] = "readback_before_retry"
8718
+ response["verification"]["readback_before_retry"] = True
8594
8719
  if verification_error is not None:
8595
8720
  response["recoverable"] = True
8596
8721
  response["error_code"] = response.get("error_code") or (
8597
8722
  "READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
8598
8723
  )
8599
8724
  response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
8725
+ response["write_may_have_succeeded"] = True
8726
+ response["next_action"] = "readback_before_retry"
8727
+ response["verification"]["readback_before_retry"] = True
8600
8728
  response["request_id"] = response.get("request_id") or verification_error.request_id
8601
8729
  details = response.get("details")
8602
8730
  if not isinstance(details, dict):
@@ -12374,6 +12502,28 @@ class AiBuilderFacade:
12374
12502
  except (QingflowApiError, RuntimeError) as error:
12375
12503
  api_error = _coerce_api_error(error)
12376
12504
  request_route = self._current_request_route(profile)
12505
+ if _is_uncertain_write_transport_error(api_error):
12506
+ return _post_write_may_have_succeeded_result(
12507
+ error_code="APP_CREATE_WRITE_RESULT_UNCERTAIN",
12508
+ message="app create request did not return a final result; resolve the app in the package before retrying",
12509
+ details={
12510
+ "app_name": app_name,
12511
+ "package_tag_id": package_tag_id,
12512
+ "request_route": request_route,
12513
+ "transport_error": _transport_error_payload(api_error),
12514
+ },
12515
+ suggested_next_call={
12516
+ "tool_name": "app_resolve",
12517
+ "arguments": {
12518
+ "profile": profile,
12519
+ "app_name": app_name,
12520
+ "package_id": package_tag_id,
12521
+ },
12522
+ },
12523
+ request_id=api_error.request_id,
12524
+ backend_code=api_error.backend_code,
12525
+ http_status=api_error.http_status,
12526
+ )
12377
12527
  return _failed_from_api_error(
12378
12528
  "CREATE_APP_ROUTE_NOT_FOUND" if api_error.http_status == 404 else "APP_CREATE_FAILED",
12379
12529
  api_error,
@@ -12397,12 +12547,29 @@ class AiBuilderFacade:
12397
12547
  except (QingflowApiError, RuntimeError) as error:
12398
12548
  api_error = _coerce_api_error(error)
12399
12549
  if api_error.http_status != 404:
12400
- return _failed_from_api_error(
12401
- "APP_CREATE_READBACK_FAILED",
12402
- api_error,
12550
+ pending = _post_write_readback_pending_result(
12551
+ error_code="APP_CREATE_READBACK_PENDING",
12552
+ message="created app; base readback is unavailable",
12403
12553
  details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
12404
12554
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": new_app_key}},
12555
+ request_id=api_error.request_id,
12556
+ backend_code=api_error.backend_code,
12557
+ http_status=api_error.http_status,
12558
+ )
12559
+ pending.update(
12560
+ {
12561
+ "app_key": new_app_key,
12562
+ "app_name": app_name or "未命名应用",
12563
+ "app_icon": payload.get("appIcon"),
12564
+ "tag_ids": [package_tag_id] if package_tag_id and package_tag_id > 0 else [],
12565
+ "created": True,
12566
+ }
12405
12567
  )
12568
+ pending.setdefault("details", {})["readback_error"] = {
12569
+ "message": api_error.message,
12570
+ **_transport_error_payload(api_error),
12571
+ }
12572
+ return pending
12406
12573
  return {
12407
12574
  "status": "success",
12408
12575
  "error_code": None,
@@ -12610,7 +12777,7 @@ class AiBuilderFacade:
12610
12777
  **deepcopy(section.config),
12611
12778
  }
12612
12779
  component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
12613
- layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type})
12780
+ layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
12614
12781
  elif section.source_type == "view":
12615
12782
  resolved_view = _resolve_view_reference(
12616
12783
  facade=self,
@@ -12633,27 +12800,27 @@ class AiBuilderFacade:
12633
12800
  **deepcopy(section.config),
12634
12801
  }
12635
12802
  component = {"type": 10, "position": position_payload, "viewgraphConfig": _compact_dict(view_config)}
12636
- layout_metadata.append({"source_type": section.source_type})
12803
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12637
12804
  elif section.source_type == "grid":
12638
12805
  component = {
12639
12806
  "type": 2,
12640
12807
  "position": position_payload,
12641
12808
  "gridConfig": _compact_dict({"gridTitle": section.title, "beingShowTitle": True, **deepcopy(section.config)}),
12642
12809
  }
12643
- layout_metadata.append({"source_type": section.source_type})
12810
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12644
12811
  elif section.source_type == "filter":
12645
12812
  component = {"type": 6, "position": position_payload, "filterConfig": deepcopy(section.config)}
12646
- layout_metadata.append({"source_type": section.source_type})
12813
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12647
12814
  elif section.source_type == "text":
12648
12815
  component = {"type": 5, "position": position_payload, "textConfig": {"text": section.text or "", **deepcopy(section.config)}}
12649
- layout_metadata.append({"source_type": section.source_type})
12816
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12650
12817
  else:
12651
12818
  component = {
12652
12819
  "type": 4,
12653
12820
  "position": position_payload,
12654
12821
  "linkConfig": {"url": section.url or "", "beingLoginAuth": False, **deepcopy(section.config)},
12655
12822
  }
12656
- layout_metadata.append({"source_type": section.source_type})
12823
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12657
12824
  if dash_style is not None:
12658
12825
  component["dashStyleConfigBO"] = dash_style
12659
12826
  resolved_components.append(component)
@@ -13750,7 +13917,7 @@ def _failed_from_api_error(
13750
13917
  "category": error.category,
13751
13918
  },
13752
13919
  )
13753
- return _failed(
13920
+ result = _failed(
13754
13921
  effective_error_code,
13755
13922
  public_message,
13756
13923
  recoverable=recoverable,
@@ -13763,6 +13930,14 @@ def _failed_from_api_error(
13763
13930
  backend_code=error.backend_code,
13764
13931
  http_status=public_http_status,
13765
13932
  )
13933
+ if _is_environment_quota_code(error.backend_code):
13934
+ _mark_environment_quota_block(
13935
+ result,
13936
+ write_executed=False,
13937
+ next_action="retry_after_quota_restored",
13938
+ message="backend quota/AI assistant limit blocked this operation; retry after quota is restored",
13939
+ )
13940
+ return result
13766
13941
 
13767
13942
 
13768
13943
  def _post_write_readback_pending_result(
@@ -13789,7 +13964,7 @@ def _post_write_readback_pending_result(
13789
13964
  ):
13790
13965
  if value is not None:
13791
13966
  warning[key] = value
13792
- return {
13967
+ result = {
13793
13968
  "status": "partial_success",
13794
13969
  "error_code": error_code,
13795
13970
  "recoverable": True,
@@ -13811,8 +13986,84 @@ def _post_write_readback_pending_result(
13811
13986
  "verified": False,
13812
13987
  "write_executed": True,
13813
13988
  "write_succeeded": True,
13989
+ "write_may_have_succeeded": True,
13814
13990
  "safe_to_retry": False,
13991
+ "next_action": "readback_before_retry",
13815
13992
  }
13993
+ result["verification"]["readback_before_retry"] = True
13994
+ if _is_environment_quota_code(effective_backend_code):
13995
+ result["readback_blocked_by_environment"] = True
13996
+ result["verification"]["readback_blocked_by_environment"] = True
13997
+ _mark_environment_quota_block(
13998
+ result,
13999
+ write_executed=True,
14000
+ next_action="retry_after_quota_restored",
14001
+ message="post-write readback was blocked by backend quota/AI assistant limit; retry readback after quota is restored",
14002
+ )
14003
+ return result
14004
+
14005
+
14006
+ def _post_write_may_have_succeeded_result(
14007
+ *,
14008
+ error_code: str,
14009
+ message: str,
14010
+ normalized_args: JSONObject | None = None,
14011
+ details: JSONObject | None = None,
14012
+ suggested_next_call: JSONObject | None = None,
14013
+ request_id: str | None = None,
14014
+ backend_code: Any = None,
14015
+ http_status: int | None = None,
14016
+ ) -> JSONObject:
14017
+ effective_details = details or {}
14018
+ transport_error = _readback_transport_error_from_details(effective_details)
14019
+ effective_backend_code = backend_code if backend_code is not None else (transport_error or {}).get("backend_code")
14020
+ effective_http_status = http_status if http_status is not None else (transport_error or {}).get("http_status")
14021
+ effective_request_id = request_id if request_id is not None else (transport_error or {}).get("request_id")
14022
+ warning = _warning("WRITE_RESULT_UNCERTAIN", "write request may have succeeded but no final response was received")
14023
+ for key, value in (
14024
+ ("backend_code", effective_backend_code),
14025
+ ("http_status", effective_http_status),
14026
+ ("request_id", effective_request_id),
14027
+ ):
14028
+ if value is not None:
14029
+ warning[key] = value
14030
+ result = {
14031
+ "status": "partial_success",
14032
+ "error_code": error_code,
14033
+ "recoverable": True,
14034
+ "message": message,
14035
+ "normalized_args": normalized_args or {},
14036
+ "missing_fields": [],
14037
+ "allowed_values": {},
14038
+ "details": effective_details,
14039
+ "suggested_next_call": suggested_next_call,
14040
+ "request_id": effective_request_id,
14041
+ "backend_code": effective_backend_code,
14042
+ "http_status": effective_http_status,
14043
+ "noop": False,
14044
+ "warnings": [warning],
14045
+ "verification": {
14046
+ "readback_unavailable": True,
14047
+ "metadata_unverified": True,
14048
+ "readback_before_retry": True,
14049
+ },
14050
+ "verified": False,
14051
+ "write_executed": True,
14052
+ "write_succeeded": False,
14053
+ "write_may_have_succeeded": True,
14054
+ "safe_to_retry": False,
14055
+ "next_action": "readback_before_retry",
14056
+ }
14057
+ if _is_environment_quota_code(effective_backend_code):
14058
+ result["readback_blocked_by_environment"] = True
14059
+ result["verification"]["readback_blocked_by_environment"] = True
14060
+ _mark_environment_quota_block(
14061
+ result,
14062
+ write_executed=True,
14063
+ next_action="retry_after_quota_restored",
14064
+ message="write result or readback was blocked by backend quota/AI assistant limit; retry after quota is restored",
14065
+ )
14066
+ return result
13816
14067
 
13817
14068
 
13818
14069
  def _readback_transport_error_from_details(details: JSONObject) -> JSONObject | None:
@@ -13859,6 +14110,69 @@ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
13859
14110
  }
13860
14111
 
13861
14112
 
14113
+ def _is_environment_quota_code(code: Any) -> bool:
14114
+ return backend_code_value_int(code) == 59004
14115
+
14116
+
14117
+ def _mark_environment_quota_block(
14118
+ payload: JSONObject,
14119
+ *,
14120
+ write_executed: bool,
14121
+ next_action: str,
14122
+ message: str,
14123
+ ) -> None:
14124
+ payload["environment_blocked"] = True
14125
+ payload["blocker_type"] = "quota_limit"
14126
+ payload["next_action"] = next_action
14127
+ payload["safe_to_retry"] = False
14128
+ payload["write_executed"] = bool(write_executed)
14129
+ details = payload.get("details")
14130
+ if not isinstance(details, dict):
14131
+ details = {}
14132
+ payload["details"] = details
14133
+ details.setdefault("environment_blocked", True)
14134
+ details.setdefault("blocker_type", "quota_limit")
14135
+ details.setdefault("next_action", next_action)
14136
+ details.setdefault("fix_hint", message)
14137
+ warnings = payload.get("warnings")
14138
+ if not isinstance(warnings, list):
14139
+ warnings = []
14140
+ payload["warnings"] = warnings
14141
+ if not any(isinstance(item, dict) and item.get("code") == "ENVIRONMENT_QUOTA_LIMIT" for item in warnings):
14142
+ warning = _warning("ENVIRONMENT_QUOTA_LIMIT", message)
14143
+ for key in ("backend_code", "http_status", "request_id"):
14144
+ value = payload.get(key)
14145
+ if value is not None:
14146
+ warning[key] = value
14147
+ warnings.append(warning)
14148
+
14149
+
14150
+ def _is_uncertain_write_transport_error(error: QingflowApiError) -> bool:
14151
+ if is_auth_like_error(error):
14152
+ return False
14153
+ category = str(error.category or "").strip().lower()
14154
+ message = str(error.message or "").strip().lower()
14155
+ if category == "timeout":
14156
+ return True
14157
+ if category != "network":
14158
+ return False
14159
+ return any(
14160
+ marker in message
14161
+ for marker in (
14162
+ "timeout",
14163
+ "timed out",
14164
+ "read timed out",
14165
+ "write timed out",
14166
+ "readtimeout",
14167
+ "writetimeout",
14168
+ "server disconnected",
14169
+ "connection reset",
14170
+ "remote protocol error",
14171
+ "response ended prematurely",
14172
+ )
14173
+ )
14174
+
14175
+
13862
14176
  def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
13863
14177
  if is_auth_like_error(error):
13864
14178
  return False
@@ -14317,7 +14631,29 @@ _CHART_PARTIAL_PATCH_KEY_ALIASES = {
14317
14631
  "indicator_field_ids": "indicator_field_ids",
14318
14632
  "indicatorFieldIds": "indicator_field_ids",
14319
14633
  "metric_field_ids": "indicator_field_ids",
14634
+ "group_by": "group_by",
14635
+ "groupBy": "group_by",
14636
+ "dimensions": "group_by",
14637
+ "rows": "rows",
14638
+ "columns": "columns",
14639
+ "metric": "metric",
14640
+ "metrics": "metrics",
14641
+ "x_metric": "x_metric",
14642
+ "xMetric": "x_metric",
14643
+ "y_metric": "y_metric",
14644
+ "yMetric": "y_metric",
14645
+ "left_metric": "left_metric",
14646
+ "leftMetric": "left_metric",
14647
+ "right_metric": "right_metric",
14648
+ "rightMetric": "right_metric",
14649
+ "value_metric": "value_metric",
14650
+ "valueMetric": "value_metric",
14651
+ "target_metric": "target_metric",
14652
+ "targetMetric": "target_metric",
14653
+ "where": "filters",
14320
14654
  "filters": "filters",
14655
+ "filter_rules": "filters",
14656
+ "filterRules": "filters",
14321
14657
  "question_config": "question_config",
14322
14658
  "questionConfig": "question_config",
14323
14659
  "user_config": "user_config",
@@ -14331,6 +14667,17 @@ _CHART_PARTIAL_SET_KEYS = {
14331
14667
  "chart_type",
14332
14668
  "dimension_field_ids",
14333
14669
  "indicator_field_ids",
14670
+ "group_by",
14671
+ "rows",
14672
+ "columns",
14673
+ "metric",
14674
+ "metrics",
14675
+ "x_metric",
14676
+ "y_metric",
14677
+ "left_metric",
14678
+ "right_metric",
14679
+ "value_metric",
14680
+ "target_metric",
14334
14681
  "filters",
14335
14682
  "question_config",
14336
14683
  "user_config",
@@ -14717,12 +15064,43 @@ def _compact_public_chart_fields_read(
14717
15064
  "field_type": field.get("fieldType") or field.get("field_type"),
14718
15065
  "system_field": bool(que_id is not None and not isinstance(form_field, dict)),
14719
15066
  "available_for_charts": True,
15067
+ "chart_apply_examples": _chart_apply_examples_for_field(
15068
+ title=title,
15069
+ field_type=field.get("fieldType") or field.get("field_type"),
15070
+ ),
14720
15071
  }
14721
15072
  )
14722
15073
  )
14723
15074
  return compact_fields
14724
15075
 
14725
15076
 
15077
+ def _chart_apply_examples_for_field(*, title: str, field_type: Any) -> dict[str, Any]:
15078
+ field_name = str(title or "").strip()
15079
+ if not field_name:
15080
+ return {}
15081
+ examples: dict[str, Any] = {
15082
+ "count_by_field": {
15083
+ "name": f"按{field_name}分布",
15084
+ "chart_type": "bar",
15085
+ "group_by": [field_name],
15086
+ "metric": "count(*)",
15087
+ },
15088
+ "filtered_count": {
15089
+ "name": f"{field_name}筛选数量",
15090
+ "chart_type": "target",
15091
+ "metric": "count(*)",
15092
+ "where": [{"field": field_name, "op": "eq", "value": "REPLACE_WITH_VALUE"}],
15093
+ },
15094
+ }
15095
+ if str(field_type or "").strip().lower() in _QINGBI_DECIMAL_FIELD_TYPES:
15096
+ examples["sum_metric"] = {
15097
+ "name": f"{field_name}合计",
15098
+ "chart_type": "target",
15099
+ "metric": f"sum({field_name})",
15100
+ }
15101
+ return examples
15102
+
15103
+
14726
15104
  def _chart_field_candidates(
14727
15105
  selector: Any,
14728
15106
  *,
@@ -15031,37 +15409,80 @@ def _build_public_metric_fields(
15031
15409
  metrics: list[dict[str, Any]] = []
15032
15410
  for selector in selectors:
15033
15411
  qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
15034
- field_id = _chart_field_id(qingbi_field)
15035
- if field_id == _QINGBI_TOTAL_FIELD_ID:
15036
- metrics.append(qingbi_field)
15412
+ metrics.append(_public_qingbi_metric_field(qingbi_field, aggregate=normalized_aggregate))
15413
+ return metrics or [_default_public_total_metric()]
15414
+
15415
+
15416
+ def _public_qingbi_metric_field(qingbi_field: dict[str, Any], *, aggregate: str) -> dict[str, Any]:
15417
+ field_id = _chart_field_id(qingbi_field)
15418
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
15419
+ return deepcopy(qingbi_field)
15420
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
15421
+ normalized_aggregate = str(aggregate or "sum").strip().lower()
15422
+ aggre_type = {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min"}.get(normalized_aggregate, "sum")
15423
+ return {
15424
+ "fieldId": field_id,
15425
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
15426
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
15427
+ "orderType": "default",
15428
+ "alignType": "left",
15429
+ "dateFormat": "yyyy-MM-dd",
15430
+ "numberFormat": "default",
15431
+ "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
15432
+ "digit": None,
15433
+ "aggreType": aggre_type,
15434
+ "orderPriority": None,
15435
+ "width": None,
15436
+ "verticalAlign": "middle",
15437
+ "formula": qingbi_field.get("formula"),
15438
+ "fieldSource": qingbi_field.get("fieldSource") or "default",
15439
+ "status": qingbi_field.get("status"),
15440
+ "supId": qingbi_field.get("supId"),
15441
+ "beingTable": bool(qingbi_field.get("beingTable", False)),
15442
+ "returnType": qingbi_field.get("returnType"),
15443
+ "biFormulaType": qingbi_field.get("biFormulaType"),
15444
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
15445
+ }
15446
+
15447
+
15448
+ def _build_public_semantic_metric_fields(
15449
+ metrics: list[ChartMetricPatch],
15450
+ *,
15451
+ app_key: str,
15452
+ field_lookup: dict[str, dict[str, Any]],
15453
+ chart_field_lookup: dict[str, Any],
15454
+ qingbi_fields_by_id: dict[str, dict[str, Any]],
15455
+ chart_type: str = "chart",
15456
+ ) -> list[dict[str, Any]]:
15457
+ if not metrics:
15458
+ return [_default_public_total_metric()]
15459
+ selected_metrics: list[dict[str, Any]] = []
15460
+ for metric in metrics:
15461
+ op = str(metric.op or "count").strip().lower()
15462
+ field_name = str(metric.field_name or "").strip()
15463
+ if op == "count":
15464
+ if field_name:
15465
+ _raise_chart_rule(
15466
+ rule_code="CHART_COUNT_FIELD_UNSUPPORTED",
15467
+ chart_type=chart_type,
15468
+ message="count metric currently supports count(*) only",
15469
+ expected='Use metric: "count(*)" or {"op": "count"} for record count.',
15470
+ actual={"metric": metric.model_dump(mode="json")},
15471
+ next_action='Use count(*) for count cards; use sum(field), avg(field), max(field), or min(field) for field aggregation.',
15472
+ )
15473
+ selected_metrics.append(_default_public_total_metric())
15037
15474
  continue
15038
- form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
15039
- metrics.append(
15040
- {
15041
- "fieldId": field_id,
15042
- "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
15043
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
15044
- "orderType": "default",
15045
- "alignType": "left",
15046
- "dateFormat": "yyyy-MM-dd",
15047
- "numberFormat": "default",
15048
- "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
15049
- "digit": None,
15050
- "aggreType": {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min", "count": "sum", "distinct_count": "sum"}.get(normalized_aggregate, "sum"),
15051
- "orderPriority": None,
15052
- "width": None,
15053
- "verticalAlign": "middle",
15054
- "formula": qingbi_field.get("formula"),
15055
- "fieldSource": qingbi_field.get("fieldSource") or "default",
15056
- "status": qingbi_field.get("status"),
15057
- "supId": qingbi_field.get("supId"),
15058
- "beingTable": bool(qingbi_field.get("beingTable", False)),
15059
- "returnType": qingbi_field.get("returnType"),
15060
- "biFormulaType": qingbi_field.get("biFormulaType"),
15061
- "aggreFieldId": qingbi_field.get("aggreFieldId"),
15062
- }
15475
+ qingbi_field = _resolve_qingbi_chart_field(
15476
+ field_name,
15477
+ chart_field_lookup=chart_field_lookup,
15478
+ chart_type=chart_type,
15479
+ role="metric",
15063
15480
  )
15064
- return metrics or [_default_public_total_metric()]
15481
+ metric_payload = _public_qingbi_metric_field(qingbi_field, aggregate=op)
15482
+ if metric.alias:
15483
+ metric_payload["fieldName"] = metric.alias
15484
+ selected_metrics.append(metric_payload)
15485
+ return selected_metrics or [_default_public_total_metric()]
15065
15486
 
15066
15487
 
15067
15488
  def _split_axis_metric_fields(metrics: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
@@ -15261,6 +15682,61 @@ def _public_chart_filter_groups_from_qingbi_config(config: dict[str, Any]) -> li
15261
15682
  return groups
15262
15683
 
15263
15684
 
15685
+ def _public_chart_group_by_from_qingbi_config(config: dict[str, Any]) -> list[str]:
15686
+ fields: list[dict[str, Any]] = []
15687
+ for key in ("selectedDimensions", "xDimensions", "yDimensions", "selectedTime"):
15688
+ fields.extend(_chart_fields(config, key))
15689
+ group_by: list[str] = []
15690
+ seen: set[str] = set()
15691
+ for field in fields:
15692
+ name = _stringify_condition_value(
15693
+ field.get("fieldName")
15694
+ or field.get("field_name")
15695
+ or field.get("queTitle")
15696
+ or field.get("title")
15697
+ or field.get("fieldId")
15698
+ or field.get("field_id")
15699
+ ).strip()
15700
+ if not name or name in seen:
15701
+ continue
15702
+ seen.add(name)
15703
+ group_by.append(name)
15704
+ return group_by
15705
+
15706
+
15707
+ def _public_chart_metrics_from_qingbi_config(config: dict[str, Any]) -> list[dict[str, Any]]:
15708
+ fields: list[dict[str, Any]] = []
15709
+ for key in ("selectedMetrics", "xMetrics", "yMetrics", "leftMetrics", "rightMetrics"):
15710
+ fields.extend(_chart_fields(config, key))
15711
+ metrics: list[dict[str, Any]] = []
15712
+ seen: set[tuple[str, str]] = set()
15713
+ for field in fields:
15714
+ field_id = _chart_field_id(field)
15715
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
15716
+ metric = {"op": "count", "expr": "count(*)"}
15717
+ else:
15718
+ op = str(field.get("aggreType") or field.get("aggregate") or "sum").strip().lower()
15719
+ if op == "average":
15720
+ op = "avg"
15721
+ field_name = _stringify_condition_value(
15722
+ field.get("fieldName")
15723
+ or field.get("field_name")
15724
+ or field.get("queTitle")
15725
+ or field.get("title")
15726
+ or field_id
15727
+ ).strip()
15728
+ metric = {"op": op or "sum", "field_name": field_name}
15729
+ if field_id:
15730
+ metric["field_id"] = field_id
15731
+ metric["expr"] = f"{metric['op']}({field_name})" if field_name else metric["op"]
15732
+ identity = (str(metric.get("op") or ""), str(metric.get("field_id") or metric.get("field_name") or metric.get("expr") or ""))
15733
+ if identity in seen:
15734
+ continue
15735
+ seen.add(identity)
15736
+ metrics.append(metric)
15737
+ return metrics
15738
+
15739
+
15264
15740
  def _public_chart_filter_operator_from_judge_type(judge_type: Any) -> str:
15265
15741
  normalized = _stringify_condition_value(judge_type).strip()
15266
15742
  mapping = {
@@ -15332,9 +15808,14 @@ def _build_public_chart_config_payload(
15332
15808
  ) -> dict[str, Any]:
15333
15809
  config = deepcopy(patch.config)
15334
15810
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
15335
- if "dimension_field_ids" in explicit_fields:
15811
+ semantic_dimension_fields = bool({"dimension_field_ids", "group_by", "rows", "columns"} & explicit_fields)
15812
+ semantic_metric_fields = bool(
15813
+ {"indicator_field_ids", "metric", "metrics", "x_metric", "y_metric", "left_metric", "right_metric", "value_metric", "target_metric"}
15814
+ & explicit_fields
15815
+ )
15816
+ if semantic_dimension_fields:
15336
15817
  config.pop("selectedDimensions", None)
15337
- if "indicator_field_ids" in explicit_fields:
15818
+ if semantic_metric_fields:
15338
15819
  config.pop("selectedMetrics", None)
15339
15820
  if "filters" in explicit_fields:
15340
15821
  config.pop("beforeAggregationFilterMatrix", None)
@@ -15360,8 +15841,8 @@ def _build_public_chart_config_payload(
15360
15841
  )
15361
15842
  query_condition_field_ids.append(_chart_field_id(field))
15362
15843
  backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
15363
- if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
15364
- raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
15844
+ if backend_chart_type == "gauge" and not patch.indicator_field_ids and not patch.metrics and "selectedMetrics" not in config:
15845
+ raise ValueError("gauge charts require at least one metric; pass value_metric or metric and the CLI will pair it with 数据总量")
15365
15846
  selected_dimensions = _build_public_dimension_fields(
15366
15847
  patch.dimension_field_ids,
15367
15848
  app_key=app_key,
@@ -15370,15 +15851,25 @@ def _build_public_chart_config_payload(
15370
15851
  qingbi_fields_by_id=qingbi_fields_by_id,
15371
15852
  chart_type=patch.chart_type.value,
15372
15853
  )
15373
- selected_metrics = _build_public_metric_fields(
15374
- patch.indicator_field_ids,
15375
- app_key=app_key,
15376
- field_lookup=field_lookup,
15377
- chart_field_lookup=chart_field_lookup,
15378
- qingbi_fields_by_id=qingbi_fields_by_id,
15379
- aggregate=aggregate,
15380
- chart_type=patch.chart_type.value,
15381
- )
15854
+ if patch.metrics:
15855
+ selected_metrics = _build_public_semantic_metric_fields(
15856
+ patch.metrics,
15857
+ app_key=app_key,
15858
+ field_lookup=field_lookup,
15859
+ chart_field_lookup=chart_field_lookup,
15860
+ qingbi_fields_by_id=qingbi_fields_by_id,
15861
+ chart_type=patch.chart_type.value,
15862
+ )
15863
+ else:
15864
+ selected_metrics = _build_public_metric_fields(
15865
+ patch.indicator_field_ids,
15866
+ app_key=app_key,
15867
+ field_lookup=field_lookup,
15868
+ chart_field_lookup=chart_field_lookup,
15869
+ qingbi_fields_by_id=qingbi_fields_by_id,
15870
+ aggregate=aggregate,
15871
+ chart_type=patch.chart_type.value,
15872
+ )
15382
15873
  payload: dict[str, Any] = {
15383
15874
  "chartName": patch.name,
15384
15875
  "chartType": backend_chart_type,
@@ -15403,7 +15894,15 @@ def _build_public_chart_config_payload(
15403
15894
  if backend_chart_type == "summary":
15404
15895
  payload.pop("selectedDimensions", None)
15405
15896
  payload.setdefault("xDimensions", deepcopy(selected_dimensions))
15406
- payload.setdefault("yDimensions", [])
15897
+ y_dimensions = _build_public_dimension_fields(
15898
+ patch.columns,
15899
+ app_key=app_key,
15900
+ field_lookup=field_lookup,
15901
+ chart_field_lookup=chart_field_lookup,
15902
+ qingbi_fields_by_id=qingbi_fields_by_id,
15903
+ chart_type=patch.chart_type.value,
15904
+ )
15905
+ payload.setdefault("yDimensions", y_dimensions)
15407
15906
  elif backend_chart_type == "scatter":
15408
15907
  x_metrics, y_metrics = _split_axis_metric_fields(selected_metrics)
15409
15908
  payload.pop("selectedMetrics", None)
@@ -15437,7 +15936,26 @@ def _build_public_chart_config_payload(
15437
15936
 
15438
15937
  def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
15439
15938
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
15440
- return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
15939
+ return bool(
15940
+ {
15941
+ "dimension_field_ids",
15942
+ "indicator_field_ids",
15943
+ "group_by",
15944
+ "rows",
15945
+ "columns",
15946
+ "metric",
15947
+ "metrics",
15948
+ "x_metric",
15949
+ "y_metric",
15950
+ "left_metric",
15951
+ "right_metric",
15952
+ "value_metric",
15953
+ "target_metric",
15954
+ "filters",
15955
+ "config",
15956
+ }
15957
+ & explicit_fields
15958
+ )
15441
15959
 
15442
15960
 
15443
15961
  def _chart_patch_dataset_source_type(patch: ChartUpsertPatch) -> str:
@@ -15697,6 +16215,7 @@ def _empty_portal_layout_diagnostics() -> dict[str, Any]:
15697
16215
  "section_count": 0,
15698
16216
  "explicit_position_count": 0,
15699
16217
  "max_pc_right": None,
16218
+ "standard_template_counts": {"metric_cards": 0, "bi_charts": 0, "views": 0},
15700
16219
  "safe_for_display": True,
15701
16220
  "warnings": [],
15702
16221
  }
@@ -15714,6 +16233,8 @@ def _portal_layout_diagnostics(
15714
16233
  diagnostics["explicit_position_count"] = explicit_count
15715
16234
  pc_positions: list[dict[str, Any]] = []
15716
16235
  warnings: list[dict[str, Any]] = []
16236
+ standard_counts = {"metric_cards": 0, "bi_charts": 0, "views": 0}
16237
+ has_business_grid = False
15717
16238
  for index, component in enumerate(components):
15718
16239
  if not isinstance(component, dict):
15719
16240
  continue
@@ -15728,9 +16249,28 @@ def _portal_layout_diagnostics(
15728
16249
  cols = int(pc.get("cols") or 0)
15729
16250
  rows = int(pc.get("rows") or 0)
15730
16251
  chart_type = str(metadata.get("chart_type") or "").strip().lower()
16252
+ role = str(metadata.get("role") or getattr(section, "role", "") or "").strip().lower() if section is not None else ""
15731
16253
  is_metric_chart = chart_type in {"target", "indicator"}
16254
+ if source_type == "chart" and (is_metric_chart or role in {"metric", "metrics", "indicator", "kpi"}):
16255
+ standard_counts["metric_cards"] += 1
16256
+ elif source_type == "chart":
16257
+ standard_counts["bi_charts"] += 1
16258
+ elif source_type == "view":
16259
+ standard_counts["views"] += 1
15732
16260
  min_chart_cols = 6 if is_metric_chart else 8
15733
16261
  min_chart_rows = 5 if is_metric_chart else 7
16262
+ if source_type == "grid":
16263
+ has_business_grid = True
16264
+ grid_config = component.get("gridConfig") if isinstance(component.get("gridConfig"), dict) else {}
16265
+ grid_items = grid_config.get("items") if isinstance(grid_config, dict) else None
16266
+ if not isinstance(grid_items, list) or not grid_items:
16267
+ warnings.append(_warning(
16268
+ "PORTAL_GRID_ITEMS_EMPTY",
16269
+ "grid portal section has no config.items; frontend will show an empty entry container",
16270
+ section_index=index,
16271
+ title=title,
16272
+ fix_hint="Pass config.items with entries such as {type:1,jumpMode:1,linkAppKey,linkFormType,title}.",
16273
+ ))
15734
16274
  if source_type == "chart" and (cols < min_chart_cols or rows < min_chart_rows):
15735
16275
  warnings.append(_warning(
15736
16276
  "PORTAL_CHART_CARD_TOO_SMALL",
@@ -15744,6 +16284,16 @@ def _portal_layout_diagnostics(
15744
16284
  chart_type=chart_type or None,
15745
16285
  pc=deepcopy(pc),
15746
16286
  ))
16287
+ if source_type == "chart" and role in {"metric", "metrics", "indicator", "kpi"} and not is_metric_chart:
16288
+ warnings.append(_warning(
16289
+ "PORTAL_METRIC_SECTION_CHART_TYPE_MISMATCH",
16290
+ "metric portal section must reference a target/indicator chart; create the missing metric chart before assembling the portal",
16291
+ section_index=index,
16292
+ title=title,
16293
+ role=role,
16294
+ chart_type=chart_type or None,
16295
+ fix_hint="Use app_charts_apply with chart_type=target and metric='count(*)' or another metric expression, then reference that chart.",
16296
+ ))
15747
16297
  if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
15748
16298
  warnings.append(_warning(
15749
16299
  "PORTAL_MOBILE_POSITION_MISSING",
@@ -15762,11 +16312,66 @@ def _portal_layout_diagnostics(
15762
16312
  max_pc_right=max_right,
15763
16313
  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.",
15764
16314
  ))
16315
+ standard_categories_present = sum(1 for count in standard_counts.values() if int(count or 0) > 0)
16316
+ _append_portal_standard_count_warnings(
16317
+ warnings=warnings,
16318
+ standard_counts=standard_counts,
16319
+ require_complete_standard=has_business_grid or standard_categories_present == 3,
16320
+ )
16321
+ diagnostics["standard_template_counts"] = standard_counts
15765
16322
  diagnostics["warnings"] = warnings
15766
- diagnostics["safe_for_display"] = not any(item.get("code") in {"PORTAL_LAYOUT_HALF_WIDTH", "PORTAL_CHART_CARD_TOO_SMALL"} for item in warnings)
16323
+ diagnostics["safe_for_display"] = not any(
16324
+ item.get("code") in {
16325
+ "PORTAL_LAYOUT_HALF_WIDTH",
16326
+ "PORTAL_CHART_CARD_TOO_SMALL",
16327
+ "PORTAL_GRID_ITEMS_EMPTY",
16328
+ "PORTAL_METRIC_SECTION_CHART_TYPE_MISMATCH",
16329
+ "PORTAL_STANDARD_METRIC_COUNT_OUT_OF_RANGE",
16330
+ "PORTAL_STANDARD_BI_COUNT_OUT_OF_RANGE",
16331
+ "PORTAL_STANDARD_VIEW_COUNT_OUT_OF_RANGE",
16332
+ }
16333
+ for item in warnings
16334
+ )
15767
16335
  return diagnostics
15768
16336
 
15769
16337
 
16338
+ def _append_portal_standard_count_warnings(
16339
+ *,
16340
+ warnings: list[dict[str, Any]],
16341
+ standard_counts: dict[str, int],
16342
+ require_complete_standard: bool = False,
16343
+ ) -> None:
16344
+ metric_count = int(standard_counts.get("metric_cards") or 0)
16345
+ bi_count = int(standard_counts.get("bi_charts") or 0)
16346
+ view_count = int(standard_counts.get("views") or 0)
16347
+ if not require_complete_standard:
16348
+ return
16349
+ if (metric_count or require_complete_standard) and not 4 <= metric_count <= 6:
16350
+ warnings.append(_warning(
16351
+ "PORTAL_STANDARD_METRIC_COUNT_OUT_OF_RANGE",
16352
+ "standard portal metric area should contain 4-6 metric cards",
16353
+ actual_count=metric_count,
16354
+ expected_count="4-6",
16355
+ fix_hint="Create missing target/indicator charts first, or keep 4 metric cards in one row with pc.cols=6, pc.rows=5.",
16356
+ ))
16357
+ if (bi_count or require_complete_standard) and not 2 <= bi_count <= 3:
16358
+ warnings.append(_warning(
16359
+ "PORTAL_STANDARD_BI_COUNT_OUT_OF_RANGE",
16360
+ "standard portal BI area should contain 2-3 visualization charts",
16361
+ actual_count=bi_count,
16362
+ expected_count="2-3",
16363
+ fix_hint="Use two half-width charts or three one-third-width charts with pc.rows=7.",
16364
+ ))
16365
+ if (view_count or require_complete_standard) and not 1 <= view_count <= 2:
16366
+ warnings.append(_warning(
16367
+ "PORTAL_STANDARD_VIEW_COUNT_OUT_OF_RANGE",
16368
+ "standard portal data view area should contain 1-2 business views",
16369
+ actual_count=view_count,
16370
+ expected_count="1-2",
16371
+ fix_hint="Reference 1-2 business views and avoid default 全部数据 / 我的数据 views as the main portal table.",
16372
+ ))
16373
+
16374
+
15770
16375
  def _portal_layout_warning_items(layout_diagnostics: dict[str, Any]) -> list[dict[str, Any]]:
15771
16376
  warnings = layout_diagnostics.get("warnings") if isinstance(layout_diagnostics, dict) else None
15772
16377
  return [deepcopy(item) for item in warnings if isinstance(item, dict)] if isinstance(warnings, list) else []
@@ -16603,6 +17208,149 @@ def _verify_relation_readback_by_name(
16603
17208
  return True
16604
17209
 
16605
17210
 
17211
+ def _relation_field_names(values: object) -> list[str]:
17212
+ names: list[str] = []
17213
+ if not isinstance(values, list):
17214
+ return names
17215
+ for item in values:
17216
+ if not isinstance(item, dict):
17217
+ continue
17218
+ name = str(item.get("name") or "").strip()
17219
+ if name:
17220
+ names.append(name)
17221
+ return names
17222
+
17223
+
17224
+ def _relation_field_public_selector(value: object) -> dict[str, Any] | None:
17225
+ if not isinstance(value, dict):
17226
+ return None
17227
+ return {
17228
+ "name": str(value.get("name") or "").strip() or None,
17229
+ "que_id": _coerce_nonnegative_int(value.get("que_id")),
17230
+ "field_id": str(value.get("field_id") or "").strip() or None,
17231
+ }
17232
+
17233
+
17234
+ def _schema_relation_readback_matrix(
17235
+ *,
17236
+ expected_fields: list[dict[str, Any]],
17237
+ verified_fields: list[dict[str, Any]],
17238
+ changed_field_names: set[str],
17239
+ degraded_expectations: list[dict[str, Any]],
17240
+ ) -> list[dict[str, Any]]:
17241
+ degraded_by_name = {
17242
+ str(item.get("field_name") or "").strip(): item
17243
+ for item in degraded_expectations
17244
+ if isinstance(item, dict) and str(item.get("field_name") or "").strip()
17245
+ }
17246
+ relation_names = {
17247
+ str(field.get("name") or "").strip()
17248
+ for field in expected_fields
17249
+ if isinstance(field, dict)
17250
+ and str(field.get("type") or "") == FieldType.relation.value
17251
+ and str(field.get("name") or "").strip() in changed_field_names
17252
+ }
17253
+ relation_names.update(name for name in degraded_by_name if name)
17254
+ if not relation_names:
17255
+ return []
17256
+
17257
+ expected_by_name = {
17258
+ str(field.get("name") or "").strip(): field
17259
+ for field in expected_fields
17260
+ if isinstance(field, dict)
17261
+ and str(field.get("type") or "") == FieldType.relation.value
17262
+ and str(field.get("name") or "").strip()
17263
+ }
17264
+ verified_by_name = {
17265
+ str(field.get("name") or "").strip(): field
17266
+ for field in verified_fields
17267
+ if isinstance(field, dict) and str(field.get("name") or "").strip()
17268
+ }
17269
+ rows: list[dict[str, Any]] = []
17270
+ for field_name in sorted(relation_names):
17271
+ expected = expected_by_name.get(field_name)
17272
+ actual = verified_by_name.get(field_name)
17273
+ degraded = degraded_by_name.get(field_name)
17274
+ expected_target = str((expected or degraded or {}).get("target_app_key") or "").strip() or None
17275
+ actual_target = str((actual or {}).get("target_app_key") or "").strip() or None
17276
+ expected_mode = _normalize_relation_mode((expected or degraded or {}).get("relation_mode"))
17277
+ actual_mode = _normalize_relation_mode((actual or {}).get("relation_mode"))
17278
+ expected_display = _relation_field_public_selector((expected or degraded or {}).get("display_field"))
17279
+ actual_display = _relation_field_public_selector((actual or {}).get("display_field"))
17280
+ expected_visible_names = _relation_field_names((expected or degraded or {}).get("visible_fields"))
17281
+ actual_visible_names = _relation_field_names((actual or {}).get("visible_fields"))
17282
+
17283
+ checks = {
17284
+ "field_exists": isinstance(actual, dict),
17285
+ "target_app_key": expected_target == actual_target,
17286
+ "relation_mode": expected_mode == actual_mode,
17287
+ "display_field": (expected_display or {}).get("name") == (actual_display or {}).get("name"),
17288
+ "visible_fields": expected_visible_names == actual_visible_names,
17289
+ }
17290
+ readback_verified = all(checks.values())
17291
+ if not isinstance(actual, dict):
17292
+ status = "missing"
17293
+ elif readback_verified and degraded is not None:
17294
+ status = "matched_by_name"
17295
+ elif readback_verified:
17296
+ status = "matched"
17297
+ else:
17298
+ status = "mismatch"
17299
+ rows.append(
17300
+ {
17301
+ "field_name": field_name,
17302
+ "readback_status": status,
17303
+ "readback_verified": readback_verified,
17304
+ "metadata_verified": degraded is None,
17305
+ "checks": checks,
17306
+ "expected": {
17307
+ "target_app_key": expected_target,
17308
+ "relation_mode": expected_mode,
17309
+ "display_field": expected_display,
17310
+ "visible_fields": expected_visible_names,
17311
+ },
17312
+ "actual": {
17313
+ "target_app_key": actual_target,
17314
+ "relation_mode": actual_mode,
17315
+ "display_field": actual_display,
17316
+ "visible_fields": actual_visible_names,
17317
+ },
17318
+ "data_impact": (
17319
+ "none_detected"
17320
+ if readback_verified
17321
+ else "relation config mismatch can affect existing referenced values; inspect existing records before changing target_app_key or display fields"
17322
+ ),
17323
+ }
17324
+ )
17325
+ return rows
17326
+
17327
+
17328
+ def _schema_relation_repair_plan(relation_readback_matrix: list[dict[str, Any]]) -> list[dict[str, Any]]:
17329
+ plan: list[dict[str, Any]] = []
17330
+ for row in relation_readback_matrix:
17331
+ if bool(row.get("readback_verified")):
17332
+ continue
17333
+ expected = row.get("expected") if isinstance(row.get("expected"), dict) else {}
17334
+ plan.append(
17335
+ {
17336
+ "field_name": row.get("field_name"),
17337
+ "mode": "update_fields_relation_patch",
17338
+ "next_action": "Use app_schema_apply update_fields with selector.name and set target_app_key/relation_mode/display_field/visible_fields, then read back relation_readback_matrix again.",
17339
+ "suggested_patch": {
17340
+ "selector": {"name": row.get("field_name")},
17341
+ "set": {
17342
+ "target_app_key": expected.get("target_app_key"),
17343
+ "relation_mode": expected.get("relation_mode"),
17344
+ "display_field": expected.get("display_field"),
17345
+ "visible_fields": [{"name": name} for name in cast(list[Any], expected.get("visible_fields") or [])],
17346
+ },
17347
+ },
17348
+ "data_impact": row.get("data_impact"),
17349
+ }
17350
+ )
17351
+ return plan
17352
+
17353
+
16606
17354
  def _relation_target_metadata_skip_outcome(*, degraded_entries: list[dict[str, Any]]) -> PermissionCheckOutcome | None:
16607
17355
  if not degraded_entries:
16608
17356
  return None