@josephyan/qingflow-app-user-mcp 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/references/public-surface-sync.md +1 -0
- package/skills/qingflow-record-analysis/SKILL.md +3 -2
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +1 -0
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +5 -4
- package/src/qingflow_mcp/response_trim.py +2 -0
- package/src/qingflow_mcp/server_app_user.py +4 -2
- package/src/qingflow_mcp/tools/record_tools.py +341 -52
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.0.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@1.0.8
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@1.0.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@1.0.8 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -8,6 +8,7 @@ It is not a user-facing product spec. It exists to prevent skill drift.
|
|
|
8
8
|
### User data
|
|
9
9
|
|
|
10
10
|
- Read range first with `app_get`, then `record_browse_schema_get(view_id=...)`
|
|
11
|
+
- Treat `record_browse_schema_get.fields` as the selected Qingflow table view header schema; `record_access.fields` and `schema.json` must stay aligned with it.
|
|
11
12
|
- Standard flows:
|
|
12
13
|
- analyze: `app_get -> record_browse_schema_get -> record_access -> Python`
|
|
13
14
|
- browse detail: `app_get -> record_browse_schema_get -> record_list / record_get`
|
|
@@ -9,7 +9,7 @@ metadata:
|
|
|
9
9
|
|
|
10
10
|
This skill is for final statistical conclusions only.
|
|
11
11
|
Assumes MCP is connected, authenticated, and on the correct workspace.
|
|
12
|
-
Analysis tasks must start with `app_get`, then `record_browse_schema_get(view_id=...)`. Read top-level `fields` and `suggested_*`, then choose field_id-based columns and filters only.
|
|
12
|
+
Analysis tasks must start with `app_get`, then `record_browse_schema_get(view_id=...)`. `record_browse_schema_get.fields` is the selected Qingflow table view's readable header schema and is the same field source used by `record_access.fields` / `schema.json`. Read top-level `fields` and `suggested_*`, then choose field_id-based columns and filters only.
|
|
13
13
|
If `app_get.accessible_views` marks a view with `analysis_supported=false`, do not use that view for `record_access`, `record_list`, or `record_analyze`. `boardView` and `ganttView` are special UI views, not data-access targets.
|
|
14
14
|
|
|
15
15
|
## Step 1: `app_get` → Step 2: `record_browse_schema_get(view_id=...)` → Step 3: `record_access` → Step 4: Python
|
|
@@ -73,9 +73,10 @@ Use `record_access` to fetch detail rows into local CSV shards. It does not anal
|
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
Then run Python against every `files[].local_path`. CSV columns are stable: `record_id`, then `field_<field_id>`. Use `fields[]`
|
|
76
|
+
Then run Python against every `files[].local_path`. CSV columns are stable: `record_id`, then `field_<field_id>`. Use `fields[]` plus `metadata_files.schema` / `metadata_files.readme` in the same output directory to map titles and types, especially when reusing the CSV later.
|
|
77
77
|
|
|
78
78
|
- Never ask for `page`, `page_size`, `limit`, or `max_rows`; `record_access` owns paging internally and follows the backend's native paging capability.
|
|
79
|
+
- Never treat backend `searchQueIds` as column selection; it is only a full-text search scope.
|
|
79
80
|
- If multiple CSV files are returned, read them all.
|
|
80
81
|
- If `complete=false` or `safe_for_final_conclusion=false`, downgrade the answer and disclose the limitation.
|
|
81
82
|
- `record_export_direct` is only for explicit export/download/Excel requests, not default analysis.
|
|
@@ -94,6 +94,7 @@ Examples of the right recovery question:
|
|
|
94
94
|
- Do not invent `page_size`
|
|
95
95
|
- Do not invent `requested_pages`
|
|
96
96
|
- Do not invent `scan_max_pages`
|
|
97
|
+
- Do not rename CSV columns before analysis; use `metadata_files.schema` / `metadata_files.readme` to interpret `field_<id>` columns.
|
|
97
98
|
- Do not invent `auto_expand_pages`
|
|
98
99
|
- Do not invent `max_rows`
|
|
99
100
|
|
|
@@ -28,11 +28,12 @@ Result reading order:
|
|
|
28
28
|
1. `record_access.complete`
|
|
29
29
|
2. `record_access.safe_for_final_conclusion`
|
|
30
30
|
3. `record_access.files[].local_path`
|
|
31
|
-
4.
|
|
32
|
-
5.
|
|
33
|
-
6. `record_access.
|
|
31
|
+
4. `record_access.metadata_files.schema`
|
|
32
|
+
5. Python outputs
|
|
33
|
+
6. `record_access.fields`
|
|
34
|
+
7. `record_access.warnings`
|
|
34
35
|
|
|
35
|
-
Treat `record_browse_schema_get` as the browse-schema source of truth. Missing fields are permission boundaries, not invitations to guess hidden ids.
|
|
36
|
+
Treat `record_browse_schema_get` as the browse-schema source of truth. It matches the selected Qingflow table view header and is the same schema source used by `record_access.fields` and `schema.json`. Missing fields are permission boundaries, not invitations to guess hidden ids.
|
|
36
37
|
|
|
37
38
|
## Lightweight `record_analyze` helper sequence
|
|
38
39
|
|
|
@@ -455,11 +455,13 @@ def _trim_record_access(payload: JSONObject) -> None:
|
|
|
455
455
|
"app_key",
|
|
456
456
|
"view_id",
|
|
457
457
|
"format",
|
|
458
|
+
"local_dir",
|
|
458
459
|
"row_count",
|
|
459
460
|
"complete",
|
|
460
461
|
"truncated",
|
|
461
462
|
"safe_for_final_conclusion",
|
|
462
463
|
"files",
|
|
464
|
+
"metadata_files",
|
|
463
465
|
"fields",
|
|
464
466
|
"warnings",
|
|
465
467
|
"verification",
|
|
@@ -60,7 +60,9 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
60
60
|
`record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
|
|
61
61
|
Inside `optional_fields`, any field with `may_become_required=true` is still writable, but may become required when linked visibility or option-driven runtime rules activate.
|
|
62
62
|
`record_update_schema_get` returns the current record's overall update-ready writable field set across matched accessible views; read `writable_fields` and `payload_template`.
|
|
63
|
-
`record_browse_schema_get(view_id=...)` returns
|
|
63
|
+
`record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
|
|
64
|
+
`record_access.fields` and its `schema.json` use that exact same view schema; a missing field means it is not readable in that view.
|
|
65
|
+
`searchQueIds` is a backend full-text search scope, not an output-column/projection mechanism.
|
|
64
66
|
`record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
|
|
65
67
|
`record_import_schema_get` returns import-ready column metadata.
|
|
66
68
|
|
|
@@ -74,7 +76,7 @@ Inside `optional_fields`, any field with `may_become_required=true` is still wri
|
|
|
74
76
|
|
|
75
77
|
Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
|
|
76
78
|
|
|
77
|
-
Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`.
|
|
79
|
+
Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`. Use `fields[]` and the same-directory `metadata_files.schema` / `metadata_files.readme` to map stable `field_<id>` CSV columns back to titles and types.
|
|
78
80
|
Use `chart_get` only when the user provides a report URL / chart_id or explicitly asks to read an existing report. Do not use QingBI as the default analysis route.
|
|
79
81
|
|
|
80
82
|
Use this data-access DSL shape:
|
|
@@ -602,7 +602,7 @@ class RecordTools(ToolBase):
|
|
|
602
602
|
),
|
|
603
603
|
}
|
|
604
604
|
)
|
|
605
|
-
browse_scope = self.
|
|
605
|
+
browse_scope = self._build_browse_read_scope(
|
|
606
606
|
profile,
|
|
607
607
|
context,
|
|
608
608
|
app_key,
|
|
@@ -1848,25 +1848,20 @@ class RecordTools(ToolBase):
|
|
|
1848
1848
|
sorts = self._normalize_record_list_order_by(order_by)
|
|
1849
1849
|
|
|
1850
1850
|
def runner(session_profile, context):
|
|
1851
|
-
|
|
1852
|
-
|
|
1851
|
+
browse_scope = self._build_browse_read_scope(
|
|
1852
|
+
profile,
|
|
1853
|
+
context,
|
|
1854
|
+
app_key,
|
|
1855
|
+
view_route,
|
|
1856
|
+
force_refresh=False,
|
|
1857
|
+
)
|
|
1858
|
+
index = cast(FieldIndex, browse_scope["index"])
|
|
1859
|
+
selected_fields = self._resolve_record_access_columns(
|
|
1853
1860
|
normalized_columns,
|
|
1854
1861
|
index,
|
|
1855
|
-
|
|
1856
|
-
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1862
|
+
view_route=view_route,
|
|
1857
1863
|
)
|
|
1858
|
-
selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
|
|
1859
|
-
primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
|
|
1860
1864
|
view_selection = view_route.view_selection
|
|
1861
|
-
if view_selection is not None and not _view_selection_supported_by_search_ids(
|
|
1862
|
-
view_selection,
|
|
1863
|
-
primary_search_que_ids,
|
|
1864
|
-
):
|
|
1865
|
-
primary_search_que_ids = None
|
|
1866
|
-
remaining_field_batches: list[list[FormField]] = []
|
|
1867
|
-
else:
|
|
1868
|
-
remaining_field_batches = selected_field_batches[1:]
|
|
1869
|
-
primary_search_que_ids = primary_search_que_ids or None
|
|
1870
1865
|
match_rules = self._resolve_match_rules(context, filters, index)
|
|
1871
1866
|
sort_rules = self._resolve_sorts(sorts, index)
|
|
1872
1867
|
used_list_type: int | None = None
|
|
@@ -1932,6 +1927,7 @@ class RecordTools(ToolBase):
|
|
|
1932
1927
|
current_page = 1
|
|
1933
1928
|
has_more = False
|
|
1934
1929
|
reported_total: int | None = None
|
|
1930
|
+
created_at = datetime.now(UTC).isoformat()
|
|
1935
1931
|
try:
|
|
1936
1932
|
while True:
|
|
1937
1933
|
if used_list_type is None:
|
|
@@ -1948,7 +1944,7 @@ class RecordTools(ToolBase):
|
|
|
1948
1944
|
query_key=None,
|
|
1949
1945
|
match_rules=match_rules,
|
|
1950
1946
|
sorts=sort_rules,
|
|
1951
|
-
search_que_ids=
|
|
1947
|
+
search_que_ids=None,
|
|
1952
1948
|
list_type=candidate_list_type,
|
|
1953
1949
|
)
|
|
1954
1950
|
used_list_type = None if view_selection is not None else candidate_list_type
|
|
@@ -1975,7 +1971,7 @@ class RecordTools(ToolBase):
|
|
|
1975
1971
|
query_key=None,
|
|
1976
1972
|
match_rules=match_rules,
|
|
1977
1973
|
sorts=sort_rules,
|
|
1978
|
-
search_que_ids=
|
|
1974
|
+
search_que_ids=None,
|
|
1979
1975
|
list_type=used_list_type,
|
|
1980
1976
|
)
|
|
1981
1977
|
page_rows = page.get("list")
|
|
@@ -1995,34 +1991,6 @@ class RecordTools(ToolBase):
|
|
|
1995
1991
|
continue
|
|
1996
1992
|
page_apply_order.append(apply_id)
|
|
1997
1993
|
page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
|
|
1998
|
-
if page_apply_order and remaining_field_batches:
|
|
1999
|
-
for batch in remaining_field_batches:
|
|
2000
|
-
extra_page = self._search_page(
|
|
2001
|
-
context,
|
|
2002
|
-
app_key=app_key,
|
|
2003
|
-
view_selection=view_selection,
|
|
2004
|
-
page_num=current_page,
|
|
2005
|
-
page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
|
|
2006
|
-
query_key=None,
|
|
2007
|
-
match_rules=match_rules,
|
|
2008
|
-
sorts=sort_rules,
|
|
2009
|
-
search_que_ids=[field.que_id for field in batch],
|
|
2010
|
-
list_type=used_list_type or DEFAULT_RECORD_LIST_TYPE,
|
|
2011
|
-
)
|
|
2012
|
-
extra_rows = extra_page.get("list")
|
|
2013
|
-
extra_items = extra_rows if isinstance(extra_rows, list) else []
|
|
2014
|
-
for extra_item in extra_items:
|
|
2015
|
-
if not isinstance(extra_item, dict):
|
|
2016
|
-
continue
|
|
2017
|
-
apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
|
|
2018
|
-
if apply_id is None or apply_id not in page_answer_map:
|
|
2019
|
-
continue
|
|
2020
|
-
extra_answers = extra_item.get("answers")
|
|
2021
|
-
extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
|
|
2022
|
-
page_answer_map[apply_id] = _merge_answer_lists_by_field_id(
|
|
2023
|
-
page_answer_map.get(apply_id, []),
|
|
2024
|
-
cast(list[JSONValue], extra_answer_list),
|
|
2025
|
-
)
|
|
2026
1994
|
for apply_id in page_apply_order:
|
|
2027
1995
|
write_record(apply_id, page_answer_map.get(apply_id, []))
|
|
2028
1996
|
if not has_more:
|
|
@@ -2049,6 +2017,25 @@ class RecordTools(ToolBase):
|
|
|
2049
2017
|
safe_for_final_conclusion = complete and not any(
|
|
2050
2018
|
warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
|
|
2051
2019
|
)
|
|
2020
|
+
fields_payload = [_record_access_field_payload(field) for field in selected_fields]
|
|
2021
|
+
verification_payload: JSONObject = {
|
|
2022
|
+
**_view_filter_verification_payload(view_route),
|
|
2023
|
+
"reported_total": reported_total,
|
|
2024
|
+
"list_type_used": used_list_type,
|
|
2025
|
+
}
|
|
2026
|
+
metadata_files = _write_record_access_metadata_files(
|
|
2027
|
+
run_dir=run_dir,
|
|
2028
|
+
created_at=created_at,
|
|
2029
|
+
app_key=app_key,
|
|
2030
|
+
view_route=view_route,
|
|
2031
|
+
row_count=row_count,
|
|
2032
|
+
complete=complete,
|
|
2033
|
+
safe_for_final_conclusion=safe_for_final_conclusion,
|
|
2034
|
+
files=files,
|
|
2035
|
+
fields=fields_payload,
|
|
2036
|
+
warnings=warnings,
|
|
2037
|
+
verification=verification_payload,
|
|
2038
|
+
)
|
|
2052
2039
|
return {
|
|
2053
2040
|
"profile": profile,
|
|
2054
2041
|
"ws_id": session_profile.selected_ws_id,
|
|
@@ -2057,18 +2044,16 @@ class RecordTools(ToolBase):
|
|
|
2057
2044
|
"app_key": app_key,
|
|
2058
2045
|
"view_id": view_route.view_id,
|
|
2059
2046
|
"format": "csv",
|
|
2047
|
+
"local_dir": str(run_dir),
|
|
2060
2048
|
"row_count": row_count,
|
|
2061
2049
|
"complete": complete,
|
|
2062
2050
|
"truncated": not complete,
|
|
2063
2051
|
"safe_for_final_conclusion": safe_for_final_conclusion,
|
|
2064
2052
|
"files": files,
|
|
2065
|
-
"
|
|
2053
|
+
"metadata_files": metadata_files,
|
|
2054
|
+
"fields": fields_payload,
|
|
2066
2055
|
"warnings": warnings,
|
|
2067
|
-
"verification":
|
|
2068
|
-
**_view_filter_verification_payload(view_route),
|
|
2069
|
-
"reported_total": reported_total,
|
|
2070
|
-
"list_type_used": used_list_type,
|
|
2071
|
-
},
|
|
2056
|
+
"verification": verification_payload,
|
|
2072
2057
|
"request_route": self._request_route_payload(context),
|
|
2073
2058
|
}
|
|
2074
2059
|
|
|
@@ -7409,6 +7394,39 @@ class RecordTools(ToolBase):
|
|
|
7409
7394
|
self._form_cache[cache_key] = normalized
|
|
7410
7395
|
return normalized
|
|
7411
7396
|
|
|
7397
|
+
def _get_system_browse_base_info_schema(
|
|
7398
|
+
self,
|
|
7399
|
+
profile: str,
|
|
7400
|
+
context, # type: ignore[no-untyped-def]
|
|
7401
|
+
app_key: str,
|
|
7402
|
+
*,
|
|
7403
|
+
list_type: int,
|
|
7404
|
+
force_refresh: bool,
|
|
7405
|
+
) -> JSONObject:
|
|
7406
|
+
"""Return system view apply/baseInfo without falling back to app form metadata."""
|
|
7407
|
+
cache_key = (profile, app_key, "browse_system_base_info", list_type)
|
|
7408
|
+
if not force_refresh and cache_key in self._form_cache:
|
|
7409
|
+
return self._form_cache[cache_key]
|
|
7410
|
+
payload = self.backend.request(
|
|
7411
|
+
"GET",
|
|
7412
|
+
context,
|
|
7413
|
+
f"/app/{app_key}/apply/baseInfo",
|
|
7414
|
+
params={"type": list_type},
|
|
7415
|
+
)
|
|
7416
|
+
normalized = _normalize_data_list_base_info_schema(payload)
|
|
7417
|
+
self._form_cache[cache_key] = normalized
|
|
7418
|
+
return normalized
|
|
7419
|
+
|
|
7420
|
+
def _get_custom_view_browse_schema(self, profile: str, context, view_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
|
|
7421
|
+
"""Return the same baseInfo schema used by the Qingflow table view UI."""
|
|
7422
|
+
cache_key = (profile, f"view:{view_key}", "browse_view_base_info", None)
|
|
7423
|
+
if not force_refresh and cache_key in self._form_cache:
|
|
7424
|
+
return self._form_cache[cache_key]
|
|
7425
|
+
payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
|
|
7426
|
+
normalized = _normalize_data_list_base_info_schema(payload)
|
|
7427
|
+
self._form_cache[cache_key] = normalized
|
|
7428
|
+
return normalized
|
|
7429
|
+
|
|
7412
7430
|
def _get_view_field_index(self, profile: str, context, view_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
|
|
7413
7431
|
"""执行内部辅助逻辑。"""
|
|
7414
7432
|
return _build_field_index(self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh))
|
|
@@ -7451,6 +7469,80 @@ class RecordTools(ToolBase):
|
|
|
7451
7469
|
force_refresh=force_refresh,
|
|
7452
7470
|
)["index"]
|
|
7453
7471
|
|
|
7472
|
+
def _build_browse_read_scope(
|
|
7473
|
+
self,
|
|
7474
|
+
profile: str,
|
|
7475
|
+
context, # type: ignore[no-untyped-def]
|
|
7476
|
+
app_key: str,
|
|
7477
|
+
resolved_view: AccessibleViewRoute | None,
|
|
7478
|
+
*,
|
|
7479
|
+
force_refresh: bool,
|
|
7480
|
+
) -> JSONObject:
|
|
7481
|
+
"""Build the UI/table-view readable field scope from apply/baseInfo."""
|
|
7482
|
+
applicant_index: FieldIndex | None
|
|
7483
|
+
applicant_writable_field_ids: set[int]
|
|
7484
|
+
try:
|
|
7485
|
+
applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
|
|
7486
|
+
except QingflowApiError as exc:
|
|
7487
|
+
if exc.backend_code != 40002:
|
|
7488
|
+
raise
|
|
7489
|
+
applicant_index = None
|
|
7490
|
+
applicant_writable_field_ids = set()
|
|
7491
|
+
else:
|
|
7492
|
+
applicant_writable_field_ids = {
|
|
7493
|
+
field.que_id
|
|
7494
|
+
for field in applicant_index.by_id.values()
|
|
7495
|
+
if bool(self._schema_write_hints(field)["writable"])
|
|
7496
|
+
}
|
|
7497
|
+
|
|
7498
|
+
if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
|
|
7499
|
+
schema = self._get_custom_view_browse_schema(
|
|
7500
|
+
profile,
|
|
7501
|
+
context,
|
|
7502
|
+
resolved_view.view_selection.view_key,
|
|
7503
|
+
force_refresh=force_refresh,
|
|
7504
|
+
)
|
|
7505
|
+
index = _build_top_level_field_index(schema)
|
|
7506
|
+
elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
|
|
7507
|
+
schema = self._get_system_browse_base_info_schema(
|
|
7508
|
+
profile,
|
|
7509
|
+
context,
|
|
7510
|
+
app_key,
|
|
7511
|
+
list_type=resolved_view.list_type,
|
|
7512
|
+
force_refresh=force_refresh,
|
|
7513
|
+
)
|
|
7514
|
+
index = _build_top_level_field_index(schema)
|
|
7515
|
+
else:
|
|
7516
|
+
index = applicant_index or _build_top_level_field_index(
|
|
7517
|
+
self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
|
|
7518
|
+
)
|
|
7519
|
+
|
|
7520
|
+
if applicant_index is not None and index.by_id:
|
|
7521
|
+
enriched_fields = [
|
|
7522
|
+
_enrich_read_field_from_applicant(field, applicant_index.by_id.get(str(field.que_id)))
|
|
7523
|
+
for field in index.by_id.values()
|
|
7524
|
+
]
|
|
7525
|
+
index = _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in enriched_fields]]})
|
|
7526
|
+
|
|
7527
|
+
visible_question_ids = {field.que_id for field in index.by_id.values()}
|
|
7528
|
+
if applicant_index is None:
|
|
7529
|
+
writable_field_ids = {
|
|
7530
|
+
field.que_id
|
|
7531
|
+
for field in index.by_id.values()
|
|
7532
|
+
if bool(self._schema_write_hints(field)["writable"])
|
|
7533
|
+
}
|
|
7534
|
+
else:
|
|
7535
|
+
writable_field_ids = {
|
|
7536
|
+
field_id
|
|
7537
|
+
for field_id in visible_question_ids
|
|
7538
|
+
if field_id in applicant_writable_field_ids
|
|
7539
|
+
}
|
|
7540
|
+
return {
|
|
7541
|
+
"index": index,
|
|
7542
|
+
"writable_field_ids": writable_field_ids,
|
|
7543
|
+
"visible_question_ids": visible_question_ids,
|
|
7544
|
+
}
|
|
7545
|
+
|
|
7454
7546
|
def _build_browse_write_scope(
|
|
7455
7547
|
self,
|
|
7456
7548
|
profile: str,
|
|
@@ -9520,6 +9612,44 @@ class RecordTools(ToolBase):
|
|
|
9520
9612
|
break
|
|
9521
9613
|
return fields
|
|
9522
9614
|
|
|
9615
|
+
def _resolve_record_access_columns(
|
|
9616
|
+
self,
|
|
9617
|
+
selectors: list[int],
|
|
9618
|
+
index: FieldIndex,
|
|
9619
|
+
*,
|
|
9620
|
+
view_route: AccessibleViewRoute,
|
|
9621
|
+
) -> list[FormField]:
|
|
9622
|
+
"""Resolve record_access columns against the selected view's baseInfo schema."""
|
|
9623
|
+
if not selectors:
|
|
9624
|
+
raise_tool_error(QingflowApiError.config_error("columns is required"))
|
|
9625
|
+
fields: list[FormField] = []
|
|
9626
|
+
seen: set[int] = set()
|
|
9627
|
+
for selector in selectors:
|
|
9628
|
+
try:
|
|
9629
|
+
field = self._resolve_field_selector(selector, index, location="record_access.columns")
|
|
9630
|
+
except RecordInputError as exc:
|
|
9631
|
+
if exc.error_code == "FIELD_NOT_FOUND":
|
|
9632
|
+
raise RecordInputError(
|
|
9633
|
+
message=(
|
|
9634
|
+
f"record_access column field_id '{selector}' is not in the selected view schema "
|
|
9635
|
+
f"({view_route.view_id})."
|
|
9636
|
+
),
|
|
9637
|
+
error_code="FIELD_NOT_IN_VIEW_SCHEMA",
|
|
9638
|
+
fix_hint="Call record_browse_schema_get for this exact view_id and pass only field_id values from its fields[].",
|
|
9639
|
+
details={
|
|
9640
|
+
"location": "record_access.columns",
|
|
9641
|
+
"requested": selector,
|
|
9642
|
+
"view_id": view_route.view_id,
|
|
9643
|
+
"view_name": view_route.name,
|
|
9644
|
+
},
|
|
9645
|
+
) from exc
|
|
9646
|
+
raise
|
|
9647
|
+
if field.que_id in seen:
|
|
9648
|
+
continue
|
|
9649
|
+
fields.append(field)
|
|
9650
|
+
seen.add(field.que_id)
|
|
9651
|
+
return fields
|
|
9652
|
+
|
|
9523
9653
|
def _resolve_summary_preview_fields(
|
|
9524
9654
|
self,
|
|
9525
9655
|
selectors: list[str | int],
|
|
@@ -11277,6 +11407,38 @@ def _clone_form_field(field: FormField, *, readonly: bool | None = None) -> Form
|
|
|
11277
11407
|
)
|
|
11278
11408
|
|
|
11279
11409
|
|
|
11410
|
+
def _enrich_read_field_from_applicant(field: FormField, applicant_field: FormField | None) -> FormField:
|
|
11411
|
+
if applicant_field is None:
|
|
11412
|
+
return field
|
|
11413
|
+
raw = dict(applicant_field.raw) if isinstance(applicant_field.raw, dict) else {}
|
|
11414
|
+
raw.update(dict(field.raw) if isinstance(field.raw, dict) else {})
|
|
11415
|
+
raw["queId"] = field.que_id
|
|
11416
|
+
raw["queTitle"] = field.que_title
|
|
11417
|
+
que_type = field.que_type if field.que_type is not None else applicant_field.que_type
|
|
11418
|
+
if que_type is not None:
|
|
11419
|
+
raw["queType"] = que_type
|
|
11420
|
+
readonly_source = field if any(key in field.raw for key in ("readonly", "beingReadonly", "canEdit")) else applicant_field
|
|
11421
|
+
enriched = FormField(
|
|
11422
|
+
que_id=field.que_id,
|
|
11423
|
+
que_title=field.que_title,
|
|
11424
|
+
que_type=que_type,
|
|
11425
|
+
required=field.required or applicant_field.required,
|
|
11426
|
+
readonly=readonly_source.readonly,
|
|
11427
|
+
system=field.system or applicant_field.system,
|
|
11428
|
+
options=list(field.options or applicant_field.options),
|
|
11429
|
+
aliases=[],
|
|
11430
|
+
target_app_key=field.target_app_key or applicant_field.target_app_key,
|
|
11431
|
+
target_app_name_hint=field.target_app_name_hint or applicant_field.target_app_name_hint,
|
|
11432
|
+
member_select_scope_type=field.member_select_scope_type if field.member_select_scope_type is not None else applicant_field.member_select_scope_type,
|
|
11433
|
+
member_select_scope=field.member_select_scope or applicant_field.member_select_scope,
|
|
11434
|
+
dept_select_scope_type=field.dept_select_scope_type if field.dept_select_scope_type is not None else applicant_field.dept_select_scope_type,
|
|
11435
|
+
dept_select_scope=field.dept_select_scope or applicant_field.dept_select_scope,
|
|
11436
|
+
raw=raw,
|
|
11437
|
+
)
|
|
11438
|
+
enriched.aliases = sorted(_field_alias_candidates(enriched))
|
|
11439
|
+
return enriched
|
|
11440
|
+
|
|
11441
|
+
|
|
11280
11442
|
def _form_field_to_question(field: FormField) -> JSONObject:
|
|
11281
11443
|
question = dict(field.raw) if isinstance(field.raw, dict) else {}
|
|
11282
11444
|
question.setdefault("queId", field.que_id)
|
|
@@ -11503,6 +11665,133 @@ def _record_access_field_payload(field: FormField) -> JSONObject:
|
|
|
11503
11665
|
}
|
|
11504
11666
|
|
|
11505
11667
|
|
|
11668
|
+
def _write_record_access_metadata_files(
|
|
11669
|
+
*,
|
|
11670
|
+
run_dir: Path,
|
|
11671
|
+
created_at: str,
|
|
11672
|
+
app_key: str,
|
|
11673
|
+
view_route: AccessibleViewRoute,
|
|
11674
|
+
row_count: int,
|
|
11675
|
+
complete: bool,
|
|
11676
|
+
safe_for_final_conclusion: bool,
|
|
11677
|
+
files: list[JSONObject],
|
|
11678
|
+
fields: list[JSONObject],
|
|
11679
|
+
warnings: list[JSONObject],
|
|
11680
|
+
verification: JSONObject,
|
|
11681
|
+
) -> JSONObject:
|
|
11682
|
+
schema_path = run_dir / "schema.json"
|
|
11683
|
+
readme_path = run_dir / "README.md"
|
|
11684
|
+
schema_payload: JSONObject = {
|
|
11685
|
+
"created_at": created_at,
|
|
11686
|
+
"app_key": app_key,
|
|
11687
|
+
"view_id": view_route.view_id,
|
|
11688
|
+
"view_name": view_route.name,
|
|
11689
|
+
"view_type": view_route.view_type,
|
|
11690
|
+
"format": "csv",
|
|
11691
|
+
"row_count": row_count,
|
|
11692
|
+
"complete": complete,
|
|
11693
|
+
"truncated": not complete,
|
|
11694
|
+
"safe_for_final_conclusion": safe_for_final_conclusion,
|
|
11695
|
+
"files": files,
|
|
11696
|
+
"fields": fields,
|
|
11697
|
+
"warnings": warnings,
|
|
11698
|
+
"verification": verification,
|
|
11699
|
+
"csv_columns": ["record_id"] + [str(field["column_name"]) for field in fields if field.get("column_name")],
|
|
11700
|
+
}
|
|
11701
|
+
schema_path.write_text(json.dumps(schema_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
11702
|
+
readme_path.write_text(
|
|
11703
|
+
_record_access_readme(
|
|
11704
|
+
created_at=created_at,
|
|
11705
|
+
app_key=app_key,
|
|
11706
|
+
view_route=view_route,
|
|
11707
|
+
row_count=row_count,
|
|
11708
|
+
complete=complete,
|
|
11709
|
+
safe_for_final_conclusion=safe_for_final_conclusion,
|
|
11710
|
+
files=files,
|
|
11711
|
+
fields=fields,
|
|
11712
|
+
warnings=warnings,
|
|
11713
|
+
),
|
|
11714
|
+
encoding="utf-8",
|
|
11715
|
+
)
|
|
11716
|
+
return {
|
|
11717
|
+
"schema": str(schema_path),
|
|
11718
|
+
"readme": str(readme_path),
|
|
11719
|
+
}
|
|
11720
|
+
|
|
11721
|
+
|
|
11722
|
+
def _record_access_readme(
|
|
11723
|
+
*,
|
|
11724
|
+
created_at: str,
|
|
11725
|
+
app_key: str,
|
|
11726
|
+
view_route: AccessibleViewRoute,
|
|
11727
|
+
row_count: int,
|
|
11728
|
+
complete: bool,
|
|
11729
|
+
safe_for_final_conclusion: bool,
|
|
11730
|
+
files: list[JSONObject],
|
|
11731
|
+
fields: list[JSONObject],
|
|
11732
|
+
warnings: list[JSONObject],
|
|
11733
|
+
) -> str:
|
|
11734
|
+
lines = [
|
|
11735
|
+
"# Qingflow Record Access",
|
|
11736
|
+
"",
|
|
11737
|
+
f"- Created at: {created_at}",
|
|
11738
|
+
f"- App key: `{app_key}`",
|
|
11739
|
+
f"- View: {view_route.name} (`{view_route.view_id}`)",
|
|
11740
|
+
f"- Format: CSV",
|
|
11741
|
+
f"- Rows: {row_count}",
|
|
11742
|
+
f"- Complete: {str(complete).lower()}",
|
|
11743
|
+
f"- Safe for final conclusion: {str(safe_for_final_conclusion).lower()}",
|
|
11744
|
+
"",
|
|
11745
|
+
"## Data Files",
|
|
11746
|
+
"",
|
|
11747
|
+
]
|
|
11748
|
+
if files:
|
|
11749
|
+
for file_info in files:
|
|
11750
|
+
local_path = _normalize_optional_text(file_info.get("local_path")) or ""
|
|
11751
|
+
part = file_info.get("part")
|
|
11752
|
+
file_rows = file_info.get("row_count")
|
|
11753
|
+
lines.append(f"- `{Path(local_path).name}`: part {part}, {file_rows} rows")
|
|
11754
|
+
else:
|
|
11755
|
+
lines.append("- No CSV shard files were written because the query returned 0 rows.")
|
|
11756
|
+
lines.extend(
|
|
11757
|
+
[
|
|
11758
|
+
"",
|
|
11759
|
+
"## Columns",
|
|
11760
|
+
"",
|
|
11761
|
+
"| CSV column | Field ID | Title | Type |",
|
|
11762
|
+
"|---|---:|---|---|",
|
|
11763
|
+
"| `record_id` | - | Record ID | record_id |",
|
|
11764
|
+
]
|
|
11765
|
+
)
|
|
11766
|
+
for field in fields:
|
|
11767
|
+
column_name = _markdown_escape(str(field.get("column_name", "")))
|
|
11768
|
+
field_id = field.get("field_id", "")
|
|
11769
|
+
title = _markdown_escape(str(field.get("title", "")))
|
|
11770
|
+
field_type = _markdown_escape(str(field.get("type", "")))
|
|
11771
|
+
lines.append(f"| `{column_name}` | {field_id} | {title} | {field_type} |")
|
|
11772
|
+
if warnings:
|
|
11773
|
+
lines.extend(["", "## Warnings", ""])
|
|
11774
|
+
for warning in warnings:
|
|
11775
|
+
code = _normalize_optional_text(warning.get("code")) or "WARNING"
|
|
11776
|
+
message = _normalize_optional_text(warning.get("message")) or ""
|
|
11777
|
+
lines.append(f"- `{code}`: {message}")
|
|
11778
|
+
lines.extend(
|
|
11779
|
+
[
|
|
11780
|
+
"",
|
|
11781
|
+
"## Usage Notes",
|
|
11782
|
+
"",
|
|
11783
|
+
"- Use `records-*.csv` for Python or pandas processing; column names are stable and field-id based.",
|
|
11784
|
+
"- Use `schema.json` or this README to map `field_<field_id>` columns back to Qingflow field titles and types.",
|
|
11785
|
+
"",
|
|
11786
|
+
]
|
|
11787
|
+
)
|
|
11788
|
+
return "\n".join(lines)
|
|
11789
|
+
|
|
11790
|
+
|
|
11791
|
+
def _markdown_escape(value: str) -> str:
|
|
11792
|
+
return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
|
|
11793
|
+
|
|
11794
|
+
|
|
11506
11795
|
def _record_access_field_type(field: FormField) -> str:
|
|
11507
11796
|
if field.que_type in DATE_QUE_TYPES:
|
|
11508
11797
|
return "datetime"
|