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

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.
@@ -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,
@@ -7185,6 +7186,8 @@ class AiBuilderFacade:
7185
7186
  base=deepcopy(base) if isinstance(base, dict) else {},
7186
7187
  visibility=_public_visibility_from_chart_visible_auth(base.get("visibleAuth")),
7187
7188
  filters=_public_chart_filter_groups_from_qingbi_config(config) if isinstance(config, dict) else [],
7189
+ group_by=_public_chart_group_by_from_qingbi_config(config) if isinstance(config, dict) else [],
7190
+ metrics=_public_chart_metrics_from_qingbi_config(config) if isinstance(config, dict) else [],
7188
7191
  config=deepcopy(config) if isinstance(config, dict) else {},
7189
7192
  )
7190
7193
  return {
@@ -7901,6 +7904,10 @@ class AiBuilderFacade:
7901
7904
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
7902
7905
  resolved["normalized_args"] = normalized_args
7903
7906
  return finalize(resolved)
7907
+ if resolved.get("status") == "partial_success" and not str(resolved.get("app_key") or "").strip():
7908
+ if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
7909
+ resolved["normalized_args"] = normalized_args
7910
+ return finalize(resolved)
7904
7911
  resolved_outcome = _permission_outcome_from_result(resolved)
7905
7912
  if resolved_outcome is not None:
7906
7913
  permission_outcomes.append(resolved_outcome)
@@ -7958,27 +7965,35 @@ class AiBuilderFacade:
7958
7965
  "request_id": None,
7959
7966
  }
7960
7967
  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",
7968
+ shell_readback_pending = (
7969
+ resolved.get("status") == "partial_success"
7970
+ or bool((resolved.get("verification") if isinstance(resolved.get("verification"), dict) else {}).get("readback_unavailable"))
7971
+ or str(resolved.get("next_action") or "") == "readback_before_retry"
7972
+ )
7973
+ shell_verification = {
7974
+ "fields_verified": not shell_readback_pending,
7975
+ "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7976
+ "relation_field_limit_verified": True,
7977
+ "app_visuals_verified": not shell_readback_pending,
7978
+ "app_base_verified": not shell_readback_pending,
7979
+ "publish_skipped": True,
7980
+ }
7981
+ if isinstance(resolved.get("verification"), dict):
7982
+ shell_verification.update(deepcopy(resolved.get("verification") or {}))
7983
+ created_shell_response = {
7984
+ "status": "partial_success" if shell_readback_pending else "success",
7985
+ "error_code": resolved.get("error_code") if shell_readback_pending else None,
7986
+ "recoverable": bool(shell_readback_pending),
7987
+ "message": str(resolved.get("message") or "created app shell") if shell_readback_pending else "created app shell",
7966
7988
  "normalized_args": normalized_args,
7967
7989
  "missing_fields": [],
7968
7990
  "allowed_values": {"field_types": [item.value for item in PublicFieldType]},
7969
7991
  "details": {"publish_skipped": True},
7970
- "request_id": None,
7992
+ "request_id": resolved.get("request_id"),
7971
7993
  "suggested_next_call": None,
7972
7994
  "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
- },
7995
+ "warnings": deepcopy(resolved.get("warnings") or []),
7996
+ "verification": shell_verification,
7982
7997
  "app_key": target.app_key,
7983
7998
  "app_icon": str(resolved.get("app_icon") or visual_result.get("app_icon") or "").strip() or None,
7984
7999
  "app_name": str(visual_result.get("app_name_after") or target.app_name),
@@ -7989,19 +8004,28 @@ class AiBuilderFacade:
7989
8004
  "field_diff": {"added": [], "updated": [], "removed": []},
7990
8005
  "verified": True,
7991
8006
  "write_executed": True,
7992
- "write_succeeded": True,
8007
+ "write_succeeded": not shell_readback_pending or bool(resolved.get("write_succeeded", True)),
7993
8008
  "safe_to_retry": False,
7994
8009
  "tag_ids_after": list(target.tag_ids),
7995
8010
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7996
8011
  "publish_requested": False,
7997
8012
  "published": False,
7998
- })
8013
+ }
8014
+ if shell_readback_pending:
8015
+ created_shell_response["write_may_have_succeeded"] = True
8016
+ created_shell_response["next_action"] = "readback_before_retry"
8017
+ created_shell_response["suggested_next_call"] = resolved.get("suggested_next_call") or {
8018
+ "tool_name": "app_get",
8019
+ "arguments": {"profile": profile, "app_key": target.app_key},
8020
+ }
8021
+ return finalize(created_shell_response)
7999
8022
  schema_readback_delayed = False
8023
+ schema_readback_delayed_error: JSONObject | None = None
8000
8024
  try:
8001
8025
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
8002
8026
  except (QingflowApiError, RuntimeError) as error:
8003
8027
  api_error = _coerce_api_error(error)
