@josephyan/qingflow-cli 0.2.0-beta.74 → 0.2.0-beta.76

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.
@@ -6,6 +6,7 @@ import time
6
6
 
7
7
  from pydantic import ValidationError
8
8
 
9
+ from ..public_surface import public_builder_contract_tool_names
9
10
  from ..config import DEFAULT_PROFILE
10
11
  from ..errors import QingflowApiError
11
12
  from ..json_types import JSONObject
@@ -462,11 +463,7 @@ class AiBuilderTools(ToolBase):
462
463
 
463
464
  def builder_tool_contract(self, *, tool_name: str) -> JSONObject:
464
465
  requested = str(tool_name or "").strip()
465
- public_tool_names = sorted(
466
- name
467
- for name in _BUILDER_TOOL_CONTRACTS.keys()
468
- if name not in _PRIVATE_BUILDER_TOOL_CONTRACTS and name not in _BUILDER_TOOL_CONTRACT_ALIASES
469
- )
466
+ public_tool_names = public_builder_contract_tool_names()
470
467
  if requested in _PRIVATE_BUILDER_TOOL_CONTRACTS:
471
468
  lookup_name = ""
472
469
  elif requested in _BUILDER_TOOL_CONTRACTS:
@@ -2207,6 +2204,11 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2207
2204
  "allowed_values": {
2208
2205
  "payload.trigger_action": [member.value for member in PublicButtonTriggerAction],
2209
2206
  },
