@qingflow-tech/qingflow-app-user-mcp 1.0.40 → 1.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -4
- package/docs/local-agent-install.md +4 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +5 -3
- package/skills/qingflow-mcp-setup/SKILL.md +2 -0
- package/skills/qingflow-record-analysis/SKILL.md +3 -1
- package/skills/qingflow-record-delete/SKILL.md +2 -0
- package/skills/qingflow-record-import/SKILL.md +29 -0
- package/skills/qingflow-record-insert/SKILL.md +24 -1
- package/skills/qingflow-record-update/SKILL.md +3 -0
- package/skills/qingflow-task-ops/SKILL.md +2 -0
- package/src/qingflow_mcp/builder_facade/models.py +183 -0
- package/src/qingflow_mcp/builder_facade/service.py +823 -75
- package/src/qingflow_mcp/cli/commands/builder.py +80 -6
- package/src/qingflow_mcp/cli/formatters.py +1 -0
- package/src/qingflow_mcp/cli/main.py +2 -0
- package/src/qingflow_mcp/response_trim.py +6 -4
- package/src/qingflow_mcp/tools/ai_builder_tools.py +388 -17
- package/src/qingflow_mcp/tools/record_tools.py +28 -2
- package/skills/qingflow-app-builder/SKILL.md +0 -280
- package/skills/qingflow-app-builder/agents/openai.yaml +0 -4
- package/skills/qingflow-app-builder/references/create-app.md +0 -160
- package/skills/qingflow-app-builder/references/environments.md +0 -63
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +0 -123
- package/skills/qingflow-app-builder/references/gotchas.md +0 -107
- package/skills/qingflow-app-builder/references/match-rules.md +0 -129
- package/skills/qingflow-app-builder/references/public-surface-sync.md +0 -75
- package/skills/qingflow-app-builder/references/solution-playbooks.md +0 -52
- package/skills/qingflow-app-builder/references/tool-selection.md +0 -106
- package/skills/qingflow-app-builder/references/update-flow.md +0 -158
- package/skills/qingflow-app-builder/references/update-layout.md +0 -68
- package/skills/qingflow-app-builder/references/update-schema.md +0 -75
- package/skills/qingflow-app-builder/references/update-views.md +0 -286
- package/skills/qingflow-app-builder-code-integrations/SKILL.md +0 -137
- package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +0 -4
- package/skills/qingflow-app-builder-code-integrations/references/code-block.md +0 -66
- package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +0 -77
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.
|
|
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.
|
|
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 @
|
|
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
|
-
"@
|
|
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
|
-
"@
|
|
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. 不要混用不同版本的 `@
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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 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.
|
|
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
|
|
|
@@ -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
|
|