@josephyan/qingflow-app-user-mcp 1.1.26 → 1.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -6
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-app-user/SKILL.md +5 -3
  5. package/skills/qingflow-app-user/references/public-surface-sync.md +4 -2
  6. package/src/qingflow_mcp/builder_facade/models.py +167 -71
  7. package/src/qingflow_mcp/builder_facade/service.py +617 -155
  8. package/src/qingflow_mcp/cli/commands/builder.py +3 -1
  9. package/src/qingflow_mcp/cli/main.py +8 -3
  10. package/src/qingflow_mcp/server_app_builder.py +9 -8
  11. package/src/qingflow_mcp/tools/ai_builder_tools.py +231 -180
  12. package/src/qingflow_mcp/version.py +2 -0
  13. package/skills/qingflow-app-builder/SKILL.md +0 -277
  14. package/skills/qingflow-app-builder/agents/openai.yaml +0 -4
  15. package/skills/qingflow-app-builder/references/build-complete-system.md +0 -428
  16. package/skills/qingflow-app-builder/references/build-single-app.md +0 -530
  17. package/skills/qingflow-app-builder/references/create-app.md +0 -149
  18. package/skills/qingflow-app-builder/references/environments.md +0 -63
  19. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +0 -123
  20. package/skills/qingflow-app-builder/references/gotchas.md +0 -108
  21. package/skills/qingflow-app-builder/references/match-rules.md +0 -114
  22. package/skills/qingflow-app-builder/references/public-surface-sync.md +0 -75
  23. package/skills/qingflow-app-builder/references/solution-playbooks.md +0 -52
  24. package/skills/qingflow-app-builder/references/tool-selection.md +0 -107
  25. package/skills/qingflow-app-builder/references/update-flow.md +0 -186
  26. package/skills/qingflow-app-builder/references/update-layout.md +0 -68
  27. package/skills/qingflow-app-builder/references/update-schema.md +0 -72
  28. package/skills/qingflow-app-builder/references/update-views.md +0 -291
  29. package/skills/qingflow-app-builder-code-integrations/SKILL.md +0 -137
  30. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +0 -4
  31. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +0 -66
  32. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +0 -77
  33. package/skills/qingflow-workflow-builder/SKILL.md +0 -96
  34. package/skills/qingflow-workflow-builder/manifest.yaml +0 -8
  35. package/skills/qingflow-workflow-builder/references/01-overview.md +0 -45
  36. package/skills/qingflow-workflow-builder/references/02-update-mode.md +0 -53
  37. package/skills/qingflow-workflow-builder/references/03-flow-patterns.md +0 -57
  38. package/skills/qingflow-workflow-builder/references/04-stage1-business-modeling.md +0 -131
  39. package/skills/qingflow-workflow-builder/references/05-stage2-members-roles.md +0 -29
  40. package/skills/qingflow-workflow-builder/references/06-stage3-build-spec.md +0 -165
  41. package/skills/qingflow-workflow-builder/references/07-stage4-validate-spec.md +0 -33
  42. package/skills/qingflow-workflow-builder/references/08-stage5-apply-verify.md +0 -51
  43. package/skills/qingflow-workflow-builder/references/09-stage6-summary.md +0 -88
  44. package/skills/qingflow-workflow-builder/references/10-node-config-reference.md +0 -93
  45. package/skills/qingflow-workflow-builder/references/11-troubleshooting.md +0 -15
  46. package/skills/qingflow-workflow-builder/scripts/diff_flow_spec.py +0 -275
  47. package/skills/qingflow-workflow-builder/scripts/validate_flow_spec.py +0 -605
@@ -11,7 +11,7 @@ import re
11
11
  import string
12
12
  import time
13
13
  import tempfile
14
- from typing import Any, cast
14
+ from typing import Any, NoReturn, cast
15
15
  from urllib.parse import quote_plus, unquote_plus
16
16
  from uuid import uuid4
17
17
 
@@ -49,6 +49,7 @@ from .models import (
49
49
  AssociatedResourceUpsertPatch,
50
50
  AssociatedResourceViewConfigPatch,
51
51
  ChartApplyRequest,
52
+ ChartMetricPatch,
52
53
  ChartPartialPatch,
53
54
  ChartUpsertPatch,
54
55
  CustomButtonsApplyRequest,
@@ -156,6 +157,7 @@ MATCH_TYPE_ACCURACY = 1
156
157
  MATCH_TYPE_QUESTION = 2
157
158
  JUDGE_EQUAL = 0
158
159
  JUDGE_UNEQUAL = 1
160
+ JUDGE_INCLUDE = 2
159
161
  JUDGE_GREATER_OR_EQUAL = 5
160
162
  JUDGE_LESS_OR_EQUAL = 7
161
163
  JUDGE_EQUAL_ANY = 9
@@ -5238,6 +5240,20 @@ class AiBuilderFacade:
5238
5240
  result["message"] = "read app chart config"
5239
5241
  return result
5240
5242
 
5243
+ def _chart_filter_field_names_by_id(
5244
+ self,
5245
+ *,
5246
+ profile: str,
5247
+ app_key: str,
5248
+ ) -> tuple[dict[str, str], dict[str, Any] | None]:
5249
+ try:
5250
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
5251
+ except (QingflowApiError, RuntimeError) as error:
5252
+ api_error = _coerce_api_error(error)
5253
+ return {}, _transport_error_payload(api_error)
5254
+ fields = cast(list[dict[str, Any]], state["parsed"]["fields"])
5255
+ return _chart_field_names_by_id_from_public_fields(app_key=app_key, fields=fields), None
5256
+
5241
5257
  def app_get_buttons(self, *, profile: str, app_key: str) -> JSONObject:
5242
5258
  """Read custom buttons for a single app."""
5243
5259
  self.apps._require_app_key(app_key)
@@ -5796,6 +5812,8 @@ class AiBuilderFacade:
5796
5812
  )
5797
5813
  charts = _summarize_charts(items)
5798
5814
  chart_visibility_read_errors: list[dict[str, Any]] = []
5815
+ chart_config_read_errors: list[dict[str, Any]] = []
5816
+ field_name_by_id, field_name_read_error = self._chart_filter_field_names_by_id(profile=profile, app_key=resolved_app_key)
5799
5817
  for chart in charts:
5800
5818
  chart_id = str(chart.get("chart_id") or "").strip()
5801
5819
  if not chart_id:
@@ -5818,6 +5836,24 @@ class AiBuilderFacade:
5818
5836
  base_info.get("visibleAuth") if isinstance(base_info, dict) else None
5819
5837
  )
5820
5838
  )
