@josephyan/qingflow-cli 0.2.0-beta.73 → 0.2.0-beta.75

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.
@@ -57,6 +57,7 @@ from .models import (
57
57
  PortalListResponse,
58
58
  PortalReadSummaryResponse,
59
59
  PortalSectionPatch,
60
+ PublicDepartmentScopeMode,
60
61
  PublicFieldType,
61
62
  PublicRelationMode,
62
63
  PublicChartType,
@@ -379,6 +380,61 @@ class AiBuilderFacade:
379
380
  "tag_icon": tag_icon,
380
381
  }
381
382
 
383
+ def solution_install(
384
+ self,
385
+ *,
386
+ profile: str,
387
+ solution_key: str,
388
+ being_copy_data: bool = True,
389
+ solution_source: str = "solutionDetail",
390
+ ) -> JSONObject:
391
+ requested_solution_key = str(solution_key or "").strip()
392
+ requested_solution_source = str(solution_source or "").strip() or "solutionDetail"
393
+ normalized_args = {
394
+ "solution_key": requested_solution_key,
395
+ "being_copy_data": bool(being_copy_data),
396
+ "solution_source": requested_solution_source,
397
+ }
398
+ if not requested_solution_key:
399
+ return _failed(
400
+ "SOLUTION_KEY_REQUIRED",
401
+ "solution_key is required",
402
+ normalized_args=normalized_args,
403
+ suggested_next_call=None,
404
+ )
405
+ try:
406
+ created = self.apps.app_create(
407
+ profile=profile,
408
+ payload={
409
+ "solutionKey": requested_solution_key,
410
+ "beingCopyData": bool(being_copy_data),
411
+ "solutionSource": requested_solution_source,
412
+ },
413
+ )
414
+ except (QingflowApiError, RuntimeError) as error:
415
+ api_error = _coerce_api_error(error)
416
+ return _failed_from_api_error(
417
+ "SOLUTION_INSTALL_FAILED",
418
+ api_error,
419
+ normalized_args=normalized_args,
420
+ details={"solution_key": requested_solution_key},
421
+ suggested_next_call={"tool_name": "solution_install", "arguments": {"profile": profile, **normalized_args}},
422
+ )
423
+ return {
424
+ "status": "success",
425
+ "error_code": None,
426
+ "recoverable": False,
427
+ "message": "installed solution",
428
+ "normalized_args": normalized_args,
429
+ "missing_fields": [],
430
+ "allowed_values": {},
431
+ "details": {},
432
+ "request_id": created.get("request_id"),
433
+ "suggested_next_call": None,
434
+ "noop": False,
435
+ "verification": {},
436
+ }
437
+
382
438
  def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
383
439
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
384
440
  return {
@@ -1544,29 +1600,34 @@ class AiBuilderFacade:
1544
1600
  transport_error=api_error,
1545
1601
  ),
1546
1602
  )
1547
- return finalize(response)
1603
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=True, response=response))
1548
1604
  button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1549
1605
  return finalize(
1550
- {
1551
- "status": "success",
1552
- "error_code": None,
1553
- "recoverable": False,
1554
- "message": "created custom button",
1555
- "normalized_args": normalized_args,
1556
- "missing_fields": [],
1557
- "allowed_values": {},
1558
- "details": {},
1559
- "request_id": None,
1560
- "suggested_next_call": None,
1561
- "noop": False,
1562
- "warnings": [],
1563
- "verification": {"custom_button_verified": True},
1564
- "verified": True,
1565
- "app_key": app_key,
1566
- "button_id": button_id,
1567
- "edit_version_no": edit_version_no,
1568
- "button": button,
1569
- }
1606
+ self._append_publish_result(
1607
+ profile=profile,
1608
+ app_key=app_key,
1609
+ publish=True,
1610
+ response={
1611
+ "status": "success",
1612
+ "error_code": None,
1613
+ "recoverable": False,
1614
+ "message": "created custom button",
1615
+ "normalized_args": normalized_args,
1616
+ "missing_fields": [],
1617
+ "allowed_values": {},
1618
+ "details": {},
1619
+ "request_id": None,
1620
+ "suggested_next_call": None,
1621
+ "noop": False,
1622
+ "warnings": [],
1623
+ "verification": {"custom_button_verified": True},
1624
+ "verified": True,
1625
+ "app_key": app_key,
1626
+ "button_id": button_id,
1627
+ "edit_version_no": edit_version_no,
1628
+ "button": button,
1629
+ },
1630
+ )
1570
1631
  )
1571
1632
 
1572
1633
  def app_custom_button_update(
@@ -1652,29 +1713,34 @@ class AiBuilderFacade:
1652
1713
  transport_error=api_error,
1653
1714
  ),
1654
1715
  )
1655
- return finalize(response)
1716
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=True, response=response))
1656
1717
  button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1657
1718
  return finalize(
1658
- {
1659
- "status": "success",
1660
- "error_code": None,
1661
- "recoverable": False,
1662
- "message": "updated custom button",
1663
- "normalized_args": normalized_args,
1664
- "missing_fields": [],
1665
- "allowed_values": {},
1666
- "details": {},
1667
- "request_id": None,
1668
- "suggested_next_call": None,
1669
- "noop": False,
1670
- "warnings": [],
1671
- "verification": {"custom_button_verified": True},
1672
- "verified": True,
1673
- "app_key": app_key,
1674
- "button_id": button_id,
1675
- "edit_version_no": edit_version_no,
1676
- "button": button,
1677
- }
1719
+ self._append_publish_result(
1720
+ profile=profile,
1721
+ app_key=app_key,
1722
+ publish=True,
1723
+ response={
1724
+ "status": "success",
1725
+ "error_code": None,
1726
+ "recoverable": False,
1727
+ "message": "updated custom button",
1728
+ "normalized_args": normalized_args,
1729
+ "missing_fields": [],
1730
+ "allowed_values": {},
1731
+ "details": {},
1732
+ "request_id": None,
1733
+ "suggested_next_call": None,
1734
+ "noop": False,
1735
+ "warnings": [],
1736
+ "verification": {"custom_button_verified": True},
1737
+ "verified": True,
1738
+ "app_key": app_key,
1739
+ "button_id": button_id,
1740
+ "edit_version_no": edit_version_no,
1741
+ "button": button,
1742
+ },
1743
+ )
1678
1744
  )
1679
1745
 
