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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +2 -4
  2. package/docs/local-agent-install.md +4 -4
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-app-user/SKILL.md +5 -3
  6. package/skills/qingflow-mcp-setup/SKILL.md +2 -0
  7. package/skills/qingflow-record-analysis/SKILL.md +3 -1
  8. package/skills/qingflow-record-delete/SKILL.md +2 -0
  9. package/skills/qingflow-record-import/SKILL.md +29 -0
  10. package/skills/qingflow-record-insert/SKILL.md +24 -1
  11. package/skills/qingflow-record-update/SKILL.md +3 -0
  12. package/skills/qingflow-task-ops/SKILL.md +2 -0
  13. package/src/qingflow_mcp/builder_facade/models.py +183 -0
  14. package/src/qingflow_mcp/builder_facade/service.py +823 -75
  15. package/src/qingflow_mcp/cli/commands/builder.py +80 -6
  16. package/src/qingflow_mcp/cli/formatters.py +1 -0
  17. package/src/qingflow_mcp/cli/main.py +2 -0
  18. package/src/qingflow_mcp/response_trim.py +6 -4
  19. package/src/qingflow_mcp/tools/ai_builder_tools.py +388 -17
  20. package/src/qingflow_mcp/tools/record_tools.py +28 -2
  21. package/skills/qingflow-app-builder/SKILL.md +0 -280
  22. package/skills/qingflow-app-builder/agents/openai.yaml +0 -4
  23. package/skills/qingflow-app-builder/references/create-app.md +0 -160
  24. package/skills/qingflow-app-builder/references/environments.md +0 -63
  25. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +0 -123
  26. package/skills/qingflow-app-builder/references/gotchas.md +0 -107
  27. package/skills/qingflow-app-builder/references/match-rules.md +0 -129
  28. package/skills/qingflow-app-builder/references/public-surface-sync.md +0 -75
  29. package/skills/qingflow-app-builder/references/solution-playbooks.md +0 -52
  30. package/skills/qingflow-app-builder/references/tool-selection.md +0 -106
  31. package/skills/qingflow-app-builder/references/update-flow.md +0 -158
  32. package/skills/qingflow-app-builder/references/update-layout.md +0 -68
  33. package/skills/qingflow-app-builder/references/update-schema.md +0 -75
  34. package/skills/qingflow-app-builder/references/update-views.md +0 -286
  35. package/skills/qingflow-app-builder-code-integrations/SKILL.md +0 -137
  36. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +0 -4
  37. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +0 -66
  38. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +0 -77
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @qingflow-tech/qingflow-app-user-mcp@1.0.40
6
+ npm install @qingflow-tech/qingflow-app-user-mcp@1.0.42
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @qingflow-tech/qingflow-app-user-mcp@1.0.40 qingflow-app-user-mcp
12
+ npx -y -p @qingflow-tech/qingflow-app-user-mcp@1.0.42 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
@@ -24,8 +24,6 @@ Bundled skills:
24
24
 
25
25
  - `skills/qingflow-app-user`
26
26
  - `skills/qingflow-mcp-setup`
27
- - `skills/qingflow-app-builder`
28
- - `skills/qingflow-app-builder-code-integrations`
29
27
  - `skills/qingflow-record-insert`
30
28
  - `skills/qingflow-record-update`
31
29
  - `skills/qingflow-record-delete`
@@ -128,7 +128,7 @@ qingflow-skills install --agent codex --scope user
128
128
  如果通过一次性 `npx -p <package>` 执行安装,请加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
129
129
 
130
130
  ```bash
131
- npx -y -p @josephyan/qingflow-cli qingflow-skills install --agent codex --scope user --copy
131
+ npx -y -p @qingflow-tech/qingflow-cli qingflow-skills install --agent codex --scope user --copy
132
132
  ```
133
133
 
134
134
  也可以挂载到项目级 agent 目录:
@@ -268,7 +268,7 @@ qingflow-app-builder-mcp-skills list
268
268
  "command": "npx",
269
269
  "args": [
270
270
  "-y",
271
- "@josephyan/qingflow-app-user-mcp"
271
+ "@qingflow-tech/qingflow-app-user-mcp"
272
272
  ],