5839
+ try:
5840
+ config_response = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id)
5841
+ config = config_response.get("result") or {}
5842
+ except (QingflowApiError, RuntimeError) as error:
5843
+ api_error = _coerce_api_error(error)
5844
+ chart_config_read_errors.append(
5845
+ {
5846
+ "chart_id": chart_id,
5847
+ "request_id": api_error.request_id,
5848
+ "http_status": api_error.http_status,
5849
+ "backend_code": api_error.backend_code,
5850
+ }
5851
+ )
5852
+ continue
5853
+ if isinstance(config, dict):
5854
+ chart["group_by"] = _public_chart_group_by_from_qingbi_config(config)
5855
+ chart["metrics"] = _public_chart_metrics_from_qingbi_config(config)
5856
+ chart["filters"] = _public_chart_filter_groups_from_qingbi_config(config, field_name_by_id=field_name_by_id)
5821
5857
  response = AppChartsReadResponse(
5822
5858
  app_key=resolved_app_key,
5823
5859
  charts=charts,
@@ -5831,7 +5867,11 @@ class AiBuilderFacade:
5831
5867
  "normalized_args": {"app_key": resolved_app_key},
5832
5868
  "missing_fields": [],
5833
5869
  "allowed_values": {},
5834
- "details": {"chart_visibility_read_errors": chart_visibility_read_errors} if chart_visibility_read_errors else {},
5870
+ "details": {
5871
+ **({"chart_visibility_read_errors": chart_visibility_read_errors} if chart_visibility_read_errors else {}),
5872
+ **({"chart_config_read_errors": chart_config_read_errors} if chart_config_read_errors else {}),
5873
+ **({"chart_filter_field_name_read_error": field_name_read_error} if field_name_read_error else {}),
5874
+ },
5835
5875
  "request_id": None,
5836
5876
  "suggested_next_call": None,
5837
5877
  "noop": False,
@@ -5842,12 +5882,24 @@ class AiBuilderFacade:
5842
5882
  if chart_visibility_read_errors
5843
5883
  else []
5844
5884
  )
5885
+ + (
5886
+ [_warning("CHART_CONFIG_READ_PARTIAL", "some chart configs could not be read back; metrics/group_by/filters are incomplete for those charts")]
5887
+ if chart_config_read_errors
5888
+ else []
5889
+ )
5890
+ + (
5891
+ [_warning("CHART_FILTER_FIELD_NAMES_UNRESOLVED", "chart configs were read, but form fields could not be loaded to resolve filter field names")]
5892
+ if field_name_read_error
5893
+ else []
5894
+ )
5845
5895
  ),
5846
5896
  "verification": {
5847
5897
  "app_exists": True,
5848
5898
  "chart_order_verified": list_source == "sorted",
5849
5899
  "chart_list_source": list_source,
5850
5900
  "chart_visibility_readback_complete": not chart_visibility_read_errors,
5901
+ "chart_config_readback_complete": not chart_config_read_errors,
5902
+ "chart_filter_field_names_resolved": field_name_read_error is None,
5851
5903
  },
5852
5904
  "verified": True,
5853
5905
  **response.model_dump(mode="json"),
@@ -6877,10 +6929,27 @@ class AiBuilderFacade:
6877
6929
  suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
6878
6930
  )
6879
6931
 
6932
+ field_name_by_id: dict[str, str] = {}
6933
+ data_source = config.get("dataSource") if isinstance(config.get("dataSource"), dict) else {}
6934
+ data_source_app_key = str(data_source.get("dataSourceId") or config.get("dataSourceId") or "").strip()
6935
+ if data_source_app_key:
6936
+ field_name_by_id, field_name_error = self._chart_filter_field_names_by_id(profile=profile, app_key=data_source_app_key)
6937
+ if field_name_error:
6938
+ warnings.append(
6939
+ _warning(
6940
+ "CHART_FILTER_FIELD_NAMES_UNRESOLVED",
6941
+ "chart config was read, but form fields could not be loaded to resolve filter field names",
6942
+ **field_name_error,
6943
+ )
6944
+ )
6945
+
6880
6946
  response = ChartGetResponse(
6881
6947
  chart_id=chart_id,
6882
6948
  base=deepcopy(base) if isinstance(base, dict) else {},
6883
6949
  visibility=_public_visibility_from_chart_visible_auth(base.get("visibleAuth")),
6950
+ filters=_public_chart_filter_groups_from_qingbi_config(config, field_name_by_id=field_name_by_id) if isinstance(config, dict) else [],
6951
+ group_by=_public_chart_group_by_from_qingbi_config(config) if isinstance(config, dict) else [],
6952
+ metrics=_public_chart_metrics_from_qingbi_config(config) if isinstance(config, dict) else [],
6884
6953
  config=deepcopy(config) if isinstance(config, dict) else {},
6885
6954
  )
6886
6955
  return {
@@ -7380,6 +7449,8 @@ class AiBuilderFacade:
7380
7449
  add_fields: list[FieldPatch],
7381
7450
  update_fields: list[FieldUpdatePatch],
7382
7451
  remove_fields: list[FieldRemovePatch],
7452
+ layout_mode: LayoutApplyMode = LayoutApplyMode.merge,
7453
+ layout_sections: list[LayoutSectionPatch] | None = None,
7383
7454
  ) -> JSONObject:
7384
7455
  normalized_args = {
7385
7456
  "app_key": app_key,
@@ -7394,6 +7465,10 @@ class AiBuilderFacade:
7394
7465
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
7395
7466
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
7396
7467
  }
7468
+ layout_requested = layout_sections is not None
7469
+ requested_layout_sections = [section.model_dump(mode="json", exclude_none=True) for section in (layout_sections or [])]
7470
+ if layout_requested:
7471
+ normalized_args["form"] = {"mode": layout_mode.value, "sections": deepcopy(requested_layout_sections)}
7397
7472
  try:
7398
7473
  desired_auth = (
7399
7474
  self._compile_visibility_to_member_auth(profile=profile, visibility=visibility)
@@ -7409,7 +7484,7 @@ class AiBuilderFacade:
7409
7484
  suggested_next_call=None,
7410
7485
  )
7411
7486
  permission_outcomes: list[PermissionCheckOutcome] = []
7412
- requested_field_changes = bool(add_fields or update_fields or remove_fields)
7487
+ requested_field_changes = bool(add_fields or update_fields or remove_fields or layout_requested)
7413
7488
  resolved: JSONObject
7414
7489
  if app_key:
7415
7490
  resolved = self.app_resolve(profile=profile, app_key=app_key)
@@ -7533,7 +7608,6 @@ class AiBuilderFacade:
7533
7608
  "verification": {
7534
7609
  "fields_verified": True,
7535
7610
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7536
- "relation_field_limit_verified": True,
7537
7611
  "app_visuals_verified": True,
7538
7612
  "app_base_verified": True,
7539
7613
  "publish_skipped": True,
@@ -7573,6 +7647,9 @@ class AiBuilderFacade:
7573
7647
  current_fields = parsed["fields"]
7574
7648
  original_fields = deepcopy(current_fields)
7575
7649
  layout = parsed["layout"]
7650
+ original_layout = deepcopy(layout)
7651
+ layout_write_requested = False
7652
+ applied_layout = deepcopy(layout)
7576
7653
  existing_index = {field["field_id"]: index for index, field in enumerate(current_fields)}
7577
7654
  selector_map = _build_selector_map(current_fields)
7578
7655
  added: list[str] = []
@@ -7727,6 +7804,71 @@ class AiBuilderFacade:
7727
7804
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7728
7805
  )
7729
7806
 