2207
+ "execution_notes": [
2208
+ "custom button writes now auto-publish the current app draft as a fixed closing step",
2209
+ "background_color and text_color cannot both be white",
2210
+ "for addData buttons, put field mappings in payload.trigger_add_data_config.que_relation",
2211
+ ],
2210
2212
  "minimal_example": {
2211
2213
  "profile": "default",
2212
2214
  "app_key": "APP_KEY",
@@ -2240,6 +2242,11 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2240
2242
  "allowed_values": {
2241
2243
  "payload.trigger_action": [member.value for member in PublicButtonTriggerAction],
2242
2244
  },
2245
+ "execution_notes": [
2246
+ "custom button writes now auto-publish the current app draft as a fixed closing step",
2247
+ "background_color and text_color cannot both be white",
2248
+ "for addData buttons, put field mappings in payload.trigger_add_data_config.que_relation",
2249
+ ],
2243
2250
  "minimal_example": {
2244
2251
  "profile": "default",
2245
2252
  "app_key": "APP_KEY",
@@ -2281,6 +2288,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2281
2288
  "field.allow_multiple": "field.relation_mode",
2282
2289
  "field.optional_data_num": "field.relation_mode",
2283
2290
  "field.optionalDataNum": "field.relation_mode",
2291
+ "field.departmentScope": "field.department_scope",
2284
2292
  "field.remoteLookupConfig": "field.remote_lookup_config",
2285
2293
  "field.qLinkerBinding": "field.q_linker_binding",
2286
2294
  "field.codeBlockConfig": "field.code_block_config",
@@ -2338,6 +2346,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2338
2346
  "field.allow_multiple": "field.relation_mode",
2339
2347
  "field.optional_data_num": "field.relation_mode",
2340
2348
  "field.optionalDataNum": "field.relation_mode",
2349
+ "field.departmentScope": "field.department_scope",
2341
2350
  "field.remoteLookupConfig": "field.remote_lookup_config",
2342
2351
  "field.qLinkerBinding": "field.q_linker_binding",
2343
2352
  "field.codeBlockConfig": "field.code_block_config",
@@ -2349,6 +2358,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2349
2358
  "allowed_values": {
2350
2359
  "field.type": [member.value for member in PublicFieldType],
2351
2360
  "field.relation_mode": [member.value for member in PublicRelationMode],
2361
+ "field.department_scope.mode": ["all", "custom"],
2352
2362
  "field.code_block_binding.outputs.target_field.type": list(INTEGRATION_OUTPUT_TARGET_FIELD_TYPES),
2353
2363
  "field.q_linker_binding.outputs.target_field.type": list(INTEGRATION_OUTPUT_TARGET_FIELD_TYPES),
2354
2364
  "field_type_ids": sorted(FIELD_TYPE_ID_ALIASES.keys()),
@@ -2360,7 +2370,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2360
2370
  "multiple relation fields are backend-risky; read verification.relation_field_limit_verified and warnings before declaring the schema stable",
2361
2371
  "backend 49614 is normalized to MULTIPLE_RELATION_FIELDS_UNSUPPORTED with a workaround message",
2362
2372
  "relation_mode=multiple maps to referenceConfig.optionalDataNum=0",
2373
+ "relation fields now require both display_field and visible_fields in MCP/CLI payloads",
2363
2374
  "if relation target metadata lookup is blocked by 40161/40002/40027, explicit display_field.name and visible_fields[].name let builder degrade verification and still continue schema write",
2375
+ "department fields accept department_scope with mode=all or mode=custom; custom scope requires explicit departments[].dept_id and optional include_sub_departs",
2364
2376
  "q_linker_binding lets you declare request config, dynamic inputs, alias parsing, and target-field bindings in one step; builder writes remoteLookupConfig plus the existing backend relation-default and questionRelations structures",
2365
2377
  "code_block_binding lets you declare inputs, code, alias parsing, and target-field bindings in one step; builder writes codeBlockConfig plus the existing backend relation-default and questionRelations structures",
2366
2378
  "builder configures code blocks only; it does not execute or trigger code blocks",
@@ -2397,6 +2409,24 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2397
2409
  "update_fields": [],
2398
2410
  "remove_fields": [],
2399
2411
  },
2412
+ "department_scope_example": {
2413
+ "profile": "default",
2414
+ "app_key": "APP_LEAD",
2415
+ "publish": True,
2416
+ "add_fields": [
2417
+ {
2418
+ "name": "归属部门",
2419
+ "type": "department",
2420
+ "department_scope": {
2421
+ "mode": "custom",
2422
+ "departments": [{"dept_id": 334952, "dept_name": "生产"}],
2423
+ "include_sub_departs": False,
2424
+ },
2425
+ }
2426
+ ],
2427
+ "update_fields": [],
2428
+ "remove_fields": [],
2429
+ },
2400
2430
  "code_block_example": {
2401
2431
  "profile": "default",
2402
2432
  "app_key": "APP_SCRIPT",
@@ -117,22 +117,6 @@ class ApprovalTools(ToolBase):
117
117
  ) -> dict[str, Any]:
118
118
  return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
119
119
 
120
- @mcp.tool(description=self._high_risk_tool_description(operation="save", target="workflow task fields without advancing the workflow"))
121
- def task_save_only(
122
- profile: str = DEFAULT_PROFILE,
123
- app_key: str = "",
124
- record_id: int = 0,
125
- workflow_node_id: int = 0,
126
- fields: dict[str, Any] | None = None,
127
- ) -> dict[str, Any]:
128
- return self.task_save_only(
129
- profile=profile,
130
- app_key=app_key,
131
- record_id=record_id,
132
- workflow_node_id=workflow_node_id,
133
- fields=fields or {},
134
- )
135
-
136
120
  @mcp.tool()
137
121
  def task_transfer_candidates(
138
122
  profile: str = DEFAULT_PROFILE,
@@ -138,6 +138,7 @@ class LookupResolutionState:
138
138
  operation: str
139
139
  app_key: str
140
140
  apply_id: int | None
141
+ workflow_node_id: int | None
141
142
  force_refresh_form: bool
142
143
  context_complete: bool
143
144
  field_index: FieldIndex
@@ -252,15 +253,19 @@ class RecordTools(ToolBase):
252
253
 
253
254
  @mcp.tool(
254
255
  description=(
255
- "List current-user candidate members for a member field in the applicant-node visible schema. "
256
- "Use record_insert_schema_get or record_update_schema_get with output_profile='verbose' first, then pass the member field_id. "
257
- "This tool fails closed when the field uses dynamic or external candidate scopes."
256
+ "List current-user candidate members for a member field. "
257
+ "When record_id/workflow_node_id/fields are provided, this tool uses the backend runtime candidate scope that matches writes. "
258
+ "Without runtime context it only returns a static applicant-node preview scope. "
259
+ "Use record_insert_schema_get or record_update_schema_get with output_profile='verbose' first, then pass the member field_id."
258
260
  )
259
261
  )
260
262
  def record_member_candidates(
261
263
  profile: str = DEFAULT_PROFILE,
262
264
  app_key: str = "",
263
265
  field_id: int = 0,
266
+ record_id: int | None = None,
267
+ workflow_node_id: int | None = None,
268
+ fields: JSONObject | None = None,
264
269
  keyword: str = "",
265
270
  page_num: int = 1,
266
271
  page_size: int = 20,
@@ -269,6 +274,9 @@ class RecordTools(ToolBase):
269
274
  profile=profile,
270
275
  app_key=app_key,
271
276
  field_id=field_id,
277
+ record_id=record_id,
278
+ workflow_node_id=workflow_node_id,
279
+ fields=fields or {},
272
280
  keyword=keyword,
273
281
  page_num=page_num,
274
282
  page_size=page_size,
@@ -276,16 +284,19 @@ class RecordTools(ToolBase):
276
284
 
277
285
  @mcp.tool(
278
286
  description=(
279
- "List current-user candidate departments for a department field in the applicant-node visible schema. "
280
- "Use record_insert_schema_get or record_update_schema_get with output_profile='verbose' first, then pass the department field_id. "
281
- "This tool supports explicit department scopes and default-all department scopes, but fails closed "
282
- "for dynamic or external candidate scopes."
287
+ "List current-user candidate departments for a department field. "
288
+ "When record_id/workflow_node_id/fields are provided, this tool uses the backend runtime candidate scope that matches writes. "
289
+ "Without runtime context it only returns a static applicant-node preview scope. "
290
+ "Use record_insert_schema_get or record_update_schema_get with output_profile='verbose' first, then pass the department field_id."
283
291
  )
284
292
  )
285
293
  def record_department_candidates(
286
294
  profile: str = DEFAULT_PROFILE,
287
295
  app_key: str = "",
288
296
  field_id: int = 0,
297
+ record_id: int | None = None,
298
+ workflow_node_id: int | None = None,
299
+ fields: JSONObject | None = None,
289
300
  keyword: str = "",
290
301
  page_num: int = 1,
291
302
  page_size: int = 20,
@@ -294,6 +305,9 @@ class RecordTools(ToolBase):
294
305
  profile=profile,
295
306
  app_key=app_key,
296
307
  field_id=field_id,
308
+ record_id=record_id,
309
+ workflow_node_id=workflow_node_id,
310
+ fields=fields or {},
297
311
  keyword=keyword,
298
312
  page_num=page_num,
299
313
  page_size=page_size,
@@ -1007,6 +1021,14 @@ class RecordTools(ToolBase):
1007
1021
  payload["options"] = list(field.options)
1008
1022
  if kind in {"member", "department", "relation"}:
1009
1023
  payload["accepts_natural_input"] = True
1024
+ if kind == "department":
1025
+ payload["candidate_hint"] = {
1026
+ "tool": "record_department_candidates",
1027
+ "field_id": field.que_id,
1028
+ "scope_source": "static_applicant_scope",
1029
+ "preview_only": True,
1030
+ "runtime_context_supported": True,
1031
+ }
1010
1032
  if kind == "attachment":
1011
1033
  payload["requires_upload"] = True
1012
1034
  if kind == "relation" and field.target_app_key:
@@ -1214,6 +1236,9 @@ class RecordTools(ToolBase):
1214
1236
  profile: str,
1215
1237
  app_key: str,
1216
1238
  field_id: int,
1239
+ record_id: int | None = None,
1240
+ workflow_node_id: int | None = None,
1241
+ fields: JSONObject | None = None,
1217
1242
  keyword: str,
1218
1243
  page_num: int,
1219
1244
  page_size: int,
@@ -1243,7 +1268,33 @@ class RecordTools(ToolBase):
1243
1268
  },
1244
1269
  )
1245
1270
  )
1246
- items = self._resolve_member_candidates(context, field, keyword=keyword)
1271
+ normalized_fields = fields if isinstance(fields, dict) else {}
1272
+ runtime_lookup = self._candidate_lookup_uses_runtime_scope(
1273
+ record_id=record_id,
1274
+ workflow_node_id=workflow_node_id,
1275
+ fields=normalized_fields,
1276
+ )
1277
+ warnings: list[JSONObject] = []
1278
+ scope_source = "static_applicant_scope"
1279
+ if runtime_lookup:
1280
+ state = self._build_candidate_lookup_state(
1281
+ profile,
1282
+ context,
1283
+ app_key=app_key,
1284
+ record_id=record_id,
1285
+ workflow_node_id=workflow_node_id,
1286
+ fields=normalized_fields,
1287
+ )
1288
+ items = self._resolve_member_candidates_backend(context, field, keyword=keyword, state=state)
1289
+ scope_source = "backend_runtime_scope"
1290
+ else:
1291
+ items = self._resolve_member_candidates(context, field, keyword=keyword)
1292
+ warnings.append(
1293
+ {
1294
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1295
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1296
+ }
1297
+ )
1247
1298
  total = len(items)
1248
1299
  start = (page_num - 1) * page_size
1249
1300
  end = start + page_size
@@ -1254,7 +1305,7 @@ class RecordTools(ToolBase):
1254
1305
  "ws_id": session_profile.selected_ws_id,
1255
1306
  "ok": True,
1256
1307
  "request_route": self._request_route_payload(context),
1257
- "warnings": [],
1308
+ "warnings": warnings,
1258
1309
  "output_profile": "normal",
1259
1310
  "data": {
1260
1311
  "items": page_items,
@@ -1269,9 +1320,13 @@ class RecordTools(ToolBase):
1269
1320
  "app_key": app_key,
1270
1321
  "field_id": field.que_id,
1271
1322
  "field_title": field.que_title,
1323
+ "record_id": record_id,
1324
+ "workflow_node_id": workflow_node_id,
1325
+ "fields_present": bool(normalized_fields),
1272
1326
  "keyword": keyword,
1273
1327
  "permission_scope": "applicant_node",
1274
1328
  },
1329
+ "scope_source": scope_source,
1275
1330
  },
1276
1331
  }
