@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
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@1.1.26
6
+ npm install @josephyan/qingflow-app-user-mcp@1.1.28
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@1.1.26 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@1.1.28 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
@@ -30,12 +30,10 @@ Bundled skills:
30
30
  - `skills/qingflow-task-ops`
31
31
  - `skills/qingflow-record-analysis`
32
32
  - `skills/qingflow-mcp-setup`
33
- - `skills/qingflow-app-builder`
34
- - `skills/qingflow-app-builder-code-integrations`
35
- - `skills/qingflow-workflow-builder`
36
33
 
37
34
  Note:
38
35
 
39
36
  - The skill files are included in the npm package.
40
- - On install, the package copies them to `$CODEX_HOME/skills` (or `~/.codex/skills` if `CODEX_HOME` is unset).
37
+ - On install, the package installs bundled skills into `$CODEX_HOME/skills` (or `~/.codex/skills` if `CODEX_HOME` is unset) by symlink and refreshes them on package updates.
38
+ - You can also run `qingflow-app-user-mcp skills list` or `qingflow-app-user-mcp skills install --agent codex|claude-code|cursor|generic --scope user|project [--copy] [--force]`.
41
39
  - If a stdio MCP client reports `Transport closed`, delete `.npm-python`, reinstall the package, and make sure CLI/user/builder packages are on the same version. The stdio entrypoints refuse runtime bootstrap so install logs never corrupt MCP stdout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "1.1.26",
