@josephyan/qingflow-cli 0.2.0-beta.55
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 +30 -0
- package/docs/local-agent-install.md +235 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +204 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +547 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +985 -0
- package/src/qingflow_mcp/builder_facade/service.py +8243 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +78 -0
- package/src/qingflow_mcp/cli/commands/builder.py +184 -0
- package/src/qingflow_mcp/cli/commands/common.py +47 -0
- package/src/qingflow_mcp/cli/commands/imports.py +86 -0
- package/src/qingflow_mcp/cli/commands/record.py +202 -0
- package/src/qingflow_mcp/cli/commands/task.py +87 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
- package/src/qingflow_mcp/cli/context.py +48 -0
- package/src/qingflow_mcp/cli/formatters.py +269 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +147 -0
- package/src/qingflow_mcp/config.py +221 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/server.py +211 -0
- package/src/qingflow_mcp/server_app_builder.py +387 -0
- package/src/qingflow_mcp/server_app_user.py +317 -0
- package/src/qingflow_mcp/session_store.py +289 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2339 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
- package/src/qingflow_mcp/tools/app_tools.py +850 -0
- package/src/qingflow_mcp/tools/approval_tools.py +833 -0
- package/src/qingflow_mcp/tools/auth_tools.py +697 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
- package/src/qingflow_mcp/tools/directory_tools.py +648 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +385 -0
- package/src/qingflow_mcp/tools/import_tools.py +1971 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +240 -0
- package/src/qingflow_mcp/tools/portal_tools.py +131 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
- package/src/qingflow_mcp/tools/record_tools.py +12739 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
- package/src/qingflow_mcp/tools/task_tools.py +843 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
|
|
8
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
9
|
+
from ..json_types import JSONObject, JSONValue
|
|
10
|
+
from .record_tools import (
|
|
11
|
+
ATTACHMENT_QUE_TYPES,
|
|
12
|
+
DEPARTMENT_QUE_TYPES,
|
|
13
|
+
MEMBER_QUE_TYPES,
|
|
14
|
+
MULTI_SELECT_QUE_TYPES,
|
|
15
|
+
RELATION_QUE_TYPES,
|
|
16
|
+
SINGLE_SELECT_QUE_TYPES,
|
|
17
|
+
SUBTABLE_QUE_TYPES,
|
|
18
|
+
FieldIndex,
|
|
19
|
+
FormField,
|
|
20
|
+
RecordTools,
|
|
21
|
+
_coerce_count,
|
|
22
|
+
_collect_question_relations,
|
|
23
|
+
_field_ref_payload,
|
|
24
|
+
_normalize_optional_text,
|
|
25
|
+
_relation_ids_from_answer,
|
|
26
|
+
_stringify_json,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
CODE_BLOCK_QUE_TYPE = 26
|
|
31
|
+
CODE_BLOCK_RELATION_TYPE = 3
|
|
32
|
+
SUPPORTED_CODE_BLOCK_ROLES = {1, 2, 3, 5}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CodeBlockTools(RecordTools):
|
|
36
|
+
def register(self, mcp: FastMCP) -> None:
|
|
37
|
+
super().register(mcp)
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def record_code_block_schema_get(
|
|
41
|
+
app_key: str = "",
|
|
42
|
+
output_profile: str = "normal",
|
|
43
|
+
) -> JSONObject:
|
|
44
|
+
return self.record_code_block_schema_get_public(
|
|
45
|
+
profile=DEFAULT_PROFILE,
|
|
46
|
+
app_key=app_key,
|
|
47
|
+
output_profile=output_profile,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@mcp.tool(
|
|
51
|
+
description=(
|
|
52
|
+
"Run a form code-block field against the current record data, then reuse Qingflow's existing "
|
|
53
|
+
"relation-calculation chain to compute bound outputs and write them back automatically. "
|
|
54
|
+
"Use record_code_block_schema_get first and choose an exact code-block field selector."
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
def record_code_block_run(
|
|
58
|
+
profile: str = DEFAULT_PROFILE,
|
|
59
|
+
app_key: str = "",
|
|
60
|
+
record_id: int = 0,
|
|
61
|
+
code_block_field: str = "",
|
|
62
|
+
role: int = 1,
|
|
63
|
+
workflow_node_id: int | None = None,
|
|
64
|
+
answers: list[JSONObject] | None = None,
|
|
65
|
+
fields: JSONObject | None = None,
|
|
66
|
+
manual: bool = True,
|
|
67
|
+
verify_writeback: bool = True,
|
|
68
|
+
force_refresh_form: bool = False,
|
|
69
|
+
output_profile: str = "normal",
|
|
70
|
+
) -> JSONObject:
|
|
71
|
+
return self.record_code_block_run(
|
|
72
|
+
profile=profile,
|
|
73
|
+
app_key=app_key,
|
|
74
|
+
record_id=record_id,
|
|
75
|
+
code_block_field=code_block_field,
|
|
76
|
+
role=role,
|
|
77
|
+
workflow_node_id=workflow_node_id,
|
|
78
|
+
answers=answers or [],
|
|
79
|
+
fields=fields or {},
|
|
80
|
+
manual=manual,
|
|
81
|
+
verify_writeback=verify_writeback,
|
|
82
|
+
force_refresh_form=force_refresh_form,
|
|
83
|
+
output_profile=output_profile,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def record_code_block_schema_get_public(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
profile: str = DEFAULT_PROFILE,
|
|
90
|
+
app_key: str,
|
|
91
|
+
output_profile: str = "normal",
|
|
92
|
+
) -> JSONObject:
|
|
93
|
+
if not app_key:
|
|
94
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
95
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
96
|
+
|
|
97
|
+
def runner(session_profile, context):
|
|
98
|
+
schema = self._get_form_schema(profile, context, app_key, force_refresh=False)
|
|
99
|
+
index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=False)
|
|
100
|
+
input_fields = [
|
|
101
|
+
self._ready_schema_field_payload(
|
|
102
|
+
profile,
|
|
103
|
+
context,
|
|
104
|
+
field,
|
|
105
|
+
ws_id=session_profile.selected_ws_id,
|
|
106
|
+
required_override=None,
|
|
107
|
+
)
|
|
108
|
+
for field in index.by_id.values()
|
|
109
|
+
if field.que_type != CODE_BLOCK_QUE_TYPE and bool(self._schema_write_hints(field).get("writable"))
|
|
110
|
+
]
|
|
111
|
+
code_block_fields: list[JSONObject] = []
|
|
112
|
+
for field in index.by_id.values():
|
|
113
|
+
if field.que_type != CODE_BLOCK_QUE_TYPE:
|
|
114
|
+
continue
|
|
115
|
+
targets = _collect_code_block_relation_targets(
|
|
116
|
+
_collect_question_relations(schema),
|
|
117
|
+
code_block_que_id=field.que_id,
|
|
118
|
+
)
|
|
119
|
+
bound_output_fields = [
|
|
120
|
+
target_field.que_title
|
|
121
|
+
for target in targets
|
|
122
|
+
for target_field in [index.by_id.get(str(_coerce_count(target.get("que_id")) or -1))]
|
|
123
|
+
if target_field is not None and isinstance(target_field.que_title, str)
|
|
124
|
+
]
|
|
125
|
+
code_block_fields.append(
|
|
126
|
+
{
|
|
127
|
+
"title": field.que_title,
|
|
128
|
+
"selector": field.que_title,
|
|
129
|
+
"bound_output_fields": bound_output_fields,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
response: JSONObject = {
|
|
133
|
+
"profile": profile,
|
|
134
|
+
"ws_id": session_profile.selected_ws_id,
|
|
135
|
+
"ok": True,
|
|
136
|
+
"status": "success",
|
|
137
|
+
"request_route": self._request_route_payload(context),
|
|
138
|
+
"warnings": [],
|
|
139
|
+
"app_key": app_key,
|
|
140
|
+
"schema_scope": "code_block_ready",
|
|
141
|
+
"code_block_fields": code_block_fields,
|
|
142
|
+
"input_fields": input_fields,
|
|
143
|
+
}
|
|
144
|
+
if normalized_output_profile == "verbose":
|
|
145
|
+
response["legacy_schema"] = self.record_schema_get(
|
|
146
|
+
profile=profile,
|
|
147
|
+
app_key=app_key,
|
|
148
|
+
schema_mode="applicant",
|
|
149
|
+
output_profile="verbose",
|
|
150
|
+
)
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
return self._run_record_tool(profile, runner)
|
|
154
|
+
|
|
155
|
+
def record_code_block_run(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
profile: str,
|
|
159
|
+
app_key: str,
|
|
160
|
+
record_id: int,
|
|
161
|
+
code_block_field: str,
|
|
162
|
+
role: int = 1,
|
|
163
|
+
workflow_node_id: int | None = None,
|
|
164
|
+
answers: list[JSONObject] | None = None,
|
|
165
|
+
fields: JSONObject | None = None,
|
|
166
|
+
manual: bool = True,
|
|
167
|
+
verify_writeback: bool = True,
|
|
168
|
+
force_refresh_form: bool = False,
|
|
169
|
+
output_profile: str = "normal",
|
|
170
|
+
) -> JSONObject:
|
|
171
|
+
normalized_record_id = self._validate_app_and_record(app_key, record_id)
|
|
172
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
173
|
+
if role not in SUPPORTED_CODE_BLOCK_ROLES:
|
|
174
|
+
raise_tool_error(QingflowApiError.config_error("role must be one of 1, 2, 3, or 5"))
|
|
175
|
+
if role == 3 and (workflow_node_id is None or workflow_node_id <= 0):
|
|
176
|
+
raise_tool_error(QingflowApiError.config_error("workflow_node_id is required when role=3"))
|
|
177
|
+
if not code_block_field:
|
|
178
|
+
raise_tool_error(QingflowApiError.config_error("code_block_field is required"))
|
|
179
|
+
|
|
180
|
+
def runner(session_profile, context):
|
|
181
|
+
schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
|
|
182
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
|
|
183
|
+
code_block = self._resolve_field_selector(code_block_field, index, location="code_block_field")
|
|
184
|
+
if code_block.que_type != CODE_BLOCK_QUE_TYPE:
|
|
185
|
+
raise_tool_error(
|
|
186
|
+
QingflowApiError(
|
|
187
|
+
category="config",
|
|
188
|
+
message=f"field '{code_block.que_title}' is not a code-block field",
|
|
189
|
+
backend_code="CODE_BLOCK_FIELD_REQUIRED",
|
|
190
|
+
details={
|
|
191
|
+
"error_code": "CODE_BLOCK_FIELD_REQUIRED",
|
|
192
|
+
"field": _field_ref_payload(code_block),
|
|
193
|
+
"expected_que_type": CODE_BLOCK_QUE_TYPE,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
current_answers = self._load_record_answers_for_code_block(
|
|
199
|
+
context,
|
|
200
|
+
app_key=app_key,
|
|
201
|
+
apply_id=normalized_record_id,
|
|
202
|
+
role=role,
|
|
203
|
+
audit_node_id=workflow_node_id,
|
|
204
|
+
)
|
|
205
|
+
override_answers = (
|
|
206
|
+
self._resolve_answers(
|
|
207
|
+
profile,
|
|
208
|
+
context,
|
|
209
|
+
app_key,
|
|
210
|
+
answers=answers or [],
|
|
211
|
+
fields=fields or {},
|
|
212
|
+
force_refresh_form=force_refresh_form,
|
|
213
|
+
)
|
|
214
|
+
if answers or fields
|
|
215
|
+
else []
|
|
216
|
+
)
|
|
217
|
+
merged_answers = self._merge_record_answers(current_answers, override_answers) if override_answers else current_answers
|
|
218
|
+
key_que_values = self._answers_to_open_match_values(merged_answers, index)
|
|
219
|
+
run_body: JSONObject = {
|
|
220
|
+
"role": role,
|
|
221
|
+
"manual": bool(manual),
|
|
222
|
+
"applyId": normalized_record_id,
|
|
223
|
+
"appKey": app_key,
|
|
224
|
+
"queryQuestions": [{"queId": code_block.que_id, "ordinal": None}],
|
|
225
|
+
"keyQueValues": key_que_values,
|
|
226
|
+
}
|
|
227
|
+
if workflow_node_id is not None:
|
|
228
|
+
run_body["auditNodeId"] = workflow_node_id
|
|
229
|
+
run_result = self.backend.request(
|
|
230
|
+
"POST",
|
|
231
|
+
context,
|
|
232
|
+
f"/data/{app_key}/codeBlock/working",
|
|
233
|
+
json_body=run_body,
|
|
234
|
+
)
|
|
235
|
+
alias_results = _normalize_code_block_alias_results(run_result)
|
|
236
|
+
relation_target_fields = _collect_code_block_relation_targets(
|
|
237
|
+
_collect_question_relations(schema),
|
|
238
|
+
code_block_que_id=code_block.que_id,
|
|
239
|
+
)
|
|
240
|
+
relation_errors: list[JSONObject] = []
|
|
241
|
+
relation_items: list[JSONObject] = []
|
|
242
|
+
calculated_answers: list[JSONObject] = []
|
|
243
|
+
relation_result: JSONObject | None = None
|
|
244
|
+
if relation_target_fields:
|
|
245
|
+
relation_body: JSONObject = {
|
|
246
|
+
"role": role,
|
|
247
|
+
"manual": bool(manual),
|
|
248
|
+
"applyId": normalized_record_id,
|
|
249
|
+
"appKey": app_key,
|
|
250
|
+
"queryQuestions": [{"queId": target["que_id"], "ordinal": None} for target in relation_target_fields],
|
|
251
|
+
"keyQueValues": key_que_values,
|
|
252
|
+
"codeBlockValues": [{"queId": code_block.que_id, "values": alias_results}],
|
|
253
|
+
}
|
|
254
|
+
if workflow_node_id is not None:
|
|
255
|
+
relation_body["auditNodeId"] = workflow_node_id
|
|
256
|
+
relation_route = "/data/que/actuator"
|
|
257
|
+
try:
|
|
258
|
+
relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
|
|
259
|
+
relation_items = _relation_result_items(relation_result)
|
|
260
|
+
except QingflowApiError as exc:
|
|
261
|
+
if exc.http_status != 404:
|
|
262
|
+
raise
|
|
263
|
+
relation_route = "/que/actuator"
|
|
264
|
+
relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
|
|
265
|
+
relation_items = _relation_result_items(relation_result)
|
|
266
|
+
if not relation_items:
|
|
267
|
+
# Keep compatibility with legacy runtime deployments and lightweight test doubles
|
|
268
|
+
# that still stub the older relation-calculation route only.
|
|
269
|
+
relation_route = "/que/actuator"
|
|
270
|
+
relation_result = self.backend.request("POST", context, relation_route, json_body=relation_body)
|
|
271
|
+
relation_items = _relation_result_items(relation_result)
|
|
272
|
+
relation_errors = _relation_result_errors(relation_items)
|
|
273
|
+
calculated_answers = _relation_result_answers(relation_items)
|
|
274
|
+
write_result: JSONObject | None = None
|
|
275
|
+
verification: JSONObject | None = None
|
|
276
|
+
writeback_attempted = False
|
|
277
|
+
writeback_applied = False
|
|
278
|
+
status = "completed"
|
|
279
|
+
ok = True
|
|
280
|
+
if relation_target_fields and calculated_answers:
|
|
281
|
+
write_body: JSONObject = {"role": role, "answers": calculated_answers}
|
|
282
|
+
if workflow_node_id is not None:
|
|
283
|
+
write_body["auditNodeId"] = workflow_node_id
|
|
284
|
+
writeback_attempted = True
|
|
285
|
+
write_result = cast(
|
|
286
|
+
JSONObject,
|
|
287
|
+
self.backend.request(
|
|
288
|
+
"POST",
|
|
289
|
+
context,
|
|
290
|
+
f"/app/{app_key}/apply/{normalized_record_id}",
|
|
291
|
+
json_body=write_body,
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
writeback_applied = True
|
|
295
|
+
if verify_writeback:
|
|
296
|
+
verification = self._verify_code_block_writeback_result(
|
|
297
|
+
context,
|
|
298
|
+
app_key=app_key,
|
|
299
|
+
apply_id=normalized_record_id,
|
|
300
|
+
expected_answers=calculated_answers,
|
|
301
|
+
index=index,
|
|
302
|
+
role=role,
|
|
303
|
+
audit_node_id=workflow_node_id,
|
|
304
|
+
)
|
|
305
|
+
if not bool(verification.get("verified")):
|
|
306
|
+
status = "verification_failed"
|
|
307
|
+
ok = False
|
|
308
|
+
elif relation_errors:
|
|
309
|
+
status = "relation_failed"
|
|
310
|
+
ok = False
|
|
311
|
+
else:
|
|
312
|
+
status = "no_writeback"
|
|
313
|
+
response: JSONObject = {
|
|
314
|
+
"profile": profile,
|
|
315
|
+
"ws_id": session_profile.selected_ws_id,
|
|
316
|
+
"request_route": self._request_route_payload(context),
|
|
317
|
+
"app_key": app_key,
|
|
318
|
+
"record_id": normalized_record_id,
|
|
319
|
+
"apply_id": normalized_record_id,
|
|
320
|
+
"status": status,
|
|
321
|
+
"ok": ok,
|
|
322
|
+
"code_block_field": _field_ref_payload(code_block),
|
|
323
|
+
"execution": {
|
|
324
|
+
"executed": True,
|
|
325
|
+
"role": role,
|
|
326
|
+
"workflow_node_id": workflow_node_id,
|
|
327
|
+
"manual": bool(manual),
|
|
328
|
+
"result_count": len(alias_results),
|
|
329
|
+
},
|
|
330
|
+
"outputs": {
|
|
331
|
+
"alias_results": alias_results,
|
|
332
|
+
"alias_map": _build_alias_result_map(alias_results),
|
|
333
|
+
},
|
|
334
|
+
"relation": {
|
|
335
|
+
"target_fields": relation_target_fields,
|
|
336
|
+
"result_item_count": len(relation_items),
|
|
337
|
+
"calculated_answer_count": len(calculated_answers),
|
|
338
|
+
"errors": relation_errors,
|
|
339
|
+
},
|
|
340
|
+
"writeback": {
|
|
341
|
+
"attempted": writeback_attempted,
|
|
342
|
+
"applied": writeback_applied,
|
|
343
|
+
"verify_writeback": verify_writeback,
|
|
344
|
+
"write_verified": bool(verification.get("verified")) if verification is not None else None,
|
|
345
|
+
"result": write_result,
|
|
346
|
+
"verification": verification,
|
|
347
|
+
},
|
|
348
|
+
"resource": {"apply_id": normalized_record_id},
|
|
349
|
+
}
|
|
350
|
+
if normalized_output_profile == "verbose":
|
|
351
|
+
response["debug"] = {
|
|
352
|
+
"run_body": run_body,
|
|
353
|
+
"relation_route": relation_route if relation_target_fields else None,
|
|
354
|
+
"relation_result": relation_result,
|
|
355
|
+
"calculated_answers": calculated_answers,
|
|
356
|
+
"merged_answers": merged_answers,
|
|
357
|
+
"key_que_values": key_que_values,
|
|
358
|
+
}
|
|
359
|
+
return response
|
|
360
|
+
|
|
361
|
+
return self._run_record_tool(profile, runner)
|
|
362
|
+
|
|
363
|
+
def _load_record_answers_for_code_block(
|
|
364
|
+
self,
|
|
365
|
+
context, # type: ignore[no-untyped-def]
|
|
366
|
+
*,
|
|
367
|
+
app_key: str,
|
|
368
|
+
apply_id: int,
|
|
369
|
+
role: int,
|
|
370
|
+
audit_node_id: int | None,
|
|
371
|
+
) -> list[JSONObject]:
|
|
372
|
+
last_error: QingflowApiError | None = None
|
|
373
|
+
for list_type in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
|
|
374
|
+
params: JSONObject = {"role": role, "listType": list_type}
|
|
375
|
+
if audit_node_id is not None:
|
|
376
|
+
params["auditNodeId"] = audit_node_id
|
|
377
|
+
try:
|
|
378
|
+
record = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}", params=params)
|
|
379
|
+
answers = record.get("answers") if isinstance(record, dict) else None
|
|
380
|
+
return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
|
|
381
|
+
except QingflowApiError as exc:
|
|
382
|
+
last_error = exc
|
|
383
|
+
if exc.backend_code == 40002:
|
|
384
|
+
continue
|
|
385
|
+
raise
|
|
386
|
+
if last_error is not None:
|
|
387
|
+
raise last_error
|
|
388
|
+
raise_tool_error(QingflowApiError.config_error("record answers could not be loaded for code-block execution"))
|
|
389
|
+
|
|
390
|
+
def _answers_to_open_match_values(self, answers: list[JSONObject], index: FieldIndex) -> list[JSONObject]:
|
|
391
|
+
values: list[JSONObject] = []
|
|
392
|
+
for answer in answers:
|
|
393
|
+
if not isinstance(answer, dict):
|
|
394
|
+
continue
|
|
395
|
+
open_match = self._answer_to_open_match_value(answer, index)
|
|
396
|
+
if open_match is None:
|
|
397
|
+
continue
|
|
398
|
+
values.append(open_match)
|
|
399
|
+
return values
|
|
400
|
+
|
|
401
|
+
def _answer_to_open_match_value(self, answer: JSONObject, index: FieldIndex) -> JSONObject | None:
|
|
402
|
+
que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
|
|
403
|
+
if que_id is None or que_id <= 0:
|
|
404
|
+
return None
|
|
405
|
+
field = index.by_id.get(str(que_id))
|
|
406
|
+
if field is None:
|
|
407
|
+
return None
|
|
408
|
+
ordinal = _coerce_count(answer.get("ordinal"))
|
|
409
|
+
if field.que_type in SUBTABLE_QUE_TYPES:
|
|
410
|
+
rows = answer.get("tableValues")
|
|
411
|
+
row_values: list[list[JSONObject]] = []
|
|
412
|
+
subtable_index = self._subtable_field_index_optional(field)
|
|
413
|
+
if isinstance(rows, list) and subtable_index is not None:
|
|
414
|
+
for row in rows:
|
|
415
|
+
if not isinstance(row, list):
|
|
416
|
+
continue
|
|
417
|
+
normalized_row: list[JSONObject] = []
|
|
418
|
+
for cell in row:
|
|
419
|
+
if not isinstance(cell, dict):
|
|
420
|
+
continue
|
|
421
|
+
converted = self._answer_to_open_match_value(cell, subtable_index)
|
|
422
|
+
if converted is not None:
|
|
423
|
+
normalized_row.append(converted)
|
|
424
|
+
row_values.append(normalized_row)
|
|
425
|
+
payload: JSONObject = {"keyQueId": field.que_id, "ordinal": ordinal, "values": [], "tableValues": row_values}
|
|
426
|
+
return payload
|
|
427
|
+
return {
|
|
428
|
+
"keyQueId": field.que_id,
|
|
429
|
+
"ordinal": ordinal,
|
|
430
|
+
"values": self._answer_values_to_code_block_values(answer, field),
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
def _answer_values_to_code_block_values(self, answer: JSONObject, field: FormField) -> list[str]:
|
|
434
|
+
if field.que_type in RELATION_QUE_TYPES:
|
|
435
|
+
return _relation_ids_from_answer(answer)
|
|
436
|
+
raw_values = answer.get("values")
|
|
437
|
+
if not isinstance(raw_values, list):
|
|
438
|
+
return []
|
|
439
|
+
normalized: list[str] = []
|
|
440
|
+
for item in raw_values:
|
|
441
|
+
normalized_value = _normalize_code_block_value_item(item, field)
|
|
442
|
+
if normalized_value is None:
|
|
443
|
+
continue
|
|
444
|
+
normalized.append(normalized_value)
|
|
445
|
+
return normalized
|
|
446
|
+
|
|
447
|
+
def _verify_code_block_writeback_result(
|
|
448
|
+
self,
|
|
449
|
+
context, # type: ignore[no-untyped-def]
|
|
450
|
+
*,
|
|
451
|
+
app_key: str,
|
|
452
|
+
apply_id: int,
|
|
453
|
+
expected_answers: list[JSONObject],
|
|
454
|
+
index: FieldIndex,
|
|
455
|
+
role: int,
|
|
456
|
+
audit_node_id: int | None,
|
|
457
|
+
) -> JSONObject:
|
|
458
|
+
if role == 1 and audit_node_id is None:
|
|
459
|
+
return self._verify_record_write_result(
|
|
460
|
+
context,
|
|
461
|
+
app_key=app_key,
|
|
462
|
+
apply_id=apply_id,
|
|
463
|
+
normalized_answers=expected_answers,
|
|
464
|
+
index=index,
|
|
465
|
+
verify_list_type=DEFAULT_RECORD_LIST_TYPE,
|
|
466
|
+
)
|
|
467
|
+
actual_answers = self._load_record_answers_for_code_block(
|
|
468
|
+
context,
|
|
469
|
+
app_key=app_key,
|
|
470
|
+
apply_id=apply_id,
|
|
471
|
+
role=role,
|
|
472
|
+
audit_node_id=audit_node_id,
|
|
473
|
+
)
|
|
474
|
+
actual_by_id = {
|
|
475
|
+
que_id: item
|
|
476
|
+
for item in actual_answers
|
|
477
|
+
if isinstance(item, dict) and (que_id := _coerce_count(item.get("queId"))) is not None
|
|
478
|
+
}
|
|
479
|
+
missing_fields: list[JSONObject] = []
|
|
480
|
+
empty_fields: list[JSONObject] = []
|
|
481
|
+
count_mismatches: list[JSONObject] = []
|
|
482
|
+
for answer in expected_answers:
|
|
483
|
+
que_id = _coerce_count(answer.get("queId"))
|
|
484
|
+
if que_id is None or que_id <= 0:
|
|
485
|
+
continue
|
|
486
|
+
actual = actual_by_id.get(que_id)
|
|
487
|
+
field = index.by_id.get(str(que_id))
|
|
488
|
+
field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
|
|
489
|
+
if actual is None:
|
|
490
|
+
missing_fields.append(field_payload)
|
|
491
|
+
continue
|
|
492
|
+
expected_rows = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
|
|
493
|
+
if expected_rows:
|
|
494
|
+
actual_rows = actual.get("tableValues") if isinstance(actual.get("tableValues"), list) else []
|
|
495
|
+
self._verify_subtable_write_result(
|
|
496
|
+
field=field,
|
|
497
|
+
expected_rows=expected_rows,
|
|
498
|
+
actual_rows=actual_rows,
|
|
499
|
+
missing_fields=missing_fields,
|
|
500
|
+
empty_fields=empty_fields,
|
|
501
|
+
count_mismatches=count_mismatches,
|
|
502
|
+
)
|
|
503
|
+
continue
|
|
504
|
+
if field is not None and field.que_type in RELATION_QUE_TYPES:
|
|
505
|
+
expected_relation_ids = _relation_ids_from_answer(answer)
|
|
506
|
+
actual_relation_ids = _relation_ids_from_answer(actual)
|
|
507
|
+
if expected_relation_ids and not actual_relation_ids:
|
|
508
|
+
empty_fields.append(field_payload)
|
|
509
|
+
continue
|
|
510
|
+
if expected_relation_ids:
|
|
511
|
+
actual_id_set = set(actual_relation_ids)
|
|
512
|
+
missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
|
|
513
|
+
if missing_ids:
|
|
514
|
+
count_mismatches.append(
|
|
515
|
+
{
|
|
516
|
+
**field_payload,
|
|
517
|
+
"expected_ids": expected_relation_ids,
|
|
518
|
+
"actual_ids": actual_relation_ids,
|
|
519
|
+
"missing_ids": missing_ids,
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
continue
|
|
523
|
+
actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
|
|
524
|
+
if not actual_values:
|
|
525
|
+
empty_fields.append(field_payload)
|
|
526
|
+
continue
|
|
527
|
+
expected_values = answer.get("values") if isinstance(answer.get("values"), list) else []
|
|
528
|
+
if expected_values and len(actual_values) < len(expected_values):
|
|
529
|
+
count_mismatches.append(
|
|
530
|
+
{
|
|
531
|
+
**field_payload,
|
|
532
|
+
"expected_count": len(expected_values),
|
|
533
|
+
"actual_count": len(actual_values),
|
|
534
|
+
}
|
|
535
|
+
)
|
|
536
|
+
return {
|
|
537
|
+
"verified": not missing_fields and not empty_fields and not count_mismatches,
|
|
538
|
+
"verification_mode": "role_record_view",
|
|
539
|
+
"field_level_verified": True,
|
|
540
|
+
"missing_fields": missing_fields,
|
|
541
|
+
"empty_fields": empty_fields,
|
|
542
|
+
"count_mismatches": count_mismatches,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _normalize_code_block_value_item(value: JSONValue, field: FormField) -> str | None:
|
|
547
|
+
if field.que_type in SINGLE_SELECT_QUE_TYPES | MULTI_SELECT_QUE_TYPES:
|
|
548
|
+
return _selector_numeric_or_text(value, ("optionId", "optId", "id"), allow_text=False)
|
|
549
|
+
if field.que_type in MEMBER_QUE_TYPES:
|
|
550
|
+
return _selector_numeric_or_text(value, ("id", "uid"), allow_text=False)
|
|
551
|
+
if field.que_type in DEPARTMENT_QUE_TYPES:
|
|
552
|
+
return _selector_numeric_or_text(value, ("id", "deptId"), allow_text=False)
|
|
553
|
+
if field.que_type in ATTACHMENT_QUE_TYPES:
|
|
554
|
+
return _selector_numeric_or_text(value, ("value", "url", "otherInfo", "name", "fileName"), allow_text=True)
|
|
555
|
+
if isinstance(value, dict):
|
|
556
|
+
scalar = value.get("value")
|
|
557
|
+
return _stringify_json(scalar) if scalar is not None else None
|
|
558
|
+
text = _normalize_optional_text(value)
|
|
559
|
+
return text if text is not None else None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _selector_numeric_or_text(value: JSONValue, keys: tuple[str, ...], *, allow_text: bool) -> str | None:
|
|
563
|
+
numeric = _coerce_count(value)
|
|
564
|
+
if numeric is not None:
|
|
565
|
+
return str(numeric)
|
|
566
|
+
if not isinstance(value, dict):
|
|
567
|
+
if not allow_text:
|
|
568
|
+
return None
|
|
569
|
+
text = _normalize_optional_text(value)
|
|
570
|
+
return text if text is not None else None
|
|
571
|
+
for key in keys:
|
|
572
|
+
if key not in value:
|
|
573
|
+
continue
|
|
574
|
+
candidate = value.get(key)
|
|
575
|
+
if candidate is None:
|
|
576
|
+
continue
|
|
577
|
+
numeric = _coerce_count(candidate)
|
|
578
|
+
if numeric is not None:
|
|
579
|
+
return str(numeric)
|
|
580
|
+
if allow_text:
|
|
581
|
+
text = _normalize_optional_text(candidate)
|
|
582
|
+
if text is not None:
|
|
583
|
+
return text
|
|
584
|
+
return _normalize_optional_text(value.get("value")) if allow_text and isinstance(value.get("value"), (str, int, float)) else None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _normalize_code_block_alias_results(payload: JSONValue) -> list[JSONObject]:
|
|
588
|
+
if not isinstance(payload, dict):
|
|
589
|
+
return []
|
|
590
|
+
raw_results = payload.get("result")
|
|
591
|
+
if not isinstance(raw_results, list):
|
|
592
|
+
return []
|
|
593
|
+
results: list[JSONObject] = []
|
|
594
|
+
for item in raw_results:
|
|
595
|
+
if not isinstance(item, dict):
|
|
596
|
+
continue
|
|
597
|
+
values = item.get("value")
|
|
598
|
+
result: JSONObject = {
|
|
599
|
+
"parentAliasId": _coerce_count(item.get("parentAliasId")),
|
|
600
|
+
"parentAlias": _normalize_optional_text(item.get("parentAlias")),
|
|
601
|
+
"aliasId": _coerce_count(item.get("aliasId")),
|
|
602
|
+
"alias": _normalize_optional_text(item.get("alias")),
|
|
603
|
+
"value": [_stringify_json(entry) for entry in values] if isinstance(values, list) else [],
|
|
604
|
+
}
|
|
605
|
+
results.append(result)
|
|
606
|
+
return results
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _build_alias_result_map(alias_results: list[JSONObject]) -> JSONObject:
|
|
610
|
+
alias_map: JSONObject = {}
|
|
611
|
+
for item in alias_results:
|
|
612
|
+
alias = _normalize_optional_text(item.get("alias"))
|
|
613
|
+
if alias is None:
|
|
614
|
+
continue
|
|
615
|
+
parent_alias = _normalize_optional_text(item.get("parentAlias"))
|
|
616
|
+
key = f"{parent_alias}.{alias}" if parent_alias else alias
|
|
617
|
+
values = item.get("value")
|
|
618
|
+
alias_map[key] = values if isinstance(values, list) else []
|
|
619
|
+
return alias_map
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _collect_code_block_relation_targets(question_relations: list[JSONObject], *, code_block_que_id: int) -> list[JSONObject]:
|
|
623
|
+
targets: list[JSONObject] = []
|
|
624
|
+
seen: set[int] = set()
|
|
625
|
+
for relation in question_relations:
|
|
626
|
+
if _coerce_count(relation.get("relationType")) != CODE_BLOCK_RELATION_TYPE:
|
|
627
|
+
continue
|
|
628
|
+
if _coerce_count(relation.get("qlinkerQueId")) != code_block_que_id:
|
|
629
|
+
continue
|
|
630
|
+
target_id = _coerce_count(
|
|
631
|
+
relation.get("queId", relation.get("targetQueId", relation.get("displayedQueId")))
|
|
632
|
+
)
|
|
633
|
+
if target_id is None or target_id in seen:
|
|
634
|
+
continue
|
|
635
|
+
seen.add(target_id)
|
|
636
|
+
targets.append(
|
|
637
|
+
{
|
|
638
|
+
"que_id": target_id,
|
|
639
|
+
"alias_id": _coerce_count(relation.get("aliasId")),
|
|
640
|
+
"qlinker_que_id": _coerce_count(relation.get("qlinkerQueId")),
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
return targets
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _relation_result_items(payload: JSONValue) -> list[JSONObject]:
|
|
647
|
+
if not isinstance(payload, dict):
|
|
648
|
+
return []
|
|
649
|
+
result = payload.get("result")
|
|
650
|
+
return [item for item in result if isinstance(item, dict)] if isinstance(result, list) else []
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _relation_result_answers(items: list[JSONObject]) -> list[JSONObject]:
|
|
654
|
+
answers: list[JSONObject] = []
|
|
655
|
+
for item in items:
|
|
656
|
+
raw_answers = item.get("answers")
|
|
657
|
+
if not isinstance(raw_answers, list):
|
|
658
|
+
continue
|
|
659
|
+
for answer in raw_answers:
|
|
660
|
+
if isinstance(answer, dict):
|
|
661
|
+
answers.append(answer)
|
|
662
|
+
return answers
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _relation_result_errors(items: list[JSONObject]) -> list[JSONObject]:
|
|
666
|
+
errors: list[JSONObject] = []
|
|
667
|
+
for item in items:
|
|
668
|
+
message = _normalize_optional_text(item.get("errorMsg"))
|
|
669
|
+
if message is None:
|
|
670
|
+
continue
|
|
671
|
+
errors.append(
|
|
672
|
+
{
|
|
673
|
+
"que_id": _coerce_count(item.get("queId")),
|
|
674
|
+
"que_title": _normalize_optional_text(item.get("queTitle")),
|
|
675
|
+
"ordinal": _coerce_count(item.get("ordinal")),
|
|
676
|
+
"message": message,
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
return errors
|