@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.6
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/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +109 -0
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +44 -5
- package/src/qingflow_mcp/builder_facade/service.py +21 -8
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/builder.py +7 -0
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +20 -0
- package/src/qingflow_mcp/cli/commands/task.py +644 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +139 -4
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +2 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +6 -0
- package/src/qingflow_mcp/response_trim.py +40 -1
- package/src/qingflow_mcp/server.py +22 -0
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +104 -8
- package/src/qingflow_mcp/session_store.py +57 -6
- package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
- package/src/qingflow_mcp/tools/auth_tools.py +26 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +515 -45
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import csv
|
|
3
4
|
import json
|
|
5
|
+
import os
|
|
4
6
|
import re
|
|
5
7
|
import time
|
|
6
8
|
from copy import deepcopy
|
|
7
9
|
from dataclasses import dataclass
|
|
8
10
|
from datetime import UTC, datetime
|
|
9
11
|
from decimal import Decimal, InvalidOperation
|
|
12
|
+
from pathlib import Path
|
|
10
13
|
from typing import Any, cast
|
|
14
|
+
from uuid import uuid4
|
|
11
15
|
|
|
12
16
|
from mcp.server.fastmcp import FastMCP
|
|
13
17
|
|
|
14
|
-
from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
|
|
18
|
+
from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE, get_mcp_home
|
|
15
19
|
from ..errors import QingflowApiError, raise_tool_error
|
|
16
20
|
from ..id_utils import normalize_positive_id_int, stringify_backend_id
|
|
17
21
|
from ..json_types import JSONObject, JSONScalar, JSONValue
|
|
@@ -28,6 +32,8 @@ from .directory_tools import _directory_has_more, _directory_items
|
|
|
28
32
|
|
|
29
33
|
DEFAULT_QUERY_PAGE_SIZE = 50
|
|
30
34
|
DEFAULT_LIST_PAGE_SIZE = 200
|
|
35
|
+
BACKEND_RECORD_ACCESS_PAGE_SIZE = 1000
|
|
36
|
+
DEFAULT_RECORD_ACCESS_SHARD_ROWS = 5000
|
|
31
37
|
DEFAULT_ANALYSIS_PAGE_SIZE = 1000
|
|
32
38
|
DEFAULT_SCAN_MAX_PAGES = 10
|
|
33
39
|
DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
|
|
@@ -49,6 +55,7 @@ LOOKUP_RELATION_FILTER_PAGE_SIZE = 20
|
|
|
49
55
|
MEMBER_QUE_TYPES = {5}
|
|
50
56
|
DEPARTMENT_QUE_TYPES = {22}
|
|
51
57
|
DATE_QUE_TYPES = {4}
|
|
58
|
+
NUMBER_QUE_TYPES = {8}
|
|
52
59
|
ADDRESS_QUE_TYPES = {21}
|
|
53
60
|
SINGLE_SELECT_QUE_TYPES = {10, 11}
|
|
54
61
|
MULTI_SELECT_QUE_TYPES = {12, 15}
|
|
@@ -337,7 +344,8 @@ class RecordTools(ToolBase):
|
|
|
337
344
|
|
|
338
345
|
@mcp.tool(
|
|
339
346
|
description=(
|
|
340
|
-
"Run schema-first analytics on a Qingflow app using a restricted DSL. "
|
|
347
|
+
"Run lightweight schema-first analytics on a Qingflow app using a restricted DSL. "
|
|
348
|
+
"This is not the default analysis path; prefer record_access plus Python for final conclusions. "
|
|
341
349
|
"Use record_browse_schema_get first, then let the model build a DSL with field_id references only. "
|
|
342
350
|
"dimensions=[] means whole-table summary; dimensions!=[] means grouped analysis. "
|
|
343
351
|
"This route hides paging and scan-budget controls from callers."
|
|
@@ -404,6 +412,29 @@ class RecordTools(ToolBase):
|
|
|
404
412
|
output_profile=output_profile,
|
|
405
413
|
)
|
|
406
414
|
|
|
415
|
+
@mcp.tool(
|
|
416
|
+
description=(
|
|
417
|
+
"Access Qingflow records for analysis by writing local CSV shard files. "
|
|
418
|
+
"Use app_get -> record_browse_schema_get first, then pass field_id-only columns, where, and order_by. "
|
|
419
|
+
"This tool hides pagination and row limits from the caller and returns file metadata instead of record items."
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
def record_access(
|
|
423
|
+
app_key: str = "",
|
|
424
|
+
view_id: str = "",
|
|
425
|
+
columns: list[JSONObject | int] | None = None,
|
|
426
|
+
where: list[JSONObject] | None = None,
|
|
427
|
+
order_by: list[JSONObject] | None = None,
|
|
428
|
+
) -> JSONObject:
|
|
429
|
+
return self.record_access(
|
|
430
|
+
profile=DEFAULT_PROFILE,
|
|
431
|
+
app_key=app_key,
|
|
432
|
+
view_id=view_id,
|
|
433
|
+
columns=columns or [],
|
|
434
|
+
where=where or [],
|
|
435
|
+
order_by=order_by or [],
|
|
436
|
+
)
|
|
437
|
+
|
|
407
438
|
@mcp.tool(description="Read one Qingflow record by record_id. Use record_browse_schema_get first if columns are ambiguous.")
|
|
408
439
|
def record_get(
|
|
409
440
|
profile: str = DEFAULT_PROFILE,
|
|
@@ -1759,6 +1790,290 @@ class RecordTools(ToolBase):
|
|
|
1759
1790
|
}
|
|
1760
1791
|
return response
|
|
1761
1792
|
|
|
1793
|
+
@tool_cn_name("记录访问")
|
|
1794
|
+
def record_access(
|
|
1795
|
+
self,
|
|
1796
|
+
*,
|
|
1797
|
+
profile: str,
|
|
1798
|
+
app_key: str,
|
|
1799
|
+
view_id: str,
|
|
1800
|
+
columns: list[JSONObject | int],
|
|
1801
|
+
where: list[JSONObject],
|
|
1802
|
+
order_by: list[JSONObject],
|
|
1803
|
+
) -> JSONObject:
|
|
1804
|
+
"""Fetch records across pages and write stable CSV shards for local analysis."""
|
|
1805
|
+
if not app_key:
|
|
1806
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
1807
|
+
if not (view_id or "").strip():
|
|
1808
|
+
raise_tool_error(
|
|
1809
|
+
QingflowApiError.config_error(
|
|
1810
|
+
"record_access requires view_id. Call app_get first and pass accessible_views[].view_id."
|
|
1811
|
+
)
|
|
1812
|
+
)
|
|
1813
|
+
legacy_warnings = _detect_record_list_legacy_warnings(columns=columns, where=where, order_by=order_by)
|
|
1814
|
+
normalized_columns = _normalize_public_column_selectors(columns)
|
|
1815
|
+
if not normalized_columns:
|
|
1816
|
+
raise_tool_error(
|
|
1817
|
+
QingflowApiError.config_error(
|
|
1818
|
+
"columns is required. Call record_browse_schema_get first and pass field_id-only columns."
|
|
1819
|
+
)
|
|
1820
|
+
)
|
|
1821
|
+
view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
|
|
1822
|
+
profile=profile,
|
|
1823
|
+
app_key=app_key,
|
|
1824
|
+
view_id=view_id,
|
|
1825
|
+
list_type=None,
|
|
1826
|
+
view_key=None,
|
|
1827
|
+
view_name=None,
|
|
1828
|
+
allow_default=False,
|
|
1829
|
+
)
|
|
1830
|
+
if not _view_type_supports_analysis(view_route.view_type):
|
|
1831
|
+
raise_tool_error(
|
|
1832
|
+
QingflowApiError(
|
|
1833
|
+
category="not_supported",
|
|
1834
|
+
message=(
|
|
1835
|
+
f"record_access does not support view '{view_route.name}' "
|
|
1836
|
+
f"because its type is {view_route.view_type}."
|
|
1837
|
+
),
|
|
1838
|
+
details={
|
|
1839
|
+
"error_code": "VIEW_ACCESS_UNSUPPORTED",
|
|
1840
|
+
"view_id": view_route.view_id,
|
|
1841
|
+
"view_name": view_route.name,
|
|
1842
|
+
"view_type": view_route.view_type,
|
|
1843
|
+
"fix_hint": "Choose a system view or a custom table-style view from app_get.accessible_views.",
|
|
1844
|
+
},
|
|
1845
|
+
)
|
|
1846
|
+
)
|
|
1847
|
+
filters = self._normalize_record_list_where(where)
|
|
1848
|
+
sorts = self._normalize_record_list_order_by(order_by)
|
|
1849
|
+
|
|
1850
|
+
def runner(session_profile, context):
|
|
1851
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
1852
|
+
selected_fields = self._resolve_select_columns(
|
|
1853
|
+
normalized_columns,
|
|
1854
|
+
index,
|
|
1855
|
+
max_columns=len(normalized_columns),
|
|
1856
|
+
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1857
|
+
)
|
|
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
|
+
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
|
+
match_rules = self._resolve_match_rules(context, filters, index)
|
|
1871
|
+
sort_rules = self._resolve_sorts(sorts, index)
|
|
1872
|
+
used_list_type: int | None = None
|
|
1873
|
+
fallback_list_types = (
|
|
1874
|
+
[view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
|
|
1875
|
+
if view_selection is not None or view_route.list_type is not None
|
|
1876
|
+
else [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
run_dir = _record_access_run_dir()
|
|
1880
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1881
|
+
header = ["record_id"] + [f"field_{field.que_id}" for field in selected_fields]
|
|
1882
|
+
files: list[JSONObject] = []
|
|
1883
|
+
shard_part = 0
|
|
1884
|
+
shard_rows = 0
|
|
1885
|
+
row_count = 0
|
|
1886
|
+
file_handle: Any | None = None
|
|
1887
|
+
writer: Any | None = None
|
|
1888
|
+
|
|
1889
|
+
def close_shard() -> None:
|
|
1890
|
+
nonlocal file_handle, writer, shard_rows
|
|
1891
|
+
if file_handle is None:
|
|
1892
|
+
return
|
|
1893
|
+
path = Path(file_handle.name)
|
|
1894
|
+
file_handle.close()
|
|
1895
|
+
files.append(
|
|
1896
|
+
{
|
|
1897
|
+
"local_path": str(path),
|
|
1898
|
+
"row_count": shard_rows,
|
|
1899
|
+
"part": len(files) + 1,
|
|
1900
|
+
}
|
|
1901
|
+
)
|
|
1902
|
+
file_handle = None
|
|
1903
|
+
writer = None
|
|
1904
|
+
shard_rows = 0
|
|
1905
|
+
|
|
1906
|
+
def ensure_writer() -> Any:
|
|
1907
|
+
nonlocal file_handle, writer, shard_part
|
|
1908
|
+
if writer is not None:
|
|
1909
|
+
return writer
|
|
1910
|
+
shard_part += 1
|
|
1911
|
+
path = run_dir / f"records-{shard_part:04d}.csv"
|
|
1912
|
+
file_handle = path.open("w", newline="", encoding="utf-8-sig")
|
|
1913
|
+
writer = csv.writer(file_handle)
|
|
1914
|
+
writer.writerow(header)
|
|
1915
|
+
return writer
|
|
1916
|
+
|
|
1917
|
+
def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
|
|
1918
|
+
nonlocal row_count, shard_rows
|
|
1919
|
+
if apply_id is None:
|
|
1920
|
+
return
|
|
1921
|
+
if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
|
|
1922
|
+
close_shard()
|
|
1923
|
+
csv_writer = ensure_writer()
|
|
1924
|
+
values: list[str] = [_public_record_id_text(apply_id) or ""]
|
|
1925
|
+
for field in selected_fields:
|
|
1926
|
+
answer = _find_answer_for_field(answer_list, field)
|
|
1927
|
+
values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
|
|
1928
|
+
csv_writer.writerow(values)
|
|
1929
|
+
row_count += 1
|
|
1930
|
+
shard_rows += 1
|
|
1931
|
+
|
|
1932
|
+
current_page = 1
|
|
1933
|
+
has_more = False
|
|
1934
|
+
reported_total: int | None = None
|
|
1935
|
+
try:
|
|
1936
|
+
while True:
|
|
1937
|
+
if used_list_type is None:
|
|
1938
|
+
last_error: QingflowApiError | None = None
|
|
1939
|
+
page: JSONObject | None = None
|
|
1940
|
+
for candidate_list_type in fallback_list_types:
|
|
1941
|
+
try:
|
|
1942
|
+
page = self._search_page(
|
|
1943
|
+
context,
|
|
1944
|
+
app_key=app_key,
|
|
1945
|
+
view_selection=view_selection,
|
|
1946
|
+
page_num=current_page,
|
|
1947
|
+
page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
|
|
1948
|
+
query_key=None,
|
|
1949
|
+
match_rules=match_rules,
|
|
1950
|
+
sorts=sort_rules,
|
|
1951
|
+
search_que_ids=primary_search_que_ids,
|
|
1952
|
+
list_type=candidate_list_type,
|
|
1953
|
+
)
|
|
1954
|
+
used_list_type = None if view_selection is not None else candidate_list_type
|
|
1955
|
+
break
|
|
1956
|
+
except QingflowApiError as exc:
|
|
1957
|
+
last_error = exc
|
|
1958
|
+
if (
|
|
1959
|
+
self._should_retry_list_type_fallback(exc)
|
|
1960
|
+
and candidate_list_type != fallback_list_types[-1]
|
|
1961
|
+
):
|
|
1962
|
+
continue
|
|
1963
|
+
raise
|
|
1964
|
+
if page is None:
|
|
1965
|
+
if last_error is not None:
|
|
1966
|
+
raise last_error
|
|
1967
|
+
raise_tool_error(QingflowApiError.config_error("record_access failed: no accessible listType"))
|
|
1968
|
+
else:
|
|
1969
|
+
page = self._search_page(
|
|
1970
|
+
context,
|
|
1971
|
+
app_key=app_key,
|
|
1972
|
+
view_selection=view_selection,
|
|
1973
|
+
page_num=current_page,
|
|
1974
|
+
page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
|
|
1975
|
+
query_key=None,
|
|
1976
|
+
match_rules=match_rules,
|
|
1977
|
+
sorts=sort_rules,
|
|
1978
|
+
search_que_ids=primary_search_que_ids,
|
|
1979
|
+
list_type=used_list_type,
|
|
1980
|
+
)
|
|
1981
|
+
page_rows = page.get("list")
|
|
1982
|
+
items = page_rows if isinstance(page_rows, list) else []
|
|
1983
|
+
if reported_total is None:
|
|
1984
|
+
reported_total = _effective_total(page, BACKEND_RECORD_ACCESS_PAGE_SIZE)
|
|
1985
|
+
has_more = _page_has_more(page, current_page, BACKEND_RECORD_ACCESS_PAGE_SIZE, len(items))
|
|
1986
|
+
page_apply_order: list[int] = []
|
|
1987
|
+
page_answer_map: dict[int, list[JSONValue]] = {}
|
|
1988
|
+
for item in items:
|
|
1989
|
+
if not isinstance(item, dict):
|
|
1990
|
+
continue
|
|
1991
|
+
answers = item.get("answers")
|
|
1992
|
+
answer_list = answers if isinstance(answers, list) else []
|
|
1993
|
+
apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
|
|
1994
|
+
if apply_id is None:
|
|
1995
|
+
continue
|
|
1996
|
+
page_apply_order.append(apply_id)
|
|
1997
|
+
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
|
+
for apply_id in page_apply_order:
|
|
2027
|
+
write_record(apply_id, page_answer_map.get(apply_id, []))
|
|
2028
|
+
if not has_more:
|
|
2029
|
+
break
|
|
2030
|
+
current_page += 1
|
|
2031
|
+
finally:
|
|
2032
|
+
close_shard()
|
|
2033
|
+
|
|
2034
|
+
warnings: list[JSONObject] = []
|
|
2035
|
+
warnings.extend(legacy_warnings)
|
|
2036
|
+
warnings.extend(compatibility_warnings)
|
|
2037
|
+
warnings.extend(_view_filter_trust_warnings(view_route))
|
|
2038
|
+
if used_list_type is not None and used_list_type != DEFAULT_RECORD_LIST_TYPE:
|
|
2039
|
+
warnings.append(
|
|
2040
|
+
{
|
|
2041
|
+
"code": "LIST_TYPE_FALLBACK",
|
|
2042
|
+
"message": (
|
|
2043
|
+
f"record_access not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; "
|
|
2044
|
+
f"fell back to listType={used_list_type} ({get_record_list_type_label(used_list_type)})."
|
|
2045
|
+
),
|
|
2046
|
+
}
|
|
2047
|
+
)
|
|
2048
|
+
complete = not has_more
|
|
2049
|
+
safe_for_final_conclusion = complete and not any(
|
|
2050
|
+
warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
|
|
2051
|
+
)
|
|
2052
|
+
return {
|
|
2053
|
+
"profile": profile,
|
|
2054
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2055
|
+
"ok": True,
|
|
2056
|
+
"status": "success" if complete else "partial",
|
|
2057
|
+
"app_key": app_key,
|
|
2058
|
+
"view_id": view_route.view_id,
|
|
2059
|
+
"format": "csv",
|
|
2060
|
+
"row_count": row_count,
|
|
2061
|
+
"complete": complete,
|
|
2062
|
+
"truncated": not complete,
|
|
2063
|
+
"safe_for_final_conclusion": safe_for_final_conclusion,
|
|
2064
|
+
"files": files,
|
|
2065
|
+
"fields": [_record_access_field_payload(field) for field in selected_fields],
|
|
2066
|
+
"warnings": warnings,
|
|
2067
|
+
"verification": {
|
|
2068
|
+
**_view_filter_verification_payload(view_route),
|
|
2069
|
+
"reported_total": reported_total,
|
|
2070
|
+
"list_type_used": used_list_type,
|
|
2071
|
+
},
|
|
2072
|
+
"request_route": self._request_route_payload(context),
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
return self._run_record_tool(profile, runner)
|
|
2076
|
+
|
|
1762
2077
|
@tool_cn_name("记录详情")
|
|
1763
2078
|
def record_get_public(
|
|
1764
2079
|
self,
|
|
@@ -2920,12 +3235,14 @@ class RecordTools(ToolBase):
|
|
|
2920
3235
|
payload["category"] = error.category
|
|
2921
3236
|
if error.backend_code is not None:
|
|
2922
3237
|
payload["backend_code"] = error.backend_code
|
|
3238
|
+
if error.request_id is not None:
|
|
3239
|
+
payload["request_id"] = error.request_id
|
|
2923
3240
|
if error.http_status is not None:
|
|
2924
3241
|
payload["http_status"] = error.http_status
|
|
2925
3242
|
return payload
|
|
2926
3243
|
|
|
2927
3244
|
def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
|
|
2928
|
-
if error.backend_code in {40002, 40027, 40038, 404}:
|
|
3245
|
+
if error.backend_code in {40002, 40023, 40027, 40038, 404}:
|
|
2929
3246
|
return True
|
|
2930
3247
|
if error.http_status == 404:
|
|
2931
3248
|
return True
|
|
@@ -3059,6 +3376,107 @@ class RecordTools(ToolBase):
|
|
|
3059
3376
|
f"({get_record_list_type_label(used_list_type)})."
|
|
3060
3377
|
]
|
|
3061
3378
|
|
|
3379
|
+
def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
|
|
3380
|
+
if exc.backend_code == 500:
|
|
3381
|
+
return True
|
|
3382
|
+
if exc.http_status is not None and exc.http_status >= 500:
|
|
3383
|
+
return True
|
|
3384
|
+
normalized_message = exc.message.strip().lower()
|
|
3385
|
+
return normalized_message in {"unknown error", "internal server error"}
|
|
3386
|
+
|
|
3387
|
+
def _remap_record_update_target_context_error(
|
|
3388
|
+
self,
|
|
3389
|
+
profile: str,
|
|
3390
|
+
context, # type: ignore[no-untyped-def]
|
|
3391
|
+
*,
|
|
3392
|
+
app_key: str,
|
|
3393
|
+
apply_id: int,
|
|
3394
|
+
exc: QingflowApiError,
|
|
3395
|
+
) -> None:
|
|
3396
|
+
if not self._looks_like_generic_record_update_backend_failure(exc):
|
|
3397
|
+
return
|
|
3398
|
+
try:
|
|
3399
|
+
candidate_routes = self._candidate_update_views(profile, context, app_key)
|
|
3400
|
+
probes = self._probe_candidate_record_contexts(
|
|
3401
|
+
context,
|
|
3402
|
+
app_key=app_key,
|
|
3403
|
+
apply_id=apply_id,
|
|
3404
|
+
candidate_routes=candidate_routes,
|
|
3405
|
+
)
|
|
3406
|
+
except (QingflowApiError, RuntimeError):
|
|
3407
|
+
return
|
|
3408
|
+
if not probes or any(probe.readable for probe in probes):
|
|
3409
|
+
return
|
|
3410
|
+
|
|
3411
|
+
blocker = (
|
|
3412
|
+
"CURRENT_RECORD_CONTEXT_UNAVAILABLE"
|
|
3413
|
+
if all(probe.transport_error for probe in probes)
|
|
3414
|
+
else "NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"
|
|
3415
|
+
)
|
|
3416
|
+
recommended_next_actions = (
|
|
3417
|
+
[
|
|
3418
|
+
"Retry after the record becomes readable in the current workspace/profile context.",
|
|
3419
|
+
"If the issue persists, verify that the current profile still has read access to this record.",
|
|
3420
|
+
]
|
|
3421
|
+
if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
|
|
3422
|
+
else [
|
|
3423
|
+
"Use record_get or record_list to confirm the record still exists in the current workspace.",
|
|
3424
|
+
"Call record_update_schema_get to inspect whether any accessible view still matches this record.",
|
|
3425
|
+
]
|
|
3426
|
+
)
|
|
3427
|
+
first_error_payload = next(
|
|
3428
|
+
(
|
|
3429
|
+
cast(JSONObject, probe.error_payload)
|
|
3430
|
+
for probe in probes
|
|
3431
|
+
if isinstance(probe.error_payload, dict)
|
|
3432
|
+
),
|
|
3433
|
+
None,
|
|
3434
|
+
)
|
|
3435
|
+
backend_code = (
|
|
3436
|
+
cast(int, first_error_payload.get("backend_code"))
|
|
3437
|
+
if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("backend_code"), int)
|
|
3438
|
+
else exc.backend_code
|
|
3439
|
+
)
|
|
3440
|
+
request_id = (
|
|
3441
|
+
_normalize_optional_text(first_error_payload.get("request_id"))
|
|
3442
|
+
if isinstance(first_error_payload, dict)
|
|
3443
|
+
else None
|
|
3444
|
+
) or exc.request_id
|
|
3445
|
+
http_status = (
|
|
3446
|
+
cast(int, first_error_payload.get("http_status"))
|
|
3447
|
+
if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("http_status"), int)
|
|
3448
|
+
else exc.http_status
|
|
3449
|
+
)
|
|
3450
|
+
raise_tool_error(
|
|
3451
|
+
QingflowApiError(
|
|
3452
|
+
category="backend",
|
|
3453
|
+
message=(
|
|
3454
|
+
"Direct record edit was blocked because the current record context could not be loaded from any candidate route."
|
|
3455
|
+
if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
|
|
3456
|
+
else "Direct record edit was blocked because the target record is no longer accessible in any readable view for the current profile."
|
|
3457
|
+
),
|
|
3458
|
+
backend_code=backend_code,
|
|
3459
|
+
request_id=request_id,
|
|
3460
|
+
http_status=http_status,
|
|
3461
|
+
details={
|
|
3462
|
+
"error_code": blocker,
|
|
3463
|
+
"operation": "update",
|
|
3464
|
+
"app_key": app_key,
|
|
3465
|
+
"record_id": apply_id,
|
|
3466
|
+
"blockers": [blocker],
|
|
3467
|
+
"request_route": self._request_route_payload(context),
|
|
3468
|
+
"view_probe_summary": [
|
|
3469
|
+
self._record_context_probe_summary_payload(probe)
|
|
3470
|
+
for probe in probes
|
|
3471
|
+
],
|
|
3472
|
+
"recommended_next_actions": recommended_next_actions,
|
|
3473
|
+
"fix_hint": (
|
|
3474
|
+
"Confirm the target record still exists and remains visible in at least one accessible view before retrying the update."
|
|
3475
|
+
),
|
|
3476
|
+
},
|
|
3477
|
+
)
|
|
3478
|
+
)
|
|
3479
|
+
|
|
3062
3480
|
def _record_matches_accessible_view(
|
|
3063
3481
|
self,
|
|
3064
3482
|
context, # type: ignore[no-untyped-def]
|
|
@@ -5138,9 +5556,8 @@ class RecordTools(ToolBase):
|
|
|
5138
5556
|
source_pages: list[int] = []
|
|
5139
5557
|
result_amount: int | None = None
|
|
5140
5558
|
has_more = False
|
|
5141
|
-
dept_member_cache: dict[int, set[int]] = {}
|
|
5142
5559
|
view_selection = resolved_view.view_selection
|
|
5143
|
-
local_filtering = bool(filters)
|
|
5560
|
+
local_filtering = bool(filters)
|
|
5144
5561
|
group_stats: dict[tuple[tuple[str, object], ...], JSONObject] = {}
|
|
5145
5562
|
overall_metrics = self._initialize_metric_states(metrics)
|
|
5146
5563
|
matched_rows = 0
|
|
@@ -5184,13 +5601,6 @@ class RecordTools(ToolBase):
|
|
|
5184
5601
|
continue
|
|
5185
5602
|
answers = item.get("answers")
|
|
5186
5603
|
answer_list = answers if isinstance(answers, list) else []
|
|
5187
|
-
if not self._matches_view_selection(
|
|
5188
|
-
context,
|
|
5189
|
-
answer_list,
|
|
5190
|
-
view_selection=view_selection,
|
|
5191
|
-
dept_member_cache=dept_member_cache,
|
|
5192
|
-
):
|
|
5193
|
-
continue
|
|
5194
5604
|
if not self._matches_analyze_filters(answer_list, filters):
|
|
5195
5605
|
continue
|
|
5196
5606
|
matched_rows += 1
|
|
@@ -6276,7 +6686,6 @@ class RecordTools(ToolBase):
|
|
|
6276
6686
|
if view_selection is not None
|
|
6277
6687
|
else self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
6278
6688
|
)
|
|
6279
|
-
dept_member_cache: dict[int, set[int]] = {}
|
|
6280
6689
|
result = self._search_page(
|
|
6281
6690
|
context,
|
|
6282
6691
|
app_key=app_key,
|
|
@@ -6291,17 +6700,7 @@ class RecordTools(ToolBase):
|
|
|
6291
6700
|
)
|
|
6292
6701
|
rows = result.get("list")
|
|
6293
6702
|
raw_rows = rows if isinstance(rows, list) else []
|
|
6294
|
-
filtered_rows = [
|
|
6295
|
-
item
|
|
6296
|
-
for item in raw_rows
|
|
6297
|
-
if isinstance(item, dict)
|
|
6298
|
-
and self._matches_view_selection(
|
|
6299
|
-
context,
|
|
6300
|
-
item.get("answers") if isinstance(item.get("answers"), list) else [],
|
|
6301
|
-
view_selection=view_selection,
|
|
6302
|
-
dept_member_cache=dept_member_cache,
|
|
6303
|
-
)
|
|
6304
|
-
]
|
|
6703
|
+
filtered_rows = [item for item in raw_rows if isinstance(item, dict)]
|
|
6305
6704
|
if isinstance(rows, list):
|
|
6306
6705
|
result = dict(result)
|
|
6307
6706
|
result["list"] = filtered_rows
|
|
@@ -6355,12 +6754,22 @@ class RecordTools(ToolBase):
|
|
|
6355
6754
|
force_refresh_form=force_refresh_form,
|
|
6356
6755
|
)
|
|
6357
6756
|
self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6757
|
+
try:
|
|
6758
|
+
result = self.backend.request(
|
|
6759
|
+
"POST",
|
|
6760
|
+
context,
|
|
6761
|
+
f"/app/{app_key}/apply/{normalized_apply_id}",
|
|
6762
|
+
json_body={"role": role, "answers": normalized_answers},
|
|
6763
|
+
)
|
|
6764
|
+
except QingflowApiError as exc:
|
|
6765
|
+
self._remap_record_update_target_context_error(
|
|
6766
|
+
profile,
|
|
6767
|
+
context,
|
|
6768
|
+
app_key=app_key,
|
|
6769
|
+
apply_id=normalized_apply_id,
|
|
6770
|
+
exc=exc,
|
|
6771
|
+
)
|
|
6772
|
+
raise
|
|
6364
6773
|
verification = self._verify_record_write_result(
|
|
6365
6774
|
context,
|
|
6366
6775
|
app_key=app_key,
|
|
@@ -6541,7 +6950,6 @@ class RecordTools(ToolBase):
|
|
|
6541
6950
|
def runner(session_profile, context):
|
|
6542
6951
|
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
6543
6952
|
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
6544
|
-
dept_member_cache: dict[int, set[int]] = {}
|
|
6545
6953
|
resolved_column_cap = _bounded_column_limit(
|
|
6546
6954
|
max_columns,
|
|
6547
6955
|
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
@@ -6647,13 +7055,6 @@ class RecordTools(ToolBase):
|
|
|
6647
7055
|
continue
|
|
6648
7056
|
answers = item.get("answers")
|
|
6649
7057
|
answer_list = answers if isinstance(answers, list) else []
|
|
6650
|
-
if not self._matches_view_selection(
|
|
6651
|
-
context,
|
|
6652
|
-
answer_list,
|
|
6653
|
-
view_selection=view_selection,
|
|
6654
|
-
dept_member_cache=dept_member_cache,
|
|
6655
|
-
):
|
|
6656
|
-
continue
|
|
6657
7058
|
matched_records += 1
|
|
6658
7059
|
apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
|
|
6659
7060
|
row = _build_flat_row(answer_list, selected_fields_from_primary, apply_id=apply_id)
|
|
@@ -6890,6 +7291,8 @@ class RecordTools(ToolBase):
|
|
|
6890
7291
|
isinstance(payload.get("viewgraphLimit"), list)
|
|
6891
7292
|
or isinstance(payload.get("viewConfig"), dict)
|
|
6892
7293
|
or isinstance(payload.get("viewgraphConfig"), dict)
|
|
7294
|
+
or isinstance(payload.get("viewgraphQuestions"), list)
|
|
7295
|
+
or isinstance(payload.get("viewgraphQueIds"), list)
|
|
6893
7296
|
):
|
|
6894
7297
|
config = payload
|
|
6895
7298
|
else:
|
|
@@ -8794,7 +9197,13 @@ class RecordTools(ToolBase):
|
|
|
8794
9197
|
except json.JSONDecodeError:
|
|
8795
9198
|
parsed = None
|
|
8796
9199
|
if isinstance(parsed, dict):
|
|
8797
|
-
|
|
9200
|
+
parsed_details = parsed.get("details")
|
|
9201
|
+
details_payload = cast(JSONObject, parsed_details) if isinstance(parsed_details, dict) else None
|
|
9202
|
+
error_payload["error_code"] = (
|
|
9203
|
+
parsed.get("error_code")
|
|
9204
|
+
or (details_payload.get("error_code") if details_payload is not None else None)
|
|
9205
|
+
or error_payload["error_code"]
|
|
9206
|
+
)
|
|
8798
9207
|
error_payload["message"] = parsed.get("message") or error_payload["message"]
|
|
8799
9208
|
if parsed.get("backend_code") is not None:
|
|
8800
9209
|
error_payload["backend_code"] = parsed.get("backend_code")
|
|
@@ -8802,6 +9211,8 @@ class RecordTools(ToolBase):
|
|
|
8802
9211
|
error_payload["request_id"] = parsed.get("request_id")
|
|
8803
9212
|
if isinstance(parsed.get("request_route"), dict):
|
|
8804
9213
|
request_route = cast(JSONObject, parsed.get("request_route"))
|
|
9214
|
+
elif details_payload is not None and isinstance(details_payload.get("request_route"), dict):
|
|
9215
|
+
request_route = cast(JSONObject, details_payload.get("request_route"))
|
|
8805
9216
|
response: JSONObject = {
|
|
8806
9217
|
"profile": profile,
|
|
8807
9218
|
"ws_id": None,
|
|
@@ -10089,11 +10500,20 @@ class RecordTools(ToolBase):
|
|
|
10089
10500
|
"""执行内部辅助逻辑。"""
|
|
10090
10501
|
if not app_key:
|
|
10091
10502
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
10092
|
-
|
|
10093
|
-
|
|
10094
|
-
|
|
10095
|
-
|
|
10096
|
-
|
|
10503
|
+
return self._normalize_internal_backend_id(apply_id, field_name="apply_id")
|
|
10504
|
+
|
|
10505
|
+
def _normalize_internal_backend_id(self, value: Any, *, field_name: str) -> int:
|
|
10506
|
+
"""Normalize backend/apply ids after the public boundary has already preserved long string ids."""
|
|
10507
|
+
if value in (None, "") or isinstance(value, bool):
|
|
10508
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
|
|
10509
|
+
if isinstance(value, int):
|
|
10510
|
+
if value <= 0:
|
|
10511
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
|
|
10512
|
+
return value
|
|
10513
|
+
text = stringify_backend_id(value)
|
|
10514
|
+
if text is None or not text.isdecimal() or int(text) <= 0:
|
|
10515
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
|
|
10516
|
+
return int(text)
|
|
10097
10517
|
|
|
10098
10518
|
def _validate_record_write(self, app_key: str, answers: list[JSONObject], apply_id: int | None = None) -> None:
|
|
10099
10519
|
"""执行内部辅助逻辑。"""
|
|
@@ -11035,7 +11455,7 @@ def _list_sample_only(*, returned_items: int, row_cap: int, result_amount: int |
|
|
|
11035
11455
|
def _list_sample_warning(*, returned_items: int, row_cap: int, result_amount: int | None) -> str:
|
|
11036
11456
|
if _list_sample_only(returned_items=returned_items, row_cap=row_cap, result_amount=result_amount):
|
|
11037
11457
|
return "当前仅返回样本,不适合最终统计结论。"
|
|
11038
|
-
return "record_list
|
|
11458
|
+
return "record_list 适合浏览或样本检查;最终统计结论请改用 record_browse_schema_get -> record_access -> Python。"
|
|
11039
11459
|
|
|
11040
11460
|
|
|
11041
11461
|
def _resolve_query_mode(
|
|
@@ -11067,6 +11487,56 @@ def _chunk_fields(fields: list[FormField], chunk_size: int) -> list[list[FormFie
|
|
|
11067
11487
|
return [fields[index : index + chunk_size] for index in range(0, len(fields), chunk_size)]
|
|
11068
11488
|
|
|
11069
11489
|
|
|
11490
|
+
def _record_access_run_dir() -> Path:
|
|
11491
|
+
custom_home = os.getenv("QINGFLOW_MCP_RECORD_ACCESS_HOME")
|
|
11492
|
+
base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-access"
|
|
11493
|
+
run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
|
11494
|
+
return base_dir / run_id
|
|
11495
|
+
|
|
11496
|
+
|
|
11497
|
+
def _record_access_field_payload(field: FormField) -> JSONObject:
|
|
11498
|
+
return {
|
|
11499
|
+
"field_id": field.que_id,
|
|
11500
|
+
"title": field.que_title,
|
|
11501
|
+
"column_name": f"field_{field.que_id}",
|
|
11502
|
+
"type": _record_access_field_type(field),
|
|
11503
|
+
}
|
|
11504
|
+
|
|
11505
|
+
|
|
11506
|
+
def _record_access_field_type(field: FormField) -> str:
|
|
11507
|
+
if field.que_type in DATE_QUE_TYPES:
|
|
11508
|
+
return "datetime"
|
|
11509
|
+
if field.que_type in NUMBER_QUE_TYPES:
|
|
11510
|
+
return "number"
|
|
11511
|
+
if field.que_type in MEMBER_QUE_TYPES:
|
|
11512
|
+
return "member"
|
|
11513
|
+
if field.que_type in DEPARTMENT_QUE_TYPES:
|
|
11514
|
+
return "department"
|
|
11515
|
+
if field.que_type in ADDRESS_QUE_TYPES:
|
|
11516
|
+
return "address"
|
|
11517
|
+
if field.que_type in RELATION_QUE_TYPES:
|
|
11518
|
+
return "relation"
|
|
11519
|
+
if field.que_type in ATTACHMENT_QUE_TYPES:
|
|
11520
|
+
return "attachment"
|
|
11521
|
+
if field.que_type in SINGLE_SELECT_QUE_TYPES:
|
|
11522
|
+
return "single_select"
|
|
11523
|
+
if field.que_type in MULTI_SELECT_QUE_TYPES:
|
|
11524
|
+
return "multi_select"
|
|
11525
|
+
if field.que_type in SUBTABLE_QUE_TYPES:
|
|
11526
|
+
return "subtable"
|
|
11527
|
+
return "value"
|
|
11528
|
+
|
|
11529
|
+
|
|
11530
|
+
def _record_access_csv_cell(value: JSONValue) -> str:
|
|
11531
|
+
if value is None:
|
|
11532
|
+
return ""
|
|
11533
|
+
if isinstance(value, bool):
|
|
11534
|
+
return "true" if value else "false"
|
|
11535
|
+
if isinstance(value, int | float | str):
|
|
11536
|
+
return str(value)
|
|
11537
|
+
return json.dumps(value, ensure_ascii=False)
|
|
11538
|
+
|
|
11539
|
+
|
|
11070
11540
|
def _is_board_view_type(view_type: str | None) -> bool:
|
|
11071
11541
|
normalized = _normalize_optional_text(view_type)
|
|
11072
11542
|
return normalized is not None and normalized.lower() == "boardview"
|