7807
+ if layout_requested:
7808
+ resolved_sections, missing_selectors = _resolve_layout_sections_to_names(requested_layout_sections, current_fields)
7809
+ normalized_args["form"] = {"mode": layout_mode.value, "sections": deepcopy(resolved_sections)}
7810
+ if missing_selectors:
7811
+ return _failed(
7812
+ "UNKNOWN_LAYOUT_FIELD",
7813
+ "form layout references unknown field selectors",
7814
+ normalized_args=normalized_args,
7815
+ details={"unknown_selectors": missing_selectors, "app_key": target.app_key},
7816
+ missing_fields=[str(item) for item in missing_selectors],
7817
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7818
+ )
7819
+ fields_by_name = {field["name"]: field for field in current_fields}
7820
+ seen_layout_fields: list[str] = []
7821
+ for section in resolved_sections:
7822
+ for row in section.get("rows", []):
7823
+ for field_name in row:
7824
+ if field_name not in fields_by_name:
7825
+ return _failed(
7826
+ "UNKNOWN_LAYOUT_FIELD",
7827
+ f"form layout references unknown field '{field_name}'",
7828
+ normalized_args=normalized_args,
7829
+ details={"field_name": field_name, "app_key": target.app_key},
7830
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7831
+ )
7832
+ if field_name in seen_layout_fields:
7833
+ return _failed(
7834
+ "DUPLICATE_LAYOUT_FIELD",
7835
+ f"form layout references field '{field_name}' more than once",
7836
+ normalized_args=normalized_args,
7837
+ details={"field_name": field_name, "app_key": target.app_key},
7838
+ suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
7839
+ )
7840
+ seen_layout_fields.append(field_name)
7841
+ expected_layout_fields = {field["name"] for field in current_fields}
7842
+ if layout_mode == LayoutApplyMode.replace and set(seen_layout_fields) != expected_layout_fields:
7843
+ missing = sorted(expected_layout_fields.difference(seen_layout_fields))
7844
+ return _failed(
7845
+ "INCOMPLETE_LAYOUT",
7846
+ "form layout must reference every current field exactly once in replace mode",
7847
+ normalized_args=normalized_args,
7848
+ details={
7849
+ "missing_fields": missing,
7850
+ "auto_fill_preview": _merge_layout(
7851
+ current_layout=layout,
7852
+ requested_sections=resolved_sections,
7853
+ all_field_names=[field["name"] for field in current_fields],
7854
+ )["layout"],
7855
+ },
7856
+ missing_fields=missing,
7857
+ suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
7858
+ )
7859
+ merged_layout = _merge_layout(
7860
+ current_layout=layout,
7861
+ requested_sections=resolved_sections,
7862
+ all_field_names=[field["name"] for field in current_fields],
7863
+ )
7864
+ applied_layout = (
7865
+ {"root_rows": [], "sections": resolved_sections}
7866
+ if layout_mode == LayoutApplyMode.replace
7867
+ else merged_layout["layout"]
7868
+ )
7869
+ layout_write_requested = not _layouts_equal(layout, applied_layout)
7870
+ layout = applied_layout
7871
+
7730
7872
  schema_write_requested = bool(
7731
7873
  added
7732
7874
  or updated
@@ -7734,6 +7876,7 @@ class AiBuilderFacade:
7734
7876
  or normalized_code_block_fields
7735
7877
  or data_display_selection.has_any
7736
7878
  or bool(resolved.get("created"))
7879
+ or layout_write_requested
7737
7880
  )
7738
7881
  if schema_write_requested:
7739
7882
  try:
@@ -7756,12 +7899,6 @@ class AiBuilderFacade:
7756
7899
  )
7757
7900
 
7758
7901
  relation_field_count = _count_relation_fields(current_fields)
7759
- relation_limit_verified = relation_field_count <= 1
7760
- relation_warnings = (
7761
- [_warning("RELATION_FIELD_LIMIT_RISK", "multiple relation fields are not a stable backend capability", relation_field_count=relation_field_count)]
7762
- if not relation_limit_verified
7763
- else []
7764
- )
7765
7902
  code_block_normalization_warnings = (
7766
7903
  [
7767
7904
  _warning(
@@ -7774,7 +7911,15 @@ class AiBuilderFacade:
7774
7911
  else []
7775
7912
  )
7776
7913
 
7777
- if not added and not updated and not removed and not normalized_code_block_fields and not data_display_selection.has_any and not bool(resolved.get("created")):
7914
+ if (
7915
+ not added
7916
+ and not updated
7917
+ and not removed
7918
+ and not normalized_code_block_fields
7919
+ and not data_display_selection.has_any
7920
+ and not bool(resolved.get("created"))
7921
+ and not layout_write_requested
7922
+ ):
7778
7923
  base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
7779
7924
  tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
7780
7925
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
@@ -7796,10 +7941,10 @@ class AiBuilderFacade:
7796
7941
  "request_id": None,
7797
7942
  "suggested_next_call": None if package_attached is not False else {"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": package_tag_id, "app_key": target.app_key}},
7798
7943
  "noop": not bool(visual_result.get("updated")),
7799
- "warnings": relation_warnings + code_block_normalization_warnings,
7944
+ "warnings": code_block_normalization_warnings,
7800
7945
  "verification": {
7801
7946
  "fields_verified": True,
7802
- "relation_field_limit_verified": relation_limit_verified,
7947
+ "form_layout_verified": True,
7803
7948
  "app_visuals_verified": app_base_verified,
7804
7949
  "app_base_verified": app_base_verified,
7805
7950
  "relation_target_metadata_verified": relation_target_metadata_verified,
@@ -7817,6 +7962,12 @@ class AiBuilderFacade:
7817
7962
  "package_attached": package_attached,
7818
7963
  }
7819
7964
  response["details"]["relation_field_count"] = relation_field_count
7965
+ if layout_requested:
7966
+ response["details"]["form_layout_diff"] = {
7967
+ "mode": layout_mode.value,
7968
+ "changed": False,
7969
+ "requested_section_count": len(requested_layout_sections),
7970
+ }
7820
7971
  if normalized_code_block_fields:
7821
7972
  response["normalized_code_block_output_assignment"] = True
7822
7973
  response["normalized_code_block_fields"] = normalized_code_block_fields
@@ -7850,26 +8001,6 @@ class AiBuilderFacade:
7850
8001
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
7851
8002
  except (QingflowApiError, RuntimeError) as error:
7852
8003
  api_error = _coerce_api_error(error)
7853
- if api_error.backend_code == 49614:
7854
- return _failed(
7855
- "MULTIPLE_RELATION_FIELDS_UNSUPPORTED",
7856
- "backend currently rejects apps with more than one relation field; keep one real relation field and use text/reference summary fields for additional cross-object links.",
7857
- normalized_args=normalized_args,
7858
- details={
7859
- "app_key": target.app_key,
7860
- "field_diff": {"added": added, "updated": updated, "removed": removed},
7861
- "relation_field_count": relation_field_count,
7862
- "transport_error": {
7863
- "http_status": api_error.http_status,
7864
- "backend_code": api_error.backend_code,
7865
- "category": api_error.category,
7866
- },
7867
- },
7868
- request_id=api_error.request_id,
7869
- backend_code=api_error.backend_code,
7870
- http_status=None if api_error.http_status == 404 else api_error.http_status,
7871
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
7872
- )
7873
8004
  return _failed_from_api_error(
7874
8005
  "SCHEMA_APPLY_FAILED",
7875
8006
  api_error,
@@ -7993,13 +8124,13 @@ class AiBuilderFacade:
7993
8124
  "request_id": None,
7994
8125
  "suggested_next_call": None,
7995
8126
  "noop": False,
7996
- "warnings": relation_warnings + code_block_normalization_warnings,
8127
+ "warnings": code_block_normalization_warnings,
7997
8128
  "verification": {
7998
8129
  "fields_verified": False,
7999
8130
  "package_attached": None,
8000
8131
  "app_visuals_verified": True,
8001
8132
  "app_base_verified": True,
8002
- "relation_field_limit_verified": relation_limit_verified,
8133
+ "form_layout_verified": not layout_requested,
8003
8134
  "relation_target_metadata_verified": relation_target_metadata_verified,
8004
8135
  },
8005
8136
  "app_key": target.app_key,
@@ -8026,6 +8157,12 @@ class AiBuilderFacade:
8026
8157
  "package_attached": None,
8027
8158
  }
8028
8159
  response["details"]["relation_field_count"] = relation_field_count
8160
+ if layout_requested:
8161
+ response["details"]["form_layout_diff"] = {
8162
+ "mode": layout_mode.value,
8163
+ "changed": layout_write_requested,
8164
+ "requested_section_count": len(requested_layout_sections),
8165
+ }
8029
8166
  if normalized_code_block_fields:
8030
8167
  response["normalized_code_block_output_assignment"] = True
8031
8168
  response["normalized_code_block_fields"] = normalized_code_block_fields
@@ -8048,6 +8185,11 @@ class AiBuilderFacade:
8048
8185
  after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
8049
8186
  )
