@josephyan/qingflow-app-user-mcp 0.2.0-beta.27 → 0.2.0-beta.29

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.27
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.29
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.27 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.29 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.27",
3
+ "version": "0.2.0-beta.29",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b27"
7
+ version = "0.2.0b29"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ metadata:
9
9
 
10
10
  This skill is for final statistical conclusions only.
11
11
  Assumes MCP is connected, authenticated, and on the correct workspace.
12
- Analysis tasks must start with `record_schema_get`. Use field_id-based DSLs only.
12
+ Analysis tasks must start with `record_schema_get`. Read top-level `fields` and `suggested_*`, then build field_id-based DSLs only.
13
13
 
14
14
  ## Step 1: `record_schema_get` → Step 2: build DSL → Step 3: `record_analyze`
15
15
 
@@ -27,7 +27,7 @@ Use exactly one of these default paths:
27
27
  - `record_get`
28
28
  - `record_write`
29
29
 
30
- `record_schema_get` only exposes the current user's applicant-node visible fields; if a field is missing, treat it as unavailable in the current permission scope.
30
+ `record_schema_get` only exposes the current user's applicant-node visible fields. Read top-level `fields` and `suggested_*`; if a field is missing, treat it as unavailable in the current permission scope.
31
31
 
32
32
  ## Supporting Tools
33
33
 
@@ -42,6 +42,8 @@ Use exactly one of these default paths:
42
42
  - `file_get_upload_info`
43
43
  - `file_upload_local`
44
44
 
45
+ Use `record_member_candidates` / `record_department_candidates` as the default lookup path for member or department fields. Treat `directory_*` as org-browse or fallback tooling, not the primary field-candidate path.
46
+
45
47
  ## Standard Operating Order
46
48
 
47
49
  1. Ensure auth exists
@@ -58,6 +60,7 @@ Use exactly one of these default paths:
58
60
  ## Record Read Rules
59
61
 
60
62
  - Use `record_list` for browse/export/sample inspection only
63
+ - For `columns`, prefer `[{ "field_id": 12 }]`; bare integer field ids are accepted for compatibility
61
64
  - Use `record_get` when `record_id` is known
62
65
  - `record_get` without explicit `columns` still returns only applicant-node visible fields; do not assume it exposes the full builder-side record
63
66
  - `record_list` and `record_get` may reject hidden-field `field_id`s because record tools now validate against the applicant-node visible schema only
@@ -101,8 +104,16 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
101
104
  - Do not auto-resolve relation targets without first querying them
102
105
  - Do not assume member display names resolve automatically; `record_member_candidates` returns ids, but direct name-to-id write is not automatic
103
106
  - Do not assume department names resolve automatically; `record_department_candidates` returns ids, but direct name-to-id write is not automatic
107
+ - For default-all member or department fields, prefer the field candidate tools; do not start with `directory_*`
104
108
  - Do not assume `record_schema_get` is a builder/full-field schema.
105
109
 
110
+ ### Complex field quick examples
111
+
112
+ - `member`: `✅ {"field_id":5,"value":{"id":7}}` / `❌ {"field_id":5,"value":"张三"}`
113
+ - `department`: `✅ {"field_id":22,"value":{"id":336193,"value":"北斗组"}}` / `❌ {"field_id":22,"value":"北斗组"}`
114
+ - `relation`: `✅ {"field_id":25,"value":{"apply_id":5001}}` / `❌ {"field_id":25,"value":"客户A"}`
115
+ - `attachment`: upload first, then `✅ {"field_id":13,"value":{"value":"https://.../a.pdf","name":"a.pdf"}}` / `❌ {"field_id":13,"value":"/tmp/a.pdf"}`
116
+
106
117
  ## Response Interpretation
107
118
 
108
119
  - `record_list` returns browse/sample data, not final analysis conclusions
@@ -15,7 +15,7 @@ Remember that `record_schema_get` only exposes the current user's applicant-node
15
15
 
16
16
  Keep the browse DSL simple:
17
17
 
18
- - `columns`: field ids only
18
+ - `columns`: prefer `[{ "field_id": 12 }]`; bare integers are accepted for compatibility
19
19
  - `where`: flat AND filters only
20
20
  - `order_by`: field sorting only
21
21
  - `limit` and `page`: browsing intent only