273
273
  "env": {
274
274
  "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
@@ -281,7 +281,7 @@ qingflow-app-builder-mcp-skills list
281
281
  "command": "npx",
282
282
  "args": [
283
283
  "-y",
284
- "@josephyan/qingflow-app-builder-mcp"
284
+ "@qingflow-tech/qingflow-app-builder-mcp"
285
285
  ],
286
286
  "env": {
287
287
  "QINGFLOW_MCP_DEFAULT_BASE_URL": "https://qingflow.com/api",
@@ -323,7 +323,7 @@ npm install
323
323
 
324
324
  如果 MCP 客户端一调用工具就报 `Transport closed`,优先检查这几件事:
325
325
 
326
- 1. 不要混用不同版本的 `@josephyan/qingflow-cli`、`@josephyan/qingflow-app-user-mcp`、`@josephyan/qingflow-app-builder-mcp`
326
+ 1. 不要混用不同版本的 `@qingflow-tech/qingflow-cli`、`@qingflow-tech/qingflow-app-user-mcp`、`@qingflow-tech/qingflow-app-builder-mcp`
327
327
  2. 删除安装目录下的 `.npm-python`
328
328
  3. 重新执行 `npm install` 或重新安装对应 tgz/npm 包
329
329
  4. 再启动 MCP 客户端
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-app-user-mcp",
3
- "version": "1.0.40",
3
+ "version": "1.0.42",
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.0.40"
7
+ version = "1.0.42"
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.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
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 outside the App User MCP tool surface. Use the separate `qingflow-app-builder` skill/MCP package when that builder surface is installed; otherwise ask the user to enable the builder MCP.
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, do not continue with App User MCP tools. Use the separate `qingflow-app-builder` skill/MCP package when available, or ask the user to enable the builder MCP.
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
  - For member/department field ambiguity, keep the record insert/update route and use `record_member_candidates` / `record_department_candidates`; do not switch to `directory_*`, builder member search, external-contact lookup, or contact-directory management queries. App User MCP only exposes `directory_search` for member-visible keyword search, not directory tree/list management.
56
58
  - 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
@@ -87,4 +89,4 @@ Route to exactly one of these specialized paths:
87
89
  - Record import: [$qingflow-record-import](../qingflow-record-import/SKILL.md)
88
90
  - Task workflow operations: [$qingflow-task-ops](../qingflow-task-ops/SKILL.md)
89
91
  - Dedicated analysis workflow: [$qingflow-record-analysis](../qingflow-record-analysis/SKILL.md)
90
- - Builder workflow: [$qingflow-app-builder](../qingflow-app-builder/SKILL.md)
92
+ - Builder workflow requires the separate `qingflow-app-builder` skill/MCP package.
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow MCP Setup
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
11
+
10
12
  ## Overview
11
13
 
12
14
  This skill sets up the local Qingflow MCP server, connects it to a local AI client, and verifies authentication and workspace selection. Use it for installation, client configuration, token login, and connection troubleshooting.
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow Record Analysis
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
11
+
10
12
  Use this skill only for final statistical conclusions: counts, distributions, ratios, averages, rankings, trends, comparisons, and analysis reports.
11
13
 
12
14
  Default path, every time:
@@ -37,7 +39,7 @@ The CLI command is under the `record` command group, so the discovery path is: f
37
39
  - Before final analysis, run a field-quality profile in pandas: row count, null rate, distinct count, and period coverage for candidate grouping fields.
38
40
  - Do not use a high-missing field as the main conclusion dimension. If a candidate dimension is sparse, downgrade it to an `已填写样本观察` and choose a cleaner semantic fallback when available.
39
41
  - Full final conclusions require `record_access.complete=true` and `record_access.safe_for_final_conclusion=true`.
40
- - `record_list` is only for sample inspection after the aggregate result is understood.
42
+ - `record_list` is only for sample inspection after the aggregate result is understood; its `data.items[]` rows are flat field-title keyed objects, not nested `fields[]`.
41
43
  - `record_get` is only for single-record detail verification, logs, references, images, or readable attachments. Read images from `media_assets.items[].local_path`; read documents/tables from `file_assets.items[].local_path` and `extraction.text_path`.
42
44
  - `record_export_direct` is only for explicit export/download/Excel requests.
43
45
  - `chart_get` / QingBI is only for user-provided report URLs or chart ids. If it reports `CHART_BASE_INFO_UNAVAILABLE` but `chart_data_loaded=true`, treat the chart data as readable and only the metadata/base info as degraded.
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow Record Delete
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
11
+
10
12
  ## Default Path
11
13
 
12
14
  `record_list / record_get -> record_delete`
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow Record Import
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
11
+
10
12
  ## Default Path
11
13
 
12
14
  `app_get -> record_import_schema_get -> record_import_template_get -> record_import_verify -> (optional authorized repair) -> record_import_start -> record_import_status_get`
@@ -21,6 +23,33 @@ metadata:
21
23
  - `record_import_start`
22
24
  - `record_import_status_get`
23
25
 
26
+ ## File Shape And Field Mapping
27
+
28
+ - Official template headers are the target contract. Do not rename them in the import file.
29
+ - CSV can be used as a source format for planning or local transformation, but the current verify/start path expects the official Excel template (`.xlsx` / `.xls`).
30
+ - When the user gives CSV-like data, map each source column to the official template header first, then write the mapped rows into a copy of the template after explicit user authorization.
31
+ - Relation fields: prefer stable target `record_id` or another unique searchable value already accepted by the import schema. Do not rely on duplicated display names.
32
+ - Member / department fields: use values inside the schema/candidate scope; do not invent departments or names that are not resolvable.
33
+ - Select fields: use option labels from the schema/template; option ids are acceptable only when the import schema or prior readback proves they are supported.
34
+
35
+ Example source CSV for planning only:
36
+
37
+ ```csv
38
+ 客户名称,关联客户,状态,负责人
39
+ 上海示例客户,CUST_RECORD_ID_001,有效,张三
40
+ ```
41
+
42
+ Mapped template row concept:
43
+
44
+ ```json
45
+ {
46
+ "客户名称": "上海示例客户",
47
+ "关联客户": "CUST_RECORD_ID_001",
48
+ "状态": "有效",
49
+ "负责人": "张三"
50
+ }
51
+ ```
52
+
24
53
  ## Working Rules
25
54
 
26
55
  1. Inspect `app_get.data.import_capability` first
@@ -8,6 +8,8 @@ metadata:
8
8
 
9
9
  # Qingflow Record Insert
10
10
 
11
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
12
+
11
13
  ## Default Path
12
14
 
13
15
  `record_insert_schema_get -> record_insert(items) -> optional record_get/readback`
@@ -22,6 +24,26 @@ Default to batch-shaped insert. A single new record is `items` with one row.
22
24
  - `record_department_candidates`
23
25
  - `file_upload_local`
24
26
 
27
+ ## Special Field Write Cheatsheet
28
+
29
+ Always read the insert schema first, then use field titles as `items[].fields` keys. For special fields:
30
+
31
+ - `member`: write a unique name, email, or resolved member id. If candidates are duplicated or the tool returns `needs_confirmation`, stop and surface candidates.
32
+ - `department`: do not invent department names. Use a value inside the schema/candidate scope: unique department name, returned id/key, or returned object shape such as `{"key":"DEPT_ID","label":"部门名"}` / `{"id":"DEPT_ID","value":"部门名"}`.
33
+ - `relation`: known target record id is most stable for batch inserts. Natural display text is allowed only when it uniquely resolves through the field's `searchable_fields`; duplicates require confirmation.
34
+ - `single_select` / `multi_select`: option label and option id are both acceptable when present in schema/options. Prefer labels for readability; let the tool compile to the path-specific value.
35
+ - `attachment`: upload the local file first with `file_upload_local`, then write the returned file value. Do not write only a filename or arbitrary URL.
36
+ - System fields are read-only: `数据ID`, `编号`, `申请人`, `申请时间`, `创建人`, `创建时间`, `提交人`, `提交时间`, `更新时间`, `更新人`, `当前流程状态`, `当前处理人`, `当前处理节点`, `流程标题`.
37
+
38
+ ## Failure Repair Contract
39
+
40
+ When insert returns `blocked`, `partial_success`, or `needs_confirmation`, do not retry the whole batch blindly.
41
+
42
+ - Read `items[].failed_fields[]` first.
43
+ - Each failed field exposes `error_code`, `expected_format`, `example_value`, and `next_action`.
44
+ - For `member`, `department`, `relation`, `attachment`, and `select` fields, `next_action` is field-type specific: candidate lookup, upload first, use record/apply id, or use schema option label/id.
45
+ - Retry only the failed `row_number` with corrected values. If any row has `write_executed=true` or `created_record_ids` is non-empty, never replay the original batch.
46
+
25
47
  ## Candidate Lookup
26
48
 
27
49
  Do not pre-query member or department ids by default. Use candidate commands only when:
@@ -72,7 +94,7 @@ Without `record_id` / `workflow_node_id` / `fields-file`, the result is a static
72
94
  9. For `linkage.kind=formula_fill`, treat the field as formula/default-auto-fill driven unless the user explicitly asks to override it and the field is still writable
73
95
  10. If insert succeeds and single-record detail/readback matters, prefer `record_get`; for batch verification, rely on returned `created_record_ids` first, then use `record_get` for selected rows or `record_access -> Python` when a row-shaped bulk check is truly needed
74
96
  11. Keep subtable payloads under the parent field as a row array
75
- 12. Member / department / relation fields may accept natural strings directly, such as `"张三"`, `"直销部"`, or `"海军军医大学"`; do not pre-query ids by default
97
+ 12. Follow the Special Field Write Cheatsheet for member, department, relation, select, and attachment values; do not pre-query ids by default when a natural value is unique enough
76
98
  13. If the write returns `status="needs_confirmation"`, stop and surface the candidates
77
99
  14. Retry failed rows only with explicit ids / objects after the user confirms
78
100
  15. Keep `verify_write=true` for production inserts
@@ -123,6 +145,7 @@ qingflow --json record insert --app-key APP_KEY --items-file records.json
123
145
  - Do not invent missing required fields
124
146
  - Do not fill platform system fields such as `数据ID`, `编号`, `申请人`, `创建时间`, or `更新时间`
125
147
  - Do not flatten subtable leaf fields to the top level
148
+ - Do not invent member / department / relation candidates outside schema or candidate scope
126
149
  - Do not pre-query or silently guess member / department / relation ids when a natural string is enough
127
150
  - Do not retry a whole batch after `created_record_ids` is non-empty
128
151
  - Do not bind logic to a transient nested schema serialization detail when the field title and parent table already identify the legal payload shape
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow Record Update
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
11
+
10
12
  ## Default Path
11
13
 
12
14
  `record_get -> record_update`
@@ -33,6 +35,7 @@ metadata:
33
35
  11. If single-record readback matters, prefer `record_get` after the write and read top-level `fields[]`, `media_assets.items[].local_path`, `file_assets.items[].local_path`, `file_assets.items[].extraction.text_path`, and `semantic_context`; `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, so prefer local paths over remote URLs; use `record_list(..., output_profile="normalized")` only for batch row-shaped normalized readback
34
36
  12. For batch updates, read top-level `mode`, `dry_run`, `total`, `succeeded`, `failed`, `needs_confirmation`, `updated_record_ids`, `write_executed`, `safe_to_retry`, `verification_status`, and `items[].row_number/status/record_id`
35
37
  13. If `write_executed=true`, do not blindly retry the whole batch; use `items[]` and `updated_record_ids` to decide whether only failed rows need repair
38
+ 14. If you use `record_list` to locate candidates, parse `data.items[]` as flat row objects such as `row["客户名称"]` + `row["record_id"]`; do not expect a nested `fields[]` array in list rows
36
39
 
37
40
  ## Special Field Values
38
41
 
@@ -7,6 +7,8 @@ metadata:
7
7
 
8
8
  # Qingflow Task Ops
9
9
 
10
+ > **Skill 版本**:`qingflow-skills-2026.06.23.1`(入口文档版本;如需确认 CLI 包版本,使用 `qingflow --version` 或 `qingflow --json version`)。
11
+
10
12
  ## Overview
11
13
 
12
14
  This skill is for task workflow operations only.
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from enum import Enum
4
5
  from typing import Any
5
6
 
@@ -70,17 +71,36 @@ class PublicExternalVisibilityMode(str, Enum):
70
71
 
71
72
 
72
73
  FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
74
+ "multiline": PublicFieldType.long_text,
75
+ "multiline_text": PublicFieldType.long_text,
76
+ "multi_line": PublicFieldType.long_text,
77
+ "multi_line_text": PublicFieldType.long_text,
73
78
  "textarea": PublicFieldType.long_text,
79
+ "longtext": PublicFieldType.long_text,
80
+ "long-text": PublicFieldType.long_text,
74
81
  "amount": PublicFieldType.amount,
75
82
  "currency": PublicFieldType.amount,
76
83
  "mobile": PublicFieldType.phone,
77
84
  "user": PublicFieldType.member,
78
85
  "users": PublicFieldType.member,
79
86
  "select": PublicFieldType.single_select,
87
+ "single_choice": PublicFieldType.single_select,
88
+ "single-choice": PublicFieldType.single_select,
89
+ "single choice": PublicFieldType.single_select,
90
+ "choice": PublicFieldType.single_select,
91
+ "dropdown": PublicFieldType.single_select,
80
92
  "radio": PublicFieldType.single_select,
81
93
  "checkbox": PublicFieldType.multi_select,
82
94
  "multi_select": PublicFieldType.multi_select,
83
95
  "multi-select": PublicFieldType.multi_select,
96
+ "multi select": PublicFieldType.multi_select,
97
+ "multiselect": PublicFieldType.multi_select,
98
+ "multi_choice": PublicFieldType.multi_select,
99
+ "multi-choice": PublicFieldType.multi_select,
100
+ "multi choice": PublicFieldType.multi_select,
101
+ "multiple_choice": PublicFieldType.multi_select,
102
+ "multiple-choice": PublicFieldType.multi_select,
103
+ "multiple choice": PublicFieldType.multi_select,
84
104
  "departments": PublicFieldType.department,
85
105
  "qlinker": PublicFieldType.q_linker,
86
106
  "q_linker": PublicFieldType.q_linker,
@@ -1796,6 +1816,65 @@ class ChartFilterRulePatch(StrictModel):
1796
1816
  return self
1797
1817
 
1798
1818
 
1819
+ class ChartMetricPatch(StrictModel):
1820
+ op: str = "count"
1821
+ field_name: str | None = Field(default=None, validation_alias=AliasChoices("field", "field_name", "fieldName", "name"))
1822
+ alias: str | None = None
1823
+
1824
+ @model_validator(mode="before")
1825
+ @classmethod
1826
+ def normalize_metric(cls, value: Any) -> Any:
1827
+ if isinstance(value, str):
1828
+ raw = value.strip()
1829
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)", raw)
1830
+ if match:
1831
+ op = match.group(1).strip().lower()
1832
+ field = match.group(2).strip()
1833
+ payload: dict[str, Any] = {"op": op}
1834
+ if field and field != "*":
1835
+ payload["field_name"] = field
1836
+ return payload
1837
+ if raw:
1838
+ return {"op": "sum", "field_name": raw}
1839
+ return {"op": "count"}
1840
+ if not isinstance(value, dict):
1841
+ return value
1842
+ payload = dict(value)
1843
+ if "operation" in payload and "op" not in payload:
1844
+ payload["op"] = payload.pop("operation")
1845
+ if "agg" in payload and "op" not in payload:
1846
+ payload["op"] = payload.pop("agg")
1847
+ if "aggregate" in payload and "op" not in payload:
1848
+ payload["op"] = payload.pop("aggregate")
1849
+ if "aggregation" in payload and "op" not in payload:
1850
+ payload["op"] = payload.pop("aggregation")
1851
+ return payload
1852
+
1853
+ @model_validator(mode="after")
1854
+ def validate_metric(self) -> "ChartMetricPatch":
1855
+ normalized_op = str(self.op or "count").strip().lower()
1856
+ op_aliases = {
1857
+ "average": "avg",
1858
+ "mean": "avg",
1859
+ "total": "sum",
1860
+ "cnt": "count",
1861
+ "count_all": "count",
1862
+ }
1863
+ self.op = op_aliases.get(normalized_op, normalized_op)
1864
+ if self.field_name is not None:
1865
+ field_name = str(self.field_name).strip()
1866
+ self.field_name = field_name or None
1867
+ if self.alias is not None:
1868
+ alias = str(self.alias).strip()
1869
+ self.alias = alias or None
1870
+ supported = {"count", "sum", "avg", "max", "min"}
1871
+ if self.op not in supported:
1872
+ raise ValueError(f"chart metric op must be one of {sorted(supported)}")
1873
+ if self.op != "count" and not self.field_name:
1874
+ raise ValueError(f"chart metric op '{self.op}' requires field")
1875
+ return self
1876
+
1877
+
1799
1878
  class ChartUpsertPatch(StrictModel):
1800
1879
  chart_id: str | None = None
1801
1880
  name: str
@@ -1803,6 +1882,17 @@ class ChartUpsertPatch(StrictModel):
1803
1882
  dimension_field_ids: list[str] = Field(default_factory=list)
1804
1883
  indicator_field_ids: list[str] = Field(default_factory=list)
1805
1884
  filters: list[ChartFilterRulePatch] = Field(default_factory=list)
1885
+ group_by: list[str] = Field(default_factory=list)
1886
+ rows: list[str] = Field(default_factory=list)
1887
+ columns: list[str] = Field(default_factory=list)
1888
+ metric: ChartMetricPatch | None = None
1889
+ metrics: list[ChartMetricPatch] = Field(default_factory=list)
1890
+ x_metric: ChartMetricPatch | None = None
1891
+ y_metric: ChartMetricPatch | None = None
1892
+ left_metric: ChartMetricPatch | None = None
1893
+ right_metric: ChartMetricPatch | None = None
1894
+ value_metric: ChartMetricPatch | None = None
1895
+ target_metric: ChartMetricPatch | None = None
1806
1896
  question_config: list[dict[str, Any]] = Field(default_factory=list)
1807
1897
  user_config: list[dict[str, Any]] = Field(default_factory=list)
1808
1898
  config: dict[str, Any] = Field(default_factory=dict)
@@ -1820,10 +1910,74 @@ class ChartUpsertPatch(StrictModel):
1820
1910
  payload["chart_type"] = payload.pop("type")
1821
1911
  if "dimension_fields" in payload and "dimension_field_ids" not in payload:
1822
1912
  payload["dimension_field_ids"] = payload.pop("dimension_fields")
1913
+ if "dimensions" in payload and "dimension_field_ids" not in payload and "group_by" not in payload:
1914
+ payload["group_by"] = payload.pop("dimensions")
1915
+ if "groupBy" in payload and "group_by" not in payload:
1916
+ payload["group_by"] = payload.pop("groupBy")
1917
+ if "where" in payload and "filters" not in payload:
1918
+ payload["filters"] = payload.pop("where")
1919
+ if "filter_rules" in payload and "filters" not in payload:
1920
+ payload["filters"] = payload.pop("filter_rules")
1921
+ if "filterRules" in payload and "filters" not in payload:
1922
+ payload["filters"] = payload.pop("filterRules")
1823
1923
  if "indicator_fields" in payload and "indicator_field_ids" not in payload:
1824
1924
  payload["indicator_field_ids"] = payload.pop("indicator_fields")
1825
1925
  if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
1826
1926
  payload["indicator_field_ids"] = payload.pop("metric_field_ids")
1927
+ metric_slots: list[Any] = []
1928
+ generic_metric_keys = ("metric", "metrics")
1929
+ axis_metric_keys = (
1930
+ "x_metric",
1931
+ "xMetric",
1932
+ "y_metric",
1933
+ "yMetric",
1934
+ "left_metric",
1935
+ "leftMetric",
1936
+ "right_metric",
1937
+ "rightMetric",
1938
+ "value_metric",
1939
+ "valueMetric",
1940
+ "target_metric",
1941
+ "targetMetric",
1942
+ )
1943
+
1944
+ def has_metric_value(key: str) -> bool:
1945
+ entry = payload.get(key)
1946
+ return entry is not None and entry != "" and entry != []
1947
+
1948
+ if any(has_metric_value(key) for key in generic_metric_keys) and any(has_metric_value(key) for key in axis_metric_keys):
1949
+ raise ValueError(
1950
+ "chart metric input is ambiguous: use either metric/metrics or axis-specific "
1951
+ "x_metric/y_metric, left_metric/right_metric, value_metric/target_metric, not both"
1952
+ )
1953
+ if "metric" in payload and "metrics" not in payload:
1954
+ payload["metrics"] = [payload["metric"]]
1955
+ for key in ("x_metric", "xMetric", "left_metric", "leftMetric", "value_metric", "valueMetric"):
1956
+ if key in payload:
1957
+ slot_value = payload.get(key)
1958
+ if slot_value is not None:
1959
+ metric_slots.append(slot_value)
1960
+ canonical = {
1961
+ "xMetric": "x_metric",
1962
+ "leftMetric": "left_metric",
1963
+ "valueMetric": "value_metric",
1964
+ }.get(key)
1965
+ if canonical and canonical not in payload:
1966
+ payload[canonical] = payload.pop(key)
1967
+ for key in ("y_metric", "yMetric", "right_metric", "rightMetric", "target_metric", "targetMetric"):
1968
+ if key in payload:
1969
+ slot_value = payload.get(key)
1970
+ if slot_value is not None:
1971
+ metric_slots.append(slot_value)
1972
+ canonical = {
1973
+ "yMetric": "y_metric",
1974
+ "rightMetric": "right_metric",
1975
+ "targetMetric": "target_metric",
1976
+ }.get(key)
1977
+ if canonical and canonical not in payload:
1978
+ payload[canonical] = payload.pop(key)
1979
+ if metric_slots and "metrics" not in payload:
1980
+ payload["metrics"] = metric_slots
1827
1981
  raw_type = payload.get("chart_type")
1828
1982
  if isinstance(raw_type, str):
1829
1983
  normalized = raw_type.strip().lower()
@@ -1850,8 +2004,31 @@ class ChartUpsertPatch(StrictModel):
1850
2004
  payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
1851
2005
  if isinstance(payload.get("indicator_field_ids"), list):
1852
2006
  payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
2007
+ for key in ("group_by", "rows", "columns"):
2008
+ if isinstance(payload.get(key), str):
2009
+ payload[key] = [payload[key]]
2010
+ if isinstance(payload.get(key), list):
2011
+ payload[key] = [str(item) for item in payload[key] if item is not None and str(item).strip()]
1853
2012
  return payload
1854
2013
 
2014
+ @model_validator(mode="after")
2015
+ def apply_semantic_chart_fields(self) -> "ChartUpsertPatch":
2016
+ if self.group_by and not self.dimension_field_ids:
2017
+ self.dimension_field_ids = list(self.group_by)
2018
+ if self.rows and not self.dimension_field_ids:
2019
+ self.dimension_field_ids = list(self.rows)
2020
+ semantic_metrics: list[ChartMetricPatch] = []
2021
+ if self.metrics:
2022
+ semantic_metrics.extend(self.metrics)
2023
+ for metric in (self.x_metric, self.y_metric, self.left_metric, self.right_metric, self.value_metric, self.target_metric):
2024
+ if metric is not None and metric not in semantic_metrics:
2025
+ semantic_metrics.append(metric)
2026
+ if semantic_metrics and not self.metrics:
2027
+ self.metrics = semantic_metrics
2028
+ if self.metrics and not self.metric:
2029
+ self.metric = self.metrics[0]
2030
+ return self
2031
+
1855
2032
 
1856
2033
  class ChartPartialPatch(StrictModel):
1857
2034
  chart_id: str | None = None
@@ -1993,6 +2170,7 @@ class PortalViewRefPatch(StrictModel):
1993
2170
  class PortalSectionPatch(StrictModel):
1994
2171
  title: str
1995
2172
  source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
2173
+ role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
1996
2174
  position: PortalComponentPositionPatch | None = None
1997
2175
  dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
1998
2176
  config: dict[str, Any] = Field(default_factory=dict)
@@ -2010,6 +2188,9 @@ class PortalSectionPatch(StrictModel):
2010
2188
  raw_type = payload.get("source_type", payload.get("sourceType"))
2011
2189
  if isinstance(raw_type, str):
2012
2190
  payload["source_type"] = raw_type.strip().lower()
2191
+ raw_role = payload.get("role", payload.get("zone", payload.get("section_role", payload.get("sectionRole"))))
2192
+ if isinstance(raw_role, str):
2193
+ payload["role"] = raw_role.strip().lower()
2013
2194
  if "chartRef" in payload and "chart_ref" not in payload:
2014
2195
  payload["chart_ref"] = payload.pop("chartRef")
2015
2196
  if "viewRef" in payload and "view_ref" not in payload:
@@ -2216,6 +2397,8 @@ class ChartGetResponse(StrictModel):
2216
2397
  base: dict[str, Any] = Field(default_factory=dict)
2217
2398
  visibility: dict[str, Any] = Field(default_factory=dict)
2218
2399
  filters: list[list[dict[str, Any]]] = Field(default_factory=list)
2400
+ group_by: list[str] = Field(default_factory=list)
2401
+ metrics: list[dict[str, Any]] = Field(default_factory=list)
2219
2402
  config: dict[str, Any] = Field(default_factory=dict)
2220
2403
 
2221
2404