8050
8187
  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)
8188
+ if layout_requested:
8189
+ verified_layout = cast(dict[str, Any], verified.get("layout") or {"root_rows": [], "sections": []})
8190
+ layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
8191
+ response["verification"]["form_layout_verified"] = layout_verified
8192
+ verification_ok = verification_ok and layout_verified
8051
8193
  data_display_verification = _verify_data_display_readback(
8052
8194
  form_settings=verified.get("form_settings"),
8053
8195
  selection=data_display_selection,
@@ -8091,7 +8233,6 @@ class AiBuilderFacade:
8091
8233
  response["verification"]["package_attached"] = package_attached
8092
8234
  response["verification"]["app_visuals_verified"] = app_base_verified
8093
8235
  response["verification"]["app_base_verified"] = app_base_verified
8094
- response["verification"]["relation_field_limit_verified"] = relation_limit_verified
8095
8236
  response["verified"] = verification_ok and app_base_verified
8096
8237
  response["tag_ids_after"] = tag_ids_after
8097
8238
  response["package_attached"] = package_attached
@@ -10304,6 +10445,7 @@ class AiBuilderFacade:
10304
10445
  )
10305
10446
  if existing is None:
10306
10447
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
10448
+ chart_id = temp_chart_id
10307
10449
  create_payload = {
10308
10450
  "chartId": temp_chart_id,
10309
10451
  "chartName": patch.name,
@@ -12439,7 +12581,8 @@ def _match_mapping_operator_to_judge_type(operator: Any, *, reason_path: str) ->
12439
12581
  "le": JUDGE_LESS_OR_EQUAL,
12440
12582
  "<=": JUDGE_LESS_OR_EQUAL,
12441
12583
  "less_or_equal": JUDGE_LESS_OR_EQUAL,
12442
- "contains": JUDGE_FUZZY_MATCH,
12584
+ "contains": JUDGE_INCLUDE,
12585
+ "include": JUDGE_INCLUDE,
12443
12586
  "like": JUDGE_FUZZY_MATCH,
12444
12587
  "fuzzy": JUDGE_FUZZY_MATCH,
12445
12588
  "fuzzy_match": JUDGE_FUZZY_MATCH,
@@ -13693,7 +13836,18 @@ def _build_qingbi_chart_field_lookup(
13693
13836
  field_lookup: dict[str, dict[str, Any]],
13694
13837
  ) -> dict[str, Any]:
13695
13838
  by_selector: dict[str, list[dict[str, Any]]] = {}
13696
- form_by_que_id = field_lookup.get("by_que_id") or {}
13839
+ form_by_que_id = dict(field_lookup.get("by_que_id") or {})
13840
+ if not form_by_que_id:
13841
+ for bucket_name in ("by_name", "by_field_id"):
13842
+ bucket = field_lookup.get(bucket_name) or {}
13843
+ if not isinstance(bucket, dict):
13844
+ continue
13845
+ for form_field in bucket.values():
13846
+ if not isinstance(form_field, dict):
13847
+ continue
13848
+ que_id = _coerce_any_int(form_field.get("que_id"))
13849
+ if que_id is not None:
13850
+ form_by_que_id.setdefault(que_id, form_field)
13697
13851
 
13698
13852
  def add_selector(key: Any, field: dict[str, Any]) -> None:
13699
13853
  normalized = str(key or "").strip()
@@ -14082,37 +14236,80 @@ def _build_public_metric_fields(
14082
14236
  metrics: list[dict[str, Any]] = []
14083
14237
  for selector in selectors:
14084
14238
  qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
14085
- field_id = _chart_field_id(qingbi_field)
14086
- if field_id == _QINGBI_TOTAL_FIELD_ID:
14087
- metrics.append(qingbi_field)
14239
+ metrics.append(_public_qingbi_metric_field(qingbi_field, aggregate=normalized_aggregate))
14240
+ return metrics or [_default_public_total_metric()]
14241
+
14242
+
14243
+ def _public_qingbi_metric_field(qingbi_field: dict[str, Any], *, aggregate: str) -> dict[str, Any]:
14244
+ field_id = _chart_field_id(qingbi_field)
14245
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
14246
+ return deepcopy(qingbi_field)
14247
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
14248
+ normalized_aggregate = str(aggregate or "sum").strip().lower()
14249
+ aggre_type = {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min"}.get(normalized_aggregate, "sum")
14250
+ return {
14251
+ "fieldId": field_id,
14252
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14253
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
14254
+ "orderType": "default",
14255
+ "alignType": "left",
14256
+ "dateFormat": "yyyy-MM-dd",
14257
+ "numberFormat": "default",
14258
+ "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
14259
+ "digit": None,
14260
+ "aggreType": aggre_type,
14261
+ "orderPriority": None,
14262
+ "width": None,
14263
+ "verticalAlign": "middle",
14264
+ "formula": qingbi_field.get("formula"),
14265
+ "fieldSource": qingbi_field.get("fieldSource") or "default",
14266
+ "status": qingbi_field.get("status"),
14267
+ "supId": qingbi_field.get("supId"),
14268
+ "beingTable": bool(qingbi_field.get("beingTable", False)),
14269
+ "returnType": qingbi_field.get("returnType"),
14270
+ "biFormulaType": qingbi_field.get("biFormulaType"),
14271
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
14272
+ }
14273
+
14274
+
14275
+ def _build_public_semantic_metric_fields(
14276
+ metrics: list[ChartMetricPatch],
14277
+ *,
14278
+ app_key: str,
14279
+ field_lookup: dict[str, dict[str, Any]],
14280
+ chart_field_lookup: dict[str, Any],
14281
+ qingbi_fields_by_id: dict[str, dict[str, Any]],
14282
+ chart_type: str = "chart",
14283
+ ) -> list[dict[str, Any]]:
14284
+ if not metrics:
14285
+ return [_default_public_total_metric()]
14286
+ selected_metrics: list[dict[str, Any]] = []
14287
+ for metric in metrics:
14288
+ op = str(metric.op or "count").strip().lower()
14289
+ field_name = str(metric.field_name or "").strip()
14290
+ if op == "count":
14291
+ if field_name:
14292
+ _raise_chart_rule(
14293
+ rule_code="CHART_COUNT_FIELD_UNSUPPORTED",
14294
+ chart_type=chart_type,
14295
+ message="count metric currently supports count(*) only",
14296
+ expected='Use metric: "count(*)" or {"op": "count"} for record count.',
14297
+ actual={"metric": metric.model_dump(mode="json")},
14298
+ next_action='Use count(*) for count cards; use sum(field), avg(field), max(field), or min(field) for field aggregation.',
14299
+ )
14300
+ selected_metrics.append(_default_public_total_metric())
14088
14301
  continue
14089
- form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
14090
- metrics.append(
14091
- {
14092
- "fieldId": field_id,
14093
- "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14094
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
14095
- "orderType": "default",
14096
- "alignType": "left",
14097
- "dateFormat": "yyyy-MM-dd",
14098
- "numberFormat": "default",
14099
- "numberConfig": {"format": "splitter", "unit": "DEFAULT", "prefix": "", "suffix": "", "digit": None},
14100
- "digit": None,
14101
- "aggreType": {"sum": "sum", "avg": "avg", "average": "avg", "max": "max", "min": "min", "count": "sum", "distinct_count": "sum"}.get(normalized_aggregate, "sum"),
14102
- "orderPriority": None,
14103
- "width": None,
14104
- "verticalAlign": "middle",
14105
- "formula": qingbi_field.get("formula"),
14106
- "fieldSource": qingbi_field.get("fieldSource") or "default",
14107
- "status": qingbi_field.get("status"),
14108
- "supId": qingbi_field.get("supId"),
14109
- "beingTable": bool(qingbi_field.get("beingTable", False)),
14110
- "returnType": qingbi_field.get("returnType"),
14111
- "biFormulaType": qingbi_field.get("biFormulaType"),
14112
- "aggreFieldId": qingbi_field.get("aggreFieldId"),
14113
- }
14302
+ qingbi_field = _resolve_qingbi_chart_field(
14303
+ field_name,
14304
+ chart_field_lookup=chart_field_lookup,
14305
+ chart_type=chart_type,
14306
+ role="metric",
14114
14307
  )
14115
- return metrics or [_default_public_total_metric()]
14308
+ metric_payload = _public_qingbi_metric_field(qingbi_field, aggregate=op)
14309
+ if metric.alias:
14310
+ metric_payload["fieldName"] = metric.alias
14311
+ selected_metrics.append(metric_payload)
14312
+ return selected_metrics or [_default_public_total_metric()]
14116
14313
 
14117
14314
 
14118
14315
  def _split_axis_metric_fields(metrics: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
@@ -14134,73 +14331,6 @@ def _two_gauge_metric_fields(metrics: list[dict[str, Any]], *, requested_metric_
14134
14331
  return [deepcopy(metrics[0]), deepcopy(metrics[1])]
14135
14332
 
14136
14333
 
14137
- def _qingbi_filter_judge_type(operator: str) -> str:
14138
- return {
14139
- ViewFilterOperator.eq.value: "equal",
14140
- ViewFilterOperator.neq.value: "notEqual",
14141
- ViewFilterOperator.gte.value: "greaterOrEqual",
14142
- ViewFilterOperator.lte.value: "lessOrEqual",
14143
- ViewFilterOperator.in_.value: "anyMatch",
14144
- ViewFilterOperator.contains.value: "contains",
14145
- ViewFilterOperator.is_empty.value: "isNull",
14146
- ViewFilterOperator.not_empty.value: "isNotNull",
14147
- }.get(operator, "equal")
14148
-
14149
-
14150
- def _qingbi_option_label_lookup(*, form_field: dict[str, Any], qingbi_field: dict[str, Any]) -> dict[str, str]:
14151
- labels: dict[str, str] = {}
14152
-
14153
- def add_option(raw_option: Any) -> None:
14154
- if isinstance(raw_option, dict):
14155
- label = raw_option.get("value")
14156
- if label is None:
14157
- label = raw_option.get("label") or raw_option.get("name") or raw_option.get("title") or raw_option.get("optValue")
14158
- label_text = str(label).strip() if label is not None else ""
14159
- for key in ("id", "optionId", "option_id", "optId", "value", "label", "name", "title", "optValue"):
14160
- raw_key = raw_option.get(key)
14161
- if raw_key is not None and str(raw_key).strip() and label_text:
14162
- labels[str(raw_key).strip()] = label_text
14163
- elif raw_option is not None:
14164
- label_text = str(raw_option).strip()
14165
- if label_text:
14166
- labels[label_text] = label_text
14167
-
14168
- for option in form_field.get("option_details") or []:
14169
- add_option(option)
14170
- for option in form_field.get("options") or []:
14171
- add_option(option)
14172
- for option in qingbi_field.get("options") or qingbi_field.get("option_details") or []:
14173
- add_option(option)
14174
- return labels
14175
-
14176
-
14177
- def _qingbi_filter_text_values(values: list[Any], *, form_field: dict[str, Any], qingbi_field: dict[str, Any]) -> list[str]:
14178
- labels = _qingbi_option_label_lookup(form_field=form_field, qingbi_field=qingbi_field)
14179
- normalized: list[str] = []
14180
- for value in values:
14181
- if isinstance(value, dict):
14182
- raw_label = value.get("value") or value.get("label") or value.get("name") or value.get("title") or value.get("optValue")
14183
- if raw_label is not None and str(raw_label).strip():
14184
- normalized.append(str(raw_label).strip())
14185
- continue
14186
- raw_id = value.get("id") or value.get("optionId") or value.get("option_id") or value.get("optId")
14187
- raw_text = str(raw_id).strip() if raw_id is not None else ""
14188
- else:
14189
- raw_text = str(value).strip()
14190
- if not raw_text:
14191
- continue
14192
- normalized.append(labels.get(raw_text, raw_text))
14193
- return normalized
14194
-
14195
-
14196
- def _qingbi_filter_judge_value(values: list[str]) -> Any:
14197
- if not values:
14198
- return ""
14199
- if len(values) == 1:
14200
- return values[0]
14201
- return values
14202
-
14203
-
14204
14334
  def _build_public_chart_filter_matrix(
14205
14335
  rules: list[Any],
14206
14336
  *,
@@ -14213,6 +14343,16 @@ def _build_public_chart_filter_matrix(
14213
14343
  if not rules:
14214
14344
  return []
14215
14345
  group: list[dict[str, Any]] = []
14346
+ judge_map = {
14347
+ ViewFilterOperator.eq.value: "equal",
14348
+ ViewFilterOperator.neq.value: "unequal",
14349
+ ViewFilterOperator.gte.value: "greaterOrEqual",
14350
+ ViewFilterOperator.lte.value: "lessOrEqual",
14351
+ ViewFilterOperator.in_.value: "anyMatch",
14352
+ ViewFilterOperator.contains.value: "include",
14353
+ ViewFilterOperator.is_empty.value: "isNull",
14354
+ ViewFilterOperator.not_empty.value: "notNull",
14355
+ }
14216
14356
  for rule in rules:
14217
14357
  qingbi_field = _resolve_qingbi_chart_field(
14218
14358
  getattr(rule, "field_name", None),
@@ -14224,21 +14364,301 @@ def _build_public_chart_filter_matrix(
14224
14364
  form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
14225
14365
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
14226
14366
  values = list(getattr(rule, "values", []) or [])
14227
- text_values = _qingbi_filter_text_values(values, form_field=form_field, qingbi_field=qingbi_field)
14228
14367
  group.append(
14229
14368
  {
14230
14369
  "fieldId": field_id,
14231
14370
  "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14232
14371
  "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
14233
- "judgeType": _qingbi_filter_judge_type(operator),
14234
- "judgeValue": _qingbi_filter_judge_value(text_values),
14235
- "judgeValues": text_values,
14372
+ "judgeType": judge_map.get(operator, "equal"),
14373
+ "judgeValue": _build_qingbi_chart_filter_judge_value(
14374
+ operator=operator,
14375
+ values=values,
14376
+ form_field=form_field,
14377
+ chart_type=chart_type,
14378
+ ),
14379
+ "judgeValueDetailList": [],
14236
14380
  "matchType": 1,
14237
14381
  }
14238
14382
  )
14239
14383
  return [group] if group else []
14240
14384
 
14241
14385
 
14386
+ def _build_qingbi_chart_filter_judge_value(
14387
+ *,
14388
+ operator: str,
14389
+ values: list[Any],
14390
+ form_field: dict[str, Any],
14391
+ chart_type: str,
14392
+ ) -> str | None:
14393
+ if operator in {ViewFilterOperator.is_empty.value, ViewFilterOperator.not_empty.value}:
14394
+ return None
14395
+ if operator == ViewFilterOperator.in_.value:
14396
+ return "<&&>".join(
14397
+ _qingbi_chart_filter_value_to_text(value=value, form_field=form_field, chart_type=chart_type) for value in values
14398
+ )
14399
+ if not values:
14400
+ return ""
14401
+ return _qingbi_chart_filter_value_to_text(value=values[0], form_field=form_field, chart_type=chart_type)
14402
+
14403
+
14404
+ def _qingbi_chart_filter_value_to_text(*, value: Any, form_field: dict[str, Any], chart_type: str) -> str:
14405
+ option_details = [
14406
+ item
14407
+ for item in (form_field.get("option_details") or [])
14408
+ if isinstance(item, dict) and item.get("id") is not None and item.get("value") is not None
14409
+ ]
14410
+ if not option_details:
14411
+ if isinstance(value, dict):
14412
+ for key in ("value", "label", "name", "title"):
14413
+ raw = str(value.get(key) or "").strip()
14414
+ if raw:
14415
+ return raw
14416
+ if value.get("id") is not None:
14417
+ return str(value.get("id"))
14418
+ return _stringify_condition_value(value)
14419
+
14420
+ option_by_value = {str(item.get("value") or "").strip(): item for item in option_details if str(item.get("value") or "").strip()}
14421
+ option_by_id = {str(item.get("id")): item for item in option_details if item.get("id") is not None}
14422
+
14423
+ def resolve(raw: Any) -> str | None:
14424
+ text = _stringify_condition_value(raw).strip()
14425
+ if not text:
14426
+ return None
14427
+ matched = option_by_value.get(text) or option_by_id.get(text)
14428
+ if not matched:
14429
+ return None
14430
+ return str(matched.get("value") or "").strip()
14431
+
14432
+ def reject(raw: Any) -> NoReturn:
14433
+ allowed = [
14434
+ {"id": str(item.get("id")), "value": str(item.get("value"))}
14435
+ for item in option_details
14436
+ ]
14437
+ _raise_chart_rule(
14438
+ rule_code="CHART_FILTER_OPTION_VALUE_UNSUPPORTED",
14439
+ chart_type=chart_type,
14440
+ message="chart filter value is not in the field option list",
14441
+ expected="Use an existing option label or option id from the target field schema.",
14442
+ actual={
14443
+ "field_name": form_field.get("name"),
14444
+ "field_id": form_field.get("que_id"),
14445
+ "value": raw,
14446
+ "allowed_values": allowed,
14447
+ },
14448
+ offending_fields=[{"field_name": form_field.get("name"), "field_id": form_field.get("que_id"), "options": allowed}],
14449
+ next_action="Read schema first, then pass one of the allowed option labels or option ids.",
14450
+ )
14451
+
14452
+ if isinstance(value, dict):
14453
+ for key in ("value", "label", "name", "title"):
14454
+ if value.get(key) is not None:
14455
+ resolved = resolve(value.get(key))
14456
+ if resolved is not None:
14457
+ return resolved
14458
+ reject(value.get(key))
14459
+ if value.get("id") is not None:
14460
+ resolved = resolve(value.get("id"))
14461
+ if resolved is not None:
14462
+ return resolved
14463
+ reject(value.get("id"))
14464
+ reject(value)
14465
+ if isinstance(value, int):
14466
+ resolved = resolve(value)
14467
+ if resolved is not None:
14468
+ return resolved
14469
+ reject(value)
14470
+ resolved = resolve(value)
14471
+ if resolved is not None:
14472
+ return resolved
14473
+ reject(value)
14474
+
14475
+
14476
+ def _chart_field_names_by_id_from_public_fields(*, app_key: str, fields: list[dict[str, Any]]) -> dict[str, str]:
14477
+ field_name_by_id: dict[str, str] = {}
14478
+ for field in fields:
14479
+ if not isinstance(field, dict):
14480
+ continue
14481
+ name = str(field.get("name") or "").strip()
14482
+ if not name:
14483
+ continue
14484
+ que_id = field.get("que_id")
14485
+ field_id = str(field.get("field_id") or "").strip()
14486
+ for raw_key in (
14487
+ que_id,
14488
+ field_id,
14489
+ f"{app_key}:{que_id}" if que_id is not None else None,
14490
+ f"{app_key}:{field_id}" if field_id else None,
14491
+ ):
14492
+ key = str(raw_key or "").strip()
14493
+ if key:
14494
+ field_name_by_id.setdefault(key, name)
14495
+ return field_name_by_id
14496
+
14497
+
14498
+ def _public_chart_filter_groups_from_qingbi_config(
14499
+ config: dict[str, Any],
14500
+ *,
14501
+ field_name_by_id: dict[str, str] | None = None,
14502
+ ) -> list[list[dict[str, Any]]]:
14503
+ groups: list[list[dict[str, Any]]] = []
14504
+ raw_groups = config.get("beforeAggregationFilterMatrix")
14505
+ if not isinstance(raw_groups, list):
14506
+ return groups
14507
+ resolved_field_name_by_id = field_name_by_id or {}
14508
+ for raw_group in raw_groups:
14509
+ if not isinstance(raw_group, list):
14510
+ continue
14511
+ group: list[dict[str, Any]] = []
14512
+ for raw_rule in raw_group:
14513
+ if not isinstance(raw_rule, dict):
14514
+ continue
14515
+ operator = _public_chart_filter_operator_from_judge_type(raw_rule.get("judgeType"))
14516
+ field_id = raw_rule.get("fieldId") or raw_rule.get("field_id")
14517
+ field_id_text = _stringify_condition_value(field_id).strip() if field_id is not None else ""
14518
+ raw_field_name = (
14519
+ raw_rule.get("fieldName")
14520
+ or raw_rule.get("field_name")
14521
+ or raw_rule.get("queTitle")
14522
+ or raw_rule.get("title")
14523
+ )
14524
+ raw_field_name_text = _stringify_condition_value(raw_field_name).strip()
14525
+ field_name = (
14526
+ resolved_field_name_by_id.get(raw_field_name_text)
14527
+ or resolved_field_name_by_id.get(field_id_text)
14528
+ or raw_field_name_text
14529
+ or field_id_text
14530
+ )
14531
+ public_rule: dict[str, Any] = {
14532
+ "field_name": field_name,
14533
+ "operator": operator,
14534
+ }
14535
+ if field_id is not None:
14536
+ public_rule["field_id"] = field_id_text
14537
+ values = _public_chart_filter_values_from_rule(raw_rule, operator=operator)
14538
+ if values:
14539
+ public_rule["values"] = values
14540
+ group.append(public_rule)
14541
+ if group:
14542
+ groups.append(group)
14543
+ return groups
14544
+
14545
+
14546
+ def _public_chart_group_by_from_qingbi_config(config: dict[str, Any]) -> list[str]:
14547
+ fields: list[dict[str, Any]] = []
14548
+ for key in ("selectedDimensions", "xDimensions", "yDimensions", "selectedTime"):
14549
+ fields.extend(_chart_fields(config, key))
14550
+ group_by: list[str] = []
14551
+ seen: set[str] = set()
14552
+ for field in fields:
14553
+ name = _stringify_condition_value(
14554
+ field.get("fieldName")
14555
+ or field.get("field_name")
14556
+ or field.get("queTitle")
14557
+ or field.get("title")
14558
+ or field.get("fieldId")
14559
+ or field.get("field_id")
14560
+ ).strip()
14561
+ if not name or name in seen:
14562
+ continue
14563
+ seen.add(name)
14564
+ group_by.append(name)
14565
+ return group_by
14566
+
14567
+
14568
+ def _public_chart_metrics_from_qingbi_config(config: dict[str, Any]) -> list[dict[str, Any]]:
14569
+ fields: list[dict[str, Any]] = []
14570
+ for key in ("selectedMetrics", "xMetrics", "yMetrics", "leftMetrics", "rightMetrics"):
14571
+ fields.extend(_chart_fields(config, key))
14572
+ metrics: list[dict[str, Any]] = []
14573
+ seen: set[tuple[str, str]] = set()
14574
+ for field in fields:
14575
+ field_id = _chart_field_id(field)
14576
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
14577
+ metric = {"op": "count", "expr": "count(*)"}
14578
+ else:
14579
+ op = str(field.get("aggreType") or field.get("aggregate") or "sum").strip().lower()
14580
+ if op == "average":
14581
+ op = "avg"
14582
+ field_name = _stringify_condition_value(
14583
+ field.get("fieldName")
14584
+ or field.get("field_name")
14585
+ or field.get("queTitle")
14586
+ or field.get("title")
14587
+ or field_id
14588
+ ).strip()
14589
+ metric = {"op": op or "sum", "field_name": field_name}
14590
+ if field_id:
14591
+ metric["field_id"] = field_id
14592
+ metric["expr"] = f"{metric['op']}({field_name})" if field_name else metric["op"]
14593
+ identity = (str(metric.get("op") or ""), str(metric.get("field_id") or metric.get("field_name") or metric.get("expr") or ""))
14594
+ if identity in seen:
14595
+ continue
14596
+ seen.add(identity)
14597
+ metrics.append(metric)
14598
+ return metrics
14599
+
14600
+
14601
+ def _public_chart_filter_operator_from_judge_type(judge_type: Any) -> str:
14602
+ normalized = _stringify_condition_value(judge_type).strip()
14603
+ mapping = {
14604
+ "equal": "eq",
14605
+ "equals": "eq",
14606
+ "=": "eq",
14607
+ "unequal": "neq",
14608
+ "notEqual": "neq",
14609
+ "!=": "neq",
14610
+ "anyMatch": "in",
14611
+ "oneOf": "in",
14612
+ "include": "contains",
14613
+ "contains": "contains",
14614
+ "greaterOrEqual": "gte",
14615
+ "gte": "gte",
14616
+ "lessOrEqual": "lte",
14617
+ "lte": "lte",
14618
+ "isNull": "is_empty",
14619
+ "empty": "is_empty",
14620
+ "notNull": "not_empty",
14621
+ "not_empty": "not_empty",
14622
+ }
14623
+ if normalized in mapping:
14624
+ return mapping[normalized]
14625
+ numeric = _coerce_nonnegative_int(judge_type)
14626
+ if numeric is not None:
14627
+ return _public_view_filter_operator_from_judge_type(numeric)
14628
+ return f"judge_type:{judge_type}"
14629
+
14630
+
14631
+ def _public_chart_filter_values_from_rule(rule: dict[str, Any], *, operator: str) -> list[str]:
14632
+ if operator in {"is_empty", "not_empty"}:
14633
+ return []
14634
+ raw_value = rule.get("judgeValue")
14635
+ if raw_value is None and "judgeValues" in rule:
14636
+ raw_value = rule.get("judgeValues")
14637
+ values: list[str] = []
14638
+ if isinstance(raw_value, list):
14639
+ values = [_stringify_condition_value(value).strip() for value in raw_value]
14640
+ elif isinstance(raw_value, str) and "<&&>" in raw_value:
14641
+ values = [value.strip() for value in raw_value.split("<&&>")]
14642
+ elif raw_value is not None:
14643
+ values = [_stringify_condition_value(raw_value).strip()]
14644
+ values = [value for value in values if value]
14645
+ if values:
14646
+ return values
14647
+ for detail_key in ("judgeValueDetailList", "judgeValueDetails"):
14648
+ details = rule.get(detail_key)
14649
+ if not isinstance(details, list):
14650
+ continue
14651
+ for detail in details:
14652
+ if not isinstance(detail, dict):
14653
+ continue
14654
+ for value_key in ("value", "label", "name", "title", "dataValue", "id"):
14655
+ value = _stringify_condition_value(detail.get(value_key)).strip()
14656
+ if value:
14657
+ values.append(value)
14658
+ break
14659
+ return values
14660
+
14661
+
14242
14662
  def _build_public_chart_config_payload(
14243
14663
  *,
14244
14664
  patch: ChartUpsertPatch,
@@ -14249,9 +14669,14 @@ def _build_public_chart_config_payload(
14249
14669
  ) -> dict[str, Any]:
14250
14670
  config = deepcopy(patch.config)
14251
14671
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
14252
- if "dimension_field_ids" in explicit_fields:
14672
+ semantic_dimension_fields = bool({"dimension_field_ids", "group_by", "rows", "columns"} & explicit_fields)
14673
+ semantic_metric_fields = bool(
14674
+ {"indicator_field_ids", "metric", "metrics", "x_metric", "y_metric", "left_metric", "right_metric", "value_metric", "target_metric"}
14675
+ & explicit_fields
14676
+ )
14677
+ if semantic_dimension_fields:
14253
14678
  config.pop("selectedDimensions", None)
14254
- if "indicator_field_ids" in explicit_fields:
14679
+ if semantic_metric_fields:
14255
14680
  config.pop("selectedMetrics", None)
14256
14681
  if "filters" in explicit_fields:
14257
14682
  config.pop("beforeAggregationFilterMatrix", None)
@@ -14277,8 +14702,8 @@ def _build_public_chart_config_payload(
14277
14702
  )
14278
14703
  query_condition_field_ids.append(_chart_field_id(field))
14279
14704
  backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
14280
- if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
14281
- raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
14705
+ if backend_chart_type == "gauge" and not patch.indicator_field_ids and not patch.metrics and "selectedMetrics" not in config:
14706
+ raise ValueError("gauge charts require at least one metric; pass value_metric or metric and the CLI will pair it with 数据总量")
14282
14707
  selected_dimensions = _build_public_dimension_fields(
14283
14708
  patch.dimension_field_ids,
14284
14709
  app_key=app_key,
@@ -14287,15 +14712,25 @@ def _build_public_chart_config_payload(
14287
14712
  qingbi_fields_by_id=qingbi_fields_by_id,
14288
14713
  chart_type=patch.chart_type.value,
14289
14714
  )
14290
- selected_metrics = _build_public_metric_fields(
14291
- patch.indicator_field_ids,
14292
- app_key=app_key,
14293
- field_lookup=field_lookup,
14294
- chart_field_lookup=chart_field_lookup,
14295
- qingbi_fields_by_id=qingbi_fields_by_id,
14296
- aggregate=aggregate,
14297
- chart_type=patch.chart_type.value,
14298
- )
14715
+ if patch.metrics:
14716
+ selected_metrics = _build_public_semantic_metric_fields(
14717
+ patch.metrics,
14718
+ app_key=app_key,
14719
+ field_lookup=field_lookup,
14720
+ chart_field_lookup=chart_field_lookup,
14721
+ qingbi_fields_by_id=qingbi_fields_by_id,
14722
+ chart_type=patch.chart_type.value,
14723
+ )
14724
+ else:
14725
+ selected_metrics = _build_public_metric_fields(
14726
+ patch.indicator_field_ids,
14727
+ app_key=app_key,
14728
+ field_lookup=field_lookup,
14729
+ chart_field_lookup=chart_field_lookup,
14730
+ qingbi_fields_by_id=qingbi_fields_by_id,
14731
+ aggregate=aggregate,
14732
+ chart_type=patch.chart_type.value,
14733
+ )
14299
14734
  payload: dict[str, Any] = {
14300
14735
  "chartName": patch.name,
14301
14736
  "chartType": backend_chart_type,
@@ -14320,7 +14755,15 @@ def _build_public_chart_config_payload(
14320
14755
  if backend_chart_type == "summary":
14321
14756
  payload.pop("selectedDimensions", None)
14322
14757
  payload.setdefault("xDimensions", deepcopy(selected_dimensions))
14323
- payload.setdefault("yDimensions", [])
14758
+ y_dimensions = _build_public_dimension_fields(
14759
+ patch.columns,
14760
+ app_key=app_key,
14761
+ field_lookup=field_lookup,
14762
+ chart_field_lookup=chart_field_lookup,
14763
+ qingbi_fields_by_id=qingbi_fields_by_id,
14764
+ chart_type=patch.chart_type.value,
14765
+ )
14766
+ payload.setdefault("yDimensions", y_dimensions)
14324
14767
  elif backend_chart_type == "scatter":
14325
14768
  x_metrics, y_metrics = _split_axis_metric_fields(selected_metrics)
14326
14769
  payload.pop("selectedMetrics", None)
@@ -14354,7 +14797,26 @@ def _build_public_chart_config_payload(
14354
14797
 
14355
14798
  def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
14356
14799
  explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
14357
- return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
14800
+ return bool(
14801
+ {
14802
+ "dimension_field_ids",
14803
+ "indicator_field_ids",
14804
+ "group_by",
14805
+ "rows",
14806
+ "columns",
14807
+ "metric",
14808
+ "metrics",
14809
+ "x_metric",
14810
+ "y_metric",
14811
+ "left_metric",
14812
+ "right_metric",
14813
+ "value_metric",
14814
+ "target_metric",
14815
+ "filters",
14816
+ "config",
14817
+ }
14818
+ & explicit_fields
14819
+ )
14358
14820
 
14359
14821
 
14360
14822
  def _chart_patch_dataset_source_type(patch: ChartUpsertPatch) -> str:
@@ -21871,7 +22333,7 @@ def _translate_flow_condition_rule(*, field: dict[str, Any], rule: dict[str, Any
21871
22333
  base["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
21872
22334
  base["judgeValues"] = [_stringify_condition_value(value) for value in values]
21873
22335
  elif operator == "contains":
21874
- base["judgeType"] = JUDGE_FUZZY_MATCH
22336
+ base["judgeType"] = JUDGE_INCLUDE
21875
22337
  base["judgeValues"] = [_stringify_condition_value(values[0])]
21876
22338
  elif operator == "gte":
21877
22339
  base["judgeType"] = JUDGE_GREATER_OR_EQUAL
@@ -23638,7 +24100,7 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
23638
24100
  payload["judgeType"] = JUDGE_INCLUDE_ANY if field_type in INCLUDE_ANY_FLOW_FIELD_TYPES else JUDGE_EQUAL_ANY
23639
24101
  payload["judgeValues"] = judge_values
23640
24102
  elif operator == "contains":
23641
- payload["judgeType"] = JUDGE_FUZZY_MATCH
24103
+ payload["judgeType"] = JUDGE_INCLUDE
23642
24104
  payload["judgeValues"] = judge_values[:1] if judge_values else []
23643
24105
  elif operator == "gte":
23644
24106
  payload["judgeType"] = JUDGE_GREATER_OR_EQUAL