@josephyan/qingflow-cli 0.2.0-beta.70 → 0.2.0-beta.72
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/backend_client.py +1 -0
- package/src/qingflow_mcp/builder_facade/models.py +16 -8
- package/src/qingflow_mcp/builder_facade/service.py +208 -111
- package/src/qingflow_mcp/cli/commands/__init__.py +4 -1
- package/src/qingflow_mcp/cli/commands/builder.py +24 -64
- package/src/qingflow_mcp/cli/commands/chart.py +18 -0
- package/src/qingflow_mcp/cli/commands/portal.py +25 -0
- package/src/qingflow_mcp/cli/commands/view.py +18 -0
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/response_trim.py +211 -178
- package/src/qingflow_mcp/server_app_builder.py +18 -42
- package/src/qingflow_mcp/server_app_user.py +21 -1
- package/src/qingflow_mcp/tools/ai_builder_tools.py +165 -124
- package/src/qingflow_mcp/tools/app_tools.py +0 -4
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +58 -7
- package/src/qingflow_mcp/tools/resource_read_tools.py +399 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
7
|
+
from ..json_types import JSONObject
|
|
8
|
+
from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
|
|
9
|
+
from .app_tools import _analysis_supported_for_view_type
|
|
10
|
+
from .app_tools import AppTools
|
|
11
|
+
from .base import ToolBase
|
|
12
|
+
from .qingbi_report_tools import QingbiReportTools
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResourceReadTools(ToolBase):
|
|
16
|
+
def __init__(self, sessions, backend) -> None:
|
|
17
|
+
super().__init__(sessions, backend)
|
|
18
|
+
self.apps = AppTools(sessions, backend)
|
|
19
|
+
self.charts = QingbiReportTools(sessions, backend)
|
|
20
|
+
|
|
21
|
+
def portal_list(self, *, profile: str) -> JSONObject:
|
|
22
|
+
def runner(session_profile, context):
|
|
23
|
+
raw_items = self.backend.request("GET", context, "/dash")
|
|
24
|
+
items = _normalize_portal_list_items(raw_items)
|
|
25
|
+
return {
|
|
26
|
+
"profile": profile,
|
|
27
|
+
"ws_id": session_profile.selected_ws_id,
|
|
28
|
+
"ok": True,
|
|
29
|
+
"warnings": [],
|
|
30
|
+
"verification": {"portal_list_loaded": True},
|
|
31
|
+
"data": {
|
|
32
|
+
"items": items,
|
|
33
|
+
"total": len(items),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return self._run(profile, runner)
|
|
38
|
+
|
|
39
|
+
def portal_get(self, *, profile: str, dash_key: str) -> JSONObject:
|
|
40
|
+
self._require_dash_key(dash_key)
|
|
41
|
+
|
|
42
|
+
def runner(session_profile, context):
|
|
43
|
+
result = self.backend.request("GET", context, f"/dash/{dash_key}", params={"beingDraft": False})
|
|
44
|
+
dash_name = str(result.get("dashName") or "").strip() or None
|
|
45
|
+
dash_icon = str(result.get("dashIcon") or "").strip() or None
|
|
46
|
+
package_tag_ids = [
|
|
47
|
+
tag_id
|
|
48
|
+
for tag_id in (
|
|
49
|
+
_coerce_positive_int((tag or {}).get("tagId"))
|
|
50
|
+
for tag in (result.get("tags") or [])
|
|
51
|
+
if isinstance(tag, dict)
|
|
52
|
+
)
|
|
53
|
+
if tag_id is not None
|
|
54
|
+
]
|
|
55
|
+
components = _normalize_user_portal_components(result.get("components"))
|
|
56
|
+
return {
|
|
57
|
+
"profile": profile,
|
|
58
|
+
"ws_id": session_profile.selected_ws_id,
|
|
59
|
+
"ok": True,
|
|
60
|
+
"warnings": [],
|
|
61
|
+
"verification": {"portal_exists": True},
|
|
62
|
+
"data": {
|
|
63
|
+
"dash_key": dash_key,
|
|
64
|
+
"dash_name": dash_name,
|
|
65
|
+
"dash_icon": dash_icon,
|
|
66
|
+
"package_tag_ids": package_tag_ids,
|
|
67
|
+
"component_count": len(components),
|
|
68
|
+
"components": components,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return self._run(profile, runner)
|
|
73
|
+
|
|
74
|
+
def view_get(self, *, profile: str, view_id: str) -> JSONObject:
|
|
75
|
+
self._require_view_id(view_id)
|
|
76
|
+
view_key = _extract_custom_view_key(view_id)
|
|
77
|
+
system_view = _lookup_system_view_descriptor(view_id)
|
|
78
|
+
if view_key is None and system_view is None:
|
|
79
|
+
raise_tool_error(
|
|
80
|
+
QingflowApiError.config_error(
|
|
81
|
+
"view_get only accepts accessible view_id values such as `custom:VIEW_KEY` or `system:all`; use app_get or portal_get first to find a valid view_id."
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if system_view is not None:
|
|
85
|
+
return self._run(
|
|
86
|
+
profile,
|
|
87
|
+
lambda session_profile, _context: {
|
|
88
|
+
"profile": profile,
|
|
89
|
+
"ws_id": session_profile.selected_ws_id,
|
|
90
|
+
"ok": True,
|
|
91
|
+
"warnings": [
|
|
92
|
+
{
|
|
93
|
+
"code": "VIEW_APP_KEY_UNRESOLVED",
|
|
94
|
+
"message": f"view_get could not resolve app_key for system view `{view_id}`; keep using the app_key from the parent app context.",
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"verification": {
|
|
98
|
+
"view_exists": True,
|
|
99
|
+
"descriptor_only": True,
|
|
100
|
+
},
|
|
101
|
+
"data": {
|
|
102
|
+
"app_key": None,
|
|
103
|
+
"view_id": view_id,
|
|
104
|
+
"view_key": None,
|
|
105
|
+
"view_name": system_view["view_name"],
|
|
106
|
+
"view_type": system_view["view_type"],
|
|
107
|
+
"visible_columns": [],
|
|
108
|
+
"analysis_supported": system_view["analysis_supported"],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def runner(session_profile, context):
|
|
114
|
+
warnings: list[JSONObject] = []
|
|
115
|
+
verification = {
|
|
116
|
+
"view_exists": True,
|
|
117
|
+
"questions_verified": True,
|
|
118
|
+
}
|
|
119
|
+
config = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
|
|
120
|
+
base_info = self.backend.request("GET", context, f"/view/{view_key}/viewConfig/baseInfo")
|
|
121
|
+
questions: list[dict[str, Any]] = []
|
|
122
|
+
try:
|
|
123
|
+
questions_payload = self.backend.request("GET", context, f"/view/{view_key}/question")
|
|
124
|
+
if isinstance(questions_payload, list):
|
|
125
|
+
questions = [deepcopy(item) for item in questions_payload if isinstance(item, dict)]
|
|
126
|
+
except QingflowApiError:
|
|
127
|
+
verification["questions_verified"] = False
|
|
128
|
+
warnings.append(
|
|
129
|
+
{
|
|
130
|
+
"code": "VIEW_QUESTIONS_UNAVAILABLE",
|
|
131
|
+
"message": "view_get could not load visible columns because question readback is unavailable.",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
raw_view_type = (
|
|
136
|
+
str(base_info.get("viewgraphType") or "").strip()
|
|
137
|
+
or str(config.get("viewgraphType") or config.get("viewType") or "").strip()
|
|
138
|
+
)
|
|
139
|
+
resolved_app_key = str(base_info.get("appKey") or config.get("appKey") or "").strip() or None
|
|
140
|
+
if not resolved_app_key:
|
|
141
|
+
resolved_app_key = self._resolve_app_key_from_form_id(
|
|
142
|
+
profile=profile,
|
|
143
|
+
form_id=_coerce_positive_int(base_info.get("formId") or config.get("formId")),
|
|
144
|
+
)
|
|
145
|
+
if not resolved_app_key:
|
|
146
|
+
warnings.append(
|
|
147
|
+
{
|
|
148
|
+
"code": "VIEW_APP_KEY_UNRESOLVED",
|
|
149
|
+
"message": f"view_get could not resolve app_key for `{view_id}` from view metadata; keep using the app_key from the parent app or portal context.",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
return {
|
|
153
|
+
"profile": profile,
|
|
154
|
+
"ws_id": session_profile.selected_ws_id,
|
|
155
|
+
"ok": True,
|
|
156
|
+
"warnings": warnings,
|
|
157
|
+
"verification": verification,
|
|
158
|
+
"data": {
|
|
159
|
+
"app_key": resolved_app_key,
|
|
160
|
+
"view_id": view_id,
|
|
161
|
+
"view_key": view_key,
|
|
162
|
+
"view_name": str(
|
|
163
|
+
base_info.get("viewgraphName") or config.get("viewgraphName") or config.get("viewName") or view_key
|
|
164
|
+
).strip()
|
|
165
|
+
or view_key,
|
|
166
|
+
"view_type": _normalize_view_type(raw_view_type),
|
|
167
|
+
"visible_columns": [
|
|
168
|
+
str(item.get("queTitle") or item.get("title") or "").strip()
|
|
169
|
+
for item in questions
|
|
170
|
+
if str(item.get("queTitle") or item.get("title") or "").strip()
|
|
171
|
+
],
|
|
172
|
+
"analysis_supported": _analysis_supported_for_view_type(raw_view_type or None),
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return self._run(profile, runner)
|
|
177
|
+
|
|
178
|
+
def chart_get(self, *, profile: str, chart_id: str) -> JSONObject:
|
|
179
|
+
self._require_chart_id(chart_id)
|
|
180
|
+
|
|
181
|
+
def runner(session_profile, _context):
|
|
182
|
+
base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
|
|
183
|
+
data = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
|
|
184
|
+
data_config = data.get("config") if isinstance(data, dict) and isinstance(data.get("config"), dict) else {}
|
|
185
|
+
return {
|
|
186
|
+
"profile": profile,
|
|
187
|
+
"ws_id": session_profile.selected_ws_id,
|
|
188
|
+
"ok": True,
|
|
189
|
+
"warnings": [],
|
|
190
|
+
"verification": {
|
|
191
|
+
"chart_exists": True,
|
|
192
|
+
"chart_data_loaded": True,
|
|
193
|
+
},
|
|
194
|
+
"data": {
|
|
195
|
+
"chart_id": chart_id,
|
|
196
|
+
"chart_name": str(base.get("chartName") or base.get("name") or chart_id).strip() or chart_id,
|
|
197
|
+
"chart_type": str(base.get("chartType") or data_config.get("chartType") or "").strip() or None,
|
|
198
|
+
"data_source_type": str(base.get("dataSourceType") or "").strip() or None,
|
|
199
|
+
"data_source_id": str(base.get("dataSourceId") or "").strip() or None,
|
|
200
|
+
"data": deepcopy(data) if isinstance(data, dict) else {"value": data},
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return self._run(profile, runner)
|
|
205
|
+
|
|
206
|
+
def _require_dash_key(self, dash_key: str) -> None:
|
|
207
|
+
if not dash_key:
|
|
208
|
+
raise_tool_error(QingflowApiError.config_error("dash_key is required"))
|
|
209
|
+
|
|
210
|
+
def _require_view_id(self, view_id: str) -> None:
|
|
211
|
+
if not view_id:
|
|
212
|
+
raise_tool_error(QingflowApiError.config_error("view_id is required"))
|
|
213
|
+
|
|
214
|
+
def _require_chart_id(self, chart_id: str) -> None:
|
|
215
|
+
if not chart_id:
|
|
216
|
+
raise_tool_error(QingflowApiError.config_error("chart_id is required"))
|
|
217
|
+
|
|
218
|
+
def _resolve_app_key_from_form_id(self, *, profile: str, form_id: int | None) -> str | None:
|
|
219
|
+
if form_id is None:
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
payload = self.apps.app_list(profile=profile)
|
|
223
|
+
except QingflowApiError:
|
|
224
|
+
return None
|
|
225
|
+
items = payload.get("items")
|
|
226
|
+
if not isinstance(items, list):
|
|
227
|
+
return None
|
|
228
|
+
for item in items:
|
|
229
|
+
if not isinstance(item, dict):
|
|
230
|
+
continue
|
|
231
|
+
if _coerce_positive_int(item.get("form_id")) != form_id:
|
|
232
|
+
continue
|
|
233
|
+
app_key = str(item.get("app_key") or "").strip()
|
|
234
|
+
if app_key:
|
|
235
|
+
return app_key
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
|
|
240
|
+
if not isinstance(raw_items, list):
|
|
241
|
+
return []
|
|
242
|
+
items: list[dict[str, Any]] = []
|
|
243
|
+
for item in raw_items:
|
|
244
|
+
if not isinstance(item, dict):
|
|
245
|
+
continue
|
|
246
|
+
dash_key = str(item.get("dashKey") or "").strip()
|
|
247
|
+
dash_name = str(item.get("dashName") or "").strip()
|
|
248
|
+
dash_icon = str(item.get("dashIcon") or "").strip() or None
|
|
249
|
+
package_tag_ids = [
|
|
250
|
+
tag_id
|
|
251
|
+
for tag_id in (
|
|
252
|
+
_coerce_positive_int((tag or {}).get("tagId"))
|
|
253
|
+
for tag in (item.get("tags") or [])
|
|
254
|
+
if isinstance(tag, dict)
|
|
255
|
+
)
|
|
256
|
+
if tag_id is not None
|
|
257
|
+
]
|
|
258
|
+
if not any((dash_key, dash_name, dash_icon, package_tag_ids)):
|
|
259
|
+
continue
|
|
260
|
+
items.append(
|
|
261
|
+
{
|
|
262
|
+
"dash_key": dash_key or None,
|
|
263
|
+
"dash_name": dash_name or None,
|
|
264
|
+
"dash_icon": dash_icon,
|
|
265
|
+
"package_tag_ids": package_tag_ids,
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
return items
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _normalize_user_portal_components(components: Any) -> list[dict[str, Any]]:
|
|
272
|
+
if not isinstance(components, list):
|
|
273
|
+
return []
|
|
274
|
+
items: list[dict[str, Any]] = []
|
|
275
|
+
config_key_map = {
|
|
276
|
+
"grid": "gridConfig",
|
|
277
|
+
"link": "linkConfig",
|
|
278
|
+
"text": "textConfig",
|
|
279
|
+
"filter": "filterConfig",
|
|
280
|
+
"chart": "chartConfig",
|
|
281
|
+
"view": "viewgraphConfig",
|
|
282
|
+
}
|
|
283
|
+
for index, component in enumerate(components):
|
|
284
|
+
if not isinstance(component, dict):
|
|
285
|
+
continue
|
|
286
|
+
source_type = _normalize_portal_component_source_type(component.get("type"))
|
|
287
|
+
title = _extract_portal_component_title(component, source_type=source_type)
|
|
288
|
+
summary: dict[str, Any] = {
|
|
289
|
+
"order": index,
|
|
290
|
+
"source_type": source_type,
|
|
291
|
+
"title": title,
|
|
292
|
+
}
|
|
293
|
+
position = component.get("position")
|
|
294
|
+
if isinstance(position, dict):
|
|
295
|
+
summary["position"] = deepcopy(position)
|
|
296
|
+
config_key = config_key_map.get(source_type, "")
|
|
297
|
+
config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
|
|
298
|
+
if source_type == "chart":
|
|
299
|
+
summary["chart_ref"] = {
|
|
300
|
+
"chart_id": str(config.get("biChartId") or "").strip() or None,
|
|
301
|
+
"chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
|
|
302
|
+
}
|
|
303
|
+
elif source_type == "view":
|
|
304
|
+
view_key = str(config.get("viewgraphKey") or "").strip() or None
|
|
305
|
+
summary["view_ref"] = {
|
|
306
|
+
"app_key": str(config.get("appKey") or "").strip() or None,
|
|
307
|
+
"view_id": f"custom:{view_key}" if view_key else None,
|
|
308
|
+
"view_key": view_key,
|
|
309
|
+
"view_name": str(config.get("viewgraphName") or title or "").strip() or None,
|
|
310
|
+
}
|
|
311
|
+
elif source_type in {"grid", "link", "text", "filter"} and config:
|
|
312
|
+
summary["config"] = deepcopy(config)
|
|
313
|
+
items.append(summary)
|
|
314
|
+
return items
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _normalize_portal_component_source_type(value: Any) -> str:
|
|
318
|
+
raw = str(value or "").strip()
|
|
319
|
+
mapping = {
|
|
320
|
+
"2": "grid",
|
|
321
|
+
"4": "link",
|
|
322
|
+
"5": "text",
|
|
323
|
+
"6": "filter",
|
|
324
|
+
"9": "chart",
|
|
325
|
+
"10": "view",
|
|
326
|
+
"grid": "grid",
|
|
327
|
+
"link": "link",
|
|
328
|
+
"text": "text",
|
|
329
|
+
"filter": "filter",
|
|
330
|
+
"chart": "chart",
|
|
331
|
+
"bi": "chart",
|
|
332
|
+
"view": "view",
|
|
333
|
+
"viewgraph": "view",
|
|
334
|
+
}
|
|
335
|
+
return mapping.get(raw, raw.lower() or "unknown")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _extract_portal_component_title(component: dict[str, Any], *, source_type: str) -> str | None:
|
|
339
|
+
config_key_map = {
|
|
340
|
+
"grid": "gridConfig",
|
|
341
|
+
"link": "linkConfig",
|
|
342
|
+
"text": "textConfig",
|
|
343
|
+
"filter": "filterConfig",
|
|
344
|
+
"chart": "chartConfig",
|
|
345
|
+
"view": "viewgraphConfig",
|
|
346
|
+
}
|
|
347
|
+
config_key = config_key_map.get(source_type, "")
|
|
348
|
+
config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
|
|
349
|
+
title_candidates = {
|
|
350
|
+
"grid": ["gridTitle"],
|
|
351
|
+
"link": ["title", "linkTitle"],
|
|
352
|
+
"text": ["title", "textTitle"],
|
|
353
|
+
"filter": ["title", "filterTitle"],
|
|
354
|
+
"chart": ["chartComponentTitle", "componentTitle", "title"],
|
|
355
|
+
"view": ["componentTitle", "viewgraphName", "title"],
|
|
356
|
+
}
|
|
357
|
+
for key in title_candidates.get(source_type, []):
|
|
358
|
+
title = str(config.get(key) or "").strip()
|
|
359
|
+
if title:
|
|
360
|
+
return title
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _extract_custom_view_key(view_id: str) -> str | None:
|
|
365
|
+
value = str(view_id or "").strip()
|
|
366
|
+
if not value.startswith("custom:"):
|
|
367
|
+
return None
|
|
368
|
+
view_key = value.split(":", 1)[1].strip()
|
|
369
|
+
return view_key or None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _lookup_system_view_descriptor(view_id: str) -> dict[str, Any] | None:
|
|
373
|
+
normalized = str(view_id or "").strip()
|
|
374
|
+
for system_view_id, _list_type, name in SYSTEM_VIEW_DEFINITIONS:
|
|
375
|
+
if system_view_id != normalized:
|
|
376
|
+
continue
|
|
377
|
+
return {
|
|
378
|
+
"view_name": name,
|
|
379
|
+
"view_type": "system",
|
|
380
|
+
"analysis_supported": True,
|
|
381
|
+
}
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _normalize_view_type(view_type: Any) -> str | None:
|
|
386
|
+
value = str(view_type or "").strip()
|
|
387
|
+
if not value:
|
|
388
|
+
return None
|
|
389
|
+
if value.lower().endswith("view"):
|
|
390
|
+
value = value[:-4]
|
|
391
|
+
return value or None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
395
|
+
try:
|
|
396
|
+
number = int(value)
|
|
397
|
+
except (TypeError, ValueError):
|
|
398
|
+
return None
|
|
399
|
+
return number if number > 0 else None
|