1680
1746
  def app_custom_button_delete(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
@@ -1693,27 +1759,42 @@ class AiBuilderFacade:
1693
1759
  def finalize(response: JSONObject) -> JSONObject:
1694
1760
  return _apply_permission_outcomes(response, *permission_outcomes)
1695
1761
 
1762
+ edit_version_no, edit_context_error = self._ensure_app_edit_context(
1763
+ profile=profile,
1764
+ app_key=app_key,
1765
+ normalized_args=normalized_args,
1766
+ failure_code="CUSTOM_BUTTON_DELETE_FAILED",
1767
+ )
1768
+ if edit_context_error is not None:
1769
+ return finalize(edit_context_error)
1770
+
1696
1771
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
1697
1772
  return finalize(
1698
- {
1699
- "status": "success",
1700
- "error_code": None,
1701
- "recoverable": False,
1702
- "message": "deleted custom button",
1703
- "normalized_args": normalized_args,
1704
- "missing_fields": [],
1705
- "allowed_values": {},
1706
- "details": {},
1707
- "request_id": None,
1708
- "suggested_next_call": None,
1709
- "noop": False,
1710
- "warnings": [],
1711
- "verification": {"custom_button_deleted": True},
1712
- "verified": True,
1713
- "app_key": app_key,
1714
- "button_id": button_id,
1715
- "deleted": True,
1716
- }
1773
+ self._append_publish_result(
1774
+ profile=profile,
1775
+ app_key=app_key,
1776
+ publish=True,
1777
+ response={
1778
+ "status": "success",
1779
+ "error_code": None,
1780
+ "recoverable": False,
1781
+ "message": "deleted custom button",
1782
+ "normalized_args": normalized_args,
1783
+ "missing_fields": [],
1784
+ "allowed_values": {},
1785
+ "details": {},
1786
+ "request_id": None,
1787
+ "suggested_next_call": None,
1788
+ "noop": False,
1789
+ "warnings": [],
1790
+ "verification": {"custom_button_deleted": True},
1791
+ "verified": True,
1792
+ "app_key": app_key,
1793
+ "button_id": button_id,
1794
+ "edit_version_no": edit_version_no,
1795
+ "deleted": True,
1796
+ },
1797
+ )
1717
1798
  )
1718
1799
 
1719
1800
  def _resolve_app_matches_in_visible_apps(
@@ -2088,6 +2169,280 @@ class AiBuilderFacade:
2088
2169
  result["message"] = "read app field config"
2089
2170
  return result
2090
2171
 
2172
+ def app_repair_code_blocks(
2173
+ self,
2174
+ *,
2175
+ profile: str,
2176
+ app_key: str,
2177
+ field: str | None = None,
2178
+ apply: bool = False,
2179
+ ) -> JSONObject:
2180
+ try:
2181
+ state = self._load_base_schema_state(profile=profile, app_key=app_key)
2182
+ except (QingflowApiError, RuntimeError) as error:
2183
+ api_error = _coerce_api_error(error)
2184
+ return _failed_from_api_error(
2185
+ "FIELDS_READ_FAILED",
2186
+ api_error,
2187
+ normalized_args={"app_key": app_key, "field": field, "apply": apply},
2188
+ details={"app_key": app_key},
2189
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
2190
+ )
2191
+ parsed_fields = cast(list[dict[str, Any]], state["parsed"].get("fields") or [])
2192
+ field_lookup = _build_public_field_lookup(parsed_fields)
2193
+ selected_fields: list[dict[str, Any]]
2194
+ requested_selector = str(field or "").strip()
2195
+ if requested_selector:
2196
+ try:
2197
+ selected_field = _resolve_public_field(requested_selector, field_lookup=field_lookup)
2198
+ except ValueError:
2199
+ return _failed(
2200
+ "FIELD_NOT_FOUND",
2201
+ "field selector did not match any existing field",
2202
+ normalized_args={"app_key": app_key, "field": requested_selector, "apply": apply},
2203
+ details={"app_key": app_key, "field": requested_selector},
2204
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
2205
+ )
2206
+ if str(selected_field.get("type") or "") != FieldType.code_block.value:
2207
+ return _failed(
2208
+ "CODE_BLOCK_FIELD_REQUIRED",
2209
+ "selected field is not a code_block field",
2210
+ normalized_args={"app_key": app_key, "field": requested_selector, "apply": apply},
2211
+ details={"app_key": app_key, "field": requested_selector, "field_type": selected_field.get("type")},
2212
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
2213
+ )
2214
+ selected_fields = [selected_field]
2215
+ else:
2216
+ selected_fields = [field_item for field_item in parsed_fields if str(field_item.get("type") or "") == FieldType.code_block.value]
2217
+ plans: list[dict[str, Any]] = []
2218
+ update_fields: list[FieldUpdatePatch] = []
2219
+ applied_fields: list[str] = []
2220
+ for code_block_field in selected_fields:
2221
+ field_name = _code_block_field_display_name(code_block_field)
2222
+ normalized_binding = _normalize_code_block_binding(code_block_field.get("code_block_binding"))
2223
+ normalized_config = _normalize_code_block_config(code_block_field.get("code_block_config") or code_block_field.get("config") or {}) or None
2224
+ raw_binding = code_block_field.get("code_block_binding") if isinstance(code_block_field.get("code_block_binding"), dict) else None
2225
+ preserved_output_targets = [
2226
+ str((output.get("target_field") or {}).get("name") or "").strip()
2227
+ for output in cast(list[dict[str, Any]], (normalized_binding or {}).get("outputs") or [])
2228
+ if isinstance(output.get("target_field"), dict) and str((output.get("target_field") or {}).get("name") or "").strip()
2229
+ ]
2230
+ detected_issues: list[str] = []
2231
+ warnings: list[dict[str, Any]] = []
2232
+ normalized_code_preview: str | None = None
2233
+ normalized_binding_code = None
2234
+ normalized_config_code = None
2235
+ if normalized_binding is not None:
2236
+ raw_code = str(normalized_binding.get("code") or "")
2237
+ normalized_binding_code = _normalize_code_block_output_assignment(raw_code)
2238
+ if normalized_binding_code != raw_code:
2239
+ detected_issues.append("CONST_OR_LET_QF_OUTPUT_ASSIGNMENT")
2240
+ normalized_code_preview = normalized_binding_code
2241
+ if normalized_config is not None:
2242
+ raw_code_content = str(normalized_config.get("code_content") or "")
2243
+ normalized_config_code = _normalize_code_block_output_assignment(raw_code_content)
2244
+ if normalized_code_preview is None and normalized_config_code != raw_code_content:
2245
+ normalized_code_preview = normalized_config_code
2246
+ try:
2247
+ _validate_code_block_alias_config(
2248
+ field_name=field_name,
2249
+ raw_binding=raw_binding,
2250
+ normalized_config=normalized_config,
2251
+ )
2252
+ except _CodeBlockValidationError as error:
2253
+ detected_issues.append(error.error_code)
2254
+ warnings.append(_warning(error.error_code, error.message))
2255
+ has_outputs = bool((normalized_binding or {}).get("outputs")) or bool((normalized_config or {}).get("result_alias_path"))
2256
+ effective_code = ""
2257
+ if normalized_binding is not None:
2258
+ effective_code = str(normalized_binding_code if normalized_binding_code is not None else normalized_binding.get("code") or "")
2259
+ elif normalized_config is not None:
2260
+ code_content = str(normalized_config_code if normalized_config_code is not None else normalized_config.get("code_content") or "")
2261
+ effective_code = _strip_code_block_generated_input_prelude(code_content)
2262
+ if has_outputs and not _code_block_has_effective_output_assignment(effective_code):
2263
+ detected_issues.append("CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING")
2264
+ warnings.append(
2265
+ _warning(
2266
+ "CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING",
2267
+ "configured outputs require qf_output assignment before runtime writeback can succeed",
2268
+ )
2269
+ )
2270
+ target_relation_default_verified = True
2271
+ binding_has_target_bindings = False
2272
+ for output in cast(list[dict[str, Any]], (normalized_binding or {}).get("outputs") or []):
2273
+ target_payload = output.get("target_field")
2274
+ if not isinstance(target_payload, dict):
2275
+ continue
2276
+ if any(target_payload.values()):
2277
+ binding_has_target_bindings = True
2278
+ try:
2279
+ target_field = _resolve_field_selector_with_uniqueness(
2280
+ fields=parsed_fields,
2281
+ selector_payload=target_payload,
2282
+ location="repair.target_field",
2283
+ )
2284
+ except ValueError:
2285
+ target_relation_default_verified = False
2286
+ detected_issues.append("CODE_BLOCK_TARGET_DEFAULT_INVALID")
2287
+ warnings.append(_warning("CODE_BLOCK_TARGET_DEFAULT_INVALID", "bound output target could not be resolved during repair scan"))
2288
+ break
2289
+ if _coerce_any_int(target_field.get("default_type")) == DEFAULT_TYPE_RELATION:
2290
+ continue
2291
+ target_relation_default_verified = False
2292
+ detected_issues.append("CODE_BLOCK_TARGET_DEFAULT_INVALID")
2293
+ warnings.append(
2294
+ _warning(
2295
+ "CODE_BLOCK_TARGET_DEFAULT_INVALID",
2296
+ "bound output target is not stored as relation default and should be rebuilt through repair",
2297
+ target_field_name=_code_block_field_display_name(target_field),
2298
+ )
2299
+ )
2300
+ alias_issue_detected = "CODE_BLOCK_ALIAS_REQUIRED" in detected_issues
2301
+ assignment_missing_detected = "CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING" in detected_issues
2302
+ can_auto_repair = not alias_issue_detected and not assignment_missing_detected
2303
+ repair_mode = "none"
2304
+ patch_payload: dict[str, Any] | None = None
2305
+ safe_auto_fix = False
2306
+ if can_auto_repair and normalized_binding is not None and binding_has_target_bindings and (
2307
+ (normalized_binding_code is not None and normalized_binding_code != str(normalized_binding.get("code") or ""))
2308
+ or not target_relation_default_verified
2309
+ ):
2310
+ patch_payload = {
2311
+ "selector": _field_selector_payload_for_field(code_block_field),
2312
+ "set": {
2313
+ "code_block_binding": _public_code_block_binding_payload(
2314
+ {**normalized_binding, "code": normalized_binding_code if normalized_binding_code is not None else str(normalized_binding.get("code") or "")}
2315
+ )
2316
+ },
2317
+ }
2318
+ repair_mode = "binding"
2319
+ safe_auto_fix = True
2320
+ elif (
2321
+ can_auto_repair
2322
+ and normalized_config is not None
2323
+ and normalized_config_code is not None
2324
+ and normalized_config_code != str(normalized_config.get("code_content") or "")
2325
+ ):
2326
+ patch_payload = {
2327
+ "selector": _field_selector_payload_for_field(code_block_field),
2328
+ "set": {"code_block_config": {**normalized_config, "code_content": normalized_config_code}},
2329
+ }
2330
+ repair_mode = "config"
2331
+ safe_auto_fix = True
2332
+ if not detected_issues:
2333
+ detected_issues.append("NO_REPAIR_NEEDED")
2334
+ plan = {
2335
+ "field_name": field_name,
2336
+ "field_id": str(code_block_field.get("field_id") or "").strip() or None,
2337
+ "que_id": _coerce_positive_int(code_block_field.get("que_id")),
2338
+ "detected_issues": sorted(set(detected_issues)),
2339
+ "normalized_code_preview": normalized_code_preview,
2340
+ "would_update": bool(patch_payload and safe_auto_fix),
2341
+ "applied": False,
2342
+ "repair_mode": repair_mode,
2343
+ "preserved_output_targets": preserved_output_targets,
2344
+ "warnings": warnings,
2345
+ }
2346
+ if patch_payload and safe_auto_fix:
2347
+ update_fields.append(FieldUpdatePatch.model_validate(patch_payload))
2348
+ plans.append(plan)
2349
+ if not apply:
2350
+ return {
2351
+ "status": "success",
2352
+ "error_code": None,
2353
+ "recoverable": False,
2354
+ "message": "planned code block repair",
2355
+ "normalized_args": {"app_key": app_key, "field": requested_selector or None, "apply": False},
2356
+ "missing_fields": [],
2357
+ "allowed_values": {},
2358
+ "details": {},
2359
+ "request_id": None,
2360
+ "suggested_next_call": None,
2361
+ "noop": not bool(update_fields),
2362
+ "warnings": [],
2363
+ "verification": {
2364
+ "app_exists": True,
2365
+ "code_block_fields_scanned": len(plans),
2366
+ "would_update": bool(update_fields),
2367
+ },
2368
+ "verified": True,
2369
+ "app_key": app_key,
2370
+ "apply": False,
2371
+ "repair_plan": plans,
2372
+ "would_update_fields": [plan["field_name"] for plan in plans if plan["would_update"]],
2373
+ }
2374
+ if not update_fields:
2375
+ return {
2376
+ "status": "success",
2377
+ "error_code": None,
2378
+ "recoverable": False,
2379
+ "message": "no safe code block repairs were required",
2380
+ "normalized_args": {"app_key": app_key, "field": requested_selector or None, "apply": True},
2381
+ "missing_fields": [],
2382
+ "allowed_values": {},
2383
+ "details": {},
2384
+ "request_id": None,
2385
+ "suggested_next_call": None,
2386
+ "noop": True,
2387
+ "warnings": [],
2388
+ "verification": {
2389
+ "app_exists": True,
2390
+ "code_block_fields_scanned": len(plans),
2391
+ "would_update": False,
2392
+ "applied": False,
2393
+ },
2394
+ "verified": True,
2395
+ "app_key": app_key,
2396
+ "apply": True,
2397
+ "repair_plan": plans,
2398
+ "applied_fields": [],
2399
+ }
2400
+ apply_result = self.app_schema_apply(
2401
+ profile=profile,
2402
+ app_key=app_key,
2403
+ package_tag_id=None,
2404
+ app_name="",
2405
+ publish=True,
2406
+ add_fields=[],
2407
+ update_fields=update_fields,
2408
+ remove_fields=[],
2409
+ )
2410
+ if apply_result.get("status") == "failed":
2411
+ return apply_result
2412
+ try:
2413
+ reread = self._load_base_schema_state(profile=profile, app_key=app_key)
2414
+ verified_fields = cast(list[dict[str, Any]], reread["parsed"].get("fields") or [])
2415
+ verified_lookup = _build_public_field_lookup(verified_fields)
2416
+ for plan in plans:
2417
+ selector = plan["field_id"] or plan["field_name"] or plan["que_id"]
2418
+ if selector is None:
2419
+ continue
2420
+ try:
2421
+ verified_field = _resolve_public_field(selector, field_lookup=verified_lookup)
2422
+ except ValueError:
2423
+ continue
2424
+ verified_binding = _normalize_code_block_binding(verified_field.get("code_block_binding"))
2425
+ verified_config = _normalize_code_block_config(verified_field.get("code_block_config") or verified_field.get("config") or {}) or {}
2426
+ verified_code = str((verified_binding or {}).get("code") or "")
2427
+ verified_content = str(verified_config.get("code_content") or "")
2428
+ if "const qf_output =" not in verified_code and "let qf_output =" not in verified_code and "const qf_output =" not in verified_content and "let qf_output =" not in verified_content:
2429
+ if plan["would_update"]:
2430
+ plan["applied"] = True
2431
+ applied_fields.append(plan["field_name"])
2432
+ except (QingflowApiError, RuntimeError):
2433
+ pass
2434
+ apply_result["message"] = "repaired code block fields"
2435
+ apply_result["apply"] = True
2436
+ apply_result["repair_plan"] = plans
2437
+ apply_result["applied_fields"] = applied_fields
2438
+ apply_result["verification"] = {
2439
+ **(apply_result.get("verification") if isinstance(apply_result.get("verification"), dict) else {}),
2440
+ "code_block_fields_scanned": len(plans),
2441
+ "would_update": bool(update_fields),
2442
+ "applied": bool(applied_fields),
2443
+ }
2444
+ return apply_result
2445
+
2091
2446
  def app_get_layout(self, *, profile: str, app_key: str) -> JSONObject:
2092
2447
  result = self.app_read_layout_summary(profile=profile, app_key=app_key)
2093
2448
  if result.get("status") == "success":
@@ -3264,6 +3619,7 @@ class AiBuilderFacade:
3264
3619
  existing_field = next((field for field in current_fields if str(field.get("name") or "") == patch.name), None)
3265
3620
  if existing_field is not None:
3266
3621
  if _field_matches_patch(existing_field, patch):
3622
+ _merge_existing_field_with_patch(existing_field, patch)
3267
3623
  continue
3268
3624
  return _failed(
3269
3625
  "DUPLICATE_FIELD",
@@ -3338,18 +3694,33 @@ class AiBuilderFacade:
3338
3694
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3339
3695
  )
3340
3696
 
3697
+ normalized_code_block_fields: list[str] = []
3341
3698
  try:
3699
+ current_fields, normalized_code_block_fields = _normalize_and_validate_code_block_fields_for_write(fields=current_fields)
3342
3700
  current_fields, compiled_question_relations = _compile_code_block_binding_fields(
3343
3701
  fields=current_fields,
3344
3702
  current_schema=schema_result,
3345
3703
  )
3704
+ _ensure_code_block_targets_compiled_as_relation_defaults(fields=current_fields)
3705
+ except _CodeBlockValidationError as error:
3706
+ return _failed(
3707
+ error.error_code,
3708
+ error.message,
3709
+ normalized_args=normalized_args,
3710
+ details={"app_key": target.app_key, **error.details},
3711
+ suggested_next_call=_code_block_repair_suggested_next_call(
3712
+ profile=profile,
3713
+ app_key=target.app_key,
3714
+ field_name=str(error.details.get("field_name") or "").strip() or None,
3715
+ ),
3716
+ )
3346
3717
  except ValueError as error:
3347
3718
  return _failed(
3348
3719
  "CODE_BLOCK_BINDING_INVALID",
3349
3720
  str(error),
3350
3721
  normalized_args=normalized_args,
3351
3722
  details={"app_key": target.app_key},
3352
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3723
+ suggested_next_call=_code_block_repair_suggested_next_call(profile=profile, app_key=target.app_key),
3353
3724
  )
3354
3725
 
3355
3726
  q_linker_schema_context = deepcopy(schema_result)
@@ -3375,8 +3746,19 @@ class AiBuilderFacade:
3375
3746
  if not relation_limit_verified
3376
3747
  else []
3377
3748
  )
3749
+ code_block_normalization_warnings = (
3750
+ [
3751
+ _warning(
3752
+ "CODE_BLOCK_OUTPUT_ASSIGNMENT_NORMALIZED",
3753
+ "normalized code block qf_output assignment before schema write",
3754
+ field_names=normalized_code_block_fields,
3755
+ )
3756
+ ]
3757
+ if normalized_code_block_fields
3758
+ else []
3759
+ )
3378
3760
 
3379
- if not added and not updated and not removed and not bool(resolved.get("created")):
3761
+ if not added and not updated and not removed and not normalized_code_block_fields and not bool(resolved.get("created")):
3380
3762
  tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
3381
3763
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
3382
3764
  response = {
@@ -3391,7 +3773,7 @@ class AiBuilderFacade:
3391
3773
  "request_id": None,
3392
3774
  "suggested_next_call": None if package_attached is not False else {"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": package_tag_id, "app_key": target.app_key}},
3393
3775
  "noop": not bool(visual_result.get("updated")),
3394
- "warnings": relation_warnings,
3776
+ "warnings": relation_warnings + code_block_normalization_warnings,
3395
3777
  "verification": {
3396
3778
  "fields_verified": True,
3397
3779
  "relation_field_limit_verified": relation_limit_verified,
@@ -3407,6 +3789,9 @@ class AiBuilderFacade:
3407
3789
  "package_attached": package_attached,
3408
3790
  }
3409
3791
  response["details"]["relation_field_count"] = relation_field_count
3792
+ if normalized_code_block_fields:
3793
+ response["normalized_code_block_output_assignment"] = True
3794
+ response["normalized_code_block_fields"] = normalized_code_block_fields
3410
3795
  response = _apply_permission_outcomes(response, relation_permission_outcome)
3411
3796
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
3412
3797
 
@@ -3473,16 +3858,30 @@ class AiBuilderFacade:
3473
3858
  rebound_layout = cast(dict[str, Any], rebound_parsed.get("layout") or {"root_rows": [], "sections": []})
3474
3859
  _overlay_code_block_binding_fields(target_fields=rebound_fields, source_fields=current_fields)
3475
3860
  _overlay_q_linker_binding_fields(target_fields=rebound_fields, source_fields=current_fields)
3861
+ rebound_fields, _normalized_rebound_code_blocks = _normalize_and_validate_code_block_fields_for_write(fields=rebound_fields)
3476
3862
  rebound_fields, compiled_question_relations = _compile_code_block_binding_fields(
3477
3863
  fields=rebound_fields,
3478
3864
  current_schema=rebound_schema,
3479
3865
  )
3866
+ _ensure_code_block_targets_compiled_as_relation_defaults(fields=rebound_fields)
3480
3867
  rebound_q_linker_schema = deepcopy(rebound_schema)
3481
3868
  rebound_q_linker_schema["questionRelations"] = deepcopy(compiled_question_relations)
3482
3869
  rebound_fields, compiled_question_relations = _compile_q_linker_binding_fields(
3483
3870
  fields=rebound_fields,
3484
3871
  current_schema=rebound_q_linker_schema,
3485
3872
  )
3873
+ except _CodeBlockValidationError as error:
3874
+ return _failed(
3875
+ error.error_code,
3876
+ error.message,
3877
+ normalized_args=normalized_args,
3878
+ details={"app_key": target.app_key, **error.details},
3879
+ suggested_next_call=_code_block_repair_suggested_next_call(
3880
+ profile=profile,
3881
+ app_key=target.app_key,
3882
+ field_name=str(error.details.get("field_name") or "").strip() or None,
3883
+ ),
3884
+ )
3486
3885
  except ValueError as error:
3487
3886
  return _failed(
3488
3887
  "Q_LINKER_BINDING_INVALID" if "q_linker" in str(error) else "CODE_BLOCK_BINDING_INVALID",
@@ -3544,7 +3943,7 @@ class AiBuilderFacade:
3544
3943
  "request_id": None,
3545
3944
  "suggested_next_call": None,
3546
3945
  "noop": False,
3547
- "warnings": relation_warnings,
3946
+ "warnings": relation_warnings + code_block_normalization_warnings,
3548
3947
  "verification": {
3549
3948
  "fields_verified": False,
3550
3949
  "package_attached": None,
@@ -3565,6 +3964,9 @@ class AiBuilderFacade:
3565
3964
  "package_attached": None,
3566
3965
  }
3567
3966
  response["details"]["relation_field_count"] = relation_field_count
3967
+ if normalized_code_block_fields:
3968
+ response["normalized_code_block_output_assignment"] = True
3969
+ response["normalized_code_block_fields"] = normalized_code_block_fields
3568
3970
  if schema_readback_delayed:
3569
3971
  response["verification"]["schema_readback_delayed"] = True
3570
3972
  response = _apply_permission_outcomes(response, relation_permission_outcome)
@@ -7770,6 +8172,133 @@ def _normalize_relation_mode(value: Any) -> str:
7770
8172
  return _relation_mode_from_optional_data_num(value)
7771
8173
 
7772
8174
 
8175
+ def _normalize_department_scope_mode(value: Any) -> str | None:
8176
+ if value is None:
8177
+ return None
8178
+ if isinstance(value, int):
8179
+ if value == 1:
8180
+ return PublicDepartmentScopeMode.all.value
8181
+ if value == 2:
8182
+ return PublicDepartmentScopeMode.custom.value
8183
+ return None
8184
+ normalized = str(value).strip().lower()
8185
+ if normalized in {"all", "workspace_all", "workspace-all", "default", "default_all", "default-all", "1"}:
8186
+ return PublicDepartmentScopeMode.all.value
8187
+ if normalized in {"custom", "explicit", "selected", "2"}:
8188
+ return PublicDepartmentScopeMode.custom.value
8189
+ return normalized or None
8190
+
8191
+
8192
+ def _normalize_department_scope(value: Any) -> dict[str, Any] | None:
8193
+ if not isinstance(value, dict):
8194
+ return None
8195
+ raw_departments = value.get("departments", value.get("depart", value.get("departs")))
8196
+ departments: list[dict[str, Any]] = []
8197
+ if isinstance(raw_departments, list):
8198
+ for item in raw_departments:
8199
+ if isinstance(item, dict):
8200
+ dept_id = _coerce_positive_int(item.get("dept_id", item.get("deptId", item.get("id"))))
8201
+ dept_name = str(
8202
+ item.get("dept_name", item.get("deptName", item.get("name", item.get("value")))) or ""
8203
+ ).strip() or None
8204
+ else:
8205
+ dept_id = _coerce_positive_int(item)
8206
+ dept_name = None
8207
+ if dept_id is None and dept_name is None:
8208
+ continue
8209
+ entry: dict[str, Any] = {}
8210
+ if dept_id is not None:
8211
+ entry["dept_id"] = dept_id
8212
+ if dept_name is not None:
8213
+ entry["dept_name"] = dept_name
8214
+ departments.append(entry)
8215
+ normalized_mode = _normalize_department_scope_mode(value.get("mode"))
8216
+ if normalized_mode is None:
8217
+ normalized_mode = PublicDepartmentScopeMode.custom.value if departments else PublicDepartmentScopeMode.all.value
8218
+ return {
8219
+ "mode": normalized_mode,
8220
+ "departments": departments,
8221
+ "include_sub_departs": (
8222
+ None
8223
+ if value.get("include_sub_departs", value.get("includeSubDeparts")) is None
8224
+ else bool(value.get("include_sub_departs", value.get("includeSubDeparts")))
8225
+ ),
8226
+ }
8227
+
8228
+
8229
+ def _normalize_department_scope_from_question(question: dict[str, Any]) -> dict[str, Any] | None:
8230
+ scope_type = _coerce_positive_int(question.get("deptSelectScopeType"))
8231
+ scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
8232
+ if not isinstance(scope, dict):
8233
+ scope = {}
8234
+ if isinstance(scope.get("dynamic"), list) and scope.get("dynamic"):
8235
+ return None
8236
+ if isinstance(scope.get("externalDepartList"), list) and scope.get("externalDepartList"):
8237
+ return None
8238
+ if scope_type == 1:
8239
+ return {
8240
+ "mode": PublicDepartmentScopeMode.all.value,
8241
+ "departments": [],
8242
+ "include_sub_departs": None
8243
+ if scope.get("includeSubDeparts") is None
8244
+ else bool(scope.get("includeSubDeparts")),
8245
+ }
8246
+ if scope_type == 2:
8247
+ departments: list[dict[str, Any]] = []
8248
+ for item in cast(list[Any], scope.get("depart") or []):
8249
+ if not isinstance(item, dict):
8250
+ continue
8251
+ dept_id = _coerce_positive_int(item.get("deptId", item.get("id")))
8252
+ dept_name = str(item.get("deptName", item.get("name", item.get("value"))) or "").strip() or None
8253
+ if dept_id is None and dept_name is None:
8254
+ continue
8255
+ entry: dict[str, Any] = {}
8256
+ if dept_id is not None:
8257
+ entry["dept_id"] = dept_id
8258
+ if dept_name is not None:
8259
+ entry["dept_name"] = dept_name
8260
+ departments.append(entry)
8261
+ return {
8262
+ "mode": PublicDepartmentScopeMode.custom.value,
8263
+ "departments": departments,
8264
+ "include_sub_departs": None
8265
+ if scope.get("includeSubDeparts") is None
8266
+ else bool(scope.get("includeSubDeparts")),
8267
+ }
8268
+ return None
8269
+
8270
+
8271
+ def _serialize_department_scope_for_question(value: Any) -> tuple[int, dict[str, Any]]:
8272
+ normalized = _normalize_department_scope(value)
8273
+ if normalized is None or normalized.get("mode") == PublicDepartmentScopeMode.all.value:
8274
+ scope: dict[str, Any] = {"depart": [], "dynamic": []}
8275
+ if normalized is not None and normalized.get("include_sub_departs") is not None:
8276
+ scope["includeSubDeparts"] = bool(normalized.get("include_sub_departs"))
8277
+ return 1, scope
8278
+ departments = []
8279
+ for item in cast(list[Any], normalized.get("departments") or []):
8280
+ if not isinstance(item, dict):
8281
+ continue
8282
+ dept_id = _coerce_positive_int(item.get("dept_id"))
8283
+ dept_name = str(item.get("dept_name") or "").strip() or None
8284
+ if dept_id is None and dept_name is None:
8285
+ continue
8286
+ entry: dict[str, Any] = {}
8287
+ if dept_id is not None:
8288
+ entry["deptId"] = dept_id
8289
+ if dept_name is not None:
8290
+ entry["deptName"] = dept_name
8291
+ departments.append(entry)
8292
+ scope = {"depart": departments, "dynamic": []}
8293
+ if normalized.get("include_sub_departs") is not None:
8294
+ scope["includeSubDeparts"] = bool(normalized.get("include_sub_departs"))
8295
+ return 2, scope
8296
+
8297
+
8298
+ def _department_scope_equal(left: Any, right: Any) -> bool:
8299
+ return _normalize_department_scope(left) == _normalize_department_scope(right)
8300
+
8301
+
7773
8302
  def _is_relation_target_metadata_read_restricted_api_error(error: QingflowApiError) -> bool:
7774
8303
  return error.backend_code in {40002, 40027, 40161}
7775
8304
 
@@ -7936,8 +8465,10 @@ def _hydrate_relation_field_configs(
7936
8465
  field["config"] = config
7937
8466
  display_selector = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
7938
8467
  visible_selector_payloads = [item for item in cast(list[Any], field.get("visible_fields") or []) if isinstance(item, dict)]
7939
- if display_selector is None and not visible_selector_payloads:
7940
- continue
8468
+ if display_selector is None:
8469
+ raise ValueError("relation field requires display_field")
8470
+ if not visible_selector_payloads:
8471
+ raise ValueError("relation field requires visible_fields")
7941
8472
  target_fields = target_field_cache.get(target_app_key)
7942
8473
  if target_fields is None:
7943
8474
  try:
@@ -7974,7 +8505,31 @@ def _hydrate_relation_field_configs(
7974
8505
  )
7975
8506
  continue
7976
8507
  if not target_fields:
7977
- raise ValueError(f"target relation app '{target_app_key}' has no readable fields")
8508
+ display_field = _normalize_relation_target_stub(
8509
+ display_selector,
8510
+ fallback_name=str((display_selector or {}).get("name") or "").strip() or None,
8511
+ )
8512
+ visible_fields = [
8513
+ _normalize_relation_target_stub(item, fallback_name=str(item.get("name") or "").strip() or None)
8514
+ for item in visible_selector_payloads
8515
+ ]
8516
+ _apply_relation_target_selection(
8517
+ field=field,
8518
+ config=config,
8519
+ display_field=display_field,
8520
+ visible_fields=visible_fields,
8521
+ )
8522
+ degraded_entries.append(
8523
+ {
8524
+ "field_name": field.get("name"),
8525
+ "target_app_key": target_app_key,
8526
+ "display_field": deepcopy(field.get("display_field") or {}),
8527
+ "visible_fields": deepcopy(field.get("visible_fields") or []),
8528
+ "relation_mode": field.get("relation_mode"),
8529
+ "transport_error": None,
8530
+ }
8531
+ )
8532
+ continue
7978
8533
  display_field = _resolve_relation_target_field(
7979
8534
  target_fields=target_fields,
7980
8535
  selector_payload=display_selector,
@@ -8079,6 +8634,10 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
8079
8634
  }
8080
8635
  field["visible_fields"] = visible_fields
8081
8636
  field["field_name_show"] = bool(reference.get("fieldNameShow", True))
8637
+ if field_type == FieldType.department:
8638
+ department_scope = _normalize_department_scope_from_question(question)
8639
+ if department_scope is not None:
8640
+ field["department_scope"] = department_scope
8082
8641
  if field_type == FieldType.code_block:
8083
8642
  code_block_config = question.get("codeBlockConfig") if isinstance(question.get("codeBlockConfig"), dict) else {}
8084
8643
  field["code_block_config"] = {
@@ -8700,6 +9259,8 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any])
8700
9259
  payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
8701
9260
  payload["display_field"] = deepcopy(field.get("display_field"))
8702
9261
  payload["visible_fields"] = deepcopy(field.get("visible_fields") or [])
9262
+ if field.get("type") == FieldType.department.value and field.get("department_scope") is not None:
9263
+ payload["department_scope"] = deepcopy(field.get("department_scope"))
8703
9264
  if field.get("type") == FieldType.code_block.value:
8704
9265
  payload["code_block_config"] = deepcopy(field.get("code_block_config") or {})
8705
9266
  if field.get("auto_trigger") is not None:
@@ -8780,11 +9341,13 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
8780
9341
  "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
8781
9342
  "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
8782
9343
  "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
9344
+ "department_scope": patch.department_scope.model_dump(mode="json", exclude_none=True) if patch.department_scope is not None else None,
8783
9345
  "remote_lookup_config": remote_lookup_config,
8784
9346
  "_explicit_remote_lookup_config": remote_lookup_config is not None,
8785
9347
  "q_linker_binding": q_linker_binding,
8786
9348
  "code_block_config": code_block_config,
8787
9349
  "code_block_binding": code_block_binding,
9350
+ "_explicit_code_block_binding": code_block_binding is not None,
8788
9351
  "config": deepcopy(remote_lookup_config) if remote_lookup_config is not None else (deepcopy(code_block_config) if code_block_config is not None else {}),
8789
9352
  "auto_trigger": patch.auto_trigger,
8790
9353
  "custom_button_text_enabled": patch.custom_button_text_enabled,
@@ -8797,6 +9360,23 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
8797
9360
 
8798
9361
 
8799
9362
  def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
9363
+ field_display_selector = field.get("display_field")
9364
+ patch_display_selector = patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None
9365
+ field_visible_selectors = field.get("visible_fields")
9366
+ patch_visible_selectors = [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields]
9367
+ if (
9368
+ str(field.get("type") or "") == FieldType.relation.value
9369
+ and patch.type == PublicFieldType.relation
9370
+ and (field.get("target_app_key") or None) == patch.target_app_key
9371
+ and field_display_selector is None
9372
+ and not list(field_visible_selectors or [])
9373
+ ):
9374
+ relation_selector_match = True
9375
+ else:
9376
+ relation_selector_match = (
9377
+ _field_selector_payload_equal(field_display_selector, patch_display_selector)
9378
+ and _field_selector_list_equal(field_visible_selectors, patch_visible_selectors)
9379
+ )
8800
9380
  return (
8801
9381
  str(field.get("name") or "") == patch.name
8802
9382
  and str(field.get("type") or "") == patch.type.value
@@ -8804,9 +9384,12 @@ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
8804
9384
  and (field.get("description") or None) == patch.description
8805
9385
  and list(field.get("options") or []) == list(patch.options)
8806
9386
  and (field.get("target_app_key") or None) == patch.target_app_key
8807
- and _field_selector_payload_equal(field.get("display_field"), patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None)
8808
- and _field_selector_list_equal(field.get("visible_fields"), [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields])
9387
+ and relation_selector_match
8809
9388
  and _normalize_relation_mode(field.get("relation_mode")) == _normalize_relation_mode(patch.relation_mode.value if patch.relation_mode is not None else None)
9389
+ and (
9390
+ patch.department_scope is None
9391
+ or _department_scope_equal(field.get("department_scope"), patch.department_scope.model_dump(mode="json", exclude_none=True))
9392
+ )
8810
9393
  and _remote_lookup_config_equal(field.get("remote_lookup_config"), patch.remote_lookup_config.model_dump(mode="json", exclude_none=True) if patch.remote_lookup_config is not None else None)
8811
9394
  and _q_linker_binding_equal(field.get("q_linker_binding"), patch.q_linker_binding.model_dump(mode="json", exclude_none=True) if patch.q_linker_binding is not None else None)
8812
9395
  and _code_block_config_equal(field.get("code_block_config"), patch.code_block_config.model_dump(mode="json", exclude_none=True) if patch.code_block_config is not None else None)
@@ -8818,6 +9401,24 @@ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
8818
9401
  )
8819
9402
 
8820
9403
 
9404
+ def _merge_existing_field_with_patch(field: dict[str, Any], patch: FieldPatch) -> None:
9405
+ if str(field.get("type") or "") == FieldType.relation.value and patch.type == PublicFieldType.relation:
9406
+ if not str(field.get("target_app_key") or "").strip() and patch.target_app_key:
9407
+ field["target_app_key"] = patch.target_app_key
9408
+ if not isinstance(field.get("display_field"), dict) and patch.display_field is not None:
9409
+ field["display_field"] = patch.display_field.model_dump(mode="json", exclude_none=True)
9410
+ if not list(field.get("visible_fields") or []) and patch.visible_fields:
9411
+ field["visible_fields"] = [
9412
+ selector.model_dump(mode="json", exclude_none=True)
9413
+ for selector in patch.visible_fields
9414
+ ]
9415
+ if field.get("relation_mode") is None and patch.relation_mode is not None:
9416
+ field["relation_mode"] = patch.relation_mode.value
9417
+ if str(field.get("type") or "") == FieldType.department.value and patch.type == PublicFieldType.department:
9418
+ if field.get("department_scope") is None and patch.department_scope is not None:
9419
+ field["department_scope"] = patch.department_scope.model_dump(mode="json", exclude_none=True)
9420
+
9421
+
8821
9422
  def _field_selector_payload_equal(left: Any, right: Any) -> bool:
8822
9423
  if left is None and right is None:
8823
9424
  return True
@@ -8998,6 +9599,8 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
8998
9599
  field["visible_fields"] = list(payload["visible_fields"])
8999
9600
  if "relation_mode" in payload:
9000
9601
  field["relation_mode"] = payload["relation_mode"]
9602
+ if "department_scope" in payload:
9603
+ field["department_scope"] = payload["department_scope"]
9001
9604
  if "remote_lookup_config" in payload:
9002
9605
  field["remote_lookup_config"] = payload["remote_lookup_config"]
9003
9606
  field["config"] = deepcopy(payload["remote_lookup_config"])
@@ -9011,6 +9614,7 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
9011
9614
  field["config"] = deepcopy(payload["code_block_config"])
9012
9615
  if "code_block_binding" in payload:
9013
9616
  field["code_block_binding"] = payload["code_block_binding"]
9617
+ field["_explicit_code_block_binding"] = True
9014
9618
  if "auto_trigger" in payload:
9015
9619
  field["auto_trigger"] = payload["auto_trigger"]
9016
9620
  if "custom_button_text_enabled" in payload:
@@ -9087,7 +9691,225 @@ def _strip_code_block_generated_input_prelude(code_content: str) -> str:
9087
9691
 
9088
9692
 
9089
9693
  def _normalize_code_block_output_assignment(code_content: str) -> str:
9090
- return re.sub(r"(?m)^(\s*)(?:const|let)\s+qf_output\s*=", r"\1qf_output =", code_content)
9694
+ return re.sub(r"(?<![A-Za-z0-9_$])(?:const|let)\s+qf_output\s*=", "qf_output =", code_content)
9695
+
9696
+
9697
+ @dataclass
9698
+ class _CodeBlockValidationError(ValueError):
9699
+ error_code: str
9700
+ message: str
9701
+ details: JSONObject = field(default_factory=dict)
9702
+
9703
+ def __str__(self) -> str:
9704
+ return self.message
9705
+
9706
+
9707
+ def _code_block_has_effective_output_assignment(code_content: str) -> bool:
9708
+ return bool(re.search(r"(?<![A-Za-z0-9_$])qf_output\s*=", str(code_content or "")))
9709
+
9710
+
9711
+ def _field_selector_payload_for_field(field: dict[str, Any]) -> dict[str, Any]:
9712
+ payload: dict[str, Any] = {}
9713
+ field_id = str(field.get("field_id") or "").strip()
9714
+ que_id = _coerce_positive_int(field.get("que_id"))
9715
+ name = str(field.get("name") or "").strip()
9716
+ if field_id:
9717
+ payload["field_id"] = field_id
9718
+ elif que_id is not None:
9719
+ payload["que_id"] = que_id
9720
+ elif name:
9721
+ payload["name"] = name
9722
+ return payload
9723
+
9724
+
9725
+ def _field_selector_payload_for_selector(value: Any) -> dict[str, Any]:
9726
+ if isinstance(value, dict):
9727
+ payload = {
9728
+ "field_id": str(value.get("field_id") or "").strip() or None,
9729
+ "que_id": _coerce_positive_int(value.get("que_id")),
9730
+ "name": str(value.get("name") or "").strip() or None,
9731
+ }
9732
+ return {key: item for key, item in payload.items() if item is not None}
9733
+ if isinstance(value, str) and value.strip():
9734
+ return {"name": value.strip()}
9735
+ return {}
9736
+
9737
+
9738
+ def _code_block_field_display_name(field: dict[str, Any]) -> str:
9739
+ name = str(field.get("name") or "").strip()
9740
+ if name:
9741
+ return name
9742
+ field_id = str(field.get("field_id") or "").strip()
9743
+ if field_id:
9744
+ return field_id
9745
+ que_id = _coerce_positive_int(field.get("que_id"))
9746
+ if que_id is not None:
9747
+ return str(que_id)
9748
+ return "未命名代码块"
9749
+
9750
+
9751
+ def _public_code_block_binding_payload(binding: dict[str, Any]) -> dict[str, Any]:
9752
+ return {
9753
+ "inputs": [
9754
+ {
9755
+ "field": _field_selector_payload_for_selector(input_item.get("field")),
9756
+ "var": input_item.get("var"),
9757
+ }
9758
+ for input_item in cast(list[dict[str, Any]], binding.get("inputs") or [])
9759
+ if _field_selector_payload_for_selector(input_item.get("field"))
9760
+ ],
9761
+ "code": str(binding.get("code") or ""),
9762
+ "auto_trigger": binding.get("auto_trigger"),
9763
+ "custom_button_text_enabled": binding.get("custom_button_text_enabled"),
9764
+ "custom_button_text": binding.get("custom_button_text"),
9765
+ "outputs": [
9766
+ {
9767
+ "alias": str(output.get("alias") or "").strip(),
9768
+ "path": str(output.get("path") or "").strip(),
9769
+ "target_field": _field_selector_payload_for_selector(output.get("target_field")),
9770
+ }
9771
+ for output in cast(list[dict[str, Any]], binding.get("outputs") or [])
9772
+ if str(output.get("alias") or "").strip()
9773
+ and str(output.get("path") or "").strip()
9774
+ and _field_selector_payload_for_selector(output.get("target_field"))
9775
+ ],
9776
+ }
9777
+
9778
+
9779
+ def _raw_field_selector_present(value: Any) -> bool:
9780
+ if isinstance(value, str):
9781
+ return bool(value.strip())
9782
+ if not isinstance(value, dict):
9783
+ return False
9784
+ return bool(
9785
+ str(value.get("field_id") or "").strip()
9786
+ or _coerce_positive_int(value.get("que_id"))
9787
+ or str(value.get("name") or value.get("title") or value.get("label") or "").strip()
9788
+ )
9789
+
9790
+
9791
+ def _validate_code_block_alias_config(
9792
+ *,
9793
+ field_name: str,
9794
+ raw_binding: dict[str, Any] | None,
9795
+ normalized_config: dict[str, Any] | None,
9796
+ ) -> None:
9797
+ raw_outputs = raw_binding.get("outputs") if isinstance(raw_binding, dict) and isinstance(raw_binding.get("outputs"), list) else []
9798
+ for index, raw_output in enumerate(cast(list[Any], raw_outputs)):
9799
+ if not isinstance(raw_output, dict):
9800
+ continue
9801
+ raw_target = raw_output.get("target_field", raw_output.get("targetField"))
9802
+ if not _raw_field_selector_present(raw_target):
9803
+ continue
9804
+ alias = str(raw_output.get("alias", raw_output.get("alias_name", raw_output.get("aliasName", ""))) or "").strip()
9805
+ path = str(raw_output.get("path", raw_output.get("alias_path", raw_output.get("aliasPath", ""))) or "").strip()
9806
+ if alias and path:
9807
+ continue
9808
+ raise _CodeBlockValidationError(
9809
+ "CODE_BLOCK_ALIAS_REQUIRED",
9810
+ f"code_block field '{field_name}' requires non-empty alias and path for output target binding",
9811
+ details={"field_name": field_name, "output_index": index},
9812
+ )
9813
+ normalized_aliases = cast(list[dict[str, Any]], (normalized_config or {}).get("result_alias_path") or [])
9814
+ for index, alias_item in enumerate(normalized_aliases):
9815
+ alias_name = str(alias_item.get("alias_name") or "").strip()
9816
+ alias_path = str(alias_item.get("alias_path") or "").strip()
9817
+ if alias_name and alias_path:
9818
+ continue
9819
+ raise _CodeBlockValidationError(
9820
+ "CODE_BLOCK_ALIAS_REQUIRED",
9821
+ f"code_block field '{field_name}' contains an empty output alias configuration",
9822
+ details={"field_name": field_name, "alias_index": index},
9823
+ )
9824
+
9825
+
9826
+ def _normalize_and_validate_code_block_fields_for_write(
9827
+ *,
9828
+ fields: list[dict[str, Any]],
9829
+ ) -> tuple[list[dict[str, Any]], list[str]]:
9830
+ next_fields = deepcopy(fields)
9831
+ normalized_fields: list[str] = []
9832
+ for field in next_fields:
9833
+ if not isinstance(field, dict) or field.get("type") != FieldType.code_block.value:
9834
+ continue
9835
+ field_name = _code_block_field_display_name(field)
9836
+ raw_binding = field.get("code_block_binding") if isinstance(field.get("code_block_binding"), dict) else None
9837
+ binding = _normalize_code_block_binding(raw_binding)
9838
+ current_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or None
9839
+ _validate_code_block_alias_config(field_name=field_name, raw_binding=raw_binding, normalized_config=current_config)
9840
+ normalized = False
9841
+ if binding is not None:
9842
+ raw_code = str(binding.get("code") or "")
9843
+ normalized_code = _normalize_code_block_output_assignment(raw_code)
9844
+ if normalized_code != raw_code:
9845
+ normalized = True
9846
+ binding["code"] = normalized_code
9847
+ field["code_block_binding"] = binding
9848
+ if current_config is not None:
9849
+ raw_code_content = str(current_config.get("code_content") or "")
9850
+ normalized_code_content = _normalize_code_block_output_assignment(raw_code_content)
9851
+ if normalized_code_content != raw_code_content:
9852
+ normalized = True
9853
+ current_config["code_content"] = normalized_code_content
9854
+ field["code_block_config"] = current_config
9855
+ field["config"] = deepcopy(current_config)
9856
+ conflicting_binding_and_config = False
9857
+ if binding is not None and current_config is not None:
9858
+ current_config_code = str(current_config.get("code_content") or "")
9859
+ compiled_body = _strip_code_block_generated_input_prelude(current_config_code)
9860
+ if current_config_code.strip() and compiled_body != str(binding.get("code") or ""):
9861
+ conflicting_binding_and_config = True
9862
+ has_outputs = bool((binding or {}).get("outputs")) or bool((current_config or {}).get("result_alias_path"))
9863
+ effective_code = ""
9864
+ if binding is not None:
9865
+ effective_code = str(binding.get("code") or "")
9866
+ elif current_config is not None:
9867
+ effective_code = _strip_code_block_generated_input_prelude(str(current_config.get("code_content") or ""))
9868
+ if has_outputs and not conflicting_binding_and_config and not _code_block_has_effective_output_assignment(effective_code):
9869
+ raise _CodeBlockValidationError(
9870
+ "CODE_BLOCK_OUTPUT_ASSIGNMENT_MISSING",
9871
+ f"code_block field '{field_name}' must assign to qf_output when outputs are configured",
9872
+ details={"field_name": field_name},
9873
+ )
9874
+ if normalized:
9875
+ normalized_fields.append(field_name)
9876
+ return next_fields, sorted(set(normalized_fields))
9877
+
9878
+
9879
+ def _ensure_code_block_targets_compiled_as_relation_defaults(*, fields: list[dict[str, Any]]) -> None:
9880
+ for field in fields:
9881
+ if not isinstance(field, dict) or field.get("type") != FieldType.code_block.value:
9882
+ continue
9883
+ field_name = _code_block_field_display_name(field)
9884
+ binding = _normalize_code_block_binding(field.get("code_block_binding"))
9885
+ if binding is None:
9886
+ continue
9887
+ for output_index, output in enumerate(cast(list[dict[str, Any]], binding.get("outputs") or [])):
9888
+ target_payload = output.get("target_field")
9889
+ if not isinstance(target_payload, dict):
9890
+ continue
9891
+ target_field = _resolve_field_selector_with_uniqueness(
9892
+ fields=fields,
9893
+ selector_payload=target_payload,
9894
+ location=f"code_block_binding.outputs[{output_index}].target_field",
9895
+ )
9896
+ if _coerce_any_int(target_field.get("default_type")) == DEFAULT_TYPE_RELATION:
9897
+ continue
9898
+ raise _CodeBlockValidationError(
9899
+ "CODE_BLOCK_TARGET_DEFAULT_INVALID",
9900
+ f"code_block field '{field_name}' output target '{_code_block_field_display_name(target_field)}' is not compiled as relation default",
9901
+ details={
9902
+ "field_name": field_name,
9903
+ "target_field_name": _code_block_field_display_name(target_field),
9904
+ },
9905
+ )
9906
+
9907
+
9908
+ def _code_block_repair_suggested_next_call(*, profile: str, app_key: str, field_name: str | None = None) -> JSONObject:
9909
+ arguments: JSONObject = {"profile": profile, "app_key": app_key}
9910
+ if field_name:
9911
+ arguments["field"] = field_name
9912
+ return {"tool_name": "app_repair_code_blocks", "arguments": arguments}
9091
9913
 
9092
9914
 
9093
9915
  def _ensure_field_temp_ids(fields: list[dict[str, Any]]) -> None:
@@ -9122,12 +9944,21 @@ def _compile_code_block_binding_fields(
9122
9944
  binding = _normalize_code_block_binding(field.get("code_block_binding"))
9123
9945
  if binding is None:
9124
9946
  continue
9947
+ explicit_binding = bool(field.get("_explicit_code_block_binding"))
9948
+ binding_outputs = cast(list[dict[str, Any]], binding.get("outputs") or [])
9949
+ binding_has_target_bindings = any(
9950
+ isinstance(output.get("target_field"), dict) and any(cast(dict[str, Any], output.get("target_field") or {}).values())
9951
+ for output in binding_outputs
9952
+ )
9125
9953
  current_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
9126
9954
  "config_mode": 1,
9127
9955
  "code_content": "",
9128
9956
  "being_hide_on_form": False,
9129
9957
  "result_alias_path": [],
9130
9958
  }
9959
+ if binding_outputs and not binding_has_target_bindings and not explicit_binding:
9960
+ field["code_block_binding"] = None
9961
+ continue
9131
9962
  low_level_code = _strip_code_block_generated_input_prelude(str(current_config.get("code_content") or ""))
9132
9963
  if str(current_config.get("code_content") or "").strip() and low_level_code != str(binding.get("code") or ""):
9133
9964
  raise ValueError(f"code_block field '{field.get('name')}' has conflicting code_block_config.code_content and code_block_binding.code")
@@ -10280,6 +11111,10 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
10280
11111
  reference["referQueId"] = field.get("target_field_que_id")
10281
11112
  reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
10282
11113
  question["referenceConfig"] = reference
11114
+ if field.get("type") == FieldType.department.value:
11115
+ scope_type, scope_payload = _serialize_department_scope_for_question(field.get("department_scope"))
11116
+ question["deptSelectScopeType"] = scope_type
11117
+ question["deptSelectScope"] = scope_payload
10283
11118
  if field.get("type") == FieldType.code_block.value:
10284
11119
  code_block_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
10285
11120
  "config_mode": 1,