@qingflow-tech/qingflow-app-builder-mcp 1.0.39 → 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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +18 -7
- package/skills/qingflow-app-builder/references/complete-system-development-guide.md +59 -0
- package/skills/qingflow-app-builder/references/create-app.md +13 -7
- package/skills/qingflow-app-builder/references/gotchas.md +6 -0
- package/skills/qingflow-app-builder/references/single-app-development-guide.md +47 -0
- package/skills/qingflow-app-builder/references/solution-playbooks.md +10 -0
- package/skills/qingflow-app-builder/references/tool-selection.md +2 -2
- package/skills/qingflow-app-builder-code-integrations/SKILL.md +2 -0
- package/src/qingflow_mcp/builder_facade/models.py +183 -0
- package/src/qingflow_mcp/builder_facade/service.py +722 -74
- package/src/qingflow_mcp/cli/commands/builder.py +62 -2
- package/src/qingflow_mcp/cli/commands/common.py +12 -3
- 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 +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +515 -22
- package/src/qingflow_mcp/tools/record_tools.py +28 -2
|
@@ -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
|
-
|
|
7962
|
-
"status"
|
|
7963
|
-
"
|
|
7964
|
-
"
|
|
7965
|
-
|
|
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":
|
|
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"))
|
|
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=
|
|
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
|
-
|
|
12401
|
-
"
|
|
12402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
15374
|
-
|
|
15375
|
-
|
|
15376
|
-
|
|
15377
|
-
|
|
15378
|
-
|
|
15379
|
-
|
|
15380
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|