@@ -110,3 +110,21 @@ If the payload includes them, stop after the blocked `record_write` response and
110
110
  - Relation fields are record-id based. Resolve the referenced target first, then write the relation field with the real `record_id`.
111
111
  - Attachment fields are two-step: upload first with `file_upload_local`, then reuse the returned attachment payload in `record_write`.
112
112
  - Subtable writes require the current schema shape; when updating existing subtable rows, preserve row ids if the current record exposes them.
113
+
114
+ ### Quick field examples
115
+
116
+ ```json
117
+ { "field_id": 5, "value": { "id": 7 } }
118
+ ```
119
+
120
+ ```json
121
+ { "field_id": 22, "value": { "id": 336193, "value": "北斗组" } }
122
+ ```
123
+
124
+ ```json
125
+ { "field_id": 25, "value": { "apply_id": 5001 } }
126
+ ```
127
+
128
+ ```json
129
+ { "field_id": 13, "value": { "value": "https://.../a.pdf", "name": "a.pdf" } }
130
+ ```
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b27"
5
+ __version__ = "0.2.0b29"
@@ -53,6 +53,7 @@ Always call `record_schema_get` before `record_list`, `record_get`, `record_writ
53
53
 
54
54
  - Hidden fields are omitted.
55
55
  - Missing fields mean the field is not visible in the current permission scope.
56
+ - Read `fields` and `suggested_*` from the top level of the schema response.
56
57
 
57
58
  ## Analytics Path
58
59
 
@@ -79,6 +80,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
79
80
 
80
81
  `record_schema_get -> record_list / record_get / record_write`
81
82
 
83
+ - For `columns`, prefer `[{{field_id}}]`; bare integer field ids remain supported for compatibility.
84
+
82
85
  `record_write` uses SQL-like JSON clauses:
83
86
 
84
87
  - `insert` -> `values`
@@ -87,6 +90,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
87
90
 
88
91
  - Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
89
92
  - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
93
+ - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
90
94
 
91
95
  ## Task Center Path
92
96
 
@@ -41,6 +41,7 @@ Always call `record_schema_get` before `record_list`, `record_get`, `record_writ
41
41
 
42
42
  - Hidden fields are omitted.
43
43
  - Missing fields mean the field is not visible in the current permission scope.
44
+ - Read `fields` and `suggested_*` from the top level of the schema response.
44
45
 
45
46
  ## Analytics Path
46
47
 
@@ -67,6 +68,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
67
68
 
68
69
  `record_schema_get -> record_list / record_get / record_write`
69
70
 
71
+ - For `columns`, prefer `[{{field_id}}]`; bare integer field ids remain supported for compatibility.
72
+
70
73
  `record_write` uses SQL-like JSON clauses:
71
74
 
72
75
  - `insert` -> `values`
@@ -75,6 +78,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
75
78
 
76
79
  - Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
77
80
  - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
81
+ - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
78
82
 
79
83
  ## Task Center Path
80
84
 
@@ -269,7 +269,7 @@ class RecordTools(ToolBase):
269
269
  def record_list(
270
270
  profile: str = DEFAULT_PROFILE,
271
271
  app_key: str = "",
272
- columns: list[int] | None = None,
272
+ columns: list[JSONObject | int] | None = None,
273
273
  where: list[JSONObject] | None = None,
274
274
  order_by: list[JSONObject] | None = None,
275
275
  limit: int = 50,
@@ -296,7 +296,7 @@ class RecordTools(ToolBase):
296
296
  profile: str = DEFAULT_PROFILE,
297
297
  app_key: str = "",
298
298
  record_id: int = 0,
299
- columns: list[int] | None = None,
299
+ columns: list[JSONObject | int] | None = None,
300
300
  workflow_node_id: int | None = None,
301
301
  output_profile: str = "normal",
302
302
  ) -> JSONObject:
@@ -389,23 +389,21 @@ class RecordTools(ToolBase):
389
389
  "ok": True,
390
390
  "status": "success",
391
391
  "request_route": self._request_route_payload(context),