3
+ "version": "1.1.28",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.26"
7
+ version = "1.1.28"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow App User
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.30.1`
11
+
10
12
  ## Overview
11
13
 
12
14
  This skill is a lightweight router for operational Qingflow work.
@@ -39,7 +41,7 @@ Route to exactly one of these specialized paths:
39
41
  Switch to [$qingflow-mcp-setup](../qingflow-mcp-setup/SKILL.md)
40
42
 
41
43
  8. App / view / workflow / chart / portal / package configuration
42
- Switch to [$qingflow-app-builder](../qingflow-app-builder/SKILL.md)
44
+ This is builder work, not app-user work. Use the `qingflow-cli` skill or install/use the `qingflow-app-builder` package.
43
45
 
44
46
  ## Routing Rules
45
47
 
@@ -50,7 +52,7 @@ Route to exactly one of these specialized paths:
50
52
  - If the task is about deleting records directly, switch to `$qingflow-record-delete`
51
53
  - If the task is about import templates, import capability discovery, import-file verification, authorized local file repair, import execution, or import status, switch to `$qingflow-record-import`
52
54
  - If the task is about todo discovery, task context, approval actions, rollback or transfer, associated report review, or workflow log review, switch to `$qingflow-task-ops`
53
- - If the task is about package, app, field, layout, workflow, view, chart, portal, visibility, icon, or app base configuration, switch to `$qingflow-app-builder`
55
+ - If the task is about package, app, field, layout, workflow, view, chart, portal, visibility, icon, or app base configuration, stop this app-user route and use the `qingflow-cli` skill or the `qingflow-app-builder` package
54
56
  - If the task involves member, department, or relation fields and the user only has natural names/titles, keep the same route; direct write now supports backend-native auto resolution and may return `needs_confirmation` with candidates instead of failing blind
55
57
  - If the task involves linked visibility, upstream/downstream field dependencies, reference-driven auto fill, or formula-driven defaulting, keep the same insert/update route and read field-level `linkage` from the schema before composing payloads
56
58
  - If the task is about subtable writes, still route to the matching insert/update skill, but shape the payload as parent subtable field -> row array; do not route users toward top-level leaf selectors
@@ -86,4 +88,4 @@ Route to exactly one of these specialized paths:
86
88
  - Record import: [$qingflow-record-import](../qingflow-record-import/SKILL.md)
87
89
  - Task workflow operations: [$qingflow-task-ops](../qingflow-task-ops/SKILL.md)
88
90
  - Dedicated analysis workflow: [$qingflow-record-analysis](../qingflow-record-analysis/SKILL.md)
89
- - Builder workflow: [$qingflow-app-builder](../qingflow-app-builder/SKILL.md)
91
+ - Builder workflow: use the `qingflow-cli` skill or the `qingflow-app-builder` package
@@ -33,20 +33,22 @@ It is not a user-facing product spec. It exists to prevent skill drift.
33
33
 
34
34
  ### Builder
35
35
 
36
+ - Builder work belongs to the `qingflow-cli` skill or the `qingflow-app-builder` package; this app-user package only records the boundary to prevent routing drift.
36
37
  - Official package entry: `package_get`, `package_apply`
37
38
  - Official builder writes:
38
39
  - `app_schema_apply`
39
40
  - `app_layout_apply`
40
41
  - `app_flow_apply`
41
42
  - `app_views_apply`
42
- - `app_custom_buttons_apply`
43
43
  - `app_associated_resources_apply`
44
44
  - `app_charts_apply`
45
45
  - `portal_apply`
46
46
  - `app_publish_verify`
47
+ - Complete-system main path: package -> schema apps with `form.sections` and `target_app_ref` -> views with `action_buttons` -> portal with inline `sections[].chart`
48
+ - Single-app main path: resolve/get -> schema with `form.sections` -> views with `action_buttons`; do not default to portal
47
49
  - `portal_apply` edit mode may omit `sections` for base-info-only updates
48
50
  - `app_charts_apply.visibility` is a public capability and should be treated as a base-only visibility update
49
- - Existing object parameter replacement should use `patch_views`, `patch_buttons`, `patch_resources`, and `patch_charts`; `upsert_*` is for creation or full target config
51
+ - Existing object parameter replacement should use `patch_views`, `patch_resources`, and `patch_charts`; `upsert_*` is for creation or full target config
50
52
  - `app_get.editability` uses:
51
53
  - `can_edit_app_base`
52
54
  - `can_edit_form`
@@ -72,6 +72,9 @@ class PublicExternalVisibilityMode(str, Enum):
72
72
 
73
73
  FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
74
74
  "textarea": PublicFieldType.long_text,
75
+ "multiline": PublicFieldType.long_text,
76
+ "multi_line": PublicFieldType.long_text,
77
+ "multiline_text": PublicFieldType.long_text,
75
78
  "amount": PublicFieldType.amount,
76
79
  "currency": PublicFieldType.amount,
77
80
  "mobile": PublicFieldType.phone,
@@ -1456,6 +1459,9 @@ class CustomButtonViewButtonBindingPatch(StrictModel):
1456
1459
  raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
1457
1460
  if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1458
1461
  payload["button_limit"] = [raw_limits]
1462
+ for alias in ("buttonLimit", "visible_when", "visibleWhen"):
1463
+ if alias in payload and alias != "button_limit":
1464
+ payload.pop(alias, None)
1459
1465
  return payload
1460
1466
 
1461
1467
 
@@ -1642,7 +1648,7 @@ class ViewButtonBindingPatch(StrictModel):
1642
1648
  being_main: bool = Field(default=False, validation_alias=AliasChoices("being_main", "beingMain"))
1643
1649
  button_limit: list[list[ViewFilterRulePatch]] = Field(
1644
1650
  default_factory=list,
1645
- validation_alias=AliasChoices("button_limit", "buttonLimit"),
1651
+ validation_alias=AliasChoices("button_limit", "buttonLimit", "visible_when", "visibleWhen"),
1646
1652
  )
1647
1653
  button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
1648
1654
  button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
@@ -1671,9 +1677,12 @@ class ViewButtonBindingPatch(StrictModel):
1671
1677
  payload["config_type"] = "DETAIL"
1672
1678
  elif normalized_config in {"inside", "list", "row", "row_action"}:
1673
1679
  payload["config_type"] = "INSIDE"
1674
- raw_limits = payload.get("button_limit", payload.get("buttonLimit"))
1680
+ raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
1675
1681
  if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1676
1682
  payload["button_limit"] = [raw_limits]
1683
+ for alias in ("buttonLimit", "visible_when", "visibleWhen"):
1684
+ if alias in payload and alias != "button_limit":
1685
+ payload.pop(alias, None)
1677
1686
  return payload
1678
1687
 
1679
1688
  @model_validator(mode="after")
@@ -1763,68 +1772,63 @@ class ChartFilterRulePatch(StrictModel):
1763
1772
  return self
1764
1773
 
1765
1774
 
1766
- _CHART_METRIC_RE = re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)\s*$")
1767
-
1768
-
1769
- def _normalize_chart_string_list(value: Any) -> list[str]:
1770
- if value is None:
1771
- return []
1772
- if isinstance(value, (list, tuple)):
1773
- return [str(item).strip() for item in value if item is not None and str(item).strip()]
1774
- raw = str(value).strip()
1775
- return [raw] if raw else []
1775
+ class ChartMetricPatch(StrictModel):
1776
+ op: str = "count"
1777
+ field_name: str | None = Field(default=None, validation_alias=AliasChoices("field", "field_name", "fieldName", "name"))
1778
+ alias: str | None = None
1776
1779
 
1780
+ @model_validator(mode="before")
1781
+ @classmethod
1782
+ def normalize_metric(cls, value: Any) -> Any:
1783
+ if isinstance(value, str):
1784
+ raw = value.strip()
1785
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)", raw)
1786
+ if match:
1787
+ op = match.group(1).strip().lower()
1788
+ field = match.group(2).strip()
1789
+ payload: dict[str, Any] = {"op": op}
1790
+ if field and field != "*":
1791
+ payload["field_name"] = field
1792
+ return payload
1793
+ if raw:
1794
+ return {"op": "sum", "field_name": raw}
1795
+ return {"op": "count"}
1796
+ if not isinstance(value, dict):
1797
+ return value
1798
+ payload = dict(value)
1799
+ if "operation" in payload and "op" not in payload:
1800
+ payload["op"] = payload.pop("operation")
1801
+ if "agg" in payload and "op" not in payload:
1802
+ payload["op"] = payload.pop("agg")
1803
+ if "aggregate" in payload and "op" not in payload:
1804
+ payload["op"] = payload.pop("aggregate")
1805
+ if "aggregation" in payload and "op" not in payload:
1806
+ payload["op"] = payload.pop("aggregation")
1807
+ return payload
1777
1808
 
1778
- def _parse_chart_metric_item(value: Any) -> tuple[list[str], str | None]:
1779
- if value is None:
1780
- return [], None
1781
- if isinstance(value, dict):
1782
- op = str(value.get("op") or value.get("agg") or value.get("aggregate") or value.get("aggregation") or "").strip().lower()
1783
- field = value.get("field", value.get("field_name", value.get("name", value.get("selector"))))
1784
- selectors = _normalize_chart_string_list(field)
1785
- return selectors, op or ("count" if not selectors else None)
1786
- raw = str(value).strip()
1787
- if not raw:
1788
- return [], None
1789
- matched = _CHART_METRIC_RE.match(raw)
1790
- if matched:
1791
- op = matched.group(1).strip().lower()
1792
- field = matched.group(2).strip()
1793
- if field in {"*", ""}:
1794
- return [], op
1795
- return [field], op
1796
- if raw.lower() in {"count", "count(*)"}:
1797
- return [], "count"
1798
- return [raw], None
1799
-
1800
-
1801
- def _apply_semantic_chart_metric_aliases(payload: dict[str, Any]) -> None:
1802
- raw_metrics: list[Any] = []
1803
- if "metric" in payload:
1804
- raw_metrics.append(payload.pop("metric"))
1805
- if "metrics" in payload:
1806
- metrics = payload.pop("metrics")
1807
- raw_metrics.extend(metrics if isinstance(metrics, list) else [metrics])
1808
- if not raw_metrics:
1809
- return
1810
- selectors: list[str] = []
1811
- aggregates: list[str] = []
1812
- for metric in raw_metrics:
1813
- metric_selectors, aggregate = _parse_chart_metric_item(metric)
1814
- selectors.extend(metric_selectors)
1815
- if aggregate:
1816
- aggregates.append(aggregate)
1817
- if selectors and "indicator_field_ids" not in payload:
1818
- payload["indicator_field_ids"] = selectors
1819
- if aggregates:
1820
- normalized_aggregate = aggregates[0]
1821
- config = payload.get("config")
1822
- if not isinstance(config, dict):
1823
- config = {}
1824
- else:
1825
- config = dict(config)
1826
- config.setdefault("aggregate", normalized_aggregate)
1827
- payload["config"] = config
1809
+ @model_validator(mode="after")
1810
+ def validate_metric(self) -> "ChartMetricPatch":
1811
+ normalized_op = str(self.op or "count").strip().lower()
1812
+ op_aliases = {
1813
+ "average": "avg",
1814
+ "mean": "avg",
1815
+ "total": "sum",
1816
+ "cnt": "count",
1817
+ "count_all": "count",
1818
+ }
1819
+ self.op = op_aliases.get(normalized_op, normalized_op)
1820
+ if self.field_name is not None:
1821
+ field_name = str(self.field_name).strip()
1822
+ self.field_name = field_name or None
1823
+ if self.alias is not None:
1824
+ alias = str(self.alias).strip()
1825
+ self.alias = alias or None
1826
+ supported = {"count", "sum", "avg", "max", "min"}
1827
+ if self.op not in supported:
1828
+ raise ValueError(f"chart metric op must be one of {sorted(supported)}")
1829
+ if self.op != "count" and not self.field_name:
1830
+ raise ValueError(f"chart metric op '{self.op}' requires field")
1831
+ return self
1828
1832
 
1829
1833
 
1830
1834
  class ChartUpsertPatch(StrictModel):
@@ -1834,6 +1838,17 @@ class ChartUpsertPatch(StrictModel):
1834
1838
  dimension_field_ids: list[str] = Field(default_factory=list)
1835
1839
  indicator_field_ids: list[str] = Field(default_factory=list)
1836
1840
  filters: list[ChartFilterRulePatch] = Field(default_factory=list)
1841
+ group_by: list[str] = Field(default_factory=list)
1842
+ rows: list[str] = Field(default_factory=list)
1843
+ columns: list[str] = Field(default_factory=list)
1844
+ metric: ChartMetricPatch | None = None
1845
+ metrics: list[ChartMetricPatch] = Field(default_factory=list)
1846
+ x_metric: ChartMetricPatch | None = None
1847
+ y_metric: ChartMetricPatch | None = None
1848
+ left_metric: ChartMetricPatch | None = None
1849
+ right_metric: ChartMetricPatch | None = None
1850
+ value_metric: ChartMetricPatch | None = None
1851
+ target_metric: ChartMetricPatch | None = None
1837
1852
  question_config: list[dict[str, Any]] = Field(default_factory=list)
1838
1853
  user_config: list[dict[str, Any]] = Field(default_factory=list)
1839
1854
  config: dict[str, Any] = Field(default_factory=dict)
@@ -1851,19 +1866,74 @@ class ChartUpsertPatch(StrictModel):
1851
1866
  payload["chart_type"] = payload.pop("type")
1852
1867
  if "dimension_fields" in payload and "dimension_field_ids" not in payload:
1853
1868
  payload["dimension_field_ids"] = payload.pop("dimension_fields")
1869
+ if "dimensions" in payload and "dimension_field_ids" not in payload and "group_by" not in payload:
1870
+ payload["group_by"] = payload.pop("dimensions")
1871
+ if "groupBy" in payload and "group_by" not in payload:
1872
+ payload["group_by"] = payload.pop("groupBy")
1873
+ if "where" in payload and "filters" not in payload:
1874
+ payload["filters"] = payload.pop("where")
1875
+ if "filter_rules" in payload and "filters" not in payload:
1876
+ payload["filters"] = payload.pop("filter_rules")
1877
+ if "filterRules" in payload and "filters" not in payload:
1878
+ payload["filters"] = payload.pop("filterRules")
1854
1879
  if "indicator_fields" in payload and "indicator_field_ids" not in payload:
1855
1880
  payload["indicator_field_ids"] = payload.pop("indicator_fields")
1856
1881
  if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
1857
1882
  payload["indicator_field_ids"] = payload.pop("metric_field_ids")
1858
- if "group_by" in payload and "dimension_field_ids" not in payload:
1859
- payload["dimension_field_ids"] = _normalize_chart_string_list(payload.pop("group_by"))
1860
- if "groupBy" in payload and "dimension_field_ids" not in payload:
1861
- payload["dimension_field_ids"] = _normalize_chart_string_list(payload.pop("groupBy"))
1862
- if "where" in payload and "filters" not in payload:
1863
- payload["filters"] = payload.pop("where")
1864
- else:
1865
- payload.pop("where", None)
1866
- _apply_semantic_chart_metric_aliases(payload)
1883
+ metric_slots: list[Any] = []
1884
+ generic_metric_keys = ("metric", "metrics")
1885
+ axis_metric_keys = (
1886
+ "x_metric",
1887
+ "xMetric",
1888
+ "y_metric",
1889
+ "yMetric",
1890
+ "left_metric",
1891
+ "leftMetric",
1892
+ "right_metric",
1893
+ "rightMetric",
1894
+ "value_metric",
1895
+ "valueMetric",
1896
+ "target_metric",
1897
+ "targetMetric",
1898
+ )
1899
+
1900
+ def has_metric_value(key: str) -> bool:
1901
+ entry = payload.get(key)
1902
+ return entry is not None and entry != "" and entry != []
1903
+
1904
+ if any(has_metric_value(key) for key in generic_metric_keys) and any(has_metric_value(key) for key in axis_metric_keys):
1905
+ raise ValueError(
1906
+ "chart metric input is ambiguous: use either metric/metrics or axis-specific "
1907
+ "x_metric/y_metric, left_metric/right_metric, value_metric/target_metric, not both"
1908
+ )
1909
+ if "metric" in payload and "metrics" not in payload:
1910
+ payload["metrics"] = [payload["metric"]]
1911
+ for key in ("x_metric", "xMetric", "left_metric", "leftMetric", "value_metric", "valueMetric"):
1912
+ if key in payload:
1913
+ slot_value = payload.get(key)
1914
+ if slot_value is not None:
1915
+ metric_slots.append(slot_value)
1916
+ canonical = {
1917
+ "xMetric": "x_metric",
1918
+ "leftMetric": "left_metric",
1919
+ "valueMetric": "value_metric",
1920
+ }.get(key)
1921
+ if canonical and canonical not in payload:
1922
+ payload[canonical] = payload.pop(key)
1923
+ for key in ("y_metric", "yMetric", "right_metric", "rightMetric", "target_metric", "targetMetric"):
1924
+ if key in payload:
1925
+ slot_value = payload.get(key)
1926
+ if slot_value is not None:
1927
+ metric_slots.append(slot_value)
1928
+ canonical = {
1929
+ "yMetric": "y_metric",
1930
+ "rightMetric": "right_metric",
1931
+ "targetMetric": "target_metric",
1932
+ }.get(key)
1933
+ if canonical and canonical not in payload:
1934
+ payload[canonical] = payload.pop(key)
1935
+ if metric_slots and "metrics" not in payload:
1936
+ payload["metrics"] = metric_slots
1867
1937
  raw_type = payload.get("chart_type")
1868
1938
  if isinstance(raw_type, str):
1869
1939
  normalized = raw_type.strip().lower()
@@ -1890,8 +1960,31 @@ class ChartUpsertPatch(StrictModel):
1890
1960
  payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
1891
1961
  if isinstance(payload.get("indicator_field_ids"), list):
1892
1962
  payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
1963
+ for key in ("group_by", "rows", "columns"):
1964
+ if isinstance(payload.get(key), str):
1965
+ payload[key] = [payload[key]]
1966
+ if isinstance(payload.get(key), list):
1967
+ payload[key] = [str(item) for item in payload[key] if item is not None and str(item).strip()]
1893
1968
  return payload
1894
1969
 
1970
+ @model_validator(mode="after")
1971
+ def apply_semantic_chart_fields(self) -> "ChartUpsertPatch":
1972
+ if self.group_by and not self.dimension_field_ids:
1973
+ self.dimension_field_ids = list(self.group_by)
1974
+ if self.rows and not self.dimension_field_ids:
1975
+ self.dimension_field_ids = list(self.rows)
1976
+ semantic_metrics: list[ChartMetricPatch] = []
1977
+ if self.metrics:
1978
+ semantic_metrics.extend(self.metrics)
1979
+ for metric in (self.x_metric, self.y_metric, self.left_metric, self.right_metric, self.value_metric, self.target_metric):
1980
+ if metric is not None and metric not in semantic_metrics:
1981
+ semantic_metrics.append(metric)
1982
+ if semantic_metrics and not self.metrics:
1983
+ self.metrics = semantic_metrics
1984
+ if self.metrics and not self.metric:
1985
+ self.metric = self.metrics[0]
1986
+ return self
1987
+
1895
1988
 
1896
1989
  class ChartPartialPatch(StrictModel):
1897
1990
  chart_id: str | None = None
@@ -2287,6 +2380,9 @@ class ChartGetResponse(StrictModel):
2287
2380
  chart_id: str
2288
2381
  base: dict[str, Any] = Field(default_factory=dict)
2289
2382
  visibility: dict[str, Any] = Field(default_factory=dict)
2383
+ filters: list[list[dict[str, Any]]] = Field(default_factory=list)
2384
+ group_by: list[str] = Field(default_factory=list)
2385
+ metrics: list[dict[str, Any]] = Field(default_factory=list)
2290
2386
  config: dict[str, Any] = Field(default_factory=dict)
2291
2387
 
2292
2388