8004
- if not bool(resolved.get("created")) or api_error.http_status != 404:
8028
+ if not bool(resolved.get("created")):
8005
8029
  return finalize(_failed_from_api_error(
8006
8030
  "SCHEMA_READBACK_FAILED",
8007
8031
  api_error,
@@ -8013,6 +8037,10 @@ class AiBuilderFacade:
8013
8037
  schema_result = _empty_schema_result(effective_app_name)
8014
8038
  _schema_source = "synthetic_new_app"
8015
8039
  schema_readback_delayed = True
8040
+ schema_readback_delayed_error = {
8041
+ "message": api_error.message,
8042
+ **_transport_error_payload(api_error),
8043
+ }
8016
8044
  parsed = _parse_schema(schema_result)
8017
8045
  current_fields = parsed["fields"]
8018
8046
  original_fields = deepcopy(current_fields)
@@ -8344,6 +8372,47 @@ class AiBuilderFacade:
8344
8372
  http_status=None if api_error.http_status == 404 else api_error.http_status,
8345
8373
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
8346
8374
  )
8375
+ if _is_uncertain_write_transport_error(api_error):
8376
+ uncertain = _post_write_may_have_succeeded_result(
8377
+ error_code="SCHEMA_WRITE_RESULT_UNCERTAIN",
8378
+ message="schema write request did not return a final result; read the app schema before retrying",
8379
+ normalized_args=normalized_args,
8380
+ details={
8381
+ "app_key": target.app_key,
8382
+ "app_name": effective_app_name,
8383
+ "created": bool(resolved.get("created")),
8384
+ "field_diff": {"added": added, "updated": updated, "removed": removed},
8385
+ "transport_error": _transport_error_payload(api_error),
8386
+ },
8387
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
8388
+ request_id=api_error.request_id,
8389
+ backend_code=api_error.backend_code,
8390
+ http_status=api_error.http_status,
8391
+ )
8392
+ uncertain.update(
8393
+ {
8394
+ "app_key": target.app_key,
8395
+ "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
8396
+ "app_name": effective_app_name,
8397
+ "app_name_before": str(visual_result.get("app_name_before") or target.app_name),
8398
+ "app_name_after": effective_app_name,
8399
+ "app_base_updated": bool(visual_result.get("updated")),
8400
+ "created": bool(resolved.get("created")),
8401
+ "field_diff": {"added": added, "updated": updated, "removed": removed},
8402
+ "field_diff_details": _schema_field_diff_details(
8403
+ added=added,
8404
+ updated=updated,
8405
+ removed=removed,
8406
+ before_fields=original_fields,
8407
+ after_fields=current_fields,
8408
+ ),
8409
+ "tag_ids_after": list(target.tag_ids),
8410
+ "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
8411
+ "publish_requested": False,
8412
+ "published": False,
8413
+ }
8414
+ )
8415
+ return finalize(uncertain)
8347
8416
  return _failed_from_api_error(
8348
8417
  "SCHEMA_APPLY_FAILED",
8349
8418
  api_error,
@@ -8508,6 +8577,8 @@ class AiBuilderFacade:
8508
8577
  response["normalized_code_block_fields"] = normalized_code_block_fields
8509
8578
  if schema_readback_delayed:
8510
8579
  response["verification"]["schema_readback_delayed"] = True
8580
+ if schema_readback_delayed_error is not None:
8581
+ response["details"]["schema_readback_delayed_error"] = schema_readback_delayed_error
8511
8582
  response = _apply_permission_outcomes(response, relation_permission_outcome)
8512
8583
  response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
8513
8584
  verification_ok = False
@@ -8517,14 +8588,29 @@ class AiBuilderFacade:
8517
8588
  try:
8518
8589
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
8519
8590
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
8591
+ verified_fields = cast(list[dict[str, Any]], verified["schema"]["fields"])
8520
8592
  response["field_diff_details"] = _schema_field_diff_details(
8521
8593
  added=added,
8522
8594
  updated=updated,
8523
8595
  removed=removed,
8524
8596
  before_fields=original_fields,
8525
- after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
8597
+ after_fields=verified_fields,
8526
8598
  )
8527
8599
  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)
8600
+ relation_readback_matrix = _schema_relation_readback_matrix(
8601
+ expected_fields=current_fields,
8602
+ verified_fields=verified_fields,
8603
+ changed_field_names=set(added + updated),
8604
+ degraded_expectations=relation_degraded_expectations,
8605
+ )
8606
+ if relation_readback_matrix:
8607
+ relation_matrix_verified = all(bool(item.get("readback_verified")) for item in relation_readback_matrix)
8608
+ response["details"]["relation_readback_matrix"] = relation_readback_matrix
8609
+ response["verification"]["relation_readback_matrix_verified"] = relation_matrix_verified
8610
+ verification_ok = verification_ok and relation_matrix_verified
8611
+ relation_repair_plan = _schema_relation_repair_plan(relation_readback_matrix)
8612
+ if relation_repair_plan:
8613
+ response["details"]["relation_repair_plan"] = relation_repair_plan
8528
8614
  data_display_verification = _verify_data_display_readback(
8529
8615
  form_settings=verified.get("form_settings"),
8530
8616
  selection=data_display_selection,
@@ -8591,12 +8677,18 @@ class AiBuilderFacade:
8591
8677
  response["recoverable"] = True
8592
8678
  response["error_code"] = response.get("error_code") or "APP_BASE_READBACK_PENDING"
8593
8679
  response["message"] = f"{response.get('message') or 'apply succeeded'}; app base readback pending"
8680
+ response["write_may_have_succeeded"] = True
8681
+ response["next_action"] = "readback_before_retry"
8682
+ response["verification"]["readback_before_retry"] = True
8594
8683
  if verification_error is not None:
8595
8684
  response["recoverable"] = True
8596
8685
  response["error_code"] = response.get("error_code") or (
8597
8686
  "READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
8598
8687
  )
8599
8688
  response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
8689
+ response["write_may_have_succeeded"] = True
8690
+ response["next_action"] = "readback_before_retry"
8691
+ response["verification"]["readback_before_retry"] = True
8600
8692
  response["request_id"] = response.get("request_id") or verification_error.request_id
8601
8693
  details = response.get("details")
8602
8694
  if not isinstance(details, dict):
@@ -12374,6 +12466,28 @@ class AiBuilderFacade:
12374
12466
  except (QingflowApiError, RuntimeError) as error:
12375
12467
  api_error = _coerce_api_error(error)
12376
12468
  request_route = self._current_request_route(profile)
12469
+ if _is_uncertain_write_transport_error(api_error):
12470
+ return _post_write_may_have_succeeded_result(
12471
+ error_code="APP_CREATE_WRITE_RESULT_UNCERTAIN",
12472
+ message="app create request did not return a final result; resolve the app in the package before retrying",
12473
+ details={
12474
+ "app_name": app_name,
12475
+ "package_tag_id": package_tag_id,
12476
+ "request_route": request_route,
12477
+ "transport_error": _transport_error_payload(api_error),
12478
+ },
12479
+ suggested_next_call={
12480
+ "tool_name": "app_resolve",
12481
+ "arguments": {
12482
+ "profile": profile,
12483
+ "app_name": app_name,
12484
+ "package_id": package_tag_id,
12485
+ },
12486
+ },
12487
+ request_id=api_error.request_id,
12488
+ backend_code=api_error.backend_code,
12489
+ http_status=api_error.http_status,
12490
+ )
12377
12491
  return _failed_from_api_error(
12378
12492
  "CREATE_APP_ROUTE_NOT_FOUND" if api_error.http_status == 404 else "APP_CREATE_FAILED",
12379
12493
  api_error,
@@ -12397,12 +12511,29 @@ class AiBuilderFacade:
12397
12511
  except (QingflowApiError, RuntimeError) as error:
12398
12512
  api_error = _coerce_api_error(error)
12399
12513
  if api_error.http_status != 404:
12400
- return _failed_from_api_error(
12401
- "APP_CREATE_READBACK_FAILED",
12402
- api_error,
12514
+ pending = _post_write_readback_pending_result(
12515
+ error_code="APP_CREATE_READBACK_PENDING",
12516
+ message="created app; base readback is unavailable",
12403
12517
  details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
12404
12518
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": new_app_key}},
12519
+ request_id=api_error.request_id,
12520
+ backend_code=api_error.backend_code,
12521
+ http_status=api_error.http_status,
12405
12522
  )
12523
+ pending.update(
12524
+ {
12525
+ "app_key": new_app_key,
12526
+ "app_name": app_name or "未命名应用",
12527
+ "app_icon": payload.get("appIcon"),
12528
+ "tag_ids": [package_tag_id] if package_tag_id and package_tag_id > 0 else [],
12529
+ "created": True,
12530
+ }
12531
+ )
12532
+ pending.setdefault("details", {})["readback_error"] = {
12533
+ "message": api_error.message,
12534
+ **_transport_error_payload(api_error),
12535
+ }
12536
+ return pending
12406
12537
  return {
12407
12538
  "status": "success",
12408
12539
  "error_code": None,
@@ -12610,7 +12741,7 @@ class AiBuilderFacade:
12610
12741
  **deepcopy(section.config),
12611
12742
  }
12612
12743
  component = {"type": 9, "position": position_payload, "chartConfig": _compact_dict(chart_config)}
12613
- layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type})
12744
+ layout_metadata.append({"source_type": section.source_type, "chart_type": chart_type, "role": section.role})
12614
12745
  elif section.source_type == "view":
12615
12746
  resolved_view = _resolve_view_reference(
12616
12747
  facade=self,
@@ -12633,27 +12764,27 @@ class AiBuilderFacade:
12633
12764
  **deepcopy(section.config),
12634
12765
  }
12635
12766
  component = {"type": 10, "position": position_payload, "viewgraphConfig": _compact_dict(view_config)}
12636
- layout_metadata.append({"source_type": section.source_type})
12767
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12637
12768
  elif section.source_type == "grid":
12638
12769
  component = {
12639
12770
  "type": 2,
12640
12771
  "position": position_payload,
12641
12772
  "gridConfig": _compact_dict({"gridTitle": section.title, "beingShowTitle": True, **deepcopy(section.config)}),
12642
12773
  }
12643
- layout_metadata.append({"source_type": section.source_type})
12774
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12644
12775
  elif section.source_type == "filter":
12645
12776
  component = {"type": 6, "position": position_payload, "filterConfig": deepcopy(section.config)}
12646
- layout_metadata.append({"source_type": section.source_type})
12777
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12647
12778
  elif section.source_type == "text":
12648
12779
  component = {"type": 5, "position": position_payload, "textConfig": {"text": section.text or "", **deepcopy(section.config)}}
12649
- layout_metadata.append({"source_type": section.source_type})
12780
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12650
12781
  else:
12651
12782
  component = {
12652
12783
  "type": 4,
12653
12784
  "position": position_payload,
12654
12785
  "linkConfig": {"url": section.url or "", "beingLoginAuth": False, **deepcopy(section.config)},
12655
12786
  }
12656
- layout_metadata.append({"source_type": section.source_type})
12787
+ layout_metadata.append({"source_type": section.source_type, "role": section.role})
12657
12788
  if dash_style is not None:
12658
12789
  component["dashStyleConfigBO"] = dash_style
12659
12790
  resolved_components.append(component)
@@ -13789,7 +13920,7 @@ def _post_write_readback_pending_result(
13789
13920
  ):
13790
13921
  if value is not None:
13791
13922
  warning[key] = value
13792
- return {
13923
+ result = {
13793
13924
  "status": "partial_success",
13794
13925
  "error_code": error_code,
13795
13926
  "recoverable": True,
@@ -13811,7 +13942,64 @@ def _post_write_readback_pending_result(
13811
13942
  "verified": False,
13812
13943
  "write_executed": True,
13813
13944
  "write_succeeded": True,
13945
+ "write_may_have_succeeded": True,
13814
13946
  "safe_to_retry": False,
13947
+ "next_action": "readback_before_retry",
13948
+ }
13949
+ result["verification"]["readback_before_retry"] = True
13950
+ return result
13951
+
13952
+
13953
+ def _post_write_may_have_succeeded_result(
13954
+ *,
13955
+ error_code: str,
13956
+ message: str,
13957
+ normalized_args: JSONObject | None = None,
13958
+ details: JSONObject | None = None,
13959
+ suggested_next_call: JSONObject | None = None,
13960
+ request_id: str | None = None,
13961
+ backend_code: Any = None,
13962
+ http_status: int | None = None,
13963
+ ) -> JSONObject:
13964
+ effective_details = details or {}
13965
+ transport_error = _readback_transport_error_from_details(effective_details)
13966
+ effective_backend_code = backend_code if backend_code is not None else (transport_error or {}).get("backend_code")
13967
+ effective_http_status = http_status if http_status is not None else (transport_error or {}).get("http_status")
13968
+ effective_request_id = request_id if request_id is not None else (transport_error or {}).get("request_id")
13969
+ warning = _warning("WRITE_RESULT_UNCERTAIN", "write request may have succeeded but no final response was received")
13970
+ for key, value in (
13971
+ ("backend_code", effective_backend_code),
13972
+ ("http_status", effective_http_status),
13973
+ ("request_id", effective_request_id),
13974
+ ):
13975
+ if value is not None:
13976
+ warning[key] = value
13977
+ return {
13978
+ "status": "partial_success",
13979
+ "error_code": error_code,
13980
+ "recoverable": True,
13981
+ "message": message,
13982
+ "normalized_args": normalized_args or {},
13983
+ "missing_fields": [],
13984
+ "allowed_values": {},
13985
+ "details": effective_details,
13986
+ "suggested_next_call": suggested_next_call,
13987
+ "request_id": effective_request_id,
13988
+ "backend_code": effective_backend_code,
13989
+ "http_status": effective_http_status,
13990
+ "noop": False,
13991
+ "warnings": [warning],
13992
+ "verification": {
13993
+ "readback_unavailable": True,
13994
+ "metadata_unverified": True,
13995
+ "readback_before_retry": True,
13996
+ },
13997
+ "verified": False,
13998
+ "write_executed": True,
13999
+ "write_succeeded": False,
14000
+ "write_may_have_succeeded": True,
14001
+ "safe_to_retry": False,
14002
+ "next_action": "readback_before_retry",
13815
14003
  }
13816
14004
 
13817
14005
 
@@ -13859,6 +14047,32 @@ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
13859
14047
  }
13860
14048
 
13861
14049
 
14050
+ def _is_uncertain_write_transport_error(error: QingflowApiError) -> bool:
14051
+ if is_auth_like_error(error):
14052
+ return False
14053
+ category = str(error.category or "").strip().lower()
14054
+ message = str(error.message or "").strip().lower()
14055
+ if category == "timeout":
14056
+ return True
14057
+ if category != "network":
14058
+ return False
14059
+ return any(
14060
+ marker in message
14061
+ for marker in (
14062
+ "timeout",
14063
+ "timed out",
14064
+ "read timed out",
14065
+ "write timed out",
14066
+ "readtimeout",
14067
+ "writetimeout",
14068
+ "server disconnected",
14069
+ "connection reset",
14070
+ "remote protocol error",
14071
+ "response ended prematurely",
14072
+ )
14073
+ )
14074
+
14075
+
13862
14076
  def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
13863
14077
  if is_auth_like_error(error):
13864
14078
  return False
@@ -14317,7 +14531,29 @@ _CHART_PARTIAL_PATCH_KEY_ALIASES = {
14317
14531
  "indicator_field_ids": "indicator_field_ids",
14318
14532
  "indicatorFieldIds": "indicator_field_ids",
14319
14533
  "metric_field_ids": "indicator_field_ids",
14534
+ "group_by": "group_by",
14535
+ "groupBy": "group_by",
14536
+ "dimensions": "group_by",
14537
+ "rows": "rows",
14538
+ "columns": "columns",
14539
+ "metric": "metric",
14540
+ "metrics": "metrics",
14541
+ "x_metric": "x_metric",
14542
+ "xMetric": "x_metric",
14543
+ "y_metric": "y_metric",
14544
+ "yMetric": "y_metric",
14545
+ "left_metric": "left_metric",
14546
+ "leftMetric": "left_metric",
14547
+ "right_metric": "right_metric",
14548
+ "rightMetric": "right_metric",
14549
+ "value_metric": "value_metric",
14550
+ "valueMetric": "value_metric",
14551
+ "target_metric": "target_metric",
14552
+ "targetMetric": "target_metric",
14553
+ "where": "filters",
14320
14554
  "filters": "filters",
14555
+ "filter_rules": "filters",
14556
+ "filterRules": "filters",
14321
14557
  "question_config": "question_config",
14322
14558
  "questionConfig": "question_config",
14323
14559
  "user_config": "user_config",
@@ -14331,6 +14567,17 @@ _CHART_PARTIAL_SET_KEYS = {
14331
14567
  "chart_type",
14332
14568
  "dimension_field_ids",
14333
14569
  "indicator_field_ids",
14570
+ "group_by",
14571
+ "rows",
14572
+ "columns",
14573
+ "metric",
14574
+ "metrics",
14575
+ "x_metric",
14576
+ "y_metric",
14577
+ "left_metric",
14578
+ "right_metric",
14579
+ "value_metric",
14580
+ "target_metric",
14334
14581
  "filters",
14335
14582
  "question_config",
14336
14583
  "user_config",
@@ -14717,12 +14964,43 @@ def _compact_public_chart_fields_read(
14717
14964
  "field_type": field.get("fieldType") or field.get("field_type"),
14718
14965
  "system_field": bool(que_id is not None and not isinstance(form_field, dict)),
14719
14966
  "available_for_charts": True,
14967
+ "chart_apply_examples": _chart_apply_examples_for_field(
14968
+ title=title,
14969
+ field_type=field.get("fieldType") or field.get("field_type"),
14970
+ ),
14720
14971
  }
14721
14972
  )
14722
14973
  )
14723
14974
  return compact_fields
14724
14975
 
14725
14976
 
14977
+ def _chart_apply_examples_for_field(*, title: str, field_type: Any) -> dict[str, Any]:
14978
+ field_name = str(title or "").strip()
14979
+ if not field_name:
14980
+ return {}
14981
+ examples: dict[str, Any] = {
14982
+ "count_by_field": {
14983
+ "name": f"按{field_name}分布",
14984
+ "chart_type": "bar",
14985
+ "group_by": [field_name],
14986
+ "metric": "count(*)",
14987
+ },
14988
+ "filtered_count": {
14989
+ "name": f"{field_name}筛选数量",
14990
+ "chart_type": "target",
14991
+ "metric": "count(*)",
14992
+ "where": [{"field": field_name, "op": "eq", "value": "REPLACE_WITH_VALUE"}],
14993
+ },
14994
+ }
14995
+ if str(field_type or "").strip().lower() in _QINGBI_DECIMAL_FIELD_TYPES:
14996
+ examples["sum_metric"] = {
14997
+ "name": f"{field_name}合计",
14998
+ "chart_type": "target",
14999
+ "metric": f"sum({field_name})",
15000
+ }
15001
+ return examples
15002
+
15003
+
14726
15004
  def _chart_field_candidates(
14727
15005
  selector: Any,
14728
15006
  *,
@@ -15031,37 +15309,80 @@ def _build_public_metric_fields(
15031
15309
  metrics: list[dict[str, Any]] = []
15032
15310
  for selector in selectors:
15033
15311
  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)
15312
+ metrics.append(_public_qingbi_metric_field(qingbi_field, aggregate=normalized_aggregate))
15313
+ return metrics or [_default_public_total_metric()]
15314
+
15315
+
15316
+ def _public_qingbi_metric_field(qingbi_field: dict[str, Any], *, aggregate: str) -> dict[str, Any]:
15317
+ field_id = _chart_field_id(qingbi_field)
15318
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
15319
+ return deepcopy(qingbi_field)
15320
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
15321
+ normalized_aggregate = str(aggregate or "sum").strip().lower()
15322
+ aggre_type = {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min"}.get(normalized_aggregate, "sum")
15323
+ return {
15324
+ "fieldId": field_id,
15325
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
15326
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
15327
+ "orderType": "default",
15328
+ "alignType": "left",
15329
+ "dateFormat": "yyyy-MM-dd",
15330
+ "numberFormat": "default",
15331
+ "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
15332
+ "digit": None,
15333
+ "aggreType": aggre_type,
15334
+ "orderPriority": None,
15335
+ "width": None,
15336
+ "verticalAlign": "middle",
15337
+ "formula": qingbi_field.get("formula"),
15338
+ "fieldSource": qingbi_field.get("fieldSource") or "default",
15339
+ "status": qingbi_field.get("status"),
15340
+ "supId": qingbi_field.get("supId"),
15341
+ "beingTable": bool(qingbi_field.get("beingTable", False)),
15342
+ "returnType": qingbi_field.get("returnType"),
15343
+ "biFormulaType": qingbi_field.get("biFormulaType"),
15344
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
15345
+ }
15346
+
15347
+
15348
+ def _build_public_semantic_metric_fields(
15349
+ metrics: list[ChartMetricPatch],
15350
+ *,
15351
+ app_key: str,
15352
+ field_lookup: dict[str, dict[str, Any]],
15353
+ chart_field_lookup: dict[str, Any],
15354
+ qingbi_fields_by_id: dict[str, dict[str, Any]],
15355
+ chart_type: str = "chart",
15356
+ ) -> list[dict[str, Any]]:
15357
+ if not metrics:
15358
+ return [_default_public_total_metric()]
15359
+ selected_metrics: list[dict[str, Any]] = []
15360
+ for metric in metrics:
15361
+ op = str(metric.op or "count").strip().lower()
15362
+ field_name = str(metric.field_name or "").strip()
15363
+ if op == "count":
15364
+ if field_name:
15365
+ _raise_chart_rule(
15366
+ rule_code="CHART_COUNT_FIELD_UNSUPPORTED",
15367
+ chart_type=chart_type,
15368
+ message="count metric currently supports count(*) only",
15369
+ expected='Use metric: "count(*)" or {"op": "count"} for record count.',
15370
+ actual={"metric": metric.model_dump(mode="json")},
15371
+ next_action='Use count(*) for count cards; use sum(field), avg(field), max(field), or min(field) for field aggregation.',
15372
+ )
15373
+ selected_metrics.append(_default_public_total_metric())
15037
15374
  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
- }
15375
+ qingbi_field = _resolve_qingbi_chart_field(
15376
+ field_name,
15377
+ chart_field_lookup=chart_field_lookup,
15378
+ chart_type=chart_type,
15379
+ role="metric",
15063
15380
  )
15064
- return metrics or [_default_public_total_metric()]
15381
+ metric_payload = _public_qingbi_metric_field(qingbi_field, aggregate=op)
15382
+ if metric.alias:
15383
+ metric_payload["fieldName"] = metric.alias
15384
+ selected_metrics.append(metric_payload)
15385
+ return selected_metrics or [_default_public_total_metric()]
15065
15386
 
15066
15387
 
15067
15388
  def _split_axis_metric_fields(metrics: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
@@ -15261,6 +15582,61 @@ def _public_chart_filter_groups_from_qingbi_config(config: dict[str, Any]) -> li
15261
15582
  return groups
15262
15583
 
15263
15584
 
15585
+ def _public_chart_group_by_from_qingbi_config(config: dict[str, Any]) -> list[str]:
15586
+ fields: list[dict[str, Any]] = []
15587
+ for key in ("selectedDimensions", "xDimensions", "yDimensions", "selectedTime"):
15588
+ fields.extend(_chart_fields(config, key))
15589
+ group_by: list[str] = []
15590
+ seen: set[str] = set()
15591
+ for field in fields:
15592
+ name = _stringify_condition_value(
15593
+ field.get("fieldName")
15594
+ or field.get("field_name")
15595
+ or field.get("queTitle")
15596
+ or field.get("title")
15597
+ or field.get("fieldId")
15598
+ or field.get("field_id")
15599
+ ).strip()
15600
+ if not name or name in seen:
15601
+ continue
15602
+ seen.add(name)
15603
+ group_by.append(name)
15604
+ return group_by
15605
+
15606
+
15607
+ def _public_chart_metrics_from_qingbi_config(config: dict[str, Any]) -> list[dict[str, Any]]:
15608
+ fields: list[dict[str, Any]] = []
15609
+ for key in ("selectedMetrics", "xMetrics", "yMetrics", "leftMetrics", "rightMetrics"):
15610
+ fields.extend(_chart_fields(config, key))
15611
+ metrics: list[dict[str, Any]] = []
15612
+ seen: set[tuple[str, str]] = set()
15613
+ for field in fields:
15614
+ field_id = _chart_field_id(field)
15615
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
15616
+ metric = {"op": "count", "expr": "count(*)"}
15617
+ else:
15618
+ op = str(field.get("aggreType") or field.get("aggregate") or "sum").strip().lower()
15619
+ if op == "average":
15620
+ op = "avg"
15621
+ field_name = _stringify_condition_value(
15622
+ field.get("fieldName")
15623
+ or field.get("field_name")
15624
+ or field.get("queTitle")
15625
+ or field.get("title")
15626
+ or field_id
15627
+ ).strip()
15628
+ metric = {"op": op or "sum", "field_name": field_name}
15629
+ if field_id:
15630
+ metric["field_id"] = field_id
15631
+ metric["expr"] = f"{metric['op']}({field_name})" if field_name else metric["op"]
15632
+ identity = (str(metric.get("op") or ""), str(metric.get("field_id") or metric.get("field_name") or metric.get("expr") or ""))
15633
+ if identity in seen:
15634
+ continue
15635
+ seen.add(identity)
15636
+ metrics.append(metric)
15637
+ return metrics
15638
+
15639
+
15264
15640
  def _public_chart_filter_operator_from_judge_type(judge_type: Any) -> str:
15265
15641
  normalized = _stringify_condition_value(judge_type).strip()
15266
15642
  mapping = {
@@ -15332,9 +15708,14 @@ def _build_public_chart_config_payload(
15332
15708
  ) -> dict[str, Any]:
15333
15709
  config = deepcopy(patch.config)
15334
15710
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
15335
- if "dimension_field_ids" in explicit_fields:
15711
+ semantic_dimension_fields = bool({"dimension_field_ids", "group_by", "rows", "columns"} & explicit_fields)
15712
+ semantic_metric_fields = bool(
15713
+ {"indicator_field_ids", "metric", "metrics", "x_metric", "y_metric", "left_metric", "right_metric", "value_metric", "target_metric"}
15714
+ & explicit_fields
15715
+ )
15716
+ if semantic_dimension_fields:
15336
15717
  config.pop("selectedDimensions", None)
15337
- if "indicator_field_ids" in explicit_fields:
15718
+ if semantic_metric_fields:
15338
15719
  config.pop("selectedMetrics", None)
15339
15720
  if "filters" in explicit_fields:
15340
15721
  config.pop("beforeAggregationFilterMatrix", None)
@@ -15360,8 +15741,8 @@ def _build_public_chart_config_payload(
15360
15741
  )
15361
15742
  query_condition_field_ids.append(_chart_field_id(field))
15362
15743
  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 数据总量")
15744
+ if backend_chart_type == "gauge" and not patch.indicator_field_ids and not patch.metrics and "selectedMetrics" not in config:
15745
+ raise ValueError("gauge charts require at least one metric; pass value_metric or metric and the CLI will pair it with 数据总量")
15365
15746
  selected_dimensions = _build_public_dimension_fields(
15366
15747
  patch.dimension_field_ids,
15367
15748
  app_key=app_key,
@@ -15370,15 +15751,25 @@ def _build_public_chart_config_payload(
15370
15751
  qingbi_fields_by_id=qingbi_fields_by_id,
15371
15752
  chart_type=patch.chart_type.value,
15372
15753
  )
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
- )
15754
+ if patch.metrics:
15755
+ selected_metrics = _build_public_semantic_metric_fields(
15756
+ patch.metrics,
15757
+ app_key=app_key,
15758
+ field_lookup=field_lookup,
15759
+ chart_field_lookup=chart_field_lookup,
15760
+ qingbi_fields_by_id=qingbi_fields_by_id,
15761
+ chart_type=patch.chart_type.value,
15762
+ )
15763
+ else:
15764
+ selected_metrics = _build_public_metric_fields(
15765
+ patch.indicator_field_ids,
15766
+ app_key=app_key,
15767
+ field_lookup=field_lookup,
15768
+ chart_field_lookup=chart_field_lookup,
15769
+ qingbi_fields_by_id=qingbi_fields_by_id,
15770
+ aggregate=aggregate,
15771
+ chart_type=patch.chart_type.value,
15772
+ )
15382
15773
  payload: dict[str, Any] = {
15383
15774
  "chartName": patch.name,
15384
15775
  "chartType": backend_chart_type,
@@ -15403,7 +15794,15 @@ def _build_public_chart_config_payload(
15403
15794
  if backend_chart_type == "summary":
15404
15795
  payload.pop("selectedDimensions", None)
15405
15796
  payload.setdefault("xDimensions", deepcopy(selected_dimensions))
15406
- payload.setdefault("yDimensions", [])
15797
+ y_dimensions = _build_public_dimension_fields(
15798
+ patch.columns,
15799
+ app_key=app_key,
15800
+ field_lookup=field_lookup,
15801
+ chart_field_lookup=chart_field_lookup,
15802
+ qingbi_fields_by_id=qingbi_fields_by_id,
15803
+ chart_type=patch.chart_type.value,
15804
+ )
15805
+ payload.setdefault("yDimensions", y_dimensions)
15407
15806
  elif backend_chart_type == "scatter":
15408
15807
  x_metrics, y_metrics = _split_axis_metric_fields(selected_metrics)
15409
15808
  payload.pop("selectedMetrics", None)
@@ -15437,7 +15836,26 @@ def _build_public_chart_config_payload(
15437
15836
 
15438
15837
  def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
15439
15838
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
15440
- return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
15839
+ return bool(
15840
+ {
15841
+ "dimension_field_ids",
15842
+ "indicator_field_ids",
15843
+ "group_by",
15844
+ "rows",
15845
+ "columns",
15846
+ "metric",
15847
+ "metrics",
15848
+ "x_metric",
15849
+ "y_metric",
15850
+ "left_metric",
15851
+ "right_metric",
15852
+ "value_metric",
15853
+ "target_metric",
15854
+ "filters",
15855
+ "config",
15856
+ }
15857
+ & explicit_fields
15858
+ )
15441
15859
 
15442
15860
 
15443
15861
  def _chart_patch_dataset_source_type(patch: ChartUpsertPatch) -> str:
@@ -15697,6 +16115,7 @@ def _empty_portal_layout_diagnostics() -> dict[str, Any]:
15697
16115
  "section_count": 0,
15698
16116
  "explicit_position_count": 0,
15699
16117
  "max_pc_right": None,
16118
+ "standard_template_counts": {"metric_cards": 0, "bi_charts": 0, "views": 0},
15700
16119
  "safe_for_display": True,
15701
16120
  "warnings": [],
15702
16121
  }
@@ -15714,6 +16133,8 @@ def _portal_layout_diagnostics(
15714
16133
  diagnostics["explicit_position_count"] = explicit_count
15715
16134
  pc_positions: list[dict[str, Any]] = []
15716
16135
  warnings: list[dict[str, Any]] = []
16136
+ standard_counts = {"metric_cards": 0, "bi_charts": 0, "views": 0}
16137
+ has_business_grid = False
15717
16138
  for index, component in enumerate(components):
15718
16139
  if not isinstance(component, dict):
15719
16140
  continue
@@ -15728,9 +16149,28 @@ def _portal_layout_diagnostics(
15728
16149
  cols = int(pc.get("cols") or 0)
15729
16150
  rows = int(pc.get("rows") or 0)
15730
16151
  chart_type = str(metadata.get("chart_type") or "").strip().lower()
16152
+ role = str(metadata.get("role") or getattr(section, "role", "") or "").strip().lower() if section is not None else ""
15731
16153
  is_metric_chart = chart_type in {"target", "indicator"}
16154
+ if source_type == "chart" and (is_metric_chart or role in {"metric", "metrics", "indicator", "kpi"}):
16155
+ standard_counts["metric_cards"] += 1
16156
+ elif source_type == "chart":
16157
+ standard_counts["bi_charts"] += 1
16158
+ elif source_type == "view":
16159
+ standard_counts["views"] += 1
15732
16160
  min_chart_cols = 6 if is_metric_chart else 8
15733
16161
  min_chart_rows = 5 if is_metric_chart else 7
16162
+ if source_type == "grid":
16163
+ has_business_grid = True
16164
+ grid_config = component.get("gridConfig") if isinstance(component.get("gridConfig"), dict) else {}
16165
+ grid_items = grid_config.get("items") if isinstance(grid_config, dict) else None
16166
+ if not isinstance(grid_items, list) or not grid_items:
16167
+ warnings.append(_warning(
16168
+ "PORTAL_GRID_ITEMS_EMPTY",
16169
+ "grid portal section has no config.items; frontend will show an empty entry container",
16170
+ section_index=index,
16171
+ title=title,
16172
+ fix_hint="Pass config.items with entries such as {type:1,jumpMode:1,linkAppKey,linkFormType,title}.",
16173
+ ))
15734
16174
  if source_type == "chart" and (cols < min_chart_cols or rows < min_chart_rows):
15735
16175
  warnings.append(_warning(
15736
16176
  "PORTAL_CHART_CARD_TOO_SMALL",
@@ -15744,6 +16184,16 @@ def _portal_layout_diagnostics(
15744
16184
  chart_type=chart_type or None,
15745
16185
  pc=deepcopy(pc),
15746
16186
  ))
16187
+ if source_type == "chart" and role in {"metric", "metrics", "indicator", "kpi"} and not is_metric_chart:
16188
+ warnings.append(_warning(
16189
+ "PORTAL_METRIC_SECTION_CHART_TYPE_MISMATCH",
16190
+ "metric portal section must reference a target/indicator chart; create the missing metric chart before assembling the portal",
16191
+ section_index=index,
16192
+ title=title,
16193
+ role=role,
16194
+ chart_type=chart_type or None,
16195
+ fix_hint="Use app_charts_apply with chart_type=target and metric='count(*)' or another metric expression, then reference that chart.",
16196
+ ))
15747
16197
  if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
15748
16198
  warnings.append(_warning(
15749
16199
  "PORTAL_MOBILE_POSITION_MISSING",
@@ -15762,11 +16212,66 @@ def _portal_layout_diagnostics(
15762
16212
  max_pc_right=max_right,
15763
16213
  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
16214
  ))
16215
+ standard_categories_present = sum(1 for count in standard_counts.values() if int(count or 0) > 0)
16216
+ _append_portal_standard_count_warnings(
16217
+ warnings=warnings,
16218
+ standard_counts=standard_counts,
16219
+ require_complete_standard=has_business_grid or standard_categories_present == 3,
16220
+ )
16221
+ diagnostics["standard_template_counts"] = standard_counts
15765
16222
  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)
16223
+ diagnostics["safe_for_display"] = not any(
16224
+ item.get("code") in {
16225
+ "PORTAL_LAYOUT_HALF_WIDTH",
16226
+ "PORTAL_CHART_CARD_TOO_SMALL",
16227
+ "PORTAL_GRID_ITEMS_EMPTY",
16228
+ "PORTAL_METRIC_SECTION_CHART_TYPE_MISMATCH",
16229
+ "PORTAL_STANDARD_METRIC_COUNT_OUT_OF_RANGE",
16230
+ "PORTAL_STANDARD_BI_COUNT_OUT_OF_RANGE",
16231
+ "PORTAL_STANDARD_VIEW_COUNT_OUT_OF_RANGE",
16232
+ }
16233
+ for item in warnings
16234
+ )
15767
16235
  return diagnostics
15768
16236
 
15769
16237
 
16238
+ def _append_portal_standard_count_warnings(
16239
+ *,
16240
+ warnings: list[dict[str, Any]],
16241
+ standard_counts: dict[str, int],
16242
+ require_complete_standard: bool = False,
16243
+ ) -> None:
16244
+ metric_count = int(standard_counts.get("metric_cards") or 0)
16245
+ bi_count = int(standard_counts.get("bi_charts") or 0)
16246
+ view_count = int(standard_counts.get("views") or 0)
16247
+ if not require_complete_standard:
16248
+ return
16249
+ if (metric_count or require_complete_standard) and not 4 <= metric_count <= 6:
16250
+ warnings.append(_warning(
16251
+ "PORTAL_STANDARD_METRIC_COUNT_OUT_OF_RANGE",
16252
+ "standard portal metric area should contain 4-6 metric cards",
16253
+ actual_count=metric_count,
16254
+ expected_count="4-6",
16255
+ fix_hint="Create missing target/indicator charts first, or keep 4 metric cards in one row with pc.cols=6, pc.rows=5.",
16256
+ ))
16257
+ if (bi_count or require_complete_standard) and not 2 <= bi_count <= 3:
16258
+ warnings.append(_warning(
16259
+ "PORTAL_STANDARD_BI_COUNT_OUT_OF_RANGE",
16260
+ "standard portal BI area should contain 2-3 visualization charts",
16261
+ actual_count=bi_count,
16262
+ expected_count="2-3",
16263
+ fix_hint="Use two half-width charts or three one-third-width charts with pc.rows=7.",
16264
+ ))
16265
+ if (view_count or require_complete_standard) and not 1 <= view_count <= 2:
16266
+ warnings.append(_warning(
16267
+ "PORTAL_STANDARD_VIEW_COUNT_OUT_OF_RANGE",
16268
+ "standard portal data view area should contain 1-2 business views",
16269
+ actual_count=view_count,
16270
+ expected_count="1-2",
16271
+ fix_hint="Reference 1-2 business views and avoid default 全部数据 / 我的数据 views as the main portal table.",
16272
+ ))
16273
+
16274
+
15770
16275
  def _portal_layout_warning_items(layout_diagnostics: dict[str, Any]) -> list[dict[str, Any]]:
15771
16276
  warnings = layout_diagnostics.get("warnings") if isinstance(layout_diagnostics, dict) else None
15772
16277
  return [deepcopy(item) for item in warnings if isinstance(item, dict)] if isinstance(warnings, list) else []
@@ -16603,6 +17108,149 @@ def _verify_relation_readback_by_name(
16603
17108
  return True
16604
17109
 
16605
17110
 
17111
+ def _relation_field_names(values: object) -> list[str]:
17112
+ names: list[str] = []
17113
+ if not isinstance(values, list):
17114
+ return names
17115
+ for item in values:
17116
+ if not isinstance(item, dict):
17117
+ continue
17118
+ name = str(item.get("name") or "").strip()
17119
+ if name:
17120
+ names.append(name)
17121
+ return names
17122
+
17123
+
17124
+ def _relation_field_public_selector(value: object) -> dict[str, Any] | None:
17125
+ if not isinstance(value, dict):
17126
+ return None
17127
+ return {
17128
+ "name": str(value.get("name") or "").strip() or None,
17129
+ "que_id": _coerce_nonnegative_int(value.get("que_id")),
17130
+ "field_id": str(value.get("field_id") or "").strip() or None,
17131
+ }
17132
+
17133
+
17134
+ def _schema_relation_readback_matrix(
17135
+ *,
17136
+ expected_fields: list[dict[str, Any]],
17137
+ verified_fields: list[dict[str, Any]],
17138
+ changed_field_names: set[str],
17139
+ degraded_expectations: list[dict[str, Any]],
17140
+ ) -> list[dict[str, Any]]:
17141
+ degraded_by_name = {
17142
+ str(item.get("field_name") or "").strip(): item
17143
+ for item in degraded_expectations
17144
+ if isinstance(item, dict) and str(item.get("field_name") or "").strip()
17145
+ }
17146
+ relation_names = {
17147
+ str(field.get("name") or "").strip()
17148
+ for field in expected_fields
17149
+ if isinstance(field, dict)
17150
+ and str(field.get("type") or "") == FieldType.relation.value
17151
+ and str(field.get("name") or "").strip() in changed_field_names
17152
+ }
17153
+ relation_names.update(name for name in degraded_by_name if name)
17154
+ if not relation_names:
17155
+ return []
17156
+
17157
+ expected_by_name = {
17158
+ str(field.get("name") or "").strip(): field
17159
+ for field in expected_fields
17160
+ if isinstance(field, dict)
17161
+ and str(field.get("type") or "") == FieldType.relation.value
17162
+ and str(field.get("name") or "").strip()
17163
+ }
17164
+ verified_by_name = {
17165
+ str(field.get("name") or "").strip(): field
17166
+ for field in verified_fields
17167
+ if isinstance(field, dict) and str(field.get("name") or "").strip()
17168
+ }
17169
+ rows: list[dict[str, Any]] = []
17170
+ for field_name in sorted(relation_names):
17171
+ expected = expected_by_name.get(field_name)
17172
+ actual = verified_by_name.get(field_name)
17173
+ degraded = degraded_by_name.get(field_name)
17174
+ expected_target = str((expected or degraded or {}).get("target_app_key") or "").strip() or None
17175
+ actual_target = str((actual or {}).get("target_app_key") or "").strip() or None
17176
+ expected_mode = _normalize_relation_mode((expected or degraded or {}).get("relation_mode"))
17177
+ actual_mode = _normalize_relation_mode((actual or {}).get("relation_mode"))
17178
+ expected_display = _relation_field_public_selector((expected or degraded or {}).get("display_field"))
17179
+ actual_display = _relation_field_public_selector((actual or {}).get("display_field"))
17180
+ expected_visible_names = _relation_field_names((expected or degraded or {}).get("visible_fields"))
17181
+ actual_visible_names = _relation_field_names((actual or {}).get("visible_fields"))
17182
+
17183
+ checks = {
17184
+ "field_exists": isinstance(actual, dict),
17185
+ "target_app_key": expected_target == actual_target,
17186
+ "relation_mode": expected_mode == actual_mode,
17187
+ "display_field": (expected_display or {}).get("name") == (actual_display or {}).get("name"),
17188
+ "visible_fields": expected_visible_names == actual_visible_names,
17189
+ }
17190
+ readback_verified = all(checks.values())
17191
+ if not isinstance(actual, dict):
17192
+ status = "missing"
17193
+ elif readback_verified and degraded is not None:
17194
+ status = "matched_by_name"
17195
+ elif readback_verified:
17196
+ status = "matched"
17197
+ else:
17198
+ status = "mismatch"
17199
+ rows.append(
17200
+ {
17201
+ "field_name": field_name,
17202
+ "readback_status": status,
17203
+ "readback_verified": readback_verified,
17204
+ "metadata_verified": degraded is None,
17205
+ "checks": checks,
17206
+ "expected": {
17207
+ "target_app_key": expected_target,
17208
+ "relation_mode": expected_mode,
17209
+ "display_field": expected_display,
17210
+ "visible_fields": expected_visible_names,
17211
+ },
17212
+ "actual": {
17213
+ "target_app_key": actual_target,
17214
+ "relation_mode": actual_mode,
17215
+ "display_field": actual_display,
17216
+ "visible_fields": actual_visible_names,
17217
+ },
17218
+ "data_impact": (
17219
+ "none_detected"
17220
+ if readback_verified
17221
+ else "relation config mismatch can affect existing referenced values; inspect existing records before changing target_app_key or display fields"
17222
+ ),
17223
+ }
17224
+ )
17225
+ return rows
17226
+
17227
+
17228
+ def _schema_relation_repair_plan(relation_readback_matrix: list[dict[str, Any]]) -> list[dict[str, Any]]:
17229
+ plan: list[dict[str, Any]] = []
17230
+ for row in relation_readback_matrix:
17231
+ if bool(row.get("readback_verified")):
17232
+ continue
17233
+ expected = row.get("expected") if isinstance(row.get("expected"), dict) else {}
17234
+ plan.append(
17235
+ {
17236
+ "field_name": row.get("field_name"),
17237
+ "mode": "update_fields_relation_patch",
17238
+ "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.",
17239
+ "suggested_patch": {
17240
+ "selector": {"name": row.get("field_name")},
17241
+ "set": {
17242
+ "target_app_key": expected.get("target_app_key"),
17243
+ "relation_mode": expected.get("relation_mode"),
17244
+ "display_field": expected.get("display_field"),
17245
+ "visible_fields": [{"name": name} for name in cast(list[Any], expected.get("visible_fields") or [])],
17246
+ },
17247
+ },
17248
+ "data_impact": row.get("data_impact"),
17249
+ }
17250
+ )
17251
+ return plan
17252
+
17253
+
16606
17254
  def _relation_target_metadata_skip_outcome(*, degraded_entries: list[dict[str, Any]]) -> PermissionCheckOutcome | None:
16607
17255
  if not degraded_entries:
16608
17256
  return None