392
- "data": {
393
- "app_key": app_key,
394
- "schema_scope": "applicant_node",
395
- "workflow_node": {
396
- "workflow_node_id": applicant_node.workflow_node_id,
397
- "name": applicant_node.name,
398
- "type": applicant_node.type,
399
- },
400
- "view_resolution": _view_selection_payload(view_selection),
401
- "fields": fields,
402
- "suggested_dimensions": suggested_dimensions,
403
- "suggested_metrics": suggested_metrics,
404
- "suggested_time_fields": suggested_time_fields,
392
+ "app_key": app_key,
393
+ "schema_scope": "applicant_node",
394
+ "workflow_node": {
395
+ "workflow_node_id": applicant_node.workflow_node_id,
396
+ "name": applicant_node.name,
397
+ "type": applicant_node.type,
405
398
  },
399
+ "view_resolution": _view_selection_payload(view_selection),
400
+ "fields": fields,
401
+ "suggested_dimensions": suggested_dimensions,
402
+ "suggested_metrics": suggested_metrics,
403
+ "suggested_time_fields": suggested_time_fields,
406
404
  }
407
405
  if output_profile == "verbose":
408
- response["data"]["field_count"] = len(fields)
406
+ response["field_count"] = len(fields)
409
407
  return response
410
408
 
411
409
  return self._run_record_tool(profile, runner)
@@ -605,7 +603,7 @@ class RecordTools(ToolBase):
605
603
  *,
606
604
  profile: str,
607
605
  app_key: str,
608
- columns: list[int],
606
+ columns: list[JSONObject | int],
609
607
  where: list[JSONObject],
610
608
  order_by: list[JSONObject],
611
609
  limit: int,
@@ -617,10 +615,9 @@ class RecordTools(ToolBase):
617
615
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
618
616
  if not app_key:
619
617
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
620
- if not columns:
618
+ normalized_columns = _normalize_public_column_selectors(columns)
619
+ if not normalized_columns:
621
620
  raise_tool_error(QingflowApiError.config_error("columns is required"))
622
- if any(not isinstance(item, int) or item < 0 for item in columns):
623
- raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
624
621
  if limit <= 0:
625
622
  raise_tool_error(QingflowApiError.config_error("limit must be positive"))
626
623
  if page <= 0:
@@ -640,8 +637,8 @@ class RecordTools(ToolBase):
640
637
  filters=self._normalize_record_list_where(where),
641
638
  sorts=self._normalize_record_list_order_by(order_by),
642
639
  max_rows=limit,
643
- max_columns=len(columns),
644
- select_columns=columns,
640
+ max_columns=len(normalized_columns),
641
+ select_columns=normalized_columns,
645
642
  amount_column=None,
646
643
  time_range={},
647
644
  stat_policy={},
@@ -674,7 +671,7 @@ class RecordTools(ToolBase):
674
671
  "result_amount": pagination.get("result_amount"),
675
672
  },
676
673
  "selection": {
677
- "columns": columns,
674
+ "columns": [_column_selector_payload(field_id) for field_id in normalized_columns],
678
675
  "view": cast(JSONObject, raw["data"]).get("view"),
679
676
  },
680
677
  },
@@ -695,17 +692,16 @@ class RecordTools(ToolBase):
695
692
  profile: str,
696
693
  app_key: str,
697
694
  record_id: int,
698
- columns: list[int],
695
+ columns: list[JSONObject | int],
699
696
  workflow_node_id: int | None,
700
697
  output_profile: str,
701
698
  ) -> JSONObject:
702
699
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
703
700
  if record_id <= 0:
704
701
  raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
705
- if columns and any(not isinstance(item, int) or item < 0 for item in columns):
706
- raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
702
+ normalized_columns = _normalize_public_column_selectors(columns)
707
703
 
708
- if columns:
704
+ if normalized_columns:
709
705
  raw = self.record_query(
710
706
  profile=profile,
711
707
  query_mode="record",
@@ -720,8 +716,8 @@ class RecordTools(ToolBase):
720
716
  filters=[],
721
717
  sorts=[],
722
718
  max_rows=1,
723
- max_columns=len(columns),
724
- select_columns=columns,
719
+ max_columns=len(normalized_columns),
720
+ select_columns=normalized_columns,
725
721
  amount_column=None,
726
722
  time_range={},
727
723
  stat_policy={},
@@ -742,7 +738,7 @@ class RecordTools(ToolBase):
742
738
  "record_id": record_id,
743
739
  "record": record_data.get("row"),
744
740
  "selection": {
745
- "columns": columns,
741
+ "columns": [_column_selector_payload(field_id) for field_id in normalized_columns],
746
742
  "workflow_node_id": workflow_node_id,
747
743
  },
748
744
  },