1277
1332
 
@@ -1283,6 +1338,9 @@ class RecordTools(ToolBase):
1283
1338
  profile: str,
1284
1339
  app_key: str,
1285
1340
  field_id: int,
1341
+ record_id: int | None = None,
1342
+ workflow_node_id: int | None = None,
1343
+ fields: JSONObject | None = None,
1286
1344
  keyword: str,
1287
1345
  page_num: int,
1288
1346
  page_size: int,
@@ -1312,7 +1370,50 @@ class RecordTools(ToolBase):
1312
1370
  },
1313
1371
  )
1314
1372
  )
1315
- items = self._resolve_department_candidates(context, field, keyword=keyword)
1373
+ normalized_fields = fields if isinstance(fields, dict) else {}
1374
+ runtime_lookup = self._candidate_lookup_uses_runtime_scope(
1375
+ record_id=record_id,
1376
+ workflow_node_id=workflow_node_id,
1377
+ fields=normalized_fields,
1378
+ )
1379
+ warnings: list[JSONObject] = []
1380
+ scope_source = "static_applicant_scope"
1381
+ if runtime_lookup:
1382
+ state = self._build_candidate_lookup_state(
1383
+ profile,
1384
+ context,
1385
+ app_key=app_key,
1386
+ record_id=record_id,
1387
+ workflow_node_id=workflow_node_id,
1388
+ fields=normalized_fields,
1389
+ )
1390
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1391
+ scope_source = "backend_runtime_scope"
1392
+ else:
1393
+ items = self._resolve_department_candidates(context, field, keyword=keyword)
1394
+ scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
1395
+ if (
1396
+ not items
1397
+ and field.dept_select_scope_type == 2
1398
+ and not _scope_has_dynamic_or_external(scope)
1399
+ and not list(scope.get("depart") or [])
1400
+ ):
1401
+ state = self._build_candidate_lookup_state(
1402
+ profile,
1403
+ context,
1404
+ app_key=app_key,
1405
+ record_id=None,
1406
+ workflow_node_id=None,
1407
+ fields={},
1408
+ )
1409
+ items = self._resolve_department_candidates_backend(context, field, keyword=keyword, state=state)
1410
+ scope_source = "backend_runtime_scope"
1411
+ warnings.append(
1412
+ {
1413
+ "code": "CANDIDATE_SCOPE_PREVIEW_ONLY",
1414
+ "message": "candidate scope is a static applicant-node preview; pass record_id/workflow_node_id/fields to match runtime write scope",
1415
+ }
1416
+ )
1316
1417
  total = len(items)
