@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.
- package/README.md +2 -4
- package/docs/local-agent-install.md +4 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +5 -3
- package/skills/qingflow-mcp-setup/SKILL.md +2 -0
- package/skills/qingflow-record-analysis/SKILL.md +3 -1
- package/skills/qingflow-record-delete/SKILL.md +2 -0
- package/skills/qingflow-record-import/SKILL.md +29 -0
- package/skills/qingflow-record-insert/SKILL.md +24 -1
- package/skills/qingflow-record-update/SKILL.md +3 -0
- package/skills/qingflow-task-ops/SKILL.md +2 -0
- package/src/qingflow_mcp/builder_facade/models.py +183 -0
- package/src/qingflow_mcp/builder_facade/service.py +823 -75
- package/src/qingflow_mcp/cli/commands/builder.py +80 -6
- package/src/qingflow_mcp/cli/formatters.py +1 -0
- package/src/qingflow_mcp/cli/main.py +2 -0
- package/src/qingflow_mcp/response_trim.py +6 -4
- package/src/qingflow_mcp/tools/ai_builder_tools.py +388 -17
- package/src/qingflow_mcp/tools/record_tools.py +28 -2
- package/skills/qingflow-app-builder/SKILL.md +0 -280
- package/skills/qingflow-app-builder/agents/openai.yaml +0 -4
- package/skills/qingflow-app-builder/references/create-app.md +0 -160
- package/skills/qingflow-app-builder/references/environments.md +0 -63
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +0 -123
- package/skills/qingflow-app-builder/references/gotchas.md +0 -107
- package/skills/qingflow-app-builder/references/match-rules.md +0 -129
- package/skills/qingflow-app-builder/references/public-surface-sync.md +0 -75
- package/skills/qingflow-app-builder/references/solution-playbooks.md +0 -52
- package/skills/qingflow-app-builder/references/tool-selection.md +0 -106
- package/skills/qingflow-app-builder/references/update-flow.md +0 -158
- package/skills/qingflow-app-builder/references/update-layout.md +0 -68
- package/skills/qingflow-app-builder/references/update-schema.md +0 -75
- package/skills/qingflow-app-builder/references/update-views.md +0 -286
- package/skills/qingflow-app-builder-code-integrations/SKILL.md +0 -137
- package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +0 -4
- package/skills/qingflow-app-builder-code-integrations/references/code-block.md +0 -66
- 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
|
-
|
|
7962
|
-
"status"
|
|
7963
|
-
"
|
|
7964
|
-
"
|
|
7965
|
-
|
|
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":
|
|
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"))
|
|
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=
|
|
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
|
-
|
|
12401
|
-
"
|
|
12402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
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
|
-
|
|
15039
|
-
|
|
15040
|
-
|
|
15041
|
-
|
|
15042
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
15374
|
-
|
|
15375
|
-
|
|
15376
|
-
|
|
15377
|
-
|
|
15378
|
-
|
|
15379
|
-
|
|
15380
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|