@@ -1095,9 +1091,9 @@ class RecordTools(ToolBase):
1095
1091
  if _scope_is_default_all(scope_type, scope, keys=("member", "depart", "role")):
1096
1092
  candidates = [
1097
1093
  _normalize_candidate_member(item, source_kind="workspace")
1098
- for item in self._fetch_internal_members(context, department_id=None, role_id=None)
1094
+ for item in self._search_workspace_members(context, keyword=keyword)
1099
1095
  ]
1100
- filtered = _filter_member_candidates([item for item in candidates if item is not None], keyword)
1096
+ filtered = [item for item in candidates if item is not None]
1101
1097
  filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
1102
1098
  return filtered
1103
1099
  if scope_type != 1:
@@ -1191,7 +1187,7 @@ class RecordTools(ToolBase):
1191
1187
  merged: dict[str, JSONObject] = {}
1192
1188
  include_sub = _normalize_bool(scope.get("includeSubDeparts"))
1193
1189
  if _scope_is_default_all(scope_type, scope, keys=("depart",)):
1194
- for item in self._list_all_departments(context):
1190
+ for item in self._search_workspace_departments(context, keyword=keyword):
1195
1191
  normalized = _normalize_candidate_department(item, source_kind="workspace")
1196
1192
  if normalized is not None:
1197
1193
  self._merge_department_candidate(merged, normalized)
@@ -1408,6 +1404,56 @@ class RecordTools(ToolBase):
1408
1404
  current_page += 1
1409
1405
  return list(seen.values())
1410
1406
 
1407
+ def _search_workspace_members(self, context, *, keyword: str) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
1408
+ return self._search_workspace_directory_dimension(context, dimension="MEMBER", bucket_key="member", keyword=keyword)
1409
+
1410
+ def _search_workspace_departments(self, context, *, keyword: str) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
1411
+ return self._search_workspace_directory_dimension(context, dimension="DEPT", bucket_key="dept", keyword=keyword)
1412
+
1413
+ def _search_workspace_directory_dimension(
1414
+ self,
1415
+ context, # type: ignore[no-untyped-def]
1416
+ *,
1417
+ dimension: str,
1418
+ bucket_key: str,
1419
+ keyword: str,
1420
+ ) -> list[dict[str, Any]]:
1421
+ current_page = 1
1422
+ fetched_pages = 0
1423
+ seen: dict[str, dict[str, Any]] = {}
1424
+ while fetched_pages < MAX_MEMBER_SCOPE_FETCH_PAGES:
1425
+ result = self.backend.request(
1426
+ "POST",
1427
+ context,
1428
+ "/member/search",
1429
+ json_body={
1430
+ "dimensions": [dimension],
1431
+ "searchKey": keyword,
1432
+ "pageNum": current_page,
1433
+ "pageSize": DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
1434
+ },
1435
+ )
1436
+ bucket_payload = _member_search_bucket_payload(result, bucket_key=bucket_key)
1437
+ page_items = _directory_items(bucket_payload)
1438
+ for item in page_items:
1439
+ if not isinstance(item, dict):
1440
+ continue
1441
+ normalized = dict(item)
1442
+ item_key = _member_candidate_key(normalized) if bucket_key == "member" else _department_candidate_key(normalized)
1443
+ if not item_key or item_key in seen:
1444
+ continue
1445
+ seen[item_key] = normalized
1446
+ fetched_pages += 1
1447
+ if not _directory_has_more(
1448
+ bucket_payload,
1449
+ current_page=current_page,
1450
+ page_size=DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
1451
+ returned_items=len(page_items),
1452
+ ):
1453
+ break
1454
+ current_page += 1
1455
+ return list(seen.values())
1456
+
1411
1457
  def _schema_field_family(self, field: FormField) -> str:
1412
1458
  if self._schema_is_identifier_like(field):
1413
1459
  return "text"
@@ -3360,6 +3406,8 @@ class RecordTools(ToolBase):
3360
3406
  self._raise_if_verify_unsupported_write_field(field, item, location=field.que_title)
3361
3407
  if "values" in item and isinstance(item["values"], list):
3362
3408
  values = item["values"]
3409
+ elif field.que_type in RELATION_QUE_TYPES and isinstance(item.get("referValues"), list):
3410
+ values = item["referValues"]
3363
3411
  elif "value" in item:
3364
3412
  values = [item["value"]]
3365
3413
  else:
@@ -3369,6 +3417,8 @@ class RecordTools(ToolBase):
3369
3417
  fix_hint="Pass value for scalar fields, or values for multi-value fields.",
3370
3418
  details={"location": field.que_title, "field": _field_ref_payload(field), "expected_format": _write_format_for_field(field)},
3371
3419
  )
3420
+ if field.que_type in RELATION_QUE_TYPES:
3421
+ return self._build_relation_answer(field, values)
3372
3422
  return {
3373
3423
  "queId": field.que_id,
3374
3424
  "queType": field.que_type or 2,
@@ -3387,6 +3437,8 @@ class RecordTools(ToolBase):
3387
3437
  }
3388
3438
  self._raise_if_verify_unsupported_write_field(field, raw_value, location=str(field_selector))
3389
3439
  values = raw_value if isinstance(raw_value, list) and field.que_type in MULTI_SELECT_QUE_TYPES else [raw_value]
3440
+ if field.que_type in RELATION_QUE_TYPES:
3441
+ return self._build_relation_answer(field, values)
3390
3442
  return {
3391
3443
  "queId": field.que_id,
3392
3444
  "queType": field.que_type or 2,
@@ -3394,6 +3446,21 @@ class RecordTools(ToolBase):
3394
3446
  "tableValues": [],
3395
3447
  }
3396
3448
 
3449
+ def _build_relation_answer(self, field: FormField, raw_values: list[JSONValue]) -> JSONObject:
3450
+ values: list[JSONObject] = []
3451
+ refer_values: list[JSONObject] = []
3452
+ for raw_value in _expand_values(raw_values):
3453
+ value_payload, refer_payload = _relation_value_payload(field, raw_value)
3454
+ values.append(value_payload)
3455
+ refer_values.append(refer_payload)
3456
+ return {
3457
+ "queId": field.que_id,
3458
+ "queType": field.que_type or 25,
3459
+ "values": values,
3460
+ "referValues": refer_values,
3461
+ "tableValues": [],
3462
+ }
3463
+
3397
3464
  def _raise_if_verify_unsupported_write_field(self, field: FormField, raw_value: JSONValue, *, location: str) -> None:
3398
3465
  if field.que_type not in VERIFY_UNSUPPORTED_WRITE_QUE_TYPES:
3399
3466
  return
@@ -4435,6 +4502,25 @@ class RecordTools(ToolBase):
4435
4502
  count_mismatches=count_mismatches,
4436
4503
  )
4437
4504
  continue
4505
+ if field is not None and field.que_type in RELATION_QUE_TYPES:
4506
+ expected_relation_ids = _relation_ids_from_answer(answer)
4507
+ actual_relation_ids = _relation_ids_from_answer(actual)
4508
+ if expected_relation_ids and not actual_relation_ids:
4509
+ empty_fields.append(field_payload)
4510
+ continue
4511
+ if expected_relation_ids:
4512
+ actual_id_set = set(actual_relation_ids)
4513
+ missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
4514
+ if missing_ids:
4515
+ count_mismatches.append(
4516
+ {
4517
+ **field_payload,
4518
+ "expected_ids": expected_relation_ids,
4519
+ "actual_ids": actual_relation_ids,
4520
+ "missing_ids": missing_ids,
4521
+ }
4522
+ )
4523
+ continue
4438
4524
  actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
4439
4525
  if not actual_values:
4440
4526
  empty_fields.append(field_payload)
@@ -5262,6 +5348,28 @@ def _extract_sort_selector(item: JSONObject) -> JSONValue:
5262
5348
  return None
5263
5349
 
5264
5350
 
5351
+ def _normalize_public_column_selectors(columns: list[JSONObject | int]) -> list[int]:
5352
+ normalized: list[int] = []
5353
+ for item in columns:
5354
+ field_id: int | None = None
5355
+ if isinstance(item, int):
5356
+ field_id = item
5357
+ elif isinstance(item, dict):
5358
+ field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
5359
+ if field_id is None or field_id < 0:
5360
+ raise_tool_error(
5361
+ QingflowApiError.config_error(
5362
+ "columns must be a list of field_id integers or {field_id} objects"
5363
+ )
5364
+ )
5365
+ normalized.append(field_id)
5366
+ return normalized
5367
+
5368
+
5369
+ def _column_selector_payload(field_id: int) -> JSONObject:
5370
+ return {"field_id": field_id}
5371
+
5372
+
5265
5373
  def _resolve_sort_ascend(item: JSONObject) -> bool:
5266
5374
  if "isAscend" in item:
5267
5375
  return bool(item["isAscend"])
@@ -5896,7 +6004,24 @@ def _attachment_value(value: JSONValue) -> JSONObject:
5896
6004
 
5897
6005
 
5898
6006
  def _relation_value(value: JSONValue) -> JSONObject:
6007
+ return _relation_value_payload(None, value)[0]
6008
+
6009
+
6010
+ def _relation_value_payload(field: FormField | None, value: JSONValue) -> tuple[JSONObject, JSONObject]:
5899
6011
  if isinstance(value, dict):
6012
+ target_app_key = _normalize_optional_text(value.get("target_app_key", value.get("targetAppKey", value.get("app_key", value.get("appKey")))))
6013
+ if field is not None and field.target_app_key and target_app_key and target_app_key != field.target_app_key:
6014
+ raise RecordInputError(
6015
+ message=f"relation field '{field.que_title}' points to a different target app",
6016
+ error_code="RELATION_TARGET_APP_MISMATCH",
6017
+ fix_hint=f"Use a record from target app '{field.target_app_key}'.",
6018
+ details={
6019
+ "field": _field_ref_payload(field),
6020
+ "expected_target_app_key": field.target_app_key,
6021
+ "received_target_app_key": target_app_key,
6022
+ "received_value": value,
6023
+ },
6024
+ )
5900
6025
  apply_id = value.get("apply_id", value.get("applyId", value.get("value", value.get("id"))))
5901
6026
  if apply_id is None:
5902
6027
  raise RecordInputError(
@@ -5905,8 +6030,37 @@ def _relation_value(value: JSONValue) -> JSONObject:
5905
6030
  fix_hint="Pass relation values like {'apply_id': 5001} or numeric apply ids.",
5906
6031
  details={"received_value": value},
5907
6032
  )
5908
- return {"value": _stringify_json(apply_id)}
5909
- return {"value": _stringify_json(value)}
6033
+ normalized_apply_id = _stringify_json(apply_id)
6034
+ return ({"value": normalized_apply_id}, {"applyId": normalized_apply_id})
6035
+ normalized_apply_id = _stringify_json(value)
6036
+ return ({"value": normalized_apply_id}, {"applyId": normalized_apply_id})
6037
+
6038
+
6039
+ def _relation_ids_from_answer(answer: JSONObject) -> list[str]:
6040
+ ids: list[str] = []
6041
+ seen: set[str] = set()
6042
+ for key in ("referValues", "values"):
6043
+ values = answer.get(key)
6044
+ if not isinstance(values, list):
6045
+ continue
6046
+ for item in values:
6047
+ if not isinstance(item, dict):
6048
+ continue
6049
+ relation_id = item.get("applyId", item.get("apply_id", item.get("value", item.get("id"))))
6050
+ normalized = _normalize_optional_text(relation_id) or (_stringify_json(relation_id) if relation_id is not None else None)
6051
+ if not normalized or normalized in seen:
6052
+ continue
6053
+ ids.append(normalized)
6054
+ seen.add(normalized)
6055
+ return ids
6056
+
6057
+
6058
+ def _member_search_bucket_payload(payload: JSONValue, *, bucket_key: str) -> JSONValue:
6059
+ if isinstance(payload, dict):
6060
+ bucket = payload.get(bucket_key)
6061
+ if isinstance(bucket, (dict, list)):
6062
+ return bucket
6063
+ return payload
5910
6064
 
5911
6065
 
5912
6066
  def _normalize_candidate_member(
@@ -6247,8 +6401,8 @@ def _write_format_for_field(field: FormField) -> JSONObject:
6247
6401
  return _write_support_payload(
6248
6402
  support_level="restricted",
6249
6403
  kind="relation_record",
6250
- examples=[{"value": "5001"}],
6251
- required_presteps=["Query the target app first and use the referenced apply_id."],
6404
+ examples=[{"apply_id": 5001}],
6405
+ required_presteps=["Query the target app first and use the referenced apply_id from the target app."],
6252
6406
  )
6253
6407
  if field.que_type in SINGLE_SELECT_QUE_TYPES:
6254
6408
  return _write_support_payload(support_level="full", kind="single_select", options=field.options)