1317
1418
  start = (page_num - 1) * page_size
1318
1419
  end = start + page_size
@@ -1323,7 +1424,7 @@ class RecordTools(ToolBase):
1323
1424
  "ws_id": session_profile.selected_ws_id,
1324
1425
  "ok": True,
1325
1426
  "request_route": self._request_route_payload(context),
1326
- "warnings": [],
1427
+ "warnings": warnings,
1327
1428
  "output_profile": "normal",
1328
1429
  "data": {
1329
1430
  "items": page_items,
@@ -1338,9 +1439,13 @@ class RecordTools(ToolBase):
1338
1439
  "app_key": app_key,
1339
1440
  "field_id": field.que_id,
1340
1441
  "field_title": field.que_title,
1442
+ "record_id": record_id,
1443
+ "workflow_node_id": workflow_node_id,
1444
+ "fields_present": bool(normalized_fields),
1341
1445
  "keyword": keyword,
1342
1446
  "permission_scope": "applicant_node",
1343
1447
  },
1448
+ "scope_source": scope_source,
1344
1449
  },
1345
1450
  }
1346
1451
 
@@ -3183,6 +3288,65 @@ class RecordTools(ToolBase):
3183
3288
  def _lookup_context_is_incomplete(self, state: LookupResolutionState | None) -> bool:
3184
3289
  return bool(state is not None and not state.context_complete)
3185
3290
 
3291
+ def _candidate_lookup_uses_runtime_scope(
3292
+ self,
3293
+ *,
3294
+ record_id: int | None,
3295
+ workflow_node_id: int | None,
3296
+ fields: JSONObject | None,
3297
+ ) -> bool:
3298
+ return bool(
3299
+ (record_id is not None and record_id > 0)
3300
+ or (workflow_node_id is not None and workflow_node_id > 0)
3301
+ or bool(fields)
3302
+ )
3303
+
3304
+ def _build_candidate_lookup_state(
3305
+ self,
3306
+ profile: str,
3307
+ context, # type: ignore[no-untyped-def]
3308
+ *,
3309
+ app_key: str,
3310
+ record_id: int | None,
3311
+ workflow_node_id: int | None,
3312
+ fields: JSONObject,
3313
+ ) -> LookupResolutionState:
3314
+ index = self._get_field_index(profile, context, app_key, force_refresh=False)
3315
+ apply_id = record_id if isinstance(record_id, int) and record_id > 0 else None
3316
+ base_answers: list[JSONObject] = []
3317
+ context_complete = True
3318
+ if apply_id is not None:
3319
+ try:
3320
+ base_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
3321
+ except QingflowApiError:
3322
+ context_complete = False
3323
+ state = LookupResolutionState(
3324
+ operation="update" if apply_id is not None else "insert",
3325
+ app_key=app_key,
3326
+ apply_id=apply_id,
3327
+ workflow_node_id=workflow_node_id if isinstance(workflow_node_id, int) and workflow_node_id > 0 else None,
3328
+ force_refresh_form=False,
3329
+ context_complete=context_complete,
3330
+ field_index=index,
3331
+ base_answers=base_answers,
3332
+ normalized_answers=[],
3333
+ confirmation_requests=[],
3334
+ resolved_fields=[],
3335
+ unresolved_field_ids=set(),
3336
+ unresolved_subtable_cells=set(),
3337
+ )
3338
+ if fields:
3339
+ state.normalized_answers = self._resolve_answers(
3340
+ profile,
3341
+ context,
3342
+ app_key,
3343
+ answers=[],
3344
+ fields=fields,
3345
+ force_refresh_form=False,
3346
+ resolution_state=state,
3347
+ )
3348
+ return state
3349
+
3186
3350
  def _raise_lookup_context_unavailable(
3187
3351
  self,
3188
3352
  *,
@@ -3474,6 +3638,28 @@ class RecordTools(ToolBase):
3474
3638
  return "contains_unique_match"
3475
3639
  return reason
3476
3640
 
3641
+ def _candidate_scope_fix_hint(
3642
+ self,
3643
+ *,
3644
+ kind: str,
3645
+ state: LookupResolutionState | None,
3646
+ explicit_guidance: str,
3647
+ ) -> str:
3648
+ if state is not None and self._candidate_lookup_uses_runtime_scope(
3649
+ record_id=state.apply_id,
3650
+ workflow_node_id=state.workflow_node_id,
3651
+ fields={item.get("queId"): True for item in state.normalized_answers if isinstance(item, dict)},
3652
+ ):
3653
+ return (
3654
+ f"Use record_{kind}_candidates with the same record_id/workflow_node_id/fields context to inspect the "
3655
+ "backend runtime candidate scope and choose one returned item exactly. "
3656
+ f"{explicit_guidance}"
3657
+ )
3658
+ return (
3659
+ f"Use record_{kind}_candidates to inspect the allowed candidate scope and choose one returned item exactly. "
3660
+ f"{explicit_guidance}"
3661
+ )
3662
+
3477
3663
  def _resolve_member_candidates(self, context, field: FormField, *, keyword: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
3478
3664
  scope_type = field.member_select_scope_type
3479
3665
  scope = field.member_select_scope if isinstance(field.member_select_scope, dict) else {}
@@ -3485,7 +3671,7 @@ class RecordTools(ToolBase):
3485
3671
  filtered = [item for item in candidates if item is not None]
3486
3672
  filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
3487
3673
  return filtered
3488
- if scope_type != 1:
3674
+ if scope_type != 2:
3489
3675
  raise_tool_error(
3490
3676
  QingflowApiError(
3491
3677
  category="not_supported",
@@ -3581,7 +3767,7 @@ class RecordTools(ToolBase):
3581
3767
  if normalized is not None:
3582
3768
  self._merge_department_candidate(merged, normalized)
3583
3769
  else:
3584
- if scope_type != 1:
3770
+ if scope_type != 2:
3585
3771
  raise_tool_error(
3586
3772
  QingflowApiError(
3587
3773
  category="not_supported",
@@ -3599,6 +3785,14 @@ class RecordTools(ToolBase):
3599
3785
  if dept_id is None:
3600
3786
  continue
3601
3787
  dept_name = _normalize_optional_text(item.get("deptName", item.get("value")) if isinstance(item, dict) else None)
3788
+ configured_candidate = _normalize_candidate_department(
3789
+ {"deptId": dept_id, "deptName": dept_name},
3790
+ source_kind="department",
3791
+ source_id=dept_id,
3792
+ source_value=dept_name,
3793
+ )
3794
+ if configured_candidate is not None:
3795
+ self._merge_department_candidate(merged, configured_candidate)
3602
3796
  for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=include_sub):
3603
3797
  normalized = _normalize_candidate_department(
3604
3798
  dept,
@@ -4719,14 +4913,15 @@ class RecordTools(ToolBase):
4719
4913
  view_key: str | None,
4720
4914
  view_name: str | None,
4721
4915
  existing_answers_override: list[JSONObject] | None = None,
4916
+ field_index_override: FieldIndex | None = None,
4722
4917
  ) -> JSONObject:
4723
4918
  schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
4724
- base_index = _build_applicant_top_level_field_index(schema)
4919
+ base_index = field_index_override or _build_applicant_top_level_field_index(schema)
4725
4920
  question_relations = _collect_question_relations(schema)
4726
4921
  runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
4727
4922
  runtime_linked_field_ids.update(_collect_option_linked_field_ids(base_index))
4728
4923
  index = base_index
4729
- if operation == "create":
4924
+ if operation == "create" and field_index_override is None:
4730
4925
  linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
4731
4926
  schema,
4732
4927
  linked_field_ids=runtime_linked_field_ids,
@@ -4800,6 +4995,7 @@ class RecordTools(ToolBase):
4800
4995
  operation=operation,
4801
4996
  app_key=app_key,
4802
4997
  apply_id=apply_id,
4998
+ workflow_node_id=None,
4803
4999
  force_refresh_form=force_refresh_form,
4804
5000
  context_complete=(operation != "update" or existing_answers_loaded),
4805
5001
  field_index=index,
@@ -4824,6 +5020,7 @@ class RecordTools(ToolBase):
4824
5020
  fields=scoped_fields,
4825
5021
  force_refresh_form=force_refresh_form,
4826
5022
  resolution_state=lookup_resolution,
5023
+ field_index_override=index,
4827
5024
  )
4828
5025
  except RecordInputError as error:
4829
5026
  invalid_fields.append(
@@ -6929,13 +7126,14 @@ class RecordTools(ToolBase):
6929
7126
  fields: JSONObject,
6930
7127
  force_refresh_form: bool,
6931
7128
  resolution_state: LookupResolutionState | None = None,
7129
+ field_index_override: FieldIndex | None = None,
6932
7130
  ) -> list[JSONObject]:
6933
7131
  if not app_key:
6934
7132
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
6935
7133
  if not answers and not fields:
6936
7134
  raise_tool_error(QingflowApiError.config_error("either answers or fields is required"))
6937
7135
  if not answers and fields:
6938
- index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
7136
+ index = field_index_override or self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
6939
7137
  normalized: list[JSONObject] = []
6940
7138
  for key, value in fields.items():
6941
7139
  if value is None:
@@ -6948,7 +7146,7 @@ class RecordTools(ToolBase):
6948
7146
  return normalized
6949
7147
  if answers and not _answers_need_resolution(answers) and not fields:
6950
7148
  return answers
6951
- index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
7149
+ index = field_index_override or self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
6952
7150
  normalized: list[JSONObject] = []
6953
7151
  for item in answers:
6954
7152
  answer = self._normalize_answer_item(profile, context, index, item, resolution_state=resolution_state)
@@ -8473,9 +8671,10 @@ class RecordTools(ToolBase):
8473
8671
  raise RecordInputError(
8474
8672
  message=f"member value for field '{field.que_title}' could not be resolved in the current candidate scope",
8475
8673
  error_code="MEMBER_NOT_IN_CANDIDATE_SCOPE",
8476
- fix_hint=(
8477
- "Use record_member_candidates to inspect the allowed candidate scope and choose one returned item exactly. "
8478
- "Explicit member id/userId/email values do not bypass candidate scope."
8674
+ fix_hint=self._candidate_scope_fix_hint(
8675
+ kind="member",
8676
+ state=resolution_state,
8677
+ explicit_guidance="Explicit member id/userId/email values do not bypass candidate scope.",
8479
8678
  ),
8480
8679
  details={
8481
8680
  "field": _field_ref_payload(field),
@@ -8740,9 +8939,10 @@ class RecordTools(ToolBase):
8740
8939
  raise RecordInputError(
8741
8940
  message=f"department value for field '{field.que_title}' could not be resolved in the current candidate scope",
8742
8941
  error_code="DEPARTMENT_NOT_IN_CANDIDATE_SCOPE",
8743
- fix_hint=(
8744
- "Use record_department_candidates to inspect the allowed candidate scope and choose one returned item exactly. "
8745
- "Explicit department ids do not bypass candidate scope."
8942
+ fix_hint=self._candidate_scope_fix_hint(
8943
+ kind="department",
8944
+ state=resolution_state,
8945
+ explicit_guidance="Explicit department ids do not bypass candidate scope.",
8746
8946
  ),
8747
8947
  details={
8748
8948
  "field": _field_ref_payload(field),
@@ -9662,6 +9862,81 @@ def _build_applicant_hidden_linked_top_level_field_index(
9662
9862
  )
9663
9863
 
9664
9864
 
9865
+ def _answer_to_form_field(answer: JSONObject) -> FormField | None:
9866
+ que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
9867
+ title = _normalize_optional_text(answer.get("queTitle", answer.get("que_title")))
9868
+ que_type = _coerce_count(answer.get("queType", answer.get("que_type")))
9869
+ if que_id is None or que_id < 0 or not title or que_type in LAYOUT_ONLY_QUE_TYPES:
9870
+ return None
9871
+ raw: JSONObject = {
9872
+ "queId": que_id,
9873
+ "queTitle": title,
9874
+ }
9875
+ if que_type is not None:
9876
+ raw["queType"] = que_type
9877
+ field = FormField(
9878
+ que_id=que_id,
9879
+ que_title=title,
9880
+ que_type=que_type,
9881
+ required=False,
9882
+ readonly=False,
9883
+ system=False,
9884
+ options=[],
9885
+ aliases=[],
9886
+ target_app_key=None,
9887
+ target_app_name_hint=None,
9888
+ member_select_scope_type=None,
9889
+ member_select_scope=None,
9890
+ dept_select_scope_type=None,
9891
+ dept_select_scope=None,
9892
+ raw=raw,
9893
+ )
9894
+ field.aliases = sorted(_field_alias_candidates(field))
9895
+ return field
9896
+
9897
+
9898
+ def _build_answer_backed_field_index(
9899
+ answers: list[JSONObject] | JSONValue,
9900
+ *,
9901
+ field_id_filter: set[str] | None = None,
9902
+ ) -> FieldIndex:
9903
+ by_id: dict[str, FormField] = {}
9904
+ by_title: dict[str, list[FormField]] = {}
9905
+ by_alias: dict[str, list[FormField]] = {}
9906
+ if not isinstance(answers, list):
9907
+ return FieldIndex(
9908
+ by_id=by_id,
9909
+ by_title=by_title,
9910
+ by_alias=by_alias,
9911
+ subtable_leaf_by_id={},
9912
+ subtable_leaf_by_title={},
9913
+ subtable_leaf_by_alias={},
9914
+ )
9915
+ for item in answers:
9916
+ if not isinstance(item, dict):
9917
+ continue
9918
+ field = _answer_to_form_field(item)
9919
+ if field is None:
9920
+ continue
9921
+ field_id = str(field.que_id)
9922
+ if field_id_filter is not None and field_id not in field_id_filter:
9923
+ continue
9924
+ if field_id in by_id:
9925
+ continue
9926
+ by_id[field_id] = field
9927
+ by_title.setdefault(_normalize_field_lookup_key(field.que_title), []).append(field)
9928
+ for alias in field.aliases:
9929
+ by_alias.setdefault(_normalize_field_lookup_key(alias), []).append(field)
9930
+ return FieldIndex(
9931
+ by_id=by_id,
9932
+ by_title=by_title,
9933
+ by_alias=by_alias,
9934
+ subtable_leaf_by_id={},
9935
+ subtable_leaf_by_title={},
9936
+ subtable_leaf_by_alias={},
9937
+ )
9938
+
9939
+
9665
9940
  def _merge_field_indexes(primary: FieldIndex, extra: FieldIndex) -> FieldIndex:
9666
9941
  by_id = dict(primary.by_id)
9667
9942
  by_title = {key: list(value) for key, value in primary.by_title.items()}
@@ -12701,8 +12976,6 @@ def _scope_has_dynamic_or_external(scope: JSONObject) -> bool:
12701
12976
 
12702
12977
 
12703
12978
  def _scope_is_default_all(scope_type: int | None, scope: JSONObject, *, keys: tuple[str, ...]) -> bool:
12704
- if scope_type == 2:
12705
- return True
12706
12979
  if scope_type != 1:
12707
12980
  return False
12708
12981
  if _scope_has_dynamic_or_external(scope):