@josephyan/qingflow-cli 1.0.9 → 1.0.11
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 +2 -1
- package/src/qingflow_mcp/backend_client.py +55 -1
- package/src/qingflow_mcp/cli/commands/record.py +24 -5
- package/src/qingflow_mcp/cli/formatters.py +70 -1
- package/src/qingflow_mcp/public_surface.py +1 -1
- package/src/qingflow_mcp/response_trim.py +146 -10
- package/src/qingflow_mcp/server.py +15 -9
- package/src/qingflow_mcp/server_app_user.py +12 -6
- package/src/qingflow_mcp/tools/record_tools.py +12413 -9211
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-cli@1.0.
|
|
6
|
+
npm install @josephyan/qingflow-cli@1.0.11
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-cli@1.0.
|
|
12
|
+
npx -y -p @josephyan/qingflow-cli@1.0.11 qingflow
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qingflow-mcp"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.11"
|
|
8
8
|
description = "User-authenticated MCP server for Qingflow"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"openpyxl>=3.1,<4.0",
|
|
30
30
|
"pydantic>=2.8,<3.0",
|
|
31
31
|
"pycryptodome>=3.20,<4.0",
|
|
32
|
+
"pypdf>=5.0,<6.0",
|
|
32
33
|
"python-socketio[client]>=5.11,<6.0",
|
|
33
34
|
]
|
|
34
35
|
|
|
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|
|
4
4
|
from threading import Event
|
|
5
5
|
from time import sleep
|
|
6
6
|
from typing import Any
|
|
7
|
-
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
8
8
|
from uuid import uuid4
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
@@ -33,6 +33,15 @@ class BackendResponse:
|
|
|
33
33
|
qf_response_version: str | None = None
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class BackendBinaryResponse:
|
|
38
|
+
content: bytes
|
|
39
|
+
headers: dict[str, str]
|
|
40
|
+
http_status: int
|
|
41
|
+
final_url: str
|
|
42
|
+
redirected: bool
|
|
43
|
+
|
|
44
|
+
|
|
36
45
|
class BackendClient:
|
|
37
46
|
def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
|
|
38
47
|
self._owns_client = client is None
|
|
@@ -290,6 +299,51 @@ class BackendClient:
|
|
|
290
299
|
)
|
|
291
300
|
return response.content
|
|
292
301
|
|
|
302
|
+
def download_binary_with_cookie(
|
|
303
|
+
self,
|
|
304
|
+
context: BackendRequestContext,
|
|
305
|
+
url: str,
|
|
306
|
+
*,
|
|
307
|
+
cookie_name: str,
|
|
308
|
+
headers: dict[str, str] | None = None,
|
|
309
|
+
) -> BackendBinaryResponse:
|
|
310
|
+
qf_version, _source = self._resolve_qf_version(context.qf_version)
|
|
311
|
+
request_headers = {
|
|
312
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
313
|
+
"Qf-Request-Id": context.qf_request_id or str(uuid4()),
|
|
314
|
+
}
|
|
315
|
+
if headers:
|
|
316
|
+
request_headers.update({key: value for key, value in headers.items() if value is not None})
|
|
317
|
+
cookie_parts = [f"{cookie_name}={context.token}"]
|
|
318
|
+
if qf_version:
|
|
319
|
+
cookie_parts.append(f"qfVersion={qf_version}")
|
|
320
|
+
request_headers["Cookie"] = "; ".join(cookie_parts)
|
|
321
|
+
try:
|
|
322
|
+
response = self._client.get(url, headers=request_headers, follow_redirects=False)
|
|
323
|
+
except httpx.RequestError as exc:
|
|
324
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
325
|
+
redirected = 300 <= response.status_code < 400 and bool(response.headers.get("location"))
|
|
326
|
+
if redirected:
|
|
327
|
+
redirect_headers = {key: value for key, value in request_headers.items() if key.lower() != "cookie"}
|
|
328
|
+
redirect_url = urljoin(str(response.url), response.headers["location"])
|
|
329
|
+
try:
|
|
330
|
+
response = self._client.get(redirect_url, headers=redirect_headers)
|
|
331
|
+
except httpx.RequestError as exc:
|
|
332
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
333
|
+
if response.status_code >= 400:
|
|
334
|
+
raise QingflowApiError(
|
|
335
|
+
category="http",
|
|
336
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
337
|
+
http_status=response.status_code,
|
|
338
|
+
)
|
|
339
|
+
return BackendBinaryResponse(
|
|
340
|
+
content=response.content,
|
|
341
|
+
headers=dict(response.headers),
|
|
342
|
+
http_status=response.status_code,
|
|
343
|
+
final_url=str(response.url),
|
|
344
|
+
redirected=redirected or bool(response.history) or str(response.url) != url,
|
|
345
|
+
)
|
|
346
|
+
|
|
293
347
|
def request_multipart(
|
|
294
348
|
self,
|
|
295
349
|
method: str,
|
|
@@ -10,7 +10,11 @@ from .common import load_list_arg, load_object_arg, raise_config_error, require_
|
|
|
10
10
|
|
|
11
11
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
12
12
|
parser = subparsers.add_parser("record", help="记录与表结构")
|
|
13
|
-
record_subparsers = parser.add_subparsers(
|
|
13
|
+
record_subparsers = parser.add_subparsers(
|
|
14
|
+
dest="record_command",
|
|
15
|
+
required=True,
|
|
16
|
+
metavar="{schema,list,access,get,insert,update,delete,code-block-run}",
|
|
17
|
+
)
|
|
14
18
|
|
|
15
19
|
schema = record_subparsers.add_parser("schema", help="读取记录相关表结构")
|
|
16
20
|
schema.add_argument("--mode", dest="legacy_mode", help=argparse.SUPPRESS)
|
|
@@ -47,9 +51,11 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
47
51
|
list_parser.add_argument("--app-key", required=True)
|
|
48
52
|
list_parser.add_argument("--column", dest="columns", action="append", type=int, default=[])
|
|
49
53
|
list_parser.add_argument("--columns-file")
|
|
54
|
+
list_parser.add_argument("--query")
|
|
55
|
+
list_parser.add_argument("--query-field", dest="query_fields", action="append", type=int, default=[])
|
|
56
|
+
list_parser.add_argument("--query-fields-file")
|
|
50
57
|
list_parser.add_argument("--where-file")
|
|
51
58
|
list_parser.add_argument("--order-by-file")
|
|
52
|
-
list_parser.add_argument("--limit", type=int, default=20)
|
|
53
59
|
list_parser.add_argument("--page", type=int, default=1)
|
|
54
60
|
list_parser.add_argument("--view-id")
|
|
55
61
|
list_parser.add_argument("--list-type", dest="legacy_list_type", type=int, help=argparse.SUPPRESS)
|
|
@@ -72,7 +78,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
72
78
|
get.add_argument("--column", dest="columns", action="append", type=int, default=[])
|
|
73
79
|
get.add_argument("--columns-file")
|
|
74
80
|
get.add_argument("--view-id")
|
|
75
|
-
get.set_defaults(handler=_handle_get, format_hint="")
|
|
81
|
+
get.set_defaults(handler=_handle_get, format_hint="record_get")
|
|
76
82
|
|
|
77
83
|
insert = record_subparsers.add_parser("insert", help="新增记录")
|
|
78
84
|
insert.add_argument("--app-key", required=True)
|
|
@@ -95,7 +101,12 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
95
101
|
delete.add_argument("--record-ids-file")
|
|
96
102
|
delete.set_defaults(handler=_handle_delete, format_hint="")
|
|
97
103
|
|
|
98
|
-
analyze = record_subparsers.add_parser("analyze", help=
|
|
104
|
+
analyze = record_subparsers.add_parser("analyze", help=argparse.SUPPRESS)
|
|
105
|
+
record_subparsers._choices_actions = [ # type: ignore[attr-defined]
|
|
106
|
+
action
|
|
107
|
+
for action in record_subparsers._choices_actions # type: ignore[attr-defined]
|
|
108
|
+
if getattr(action, "dest", None) != "analyze"
|
|
109
|
+
]
|
|
99
110
|
analyze.add_argument("--app-key", required=True)
|
|
100
111
|
analyze.add_argument("--dimensions-file")
|
|
101
112
|
analyze.add_argument("--metrics-file")
|
|
@@ -131,6 +142,13 @@ def _columns(args: argparse.Namespace) -> list[Any]:
|
|
|
131
142
|
return columns
|
|
132
143
|
|
|
133
144
|
|
|
145
|
+
def _query_fields(args: argparse.Namespace) -> list[Any]:
|
|
146
|
+
query_fields: list[Any] = list(getattr(args, "query_fields", None) or [])
|
|
147
|
+
if getattr(args, "query_fields_file", None):
|
|
148
|
+
query_fields.extend(require_list_arg(args.query_fields_file, option_name="--query-fields-file"))
|
|
149
|
+
return query_fields
|
|
150
|
+
|
|
151
|
+
|
|
134
152
|
def _handle_schema_root(args: argparse.Namespace, _context: CliContext) -> dict:
|
|
135
153
|
mode = (args.legacy_mode or "").strip()
|
|
136
154
|
if mode:
|
|
@@ -225,9 +243,10 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
225
243
|
profile=args.profile,
|
|
226
244
|
app_key=args.app_key,
|
|
227
245
|
columns=_columns(args),
|
|
246
|
+
query=args.query,
|
|
247
|
+
query_fields=_query_fields(args),
|
|
228
248
|
where=load_list_arg(args.where_file, option_name="--where-file"),
|
|
229
249
|
order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
|
|
230
|
-
limit=args.limit,
|
|
231
250
|
page=args.page,
|
|
232
251
|
view_id=args.view_id,
|
|
233
252
|
)
|
|
@@ -183,7 +183,17 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
183
183
|
def _format_record_list(result: dict[str, Any]) -> str:
|
|
184
184
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
185
185
|
items = data.get("items") if isinstance(data.get("items"), list) else []
|
|
186
|
-
lines = [
|
|
186
|
+
lines: list[str] = []
|
|
187
|
+
lookup = result.get("lookup") if isinstance(result.get("lookup"), dict) else {}
|
|
188
|
+
if lookup:
|
|
189
|
+
lines.append("Lookup:")
|
|
190
|
+
lines.append(f"- query: {lookup.get('query')}")
|
|
191
|
+
lines.append(f"- confidence: {lookup.get('confidence')}")
|
|
192
|
+
lines.append(f"- next_action: {lookup.get('next_action')}")
|
|
193
|
+
lines.append(f"- total_count: {lookup.get('total_count')}")
|
|
194
|
+
lines.append(f"- returned_count: {lookup.get('returned_count')}")
|
|
195
|
+
lines.append(f"- truncated: {lookup.get('truncated')}")
|
|
196
|
+
lines.append(f"Returned Records: {len(items)}")
|
|
187
197
|
for item in items[:10]:
|
|
188
198
|
if isinstance(item, dict):
|
|
189
199
|
lines.append(json.dumps(item, ensure_ascii=False))
|
|
@@ -224,6 +234,64 @@ def _format_record_access(result: dict[str, Any]) -> str:
|
|
|
224
234
|
return "\n".join(lines) + "\n"
|
|
225
235
|
|
|
226
236
|
|
|
237
|
+
def _format_record_get(result: dict[str, Any]) -> str:
|
|
238
|
+
record = result.get("record") if isinstance(result.get("record"), dict) else {}
|
|
239
|
+
app = result.get("app") if isinstance(result.get("app"), dict) else {}
|
|
240
|
+
view = result.get("view") if isinstance(result.get("view"), dict) else {}
|
|
241
|
+
fields = result.get("fields") if isinstance(result.get("fields"), list) else []
|
|
242
|
+
data_logs = result.get("data_logs") if isinstance(result.get("data_logs"), dict) else {}
|
|
243
|
+
workflow_logs = result.get("workflow_logs") if isinstance(result.get("workflow_logs"), dict) else {}
|
|
244
|
+
media_assets = result.get("media_assets") if isinstance(result.get("media_assets"), dict) else {}
|
|
245
|
+
media_items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
|
|
246
|
+
downloaded_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
|
|
247
|
+
failed_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
|
|
248
|
+
file_assets = result.get("file_assets") if isinstance(result.get("file_assets"), dict) else {}
|
|
249
|
+
file_items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
|
|
250
|
+
downloaded_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
|
|
251
|
+
failed_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
|
|
252
|
+
extracted_files = [
|
|
253
|
+
item
|
|
254
|
+
for item in downloaded_files
|
|
255
|
+
if isinstance(item.get("extraction"), dict) and item["extraction"].get("status") == "ok"
|
|
256
|
+
]
|
|
257
|
+
associated_resources = result.get("associated_resources") if isinstance(result.get("associated_resources"), list) else []
|
|
258
|
+
unavailable_context = result.get("unavailable_context") if isinstance(result.get("unavailable_context"), list) else []
|
|
259
|
+
lines = [
|
|
260
|
+
f"Status: {result.get('status') or '-'}",
|
|
261
|
+
f"App: {app.get('app_name') or app.get('app_key') or '-'} ({app.get('app_key') or '-'})",
|
|
262
|
+
f"View: {view.get('name') or view.get('view_id') or '-'}",
|
|
263
|
+
f"Record: {record.get('title') or '-'} ({record.get('record_id') or '-'})",
|
|
264
|
+
f"Fields: {len(fields)}",
|
|
265
|
+
f"Data logs: {data_logs.get('status') or '-'} / loaded={data_logs.get('items_loaded')}",
|
|
266
|
+
f"Workflow logs: {workflow_logs.get('status') or '-'} / loaded={workflow_logs.get('items_loaded')}",
|
|
267
|
+
f"Media assets: {media_assets.get('status') or '-'} / downloaded={len(downloaded_media)} / failed={len(failed_media)}",
|
|
268
|
+
f"File assets: {file_assets.get('status') or '-'} / downloaded={len(downloaded_files)} / extracted={len(extracted_files)} / failed={len(failed_files)}",
|
|
269
|
+
f"Associated resources: {len(associated_resources)}",
|
|
270
|
+
f"Unavailable contexts: {len(unavailable_context)}",
|
|
271
|
+
]
|
|
272
|
+
if media_assets.get("local_dir"):
|
|
273
|
+
lines.append(f"Media dir: {media_assets.get('local_dir')}")
|
|
274
|
+
if file_assets.get("local_dir"):
|
|
275
|
+
lines.append(f"File dir: {file_assets.get('local_dir')}")
|
|
276
|
+
if failed_media:
|
|
277
|
+
failure_counts: dict[str, int] = {}
|
|
278
|
+
for item in failed_media:
|
|
279
|
+
key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
|
|
280
|
+
failure_counts[key] = failure_counts.get(key, 0) + 1
|
|
281
|
+
lines.append("Media failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
|
|
282
|
+
if failed_files:
|
|
283
|
+
failure_counts = {}
|
|
284
|
+
for item in failed_files:
|
|
285
|
+
key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
|
|
286
|
+
failure_counts[key] = failure_counts.get(key, 0) + 1
|
|
287
|
+
lines.append("File failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
|
|
288
|
+
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
|
|
289
|
+
if summary.get("text"):
|
|
290
|
+
lines.append(f"Summary: {summary.get('text')}")
|
|
291
|
+
_append_warnings(lines, result.get("warnings"))
|
|
292
|
+
return "\n".join(lines) + "\n"
|
|
293
|
+
|
|
294
|
+
|
|
227
295
|
def _format_task_list(result: dict[str, Any]) -> str:
|
|
228
296
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
229
297
|
items = data.get("items") if isinstance(data.get("items"), list) else []
|
|
@@ -724,6 +792,7 @@ _FORMATTERS = {
|
|
|
724
792
|
"app_get": _format_app_get,
|
|
725
793
|
"record_list": _format_record_list,
|
|
726
794
|
"record_access": _format_record_access,
|
|
795
|
+
"record_get": _format_record_get,
|
|
727
796
|
"task_list": _format_task_list,
|
|
728
797
|
"task_workbench": _format_task_workbench,
|
|
729
798
|
"task_get": _format_task_get,
|
|
@@ -80,7 +80,7 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
80
80
|
),
|
|
81
81
|
PublicToolSpec(USER_DOMAIN, "record_member_candidates", ("record_member_candidates",), cli_public=False),
|
|
82
82
|
PublicToolSpec(USER_DOMAIN, "record_department_candidates", ("record_department_candidates",), cli_public=False),
|
|
83
|
-
PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze")),
|
|
83
|
+
PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze"), mcp_public=False),
|
|
84
84
|
PublicToolSpec(USER_DOMAIN, "record_list", ("record_list",), ("record", "list"), cli_show_effective_context=True),
|
|
85
85
|
PublicToolSpec(USER_DOMAIN, "record_access", ("record_access",), ("record", "access"), cli_show_effective_context=True),
|
|
86
86
|
PublicToolSpec(USER_DOMAIN, "record_get", ("record_get_public",), ("record", "get"), cli_show_effective_context=True),
|
|
@@ -65,7 +65,10 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
|
|
|
65
65
|
if not isinstance(payload, dict):
|
|
66
66
|
return payload
|
|
67
67
|
trimmed = deepcopy(payload)
|
|
68
|
-
|
|
68
|
+
drop_keys = COMMON_SUCCESS_DROP_TOP
|
|
69
|
+
if tool_name == "user:record_get":
|
|
70
|
+
drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
|
|
71
|
+
_drop_top_keys(trimmed, drop_keys)
|
|
69
72
|
transformer = SUCCESS_POLICY_BY_TOOL.get(tool_name or "")
|
|
70
73
|
if transformer is not None:
|
|
71
74
|
transformer(trimmed)
|
|
@@ -395,6 +398,9 @@ def _trim_record_write(payload: JSONObject) -> None:
|
|
|
395
398
|
|
|
396
399
|
|
|
397
400
|
def _trim_record_get(payload: JSONObject) -> None:
|
|
401
|
+
if payload.get("fields") is not None or payload.get("semantic_context") is not None:
|
|
402
|
+
_trim_detail_context_record_get(payload)
|
|
403
|
+
return
|
|
398
404
|
data = payload.get("data")
|
|
399
405
|
if not isinstance(data, dict):
|
|
400
406
|
return
|
|
@@ -417,22 +423,142 @@ def _trim_record_get(payload: JSONObject) -> None:
|
|
|
417
423
|
payload["data"] = compact
|
|
418
424
|
|
|
419
425
|
|
|
426
|
+
def _trim_detail_context_record_get(payload: JSONObject) -> None:
|
|
427
|
+
_trim_item_list(
|
|
428
|
+
payload,
|
|
429
|
+
"fields",
|
|
430
|
+
allowed=("field_id", "title", "type", "value", "display_value", "requested_focus", "asset_ids", "file_asset_ids"),
|
|
431
|
+
)
|
|
432
|
+
references = payload.get("references")
|
|
433
|
+
if isinstance(references, list):
|
|
434
|
+
compact_refs: list[JSONObject] = []
|
|
435
|
+
for item in references:
|
|
436
|
+
if not isinstance(item, dict):
|
|
437
|
+
continue
|
|
438
|
+
compact = _pick(
|
|
439
|
+
item,
|
|
440
|
+
(
|
|
441
|
+
"field_id",
|
|
442
|
+
"field_title",
|
|
443
|
+
"target_app_key",
|
|
444
|
+
"target_record_id",
|
|
445
|
+
"target_title",
|
|
446
|
+
"target_detail_completeness",
|
|
447
|
+
"self_reference",
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
target_fields = item.get("target_fields") if isinstance(item.get("target_fields"), list) else []
|
|
451
|
+
if target_fields:
|
|
452
|
+
compact["target_fields"] = [
|
|
453
|
+
_pick(field, ("field_id", "title", "type", "value", "display_value", "asset_ids", "file_asset_ids"))
|
|
454
|
+
for field in target_fields
|
|
455
|
+
if isinstance(field, dict)
|
|
456
|
+
]
|
|
457
|
+
compact_refs.append(compact)
|
|
458
|
+
payload["references"] = compact_refs
|
|
459
|
+
media_assets = payload.get("media_assets")
|
|
460
|
+
if isinstance(media_assets, dict):
|
|
461
|
+
compact_media = _pick(media_assets, ("status", "local_dir", "warnings"))
|
|
462
|
+
items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
|
|
463
|
+
compact_media["items"] = [
|
|
464
|
+
_pick(
|
|
465
|
+
item,
|
|
466
|
+
(
|
|
467
|
+
"asset_id",
|
|
468
|
+
"kind",
|
|
469
|
+
"source",
|
|
470
|
+
"field_id",
|
|
471
|
+
"field_title",
|
|
472
|
+
"local_path",
|
|
473
|
+
"access_status",
|
|
474
|
+
"readable_by_agent",
|
|
475
|
+
"mime_type",
|
|
476
|
+
"size_bytes",
|
|
477
|
+
"download_strategy",
|
|
478
|
+
"storage_auth_type",
|
|
479
|
+
"storage_cookie_prefix",
|
|
480
|
+
"redirected",
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
for item in items
|
|
484
|
+
if isinstance(item, dict)
|
|
485
|
+
]
|
|
486
|
+
payload["media_assets"] = compact_media
|
|
487
|
+
file_assets = payload.get("file_assets")
|
|
488
|
+
if isinstance(file_assets, dict):
|
|
489
|
+
compact_files = _pick(file_assets, ("status", "local_dir", "warnings"))
|
|
490
|
+
items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
|
|
491
|
+
compact_files["items"] = [
|
|
492
|
+
_pick(
|
|
493
|
+
item,
|
|
494
|
+
(
|
|
495
|
+
"file_asset_id",
|
|
496
|
+
"media_asset_id",
|
|
497
|
+
"kind",
|
|
498
|
+
"source",
|
|
499
|
+
"field_id",
|
|
500
|
+
"field_title",
|
|
501
|
+
"file_name",
|
|
502
|
+
"local_path",
|
|
503
|
+
"access_status",
|
|
504
|
+
"readable_by_agent",
|
|
505
|
+
"mime_type",
|
|
506
|
+
"size_bytes",
|
|
507
|
+
"download_strategy",
|
|
508
|
+
"storage_auth_type",
|
|
509
|
+
"storage_cookie_prefix",
|
|
510
|
+
"redirected",
|
|
511
|
+
"extraction",
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
for item in items
|
|
515
|
+
if isinstance(item, dict)
|
|
516
|
+
]
|
|
517
|
+
payload["file_assets"] = compact_files
|
|
518
|
+
for key in ("data_logs", "workflow_logs"):
|
|
519
|
+
node = payload.get(key)
|
|
520
|
+
if not isinstance(node, dict):
|
|
521
|
+
continue
|
|
522
|
+
compact_node = _pick(
|
|
523
|
+
node,
|
|
524
|
+
(
|
|
525
|
+
"status",
|
|
526
|
+
"visible",
|
|
527
|
+
"reason",
|
|
528
|
+
"page",
|
|
529
|
+
"page_size",
|
|
530
|
+
"items_loaded",
|
|
531
|
+
"reported_total",
|
|
532
|
+
"has_more",
|
|
533
|
+
"complete",
|
|
534
|
+
"items",
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
payload[key] = compact_node
|
|
538
|
+
associated_resources = payload.get("associated_resources")
|
|
539
|
+
if isinstance(associated_resources, list):
|
|
540
|
+
payload["associated_resources"] = [
|
|
541
|
+
_pick(item, ("type", "name", "app_key", "app_name", "view_key", "chart_key", "view_type", "data_access"))
|
|
542
|
+
for item in associated_resources
|
|
543
|
+
if isinstance(item, dict)
|
|
544
|
+
]
|
|
545
|
+
_drop_deep_keys(payload, {"raw", "debug"})
|
|
546
|
+
|
|
547
|
+
|
|
420
548
|
def _trim_record_list(payload: JSONObject) -> None:
|
|
421
549
|
data = payload.get("data")
|
|
422
550
|
if not isinstance(data, dict):
|
|
423
551
|
return
|
|
424
552
|
pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
|
|
425
|
-
returned_items = pagination.get("returned_items")
|
|
426
|
-
result_amount = pagination.get("result_amount")
|
|
427
|
-
|
|
428
|
-
truncated
|
|
429
|
-
if isinstance(result_amount, int) and isinstance(returned_items, int):
|
|
553
|
+
returned_items = pagination.get("returned_count", pagination.get("returned_items"))
|
|
554
|
+
result_amount = pagination.get("total_count", pagination.get("result_amount"))
|
|
555
|
+
truncated = bool(pagination.get("truncated"))
|
|
556
|
+
if not truncated and isinstance(result_amount, int) and isinstance(returned_items, int):
|
|
430
557
|
truncated = result_amount > returned_items
|
|
431
558
|
compact_pagination = {
|
|
432
559
|
"loaded": True,
|
|
433
|
-
"
|
|
434
|
-
"
|
|
435
|
-
"reported_total": result_amount,
|
|
560
|
+
"returned_count": returned_items,
|
|
561
|
+
"total_count": result_amount,
|
|
436
562
|
"truncated": truncated,
|
|
437
563
|
}
|
|
438
564
|
selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
|
|
@@ -445,6 +571,17 @@ def _trim_record_list(payload: JSONObject) -> None:
|
|
|
445
571
|
if view:
|
|
446
572
|
compact["view"] = _pick(view, ("view_id", "name"))
|
|
447
573
|
payload["data"] = compact
|
|
574
|
+
lookup = payload.get("lookup") if isinstance(payload.get("lookup"), dict) else {}
|
|
575
|
+
if lookup:
|
|
576
|
+
payload["lookup"] = {
|
|
577
|
+
"mode": lookup.get("mode"),
|
|
578
|
+
"query": lookup.get("query"),
|
|
579
|
+
"total_count": lookup.get("total_count", lookup.get("reported_total")),
|
|
580
|
+
"returned_count": lookup.get("returned_count"),
|
|
581
|
+
"truncated": lookup.get("truncated"),
|
|
582
|
+
"confidence": lookup.get("confidence"),
|
|
583
|
+
"next_action": lookup.get("next_action"),
|
|
584
|
+
}
|
|
448
585
|
|
|
449
586
|
|
|
450
587
|
def _trim_record_access(payload: JSONObject) -> None:
|
|
@@ -461,7 +598,6 @@ def _trim_record_access(payload: JSONObject) -> None:
|
|
|
461
598
|
"truncated",
|
|
462
599
|
"safe_for_final_conclusion",
|
|
463
600
|
"files",
|
|
464
|
-
"metadata_files",
|
|
465
601
|
"fields",
|
|
466
602
|
"warnings",
|
|
467
603
|
"verification",
|
|
@@ -50,7 +50,7 @@ All resource tools operate with the logged-in user's Qingflow permissions.
|
|
|
50
50
|
|
|
51
51
|
If `app_key` is unknown, use `app_list` or `app_search` first.
|
|
52
52
|
If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
|
|
53
|
-
If an accessible view has `analysis_supported=false`, do not use it for `
|
|
53
|
+
If an accessible view has `analysis_supported=false`, do not use it for `record_access` or `record_list`. `boardView` and `ganttView` are special UI views, not data-access targets.
|
|
54
54
|
`view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
|
|
55
55
|
|
|
56
56
|
## Schema-First Rule
|
|
@@ -58,7 +58,7 @@ If an accessible view has `analysis_supported=false`, do not use it for `record_
|
|
|
58
58
|
Call `record_insert_schema_get` before `record_insert`.
|
|
59
59
|
Call `record_update_schema_get` before `record_update`.
|
|
60
60
|
Call `record_code_block_schema_get` before `record_code_block_run`.
|
|
61
|
-
Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `
|
|
61
|
+
Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, or `record_get`.
|
|
62
62
|
Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
|
|
63
63
|
|
|
64
64
|
- All `field_id` values must come from the schema response.
|
|
@@ -68,7 +68,8 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
68
68
|
|
|
69
69
|
`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`.
|
|
70
70
|
`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`.
|
|
71
|
-
`record_browse_schema_get(view_id=...)` returns
|
|
71
|
+
`record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
|
|
72
|
+
`record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema.
|
|
72
73
|
`record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
|
|
73
74
|
`record_import_schema_get` returns import-ready column metadata.
|
|
74
75
|
|
|
@@ -78,16 +79,17 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
78
79
|
|
|
79
80
|
## Analytics Path
|
|
80
81
|
|
|
81
|
-
`app_get -> record_browse_schema_get(view_id=...) ->
|
|
82
|
+
`app_get -> record_browse_schema_get(view_id=...) -> record_access -> Python`
|
|
82
83
|
|
|
83
84
|
Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
|
|
84
85
|
|
|
86
|
+
Use `record_access` to write local CSV shard files for analysis, then use Python to compute counts, rankings, ratios, trends, and final conclusions.
|
|
87
|
+
|
|
85
88
|
Use this DSL shape:
|
|
86
89
|
|
|
87
|
-
- `
|
|
88
|
-
- `
|
|
89
|
-
- `
|
|
90
|
-
- `sort`: `{{by, order}}`
|
|
90
|
+
- `columns`: `[{{field_id}}]`
|
|
91
|
+
- `where`: `[{{field_id, op, value}}]`
|
|
92
|
+
- `order_by`: `[{{field_id, direction}}]`
|
|
91
93
|
|
|
92
94
|
Important key rules:
|
|
93
95
|
|
|
@@ -108,6 +110,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
108
110
|
`record_code_block_schema_get -> record_code_block_run`
|
|
109
111
|
|
|
110
112
|
- Use `columns` as `[{{field_id}}]`
|
|
113
|
+
- Use `record_list(query=..., query_fields=[{{field_id}}])` for fuzzy single-record lookup, then follow `lookup.next_action`; `query_fields` is search scope and `columns` is display shape.
|
|
111
114
|
- Use `where` items as `{{field_id, op, value}}`
|
|
112
115
|
- Use `order_by` items as `{{field_id, direction}}`
|
|
113
116
|
- Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
|
|
@@ -121,7 +124,10 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
121
124
|
- `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
|
|
122
125
|
- `record_update_schema_get` exposes the overall writable field set for the record, but not every field combination is guaranteed; `record_update` still needs one single matched accessible view that can cover the payload.
|
|
123
126
|
- `record_delete` deletes by `record_id` or `record_ids`.
|
|
124
|
-
-
|
|
127
|
+
- `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
|
|
128
|
+
- Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
|
|
129
|
+
- `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
|
|
130
|
+
- When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
|
|
125
131
|
|
|
126
132
|
- Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
|
|
127
133
|
- Member and department fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to `record_member_candidates` or `record_department_candidates` when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
|
|
@@ -33,7 +33,7 @@ def build_user_server() -> FastMCP:
|
|
|
33
33
|
|
|
34
34
|
If `app_key` is unknown, use `app_list` or `app_search` first.
|
|
35
35
|
If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
|
|
36
|
-
If an accessible view has `analysis_supported=false`, do not use it for `record_access
|
|
36
|
+
If an accessible view has `analysis_supported=false`, do not use it for `record_access` or `record_list`. `boardView` and `ganttView` are special UI views, not data-access targets.
|
|
37
37
|
`view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
|
|
38
38
|
|
|
39
39
|
## Shared Helper
|
|
@@ -49,7 +49,7 @@ If an accessible view has `analysis_supported=false`, do not use it for `record_
|
|
|
49
49
|
Call `record_insert_schema_get` before `record_insert`.
|
|
50
50
|
Call `record_update_schema_get` before `record_update`.
|
|
51
51
|
Call `record_code_block_schema_get` before `record_code_block_run`.
|
|
52
|
-
Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`,
|
|
52
|
+
Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, or `record_get`.
|
|
53
53
|
Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
|
|
54
54
|
|
|
55
55
|
- All `field_id` values must come from the schema response.
|
|
@@ -61,7 +61,7 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
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
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
|
|
64
|
+
`record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema; a missing field means it is not readable in that view.
|
|
65
65
|
`searchQueIds` is a backend full-text search scope, not an output-column/projection mechanism.
|
|
66
66
|
`record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
|
|
67
67
|
`record_import_schema_get` returns import-ready column metadata.
|
|
@@ -76,7 +76,7 @@ Inside `optional_fields`, any field with `may_become_required=true` is still wri
|
|
|
76
76
|
|
|
77
77
|
Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
|
|
78
78
|
|
|
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`.
|
|
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`. CSV columns are readable and field-id anchored, such as `项目状态__field_343283094`, and `fields[]` is the compact metadata source.
|
|
80
80
|
For analysis-style tasks, prefer an explicit time range or business filter. If `record_access.status == "needs_scope"`, do not treat it as a failure; ask for a time/business scope or retry with a user-provided period using `scope.suggested_time_fields` / `scope.recommended_where_examples`. If `record_access.status == "partial"`, read the returned files only as a limited subset and do not give a final full-population conclusion.
|
|
81
81
|
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.
|
|
82
82
|
|
|
@@ -94,7 +94,9 @@ Important key rules:
|
|
|
94
94
|
- Do **not** use `aggregation`
|
|
95
95
|
- Do **not** use `operator`
|
|
96
96
|
|
|
97
|
-
`
|
|
97
|
+
`record_list` is for browsing and sample checks, not final analysis conclusions.
|
|
98
|
+
For fuzzy single-record lookup, use `record_list(query=..., query_fields=[{{field_id}}])` to find candidates, read `lookup.next_action`, and only call `record_get` after one candidate is clear.
|
|
99
|
+
`record_list.query_fields` maps to backend full-text search scope (`searchQueIds`); `record_list.columns` only controls displayed fields.
|
|
98
100
|
|
|
99
101
|
Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
|
|
100
102
|
|
|
@@ -109,6 +111,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
109
111
|
`portal_get -> view_get -> record_list`
|
|
110
112
|
|
|
111
113
|
- Use `columns` as `[{{field_id}}]`
|
|
114
|
+
- Use `query` plus optional `query_fields` when the user provides fuzzy record-identifying text
|
|
112
115
|
- Use `where` items as `{{field_id, op, value}}`
|
|
113
116
|
- Use `order_by` items as `{{field_id, direction}}`
|
|
114
117
|
- Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
|
|
@@ -122,7 +125,10 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
122
125
|
- `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
|
|
123
126
|
- `record_update_schema_get` exposes the overall writable field set for the record, but not every field combination is guaranteed; `record_update` still needs one single matched accessible view that can cover the payload.
|
|
124
127
|
- `record_delete` deletes by `record_id` or `record_ids`.
|
|
125
|
-
-
|
|
128
|
+
- `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
|
|
129
|
+
- Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
|
|
130
|
+
- `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
|
|
131
|
+
- When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
|
|
126
132
|
|
|
127
133
|
- Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
|
|
128
134
|
- Member and department fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to `record_member_candidates` or `record_department_candidates` when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
|