@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.5
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 +551 -16
- 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,9 @@ 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
|
+
DEFAULT_RECORD_ACCESS_SHARD_ROWS = 5000
|
|
36
|
+
DEFAULT_RECORD_ACCESS_HARD_ROWS = 50000
|
|
37
|
+
DEFAULT_RECORD_ACCESS_TIMEOUT_SECONDS = 60
|
|
31
38
|
DEFAULT_ANALYSIS_PAGE_SIZE = 1000
|
|
32
39
|
DEFAULT_SCAN_MAX_PAGES = 10
|
|
33
40
|
DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
|
|
@@ -49,6 +56,7 @@ LOOKUP_RELATION_FILTER_PAGE_SIZE = 20
|
|
|
49
56
|
MEMBER_QUE_TYPES = {5}
|
|
50
57
|
DEPARTMENT_QUE_TYPES = {22}
|
|
51
58
|
DATE_QUE_TYPES = {4}
|
|
59
|
+
NUMBER_QUE_TYPES = {8}
|
|
52
60
|
ADDRESS_QUE_TYPES = {21}
|
|
53
61
|
SINGLE_SELECT_QUE_TYPES = {10, 11}
|
|
54
62
|
MULTI_SELECT_QUE_TYPES = {12, 15}
|
|
@@ -337,7 +345,8 @@ class RecordTools(ToolBase):
|
|
|
337
345
|
|
|
338
346
|
@mcp.tool(
|
|
339
347
|
description=(
|
|
340
|
-
"Run schema-first analytics on a Qingflow app using a restricted DSL. "
|
|
348
|
+
"Run lightweight schema-first analytics on a Qingflow app using a restricted DSL. "
|
|
349
|
+
"This is not the default analysis path; prefer record_access plus Python for final conclusions. "
|
|
341
350
|
"Use record_browse_schema_get first, then let the model build a DSL with field_id references only. "
|
|
342
351
|
"dimensions=[] means whole-table summary; dimensions!=[] means grouped analysis. "
|
|
343
352
|
"This route hides paging and scan-budget controls from callers."
|
|
@@ -404,6 +413,29 @@ class RecordTools(ToolBase):
|
|
|
404
413
|
output_profile=output_profile,
|
|
405
414
|
)
|
|
406
415
|
|
|
416
|
+
@mcp.tool(
|
|
417
|
+
description=(
|
|
418
|
+
"Access Qingflow records for analysis by writing local CSV shard files. "
|
|
419
|
+
"Use app_get -> record_browse_schema_get first, then pass field_id-only columns, where, and order_by. "
|
|
420
|
+
"This tool hides pagination and row limits from the caller and returns file metadata instead of record items."
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
def record_access(
|
|
424
|
+
app_key: str = "",
|
|
425
|
+
view_id: str = "",
|
|
426
|
+
columns: list[JSONObject | int] | None = None,
|
|
427
|
+
where: list[JSONObject] | None = None,
|
|
428
|
+
order_by: list[JSONObject] | None = None,
|
|
429
|
+
) -> JSONObject:
|
|
430
|
+
return self.record_access(
|
|
431
|
+
profile=DEFAULT_PROFILE,
|
|
432
|
+
app_key=app_key,
|
|
433
|
+
view_id=view_id,
|
|
434
|
+
columns=columns or [],
|
|
435
|
+
where=where or [],
|
|
436
|
+
order_by=order_by or [],
|
|
437
|
+
)
|
|
438
|
+
|
|
407
439
|
@mcp.tool(description="Read one Qingflow record by record_id. Use record_browse_schema_get first if columns are ambiguous.")
|
|
408
440
|
def record_get(
|
|
409
441
|
profile: str = DEFAULT_PROFILE,
|
|
@@ -1759,6 +1791,327 @@ class RecordTools(ToolBase):
|
|
|
1759
1791
|
}
|
|
1760
1792
|
return response
|
|
1761
1793
|
|
|
1794
|
+
@tool_cn_name("记录访问")
|
|
1795
|
+
def record_access(
|
|
1796
|
+
self,
|
|
1797
|
+
*,
|
|
1798
|
+
profile: str,
|
|
1799
|
+
app_key: str,
|
|
1800
|
+
view_id: str,
|
|
1801
|
+
columns: list[JSONObject | int],
|
|
1802
|
+
where: list[JSONObject],
|
|
1803
|
+
order_by: list[JSONObject],
|
|
1804
|
+
) -> JSONObject:
|
|
1805
|
+
"""Fetch records across pages and write stable CSV shards for local analysis."""
|
|
1806
|
+
if not app_key:
|
|
1807
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
1808
|
+
if not (view_id or "").strip():
|
|
1809
|
+
raise_tool_error(
|
|
1810
|
+
QingflowApiError.config_error(
|
|
1811
|
+
"record_access requires view_id. Call app_get first and pass accessible_views[].view_id."
|
|
1812
|
+
)
|
|
1813
|
+
)
|
|
1814
|
+
legacy_warnings = _detect_record_list_legacy_warnings(columns=columns, where=where, order_by=order_by)
|
|
1815
|
+
normalized_columns = _normalize_public_column_selectors(columns)
|
|
1816
|
+
if not normalized_columns:
|
|
1817
|
+
raise_tool_error(
|
|
1818
|
+
QingflowApiError.config_error(
|
|
1819
|
+
"columns is required. Call record_browse_schema_get first and pass field_id-only columns."
|
|
1820
|
+
)
|
|
1821
|
+
)
|
|
1822
|
+
view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
|
|
1823
|
+
profile=profile,
|
|
1824
|
+
app_key=app_key,
|
|
1825
|
+
view_id=view_id,
|
|
1826
|
+
list_type=None,
|
|
1827
|
+
view_key=None,
|
|
1828
|
+
view_name=None,
|
|
1829
|
+
allow_default=False,
|
|
1830
|
+
)
|
|
1831
|
+
if not _view_type_supports_analysis(view_route.view_type):
|
|
1832
|
+
raise_tool_error(
|
|
1833
|
+
QingflowApiError(
|
|
1834
|
+
category="not_supported",
|
|
1835
|
+
message=(
|
|
1836
|
+
f"record_access does not support view '{view_route.name}' "
|
|
1837
|
+
f"because its type is {view_route.view_type}."
|
|
1838
|
+
),
|
|
1839
|
+
details={
|
|
1840
|
+
"error_code": "VIEW_ACCESS_UNSUPPORTED",
|
|
1841
|
+
"view_id": view_route.view_id,
|
|
1842
|
+
"view_name": view_route.name,
|
|
1843
|
+
"view_type": view_route.view_type,
|
|
1844
|
+
"fix_hint": "Choose a system view or a custom table-style view from app_get.accessible_views.",
|
|
1845
|
+
},
|
|
1846
|
+
)
|
|
1847
|
+
)
|
|
1848
|
+
filters = self._normalize_record_list_where(where)
|
|
1849
|
+
sorts = self._normalize_record_list_order_by(order_by)
|
|
1850
|
+
|
|
1851
|
+
def runner(session_profile, context):
|
|
1852
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
1853
|
+
selected_fields = self._resolve_select_columns(
|
|
1854
|
+
normalized_columns,
|
|
1855
|
+
index,
|
|
1856
|
+
max_columns=len(normalized_columns),
|
|
1857
|
+
default_limit=MAX_LIST_COLUMN_LIMIT,
|
|
1858
|
+
)
|
|
1859
|
+
selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
|
|
1860
|
+
primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
|
|
1861
|
+
view_selection = view_route.view_selection
|
|
1862
|
+
if view_selection is not None and not _view_selection_supported_by_search_ids(
|
|
1863
|
+
view_selection,
|
|
1864
|
+
primary_search_que_ids,
|
|
1865
|
+
):
|
|
1866
|
+
primary_search_que_ids = None
|
|
1867
|
+
remaining_field_batches: list[list[FormField]] = []
|
|
1868
|
+
else:
|
|
1869
|
+
remaining_field_batches = selected_field_batches[1:]
|
|
1870
|
+
primary_search_que_ids = primary_search_que_ids or None
|
|
1871
|
+
match_rules = self._resolve_match_rules(context, filters, index)
|
|
1872
|
+
sort_rules = self._resolve_sorts(sorts, index)
|
|
1873
|
+
dept_member_cache: dict[int, set[int]] = {}
|
|
1874
|
+
used_list_type: int | None = None
|
|
1875
|
+
fallback_list_types = (
|
|
1876
|
+
[view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
|
|
1877
|
+
if view_selection is not None or view_route.list_type is not None
|
|
1878
|
+
else [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
run_dir = _record_access_run_dir()
|
|
1882
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1883
|
+
header = ["record_id"] + [f"field_{field.que_id}" for field in selected_fields]
|
|
1884
|
+
files: list[JSONObject] = []
|
|
1885
|
+
shard_part = 0
|
|
1886
|
+
shard_rows = 0
|
|
1887
|
+
row_count = 0
|
|
1888
|
+
file_handle: Any | None = None
|
|
1889
|
+
writer: Any | None = None
|
|
1890
|
+
|
|
1891
|
+
def close_shard() -> None:
|
|
1892
|
+
nonlocal file_handle, writer, shard_rows
|
|
1893
|
+
if file_handle is None:
|
|
1894
|
+
return
|
|
1895
|
+
path = Path(file_handle.name)
|
|
1896
|
+
file_handle.close()
|
|
1897
|
+
files.append(
|
|
1898
|
+
{
|
|
1899
|
+
"local_path": str(path),
|
|
1900
|
+
"row_count": shard_rows,
|
|
1901
|
+
"part": len(files) + 1,
|
|
1902
|
+
}
|
|
1903
|
+
)
|
|
1904
|
+
file_handle = None
|
|
1905
|
+
writer = None
|
|
1906
|
+
shard_rows = 0
|
|
1907
|
+
|
|
1908
|
+
def ensure_writer() -> Any:
|
|
1909
|
+
nonlocal file_handle, writer, shard_part
|
|
1910
|
+
if writer is not None:
|
|
1911
|
+
return writer
|
|
1912
|
+
shard_part += 1
|
|
1913
|
+
path = run_dir / f"records-{shard_part:04d}.csv"
|
|
1914
|
+
file_handle = path.open("w", newline="", encoding="utf-8-sig")
|
|
1915
|
+
writer = csv.writer(file_handle)
|
|
1916
|
+
writer.writerow(header)
|
|
1917
|
+
return writer
|
|
1918
|
+
|
|
1919
|
+
def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
|
|
1920
|
+
nonlocal row_count, shard_rows
|
|
1921
|
+
if apply_id is None:
|
|
1922
|
+
return
|
|
1923
|
+
if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
|
|
1924
|
+
close_shard()
|
|
1925
|
+
csv_writer = ensure_writer()
|
|
1926
|
+
values: list[str] = [_public_record_id_text(apply_id) or ""]
|
|
1927
|
+
for field in selected_fields:
|
|
1928
|
+
answer = _find_answer_for_field(answer_list, field)
|
|
1929
|
+
values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
|
|
1930
|
+
csv_writer.writerow(values)
|
|
1931
|
+
row_count += 1
|
|
1932
|
+
shard_rows += 1
|
|
1933
|
+
|
|
1934
|
+
current_page = 1
|
|
1935
|
+
has_more = False
|
|
1936
|
+
reported_total: int | None = None
|
|
1937
|
+
limit_reached = False
|
|
1938
|
+
timeout_reached = False
|
|
1939
|
+
started_at = time.perf_counter()
|
|
1940
|
+
try:
|
|
1941
|
+
while True:
|
|
1942
|
+
if row_count >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
|
|
1943
|
+
limit_reached = True
|
|
1944
|
+
break
|
|
1945
|
+
if time.perf_counter() - started_at >= DEFAULT_RECORD_ACCESS_TIMEOUT_SECONDS:
|
|
1946
|
+
timeout_reached = True
|
|
1947
|
+
break
|
|
1948
|
+
if used_list_type is None:
|
|
1949
|
+
last_error: QingflowApiError | None = None
|
|
1950
|
+
page: JSONObject | None = None
|
|
1951
|
+
for candidate_list_type in fallback_list_types:
|
|
1952
|
+
try:
|
|
1953
|
+
page = self._search_page(
|
|
1954
|
+
context,
|
|
1955
|
+
app_key=app_key,
|
|
1956
|
+
view_selection=view_selection,
|
|
1957
|
+
page_num=current_page,
|
|
1958
|
+
page_size=DEFAULT_LIST_PAGE_SIZE,
|
|
1959
|
+
query_key=None,
|
|
1960
|
+
match_rules=match_rules,
|
|
1961
|
+
sorts=sort_rules,
|
|
1962
|
+
search_que_ids=primary_search_que_ids,
|
|
1963
|
+
list_type=candidate_list_type,
|
|
1964
|
+
)
|
|
1965
|
+
used_list_type = None if view_selection is not None else candidate_list_type
|
|
1966
|
+
break
|
|
1967
|
+
except QingflowApiError as exc:
|
|
1968
|
+
last_error = exc
|
|
1969
|
+
if (
|
|
1970
|
+
self._should_retry_list_type_fallback(exc)
|
|
1971
|
+
and candidate_list_type != fallback_list_types[-1]
|
|
1972
|
+
):
|
|
1973
|
+
continue
|
|
1974
|
+
raise
|
|
1975
|
+
if page is None:
|
|
1976
|
+
if last_error is not None:
|
|
1977
|
+
raise last_error
|
|
1978
|
+
raise_tool_error(QingflowApiError.config_error("record_access failed: no accessible listType"))
|
|
1979
|
+
else:
|
|
1980
|
+
page = self._search_page(
|
|
1981
|
+
context,
|
|
1982
|
+
app_key=app_key,
|
|
1983
|
+
view_selection=view_selection,
|
|
1984
|
+
page_num=current_page,
|
|
1985
|
+
page_size=DEFAULT_LIST_PAGE_SIZE,
|
|
1986
|
+
query_key=None,
|
|
1987
|
+
match_rules=match_rules,
|
|
1988
|
+
sorts=sort_rules,
|
|
1989
|
+
search_que_ids=primary_search_que_ids,
|
|
1990
|
+
list_type=used_list_type,
|
|
1991
|
+
)
|
|
1992
|
+
page_rows = page.get("list")
|
|
1993
|
+
items = page_rows if isinstance(page_rows, list) else []
|
|
1994
|
+
if reported_total is None:
|
|
1995
|
+
reported_total = _effective_total(page, DEFAULT_LIST_PAGE_SIZE)
|
|
1996
|
+
has_more = _page_has_more(page, current_page, DEFAULT_LIST_PAGE_SIZE, len(items))
|
|
1997
|
+
page_apply_order: list[int] = []
|
|
1998
|
+
page_answer_map: dict[int, list[JSONValue]] = {}
|
|
1999
|
+
for item in items:
|
|
2000
|
+
if row_count + len(page_apply_order) >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
|
|
2001
|
+
limit_reached = True
|
|
2002
|
+
break
|
|
2003
|
+
if not isinstance(item, dict):
|
|
2004
|
+
continue
|
|
2005
|
+
answers = item.get("answers")
|
|
2006
|
+
answer_list = answers if isinstance(answers, list) else []
|
|
2007
|
+
if not self._matches_view_selection(
|
|
2008
|
+
context,
|
|
2009
|
+
answer_list,
|
|
2010
|
+
view_selection=view_selection,
|
|
2011
|
+
dept_member_cache=dept_member_cache,
|
|
2012
|
+
):
|
|
2013
|
+
continue
|
|
2014
|
+
apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
|
|
2015
|
+
if apply_id is None:
|
|
2016
|
+
continue
|
|
2017
|
+
page_apply_order.append(apply_id)
|
|
2018
|
+
page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
|
|
2019
|
+
if page_apply_order and remaining_field_batches:
|
|
2020
|
+
for batch in remaining_field_batches:
|
|
2021
|
+
extra_page = self._search_page(
|
|
2022
|
+
context,
|
|
2023
|
+
app_key=app_key,
|
|
2024
|
+
view_selection=view_selection,
|
|
2025
|
+
page_num=current_page,
|
|
2026
|
+
page_size=DEFAULT_LIST_PAGE_SIZE,
|
|
2027
|
+
query_key=None,
|
|
2028
|
+
match_rules=match_rules,
|
|
2029
|
+
sorts=sort_rules,
|
|
2030
|
+
search_que_ids=[field.que_id for field in batch],
|
|
2031
|
+
list_type=used_list_type or DEFAULT_RECORD_LIST_TYPE,
|
|
2032
|
+
)
|
|
2033
|
+
extra_rows = extra_page.get("list")
|
|
2034
|
+
extra_items = extra_rows if isinstance(extra_rows, list) else []
|
|
2035
|
+
for extra_item in extra_items:
|
|
2036
|
+
if not isinstance(extra_item, dict):
|
|
2037
|
+
continue
|
|
2038
|
+
apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
|
|
2039
|
+
if apply_id is None or apply_id not in page_answer_map:
|
|
2040
|
+
continue
|
|
2041
|
+
extra_answers = extra_item.get("answers")
|
|
2042
|
+
extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
|
|
2043
|
+
page_answer_map[apply_id] = _merge_answer_lists_by_field_id(
|
|
2044
|
+
page_answer_map.get(apply_id, []),
|
|
2045
|
+
cast(list[JSONValue], extra_answer_list),
|
|
2046
|
+
)
|
|
2047
|
+
for apply_id in page_apply_order:
|
|
2048
|
+
if row_count >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
|
|
2049
|
+
limit_reached = True
|
|
2050
|
+
break
|
|
2051
|
+
write_record(apply_id, page_answer_map.get(apply_id, []))
|
|
2052
|
+
if limit_reached or not has_more:
|
|
2053
|
+
break
|
|
2054
|
+
current_page += 1
|
|
2055
|
+
finally:
|
|
2056
|
+
close_shard()
|
|
2057
|
+
|
|
2058
|
+
warnings: list[JSONObject] = []
|
|
2059
|
+
warnings.extend(legacy_warnings)
|
|
2060
|
+
warnings.extend(compatibility_warnings)
|
|
2061
|
+
warnings.extend(_view_filter_trust_warnings(view_route))
|
|
2062
|
+
if used_list_type is not None and used_list_type != DEFAULT_RECORD_LIST_TYPE:
|
|
2063
|
+
warnings.append(
|
|
2064
|
+
{
|
|
2065
|
+
"code": "LIST_TYPE_FALLBACK",
|
|
2066
|
+
"message": (
|
|
2067
|
+
f"record_access not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; "
|
|
2068
|
+
f"fell back to listType={used_list_type} ({get_record_list_type_label(used_list_type)})."
|
|
2069
|
+
),
|
|
2070
|
+
}
|
|
2071
|
+
)
|
|
2072
|
+
if limit_reached:
|
|
2073
|
+
warnings.append(
|
|
2074
|
+
{
|
|
2075
|
+
"code": "RECORD_ACCESS_INTERNAL_LIMIT_REACHED",
|
|
2076
|
+
"message": "record_access reached its internal row limit before all pages were fetched.",
|
|
2077
|
+
}
|
|
2078
|
+
)
|
|
2079
|
+
if timeout_reached:
|
|
2080
|
+
warnings.append(
|
|
2081
|
+
{
|
|
2082
|
+
"code": "RECORD_ACCESS_TIMEOUT",
|
|
2083
|
+
"message": "record_access reached its internal time limit before all pages were fetched.",
|
|
2084
|
+
}
|
|
2085
|
+
)
|
|
2086
|
+
complete = not has_more and not limit_reached and not timeout_reached
|
|
2087
|
+
safe_for_final_conclusion = complete and not any(
|
|
2088
|
+
warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
|
|
2089
|
+
)
|
|
2090
|
+
return {
|
|
2091
|
+
"profile": profile,
|
|
2092
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2093
|
+
"ok": True,
|
|
2094
|
+
"status": "success" if complete else "partial",
|
|
2095
|
+
"app_key": app_key,
|
|
2096
|
+
"view_id": view_route.view_id,
|
|
2097
|
+
"format": "csv",
|
|
2098
|
+
"row_count": row_count,
|
|
2099
|
+
"complete": complete,
|
|
2100
|
+
"truncated": not complete,
|
|
2101
|
+
"safe_for_final_conclusion": safe_for_final_conclusion,
|
|
2102
|
+
"files": files,
|
|
2103
|
+
"fields": [_record_access_field_payload(field) for field in selected_fields],
|
|
2104
|
+
"warnings": warnings,
|
|
2105
|
+
"verification": {
|
|
2106
|
+
**_view_filter_verification_payload(view_route),
|
|
2107
|
+
"reported_total": reported_total,
|
|
2108
|
+
"list_type_used": used_list_type,
|
|
2109
|
+
},
|
|
2110
|
+
"request_route": self._request_route_payload(context),
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
return self._run_record_tool(profile, runner)
|
|
2114
|
+
|
|
1762
2115
|
@tool_cn_name("记录详情")
|
|
1763
2116
|
def record_get_public(
|
|
1764
2117
|
self,
|
|
@@ -2920,12 +3273,14 @@ class RecordTools(ToolBase):
|
|
|
2920
3273
|
payload["category"] = error.category
|
|
2921
3274
|
if error.backend_code is not None:
|
|
2922
3275
|
payload["backend_code"] = error.backend_code
|
|
3276
|
+
if error.request_id is not None:
|
|
3277
|
+
payload["request_id"] = error.request_id
|
|
2923
3278
|
if error.http_status is not None:
|
|
2924
3279
|
payload["http_status"] = error.http_status
|
|
2925
3280
|
return payload
|
|
2926
3281
|
|
|
2927
3282
|
def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
|
|
2928
|
-
if error.backend_code in {40002, 40027, 40038, 404}:
|
|
3283
|
+
if error.backend_code in {40002, 40023, 40027, 40038, 404}:
|
|
2929
3284
|
return True
|
|
2930
3285
|
if error.http_status == 404:
|
|
2931
3286
|
return True
|
|
@@ -3059,6 +3414,107 @@ class RecordTools(ToolBase):
|
|
|
3059
3414
|
f"({get_record_list_type_label(used_list_type)})."
|
|
3060
3415
|
]
|
|
3061
3416
|
|
|
3417
|
+
def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
|
|
3418
|
+
if exc.backend_code == 500:
|
|
3419
|
+
return True
|
|
3420
|
+
if exc.http_status is not None and exc.http_status >= 500:
|
|
3421
|
+
return True
|
|
3422
|
+
normalized_message = exc.message.strip().lower()
|
|
3423
|
+
return normalized_message in {"unknown error", "internal server error"}
|
|
3424
|
+
|
|
3425
|
+
def _remap_record_update_target_context_error(
|
|
3426
|
+
self,
|
|
3427
|
+
profile: str,
|
|
3428
|
+
context, # type: ignore[no-untyped-def]
|
|
3429
|
+
*,
|
|
3430
|
+
app_key: str,
|
|
3431
|
+
apply_id: int,
|
|
3432
|
+
exc: QingflowApiError,
|
|
3433
|
+
) -> None:
|
|
3434
|
+
if not self._looks_like_generic_record_update_backend_failure(exc):
|
|
3435
|
+
return
|
|
3436
|
+
try:
|
|
3437
|
+
candidate_routes = self._candidate_update_views(profile, context, app_key)
|
|
3438
|
+
probes = self._probe_candidate_record_contexts(
|
|
3439
|
+
context,
|
|
3440
|
+
app_key=app_key,
|
|
3441
|
+
apply_id=apply_id,
|
|
3442
|
+
candidate_routes=candidate_routes,
|
|
3443
|
+
)
|
|
3444
|
+
except (QingflowApiError, RuntimeError):
|
|
3445
|
+
return
|
|
3446
|
+
if not probes or any(probe.readable for probe in probes):
|
|
3447
|
+
return
|
|
3448
|
+
|
|
3449
|
+
blocker = (
|
|
3450
|
+
"CURRENT_RECORD_CONTEXT_UNAVAILABLE"
|
|
3451
|
+
if all(probe.transport_error for probe in probes)
|
|
3452
|
+
else "NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"
|
|
3453
|
+
)
|
|
3454
|
+
recommended_next_actions = (
|
|
3455
|
+
[
|
|
3456
|
+
"Retry after the record becomes readable in the current workspace/profile context.",
|
|
3457
|
+
"If the issue persists, verify that the current profile still has read access to this record.",
|
|
3458
|
+
]
|
|
3459
|
+
if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
|
|
3460
|
+
else [
|
|
3461
|
+
"Use record_get or record_list to confirm the record still exists in the current workspace.",
|
|
3462
|
+
"Call record_update_schema_get to inspect whether any accessible view still matches this record.",
|
|
3463
|
+
]
|
|
3464
|
+
)
|
|
3465
|
+
first_error_payload = next(
|
|
3466
|
+
(
|
|
3467
|
+
cast(JSONObject, probe.error_payload)
|
|
3468
|
+
for probe in probes
|
|
3469
|
+
if isinstance(probe.error_payload, dict)
|
|
3470
|
+
),
|
|
3471
|
+
None,
|
|
3472
|
+
)
|
|
3473
|
+
backend_code = (
|
|
3474
|
+
cast(int, first_error_payload.get("backend_code"))
|
|
3475
|
+
if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("backend_code"), int)
|
|
3476
|
+
else exc.backend_code
|
|
3477
|
+
)
|
|
3478
|
+
request_id = (
|
|
3479
|
+
_normalize_optional_text(first_error_payload.get("request_id"))
|
|
3480
|
+
if isinstance(first_error_payload, dict)
|
|
3481
|
+
else None
|
|
3482
|
+
) or exc.request_id
|
|
3483
|
+
http_status = (
|
|
3484
|
+
cast(int, first_error_payload.get("http_status"))
|
|
3485
|
+
if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("http_status"), int)
|
|
3486
|
+
else exc.http_status
|
|
3487
|
+
)
|
|
3488
|
+
raise_tool_error(
|
|
3489
|
+
QingflowApiError(
|
|
3490
|
+
category="backend",
|
|
3491
|
+
message=(
|
|
3492
|
+
"Direct record edit was blocked because the current record context could not be loaded from any candidate route."
|
|
3493
|
+
if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
|
|
3494
|
+
else "Direct record edit was blocked because the target record is no longer accessible in any readable view for the current profile."
|
|
3495
|
+
),
|
|
3496
|
+
backend_code=backend_code,
|
|
3497
|
+
request_id=request_id,
|
|
3498
|
+
http_status=http_status,
|
|
3499
|
+
details={
|
|
3500
|
+
"error_code": blocker,
|
|
3501
|
+
"operation": "update",
|
|
3502
|
+
"app_key": app_key,
|
|
3503
|
+
"record_id": apply_id,
|
|
3504
|
+
"blockers": [blocker],
|
|
3505
|
+
"request_route": self._request_route_payload(context),
|
|
3506
|
+
"view_probe_summary": [
|
|
3507
|
+
self._record_context_probe_summary_payload(probe)
|
|
3508
|
+
for probe in probes
|
|
3509
|
+
],
|
|
3510
|
+
"recommended_next_actions": recommended_next_actions,
|
|
3511
|
+
"fix_hint": (
|
|
3512
|
+
"Confirm the target record still exists and remains visible in at least one accessible view before retrying the update."
|
|
3513
|
+
),
|
|
3514
|
+
},
|
|
3515
|
+
)
|
|
3516
|
+
)
|
|
3517
|
+
|
|
3062
3518
|
def _record_matches_accessible_view(
|
|
3063
3519
|
self,
|
|
3064
3520
|
context, # type: ignore[no-untyped-def]
|
|
@@ -6355,12 +6811,22 @@ class RecordTools(ToolBase):
|
|
|
6355
6811
|
force_refresh_form=force_refresh_form,
|
|
6356
6812
|
)
|
|
6357
6813
|
self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6814
|
+
try:
|
|
6815
|
+
result = self.backend.request(
|
|
6816
|
+
"POST",
|
|
6817
|
+
context,
|
|
6818
|
+
f"/app/{app_key}/apply/{normalized_apply_id}",
|
|
6819
|
+
json_body={"role": role, "answers": normalized_answers},
|
|
6820
|
+
)
|
|
6821
|
+
except QingflowApiError as exc:
|
|
6822
|
+
self._remap_record_update_target_context_error(
|
|
6823
|
+
profile,
|
|
6824
|
+
context,
|
|
6825
|
+
app_key=app_key,
|
|
6826
|
+
apply_id=normalized_apply_id,
|
|
6827
|
+
exc=exc,
|
|
6828
|
+
)
|
|
6829
|
+
raise
|
|
6364
6830
|
verification = self._verify_record_write_result(
|
|
6365
6831
|
context,
|
|
6366
6832
|
app_key=app_key,
|
|
@@ -6890,6 +7356,8 @@ class RecordTools(ToolBase):
|
|
|
6890
7356
|
isinstance(payload.get("viewgraphLimit"), list)
|
|
6891
7357
|
or isinstance(payload.get("viewConfig"), dict)
|
|
6892
7358
|
or isinstance(payload.get("viewgraphConfig"), dict)
|
|
7359
|
+
or isinstance(payload.get("viewgraphQuestions"), list)
|
|
7360
|
+
or isinstance(payload.get("viewgraphQueIds"), list)
|
|
6893
7361
|
):
|
|
6894
7362
|
config = payload
|
|
6895
7363
|
else:
|
|
@@ -8794,7 +9262,13 @@ class RecordTools(ToolBase):
|
|
|
8794
9262
|
except json.JSONDecodeError:
|
|
8795
9263
|
parsed = None
|
|
8796
9264
|
if isinstance(parsed, dict):
|
|
8797
|
-
|
|
9265
|
+
parsed_details = parsed.get("details")
|
|
9266
|
+
details_payload = cast(JSONObject, parsed_details) if isinstance(parsed_details, dict) else None
|
|
9267
|
+
error_payload["error_code"] = (
|
|
9268
|
+
parsed.get("error_code")
|
|
9269
|
+
or (details_payload.get("error_code") if details_payload is not None else None)
|
|
9270
|
+
or error_payload["error_code"]
|
|
9271
|
+
)
|
|
8798
9272
|
error_payload["message"] = parsed.get("message") or error_payload["message"]
|
|
8799
9273
|
if parsed.get("backend_code") is not None:
|
|
8800
9274
|
error_payload["backend_code"] = parsed.get("backend_code")
|
|
@@ -8802,6 +9276,8 @@ class RecordTools(ToolBase):
|
|
|
8802
9276
|
error_payload["request_id"] = parsed.get("request_id")
|
|
8803
9277
|
if isinstance(parsed.get("request_route"), dict):
|
|
8804
9278
|
request_route = cast(JSONObject, parsed.get("request_route"))
|
|
9279
|
+
elif details_payload is not None and isinstance(details_payload.get("request_route"), dict):
|
|
9280
|
+
request_route = cast(JSONObject, details_payload.get("request_route"))
|
|
8805
9281
|
response: JSONObject = {
|
|
8806
9282
|
"profile": profile,
|
|
8807
9283
|
"ws_id": None,
|
|
@@ -10089,11 +10565,20 @@ class RecordTools(ToolBase):
|
|
|
10089
10565
|
"""执行内部辅助逻辑。"""
|
|
10090
10566
|
if not app_key:
|
|
10091
10567
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
10092
|
-
|
|
10093
|
-
|
|
10094
|
-
|
|
10095
|
-
|
|
10096
|
-
|
|
10568
|
+
return self._normalize_internal_backend_id(apply_id, field_name="apply_id")
|
|
10569
|
+
|
|
10570
|
+
def _normalize_internal_backend_id(self, value: Any, *, field_name: str) -> int:
|
|
10571
|
+
"""Normalize backend/apply ids after the public boundary has already preserved long string ids."""
|
|
10572
|
+
if value in (None, "") or isinstance(value, bool):
|
|
10573
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
|
|
10574
|
+
if isinstance(value, int):
|
|
10575
|
+
if value <= 0:
|
|
10576
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
|
|
10577
|
+
return value
|
|
10578
|
+
text = stringify_backend_id(value)
|
|
10579
|
+
if text is None or not text.isdecimal() or int(text) <= 0:
|
|
10580
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
|
|
10581
|
+
return int(text)
|
|
10097
10582
|
|
|
10098
10583
|
def _validate_record_write(self, app_key: str, answers: list[JSONObject], apply_id: int | None = None) -> None:
|
|
10099
10584
|
"""执行内部辅助逻辑。"""
|
|
@@ -11035,7 +11520,7 @@ def _list_sample_only(*, returned_items: int, row_cap: int, result_amount: int |
|
|
|
11035
11520
|
def _list_sample_warning(*, returned_items: int, row_cap: int, result_amount: int | None) -> str:
|
|
11036
11521
|
if _list_sample_only(returned_items=returned_items, row_cap=row_cap, result_amount=result_amount):
|
|
11037
11522
|
return "当前仅返回样本,不适合最终统计结论。"
|
|
11038
|
-
return "record_list
|
|
11523
|
+
return "record_list 适合浏览或样本检查;最终统计结论请改用 record_browse_schema_get -> record_access -> Python。"
|
|
11039
11524
|
|
|
11040
11525
|
|
|
11041
11526
|
def _resolve_query_mode(
|
|
@@ -11067,6 +11552,56 @@ def _chunk_fields(fields: list[FormField], chunk_size: int) -> list[list[FormFie
|
|
|
11067
11552
|
return [fields[index : index + chunk_size] for index in range(0, len(fields), chunk_size)]
|
|
11068
11553
|
|
|
11069
11554
|
|
|
11555
|
+
def _record_access_run_dir() -> Path:
|
|
11556
|
+
custom_home = os.getenv("QINGFLOW_MCP_RECORD_ACCESS_HOME")
|
|
11557
|
+
base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-access"
|
|
11558
|
+
run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
|
11559
|
+
return base_dir / run_id
|
|
11560
|
+
|
|
11561
|
+
|
|
11562
|
+
def _record_access_field_payload(field: FormField) -> JSONObject:
|
|
11563
|
+
return {
|
|
11564
|
+
"field_id": field.que_id,
|
|
11565
|
+
"title": field.que_title,
|
|
11566
|
+
"column_name": f"field_{field.que_id}",
|
|
11567
|
+
"type": _record_access_field_type(field),
|
|
11568
|
+
}
|
|
11569
|
+
|
|
11570
|
+
|
|
11571
|
+
def _record_access_field_type(field: FormField) -> str:
|
|
11572
|
+
if field.que_type in DATE_QUE_TYPES:
|
|
11573
|
+
return "datetime"
|
|
11574
|
+
if field.que_type in NUMBER_QUE_TYPES:
|
|
11575
|
+
return "number"
|
|
11576
|
+
if field.que_type in MEMBER_QUE_TYPES:
|
|
11577
|
+
return "member"
|
|
11578
|
+
if field.que_type in DEPARTMENT_QUE_TYPES:
|
|
11579
|
+
return "department"
|
|
11580
|
+
if field.que_type in ADDRESS_QUE_TYPES:
|
|
11581
|
+
return "address"
|
|
11582
|
+
if field.que_type in RELATION_QUE_TYPES:
|
|
11583
|
+
return "relation"
|
|
11584
|
+
if field.que_type in ATTACHMENT_QUE_TYPES:
|
|
11585
|
+
return "attachment"
|
|
11586
|
+
if field.que_type in SINGLE_SELECT_QUE_TYPES:
|
|
11587
|
+
return "single_select"
|
|
11588
|
+
if field.que_type in MULTI_SELECT_QUE_TYPES:
|
|
11589
|
+
return "multi_select"
|
|
11590
|
+
if field.que_type in SUBTABLE_QUE_TYPES:
|
|
11591
|
+
return "subtable"
|
|
11592
|
+
return "value"
|
|
11593
|
+
|
|
11594
|
+
|
|
11595
|
+
def _record_access_csv_cell(value: JSONValue) -> str:
|
|
11596
|
+
if value is None:
|
|
11597
|
+
return ""
|
|
11598
|
+
if isinstance(value, bool):
|
|
11599
|
+
return "true" if value else "false"
|
|
11600
|
+
if isinstance(value, int | float | str):
|
|
11601
|
+
return str(value)
|
|
11602
|
+
return json.dumps(value, ensure_ascii=False)
|
|
11603
|
+
|
|
11604
|
+
|
|
11070
11605
|
def _is_board_view_type(view_type: str | None) -> bool:
|
|
11071
11606
|
normalized = _normalize_optional_text(view_type)
|
|
11072
11607
|
return normalized is not None and normalized.lower() == "boardview"
|