@josephyan/qingflow-app-user-mcp 1.0.6 → 1.0.8

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@1.0.6
6
+ npm install @josephyan/qingflow-app-user-mcp@1.0.8
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@1.0.6 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@1.0.8 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": "1.0.6",
3
+ "version": "1.0.8",
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 = "1.0.6"
7
+ version = "1.0.8"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,6 +8,7 @@ It is not a user-facing product spec. It exists to prevent skill drift.
8
8
  ### User data
9
9
 
10
10
  - Read range first with `app_get`, then `record_browse_schema_get(view_id=...)`
11
+ - Treat `record_browse_schema_get.fields` as the selected Qingflow table view header schema; `record_access.fields` and `schema.json` must stay aligned with it.
11
12
  - Standard flows:
12
13
  - analyze: `app_get -> record_browse_schema_get -> record_access -> Python`
13
14
  - browse detail: `app_get -> record_browse_schema_get -> record_list / record_get`
@@ -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 `app_get`, then `record_browse_schema_get(view_id=...)`. Read top-level `fields` and `suggested_*`, then choose field_id-based columns and filters only.
12
+ Analysis tasks must start with `app_get`, then `record_browse_schema_get(view_id=...)`. `record_browse_schema_get.fields` is the selected Qingflow table view's readable header schema and is the same field source used by `record_access.fields` / `schema.json`. Read top-level `fields` and `suggested_*`, then choose field_id-based columns and filters only.
13
13
  If `app_get.accessible_views` marks a view with `analysis_supported=false`, do not use that view for `record_access`, `record_list`, or `record_analyze`. `boardView` and `ganttView` are special UI views, not data-access targets.
14
14
 
15
15
  ## Step 1: `app_get` → Step 2: `record_browse_schema_get(view_id=...)` → Step 3: `record_access` → Step 4: Python
@@ -73,9 +73,10 @@ Use `record_access` to fetch detail rows into local CSV shards. It does not anal
73
73
  }
74
74
  ```
75
75
 
76
- Then run Python against every `files[].local_path`. CSV columns are stable: `record_id`, then `field_<field_id>`. Use `fields[]` metadata to map titles and types.
76
+ Then run Python against every `files[].local_path`. CSV columns are stable: `record_id`, then `field_<field_id>`. Use `fields[]` plus `metadata_files.schema` / `metadata_files.readme` in the same output directory to map titles and types, especially when reusing the CSV later.
77
77
 
78
78
  - Never ask for `page`, `page_size`, `limit`, or `max_rows`; `record_access` owns paging internally and follows the backend's native paging capability.
79
+ - Never treat backend `searchQueIds` as column selection; it is only a full-text search scope.
79
80
  - If multiple CSV files are returned, read them all.
80
81
  - If `complete=false` or `safe_for_final_conclusion=false`, downgrade the answer and disclose the limitation.
81
82
  - `record_export_direct` is only for explicit export/download/Excel requests, not default analysis.
@@ -94,6 +94,7 @@ Examples of the right recovery question:
94
94
  - Do not invent `page_size`
95
95
  - Do not invent `requested_pages`
96
96
  - Do not invent `scan_max_pages`
97
+ - Do not rename CSV columns before analysis; use `metadata_files.schema` / `metadata_files.readme` to interpret `field_<id>` columns.
97
98
  - Do not invent `auto_expand_pages`
98
99
  - Do not invent `max_rows`
99
100
 
@@ -28,11 +28,12 @@ Result reading order:
28
28
  1. `record_access.complete`
29
29
  2. `record_access.safe_for_final_conclusion`
30
30
  3. `record_access.files[].local_path`
31
- 4. Python outputs
32
- 5. `record_access.fields`
33
- 6. `record_access.warnings`
31
+ 4. `record_access.metadata_files.schema`
32
+ 5. Python outputs
33
+ 6. `record_access.fields`
34
+ 7. `record_access.warnings`
34
35
 
35
- Treat `record_browse_schema_get` as the browse-schema source of truth. Missing fields are permission boundaries, not invitations to guess hidden ids.
36
+ Treat `record_browse_schema_get` as the browse-schema source of truth. It matches the selected Qingflow table view header and is the same schema source used by `record_access.fields` and `schema.json`. Missing fields are permission boundaries, not invitations to guess hidden ids.
36
37
 
37
38
  ## Lightweight `record_analyze` helper sequence
38
39
 
@@ -455,11 +455,13 @@ def _trim_record_access(payload: JSONObject) -> None:
455
455
  "app_key",
456
456
  "view_id",
457
457
  "format",
458
+ "local_dir",
458
459
  "row_count",
459
460
  "complete",
460
461
  "truncated",
461
462
  "safe_for_final_conclusion",
462
463
  "files",
464
+ "metadata_files",
463
465
  "fields",
464
466
  "warnings",
465
467
  "verification",
@@ -60,7 +60,9 @@ Call `record_import_schema_get` when the import field mapping is unclear before
60
60
  `record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
61
61
  Inside `optional_fields`, any field with `may_become_required=true` is still writable, but may become required when linked visibility or option-driven runtime rules activate.
62
62
  `record_update_schema_get` returns the current record's overall update-ready writable field set across matched accessible views; read `writable_fields` and `payload_template`.
63
- `record_browse_schema_get(view_id=...)` returns browse-schema fields for the selected accessible view.
63
+ `record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
64
+ `record_access.fields` and its `schema.json` use that exact same view schema; a missing field means it is not readable in that view.
65
+ `searchQueIds` is a backend full-text search scope, not an output-column/projection mechanism.
64
66
  `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
65
67
  `record_import_schema_get` returns import-ready column metadata.
66
68
 
@@ -74,7 +76,7 @@ Inside `optional_fields`, any field with `may_become_required=true` is still wri
74
76
 
75
77
  Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
76
78
 
77
- Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`.
79
+ Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`. Use `fields[]` and the same-directory `metadata_files.schema` / `metadata_files.readme` to map stable `field_<id>` CSV columns back to titles and types.
78
80
  Use `chart_get` only when the user provides a report URL / chart_id or explicitly asks to read an existing report. Do not use QingBI as the default analysis route.
79
81
 
80
82
  Use this data-access DSL shape:
@@ -602,7 +602,7 @@ class RecordTools(ToolBase):
602
602
  ),
603
603
  }
604
604
  )
605
- browse_scope = self._build_browse_write_scope(
605
+ browse_scope = self._build_browse_read_scope(
606
606
  profile,
607
607
  context,
608
608
  app_key,
@@ -1848,25 +1848,20 @@ class RecordTools(ToolBase):
1848
1848
  sorts = self._normalize_record_list_order_by(order_by)
1849
1849
 
1850
1850
  def runner(session_profile, context):
1851
- index = self._get_field_index(profile, context, app_key, force_refresh=False)
1852
- selected_fields = self._resolve_select_columns(
1851
+ browse_scope = self._build_browse_read_scope(
1852
+ profile,
1853
+ context,
1854
+ app_key,
1855
+ view_route,
1856
+ force_refresh=False,
1857
+ )
1858
+ index = cast(FieldIndex, browse_scope["index"])
1859
+ selected_fields = self._resolve_record_access_columns(
1853
1860
  normalized_columns,
1854
1861
  index,
1855
- max_columns=len(normalized_columns),
1856
- default_limit=MAX_LIST_COLUMN_LIMIT,
1862
+ view_route=view_route,
1857
1863
  )
1858
- selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
1859
- primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
1860
1864
  view_selection = view_route.view_selection
1861
- if view_selection is not None and not _view_selection_supported_by_search_ids(
1862
- view_selection,
1863
- primary_search_que_ids,
1864
- ):
1865
- primary_search_que_ids = None
1866
- remaining_field_batches: list[list[FormField]] = []
1867
- else:
1868
- remaining_field_batches = selected_field_batches[1:]
1869
- primary_search_que_ids = primary_search_que_ids or None
1870
1865
  match_rules = self._resolve_match_rules(context, filters, index)
1871
1866
  sort_rules = self._resolve_sorts(sorts, index)
1872
1867
  used_list_type: int | None = None
@@ -1932,6 +1927,7 @@ class RecordTools(ToolBase):
1932
1927
  current_page = 1
1933
1928
  has_more = False
1934
1929
  reported_total: int | None = None
1930
+ created_at = datetime.now(UTC).isoformat()
1935
1931
  try:
1936
1932
  while True:
1937
1933
  if used_list_type is None:
@@ -1948,7 +1944,7 @@ class RecordTools(ToolBase):
1948
1944
  query_key=None,
1949
1945
  match_rules=match_rules,
1950
1946
  sorts=sort_rules,
1951
- search_que_ids=primary_search_que_ids,
1947
+ search_que_ids=None,
1952
1948
  list_type=candidate_list_type,
1953
1949
  )
1954
1950
  used_list_type = None if view_selection is not None else candidate_list_type
@@ -1975,7 +1971,7 @@ class RecordTools(ToolBase):
1975
1971
  query_key=None,
1976
1972
  match_rules=match_rules,
1977
1973
  sorts=sort_rules,
1978
- search_que_ids=primary_search_que_ids,
1974
+ search_que_ids=None,
1979
1975
  list_type=used_list_type,
1980
1976
  )
1981
1977
  page_rows = page.get("list")
@@ -1995,34 +1991,6 @@ class RecordTools(ToolBase):
1995
1991
  continue
1996
1992
  page_apply_order.append(apply_id)
1997
1993
  page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
1998
- if page_apply_order and remaining_field_batches:
1999
- for batch in remaining_field_batches:
2000
- extra_page = self._search_page(
2001
- context,
2002
- app_key=app_key,
2003
- view_selection=view_selection,
2004
- page_num=current_page,
2005
- page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
2006
- query_key=None,
2007
- match_rules=match_rules,
2008
- sorts=sort_rules,
2009
- search_que_ids=[field.que_id for field in batch],
2010
- list_type=used_list_type or DEFAULT_RECORD_LIST_TYPE,
2011
- )
2012
- extra_rows = extra_page.get("list")
2013
- extra_items = extra_rows if isinstance(extra_rows, list) else []
2014
- for extra_item in extra_items:
2015
- if not isinstance(extra_item, dict):
2016
- continue
2017
- apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
2018
- if apply_id is None or apply_id not in page_answer_map:
2019
- continue
2020
- extra_answers = extra_item.get("answers")
2021
- extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
2022
- page_answer_map[apply_id] = _merge_answer_lists_by_field_id(
2023
- page_answer_map.get(apply_id, []),
2024
- cast(list[JSONValue], extra_answer_list),
2025
- )
2026
1994
  for apply_id in page_apply_order:
2027
1995
  write_record(apply_id, page_answer_map.get(apply_id, []))
2028
1996
  if not has_more:
@@ -2049,6 +2017,25 @@ class RecordTools(ToolBase):
2049
2017
  safe_for_final_conclusion = complete and not any(
2050
2018
  warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
2051
2019
  )
2020
+ fields_payload = [_record_access_field_payload(field) for field in selected_fields]
2021
+ verification_payload: JSONObject = {
2022
+ **_view_filter_verification_payload(view_route),
2023
+ "reported_total": reported_total,
2024
+ "list_type_used": used_list_type,
2025
+ }
2026
+ metadata_files = _write_record_access_metadata_files(
2027
+ run_dir=run_dir,
2028
+ created_at=created_at,
2029
+ app_key=app_key,
2030
+ view_route=view_route,
2031
+ row_count=row_count,
2032
+ complete=complete,
2033
+ safe_for_final_conclusion=safe_for_final_conclusion,
2034
+ files=files,
2035
+ fields=fields_payload,
2036
+ warnings=warnings,
2037
+ verification=verification_payload,
2038
+ )
2052
2039
  return {
2053
2040
  "profile": profile,
2054
2041
  "ws_id": session_profile.selected_ws_id,
@@ -2057,18 +2044,16 @@ class RecordTools(ToolBase):
2057
2044
  "app_key": app_key,
2058
2045
  "view_id": view_route.view_id,
2059
2046
  "format": "csv",
2047
+ "local_dir": str(run_dir),
2060
2048
  "row_count": row_count,
2061
2049
  "complete": complete,
2062
2050
  "truncated": not complete,
2063
2051
  "safe_for_final_conclusion": safe_for_final_conclusion,
2064
2052
  "files": files,
2065
- "fields": [_record_access_field_payload(field) for field in selected_fields],
2053
+ "metadata_files": metadata_files,
2054
+ "fields": fields_payload,
2066
2055
  "warnings": warnings,
2067
- "verification": {
2068
- **_view_filter_verification_payload(view_route),
2069
- "reported_total": reported_total,
2070
- "list_type_used": used_list_type,
2071
- },
2056
+ "verification": verification_payload,
2072
2057
  "request_route": self._request_route_payload(context),
2073
2058
  }
2074
2059
 
@@ -7409,6 +7394,39 @@ class RecordTools(ToolBase):
7409
7394
  self._form_cache[cache_key] = normalized
7410
7395
  return normalized
7411
7396
 
7397
+ def _get_system_browse_base_info_schema(
7398
+ self,
7399
+ profile: str,
7400
+ context, # type: ignore[no-untyped-def]
7401
+ app_key: str,
7402
+ *,
7403
+ list_type: int,
7404
+ force_refresh: bool,
7405
+ ) -> JSONObject:
7406
+ """Return system view apply/baseInfo without falling back to app form metadata."""
7407
+ cache_key = (profile, app_key, "browse_system_base_info", list_type)
7408
+ if not force_refresh and cache_key in self._form_cache:
7409
+ return self._form_cache[cache_key]
7410
+ payload = self.backend.request(
7411
+ "GET",
7412
+ context,
7413
+ f"/app/{app_key}/apply/baseInfo",
7414
+ params={"type": list_type},
7415
+ )
7416
+ normalized = _normalize_data_list_base_info_schema(payload)
7417
+ self._form_cache[cache_key] = normalized
7418
+ return normalized
7419
+
7420
+ def _get_custom_view_browse_schema(self, profile: str, context, view_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
7421
+ """Return the same baseInfo schema used by the Qingflow table view UI."""
7422
+ cache_key = (profile, f"view:{view_key}", "browse_view_base_info", None)
7423
+ if not force_refresh and cache_key in self._form_cache:
7424
+ return self._form_cache[cache_key]
7425
+ payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
7426
+ normalized = _normalize_data_list_base_info_schema(payload)
7427
+ self._form_cache[cache_key] = normalized
7428
+ return normalized
7429
+
7412
7430
  def _get_view_field_index(self, profile: str, context, view_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
7413
7431
  """执行内部辅助逻辑。"""
7414
7432
  return _build_field_index(self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh))
@@ -7451,6 +7469,80 @@ class RecordTools(ToolBase):
7451
7469
  force_refresh=force_refresh,
7452
7470
  )["index"]
7453
7471
 
7472
+ def _build_browse_read_scope(
7473
+ self,
7474
+ profile: str,
7475
+ context, # type: ignore[no-untyped-def]
7476
+ app_key: str,
7477
+ resolved_view: AccessibleViewRoute | None,
7478
+ *,
7479
+ force_refresh: bool,
7480
+ ) -> JSONObject:
7481
+ """Build the UI/table-view readable field scope from apply/baseInfo."""
7482
+ applicant_index: FieldIndex | None
7483
+ applicant_writable_field_ids: set[int]
7484
+ try:
7485
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
7486
+ except QingflowApiError as exc:
7487
+ if exc.backend_code != 40002:
7488
+ raise
7489
+ applicant_index = None
7490
+ applicant_writable_field_ids = set()
7491
+ else:
7492
+ applicant_writable_field_ids = {
7493
+ field.que_id
7494
+ for field in applicant_index.by_id.values()
7495
+ if bool(self._schema_write_hints(field)["writable"])
7496
+ }
7497
+
7498
+ if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
7499
+ schema = self._get_custom_view_browse_schema(
7500
+ profile,
7501
+ context,
7502
+ resolved_view.view_selection.view_key,
7503
+ force_refresh=force_refresh,
7504
+ )
7505
+ index = _build_top_level_field_index(schema)
7506
+ elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
7507
+ schema = self._get_system_browse_base_info_schema(
7508
+ profile,
7509
+ context,
7510
+ app_key,
7511
+ list_type=resolved_view.list_type,
7512
+ force_refresh=force_refresh,
7513
+ )
7514
+ index = _build_top_level_field_index(schema)
7515
+ else:
7516
+ index = applicant_index or _build_top_level_field_index(
7517
+ self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
7518
+ )
7519
+
7520
+ if applicant_index is not None and index.by_id:
7521
+ enriched_fields = [
7522
+ _enrich_read_field_from_applicant(field, applicant_index.by_id.get(str(field.que_id)))
7523
+ for field in index.by_id.values()
7524
+ ]
7525
+ index = _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in enriched_fields]]})
7526
+
7527
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
7528
+ if applicant_index is None:
7529
+ writable_field_ids = {
7530
+ field.que_id
7531
+ for field in index.by_id.values()
7532
+ if bool(self._schema_write_hints(field)["writable"])
7533
+ }
7534
+ else:
7535
+ writable_field_ids = {
7536
+ field_id
7537
+ for field_id in visible_question_ids
7538
+ if field_id in applicant_writable_field_ids
7539
+ }
7540
+ return {
7541
+ "index": index,
7542
+ "writable_field_ids": writable_field_ids,
7543
+ "visible_question_ids": visible_question_ids,
7544
+ }
7545
+
7454
7546
  def _build_browse_write_scope(
7455
7547
  self,
7456
7548
  profile: str,
@@ -9520,6 +9612,44 @@ class RecordTools(ToolBase):
9520
9612
  break
9521
9613
  return fields
9522
9614
 
9615
+ def _resolve_record_access_columns(
9616
+ self,
9617
+ selectors: list[int],
9618
+ index: FieldIndex,
9619
+ *,
9620
+ view_route: AccessibleViewRoute,
9621
+ ) -> list[FormField]:
9622
+ """Resolve record_access columns against the selected view's baseInfo schema."""
9623
+ if not selectors:
9624
+ raise_tool_error(QingflowApiError.config_error("columns is required"))
9625
+ fields: list[FormField] = []
9626
+ seen: set[int] = set()
9627
+ for selector in selectors:
9628
+ try:
9629
+ field = self._resolve_field_selector(selector, index, location="record_access.columns")
9630
+ except RecordInputError as exc:
9631
+ if exc.error_code == "FIELD_NOT_FOUND":
9632
+ raise RecordInputError(
9633
+ message=(
9634
+ f"record_access column field_id '{selector}' is not in the selected view schema "
9635
+ f"({view_route.view_id})."
9636
+ ),
9637
+ error_code="FIELD_NOT_IN_VIEW_SCHEMA",
9638
+ fix_hint="Call record_browse_schema_get for this exact view_id and pass only field_id values from its fields[].",
9639
+ details={
9640
+ "location": "record_access.columns",
9641
+ "requested": selector,
9642
+ "view_id": view_route.view_id,
9643
+ "view_name": view_route.name,
9644
+ },
9645
+ ) from exc
9646
+ raise
9647
+ if field.que_id in seen:
9648
+ continue
9649
+ fields.append(field)
9650
+ seen.add(field.que_id)
9651
+ return fields
9652
+
9523
9653
  def _resolve_summary_preview_fields(
9524
9654
  self,
9525
9655
  selectors: list[str | int],
@@ -11277,6 +11407,38 @@ def _clone_form_field(field: FormField, *, readonly: bool | None = None) -> Form
11277
11407
  )
11278
11408
 
11279
11409
 
11410
+ def _enrich_read_field_from_applicant(field: FormField, applicant_field: FormField | None) -> FormField:
11411
+ if applicant_field is None:
11412
+ return field
11413
+ raw = dict(applicant_field.raw) if isinstance(applicant_field.raw, dict) else {}
11414
+ raw.update(dict(field.raw) if isinstance(field.raw, dict) else {})
11415
+ raw["queId"] = field.que_id
11416
+ raw["queTitle"] = field.que_title
11417
+ que_type = field.que_type if field.que_type is not None else applicant_field.que_type
11418
+ if que_type is not None:
11419
+ raw["queType"] = que_type
11420
+ readonly_source = field if any(key in field.raw for key in ("readonly", "beingReadonly", "canEdit")) else applicant_field
11421
+ enriched = FormField(
11422
+ que_id=field.que_id,
11423
+ que_title=field.que_title,
11424
+ que_type=que_type,
11425
+ required=field.required or applicant_field.required,
11426
+ readonly=readonly_source.readonly,
11427
+ system=field.system or applicant_field.system,
11428
+ options=list(field.options or applicant_field.options),
11429
+ aliases=[],
11430
+ target_app_key=field.target_app_key or applicant_field.target_app_key,
11431
+ target_app_name_hint=field.target_app_name_hint or applicant_field.target_app_name_hint,
11432
+ member_select_scope_type=field.member_select_scope_type if field.member_select_scope_type is not None else applicant_field.member_select_scope_type,
11433
+ member_select_scope=field.member_select_scope or applicant_field.member_select_scope,
11434
+ dept_select_scope_type=field.dept_select_scope_type if field.dept_select_scope_type is not None else applicant_field.dept_select_scope_type,
11435
+ dept_select_scope=field.dept_select_scope or applicant_field.dept_select_scope,
11436
+ raw=raw,
11437
+ )
11438
+ enriched.aliases = sorted(_field_alias_candidates(enriched))
11439
+ return enriched
11440
+
11441
+
11280
11442
  def _form_field_to_question(field: FormField) -> JSONObject:
11281
11443
  question = dict(field.raw) if isinstance(field.raw, dict) else {}
11282
11444
  question.setdefault("queId", field.que_id)
@@ -11503,6 +11665,133 @@ def _record_access_field_payload(field: FormField) -> JSONObject:
11503
11665
  }
11504
11666
 
11505
11667
 
11668
+ def _write_record_access_metadata_files(
11669
+ *,
11670
+ run_dir: Path,
11671
+ created_at: str,
11672
+ app_key: str,
11673
+ view_route: AccessibleViewRoute,
11674
+ row_count: int,
11675
+ complete: bool,
11676
+ safe_for_final_conclusion: bool,
11677
+ files: list[JSONObject],
11678
+ fields: list[JSONObject],
11679
+ warnings: list[JSONObject],
11680
+ verification: JSONObject,
11681
+ ) -> JSONObject:
11682
+ schema_path = run_dir / "schema.json"
11683
+ readme_path = run_dir / "README.md"
11684
+ schema_payload: JSONObject = {
11685
+ "created_at": created_at,
11686
+ "app_key": app_key,
11687
+ "view_id": view_route.view_id,
11688
+ "view_name": view_route.name,
11689
+ "view_type": view_route.view_type,
11690
+ "format": "csv",
11691
+ "row_count": row_count,
11692
+ "complete": complete,
11693
+ "truncated": not complete,
11694
+ "safe_for_final_conclusion": safe_for_final_conclusion,
11695
+ "files": files,
11696
+ "fields": fields,
11697
+ "warnings": warnings,
11698
+ "verification": verification,
11699
+ "csv_columns": ["record_id"] + [str(field["column_name"]) for field in fields if field.get("column_name")],
11700
+ }
11701
+ schema_path.write_text(json.dumps(schema_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
11702
+ readme_path.write_text(
11703
+ _record_access_readme(
11704
+ created_at=created_at,
11705
+ app_key=app_key,
11706
+ view_route=view_route,
11707
+ row_count=row_count,
11708
+ complete=complete,
11709
+ safe_for_final_conclusion=safe_for_final_conclusion,
11710
+ files=files,
11711
+ fields=fields,
11712
+ warnings=warnings,
11713
+ ),
11714
+ encoding="utf-8",
11715
+ )
11716
+ return {
11717
+ "schema": str(schema_path),
11718
+ "readme": str(readme_path),
11719
+ }
11720
+
11721
+
11722
+ def _record_access_readme(
11723
+ *,
11724
+ created_at: str,
11725
+ app_key: str,
11726
+ view_route: AccessibleViewRoute,
11727
+ row_count: int,
11728
+ complete: bool,
11729
+ safe_for_final_conclusion: bool,
11730
+ files: list[JSONObject],
11731
+ fields: list[JSONObject],
11732
+ warnings: list[JSONObject],
11733
+ ) -> str:
11734
+ lines = [
11735
+ "# Qingflow Record Access",
11736
+ "",
11737
+ f"- Created at: {created_at}",
11738
+ f"- App key: `{app_key}`",
11739
+ f"- View: {view_route.name} (`{view_route.view_id}`)",
11740
+ f"- Format: CSV",
11741
+ f"- Rows: {row_count}",
11742
+ f"- Complete: {str(complete).lower()}",
11743
+ f"- Safe for final conclusion: {str(safe_for_final_conclusion).lower()}",
11744
+ "",
11745
+ "## Data Files",
11746
+ "",
11747
+ ]
11748
+ if files:
11749
+ for file_info in files:
11750
+ local_path = _normalize_optional_text(file_info.get("local_path")) or ""
11751
+ part = file_info.get("part")
11752
+ file_rows = file_info.get("row_count")
11753
+ lines.append(f"- `{Path(local_path).name}`: part {part}, {file_rows} rows")
11754
+ else:
11755
+ lines.append("- No CSV shard files were written because the query returned 0 rows.")
11756
+ lines.extend(
11757
+ [
11758
+ "",
11759
+ "## Columns",
11760
+ "",
11761
+ "| CSV column | Field ID | Title | Type |",
11762
+ "|---|---:|---|---|",
11763
+ "| `record_id` | - | Record ID | record_id |",
11764
+ ]
11765
+ )
11766
+ for field in fields:
11767
+ column_name = _markdown_escape(str(field.get("column_name", "")))
11768
+ field_id = field.get("field_id", "")
11769
+ title = _markdown_escape(str(field.get("title", "")))
11770
+ field_type = _markdown_escape(str(field.get("type", "")))
11771
+ lines.append(f"| `{column_name}` | {field_id} | {title} | {field_type} |")
11772
+ if warnings:
11773
+ lines.extend(["", "## Warnings", ""])
11774
+ for warning in warnings:
11775
+ code = _normalize_optional_text(warning.get("code")) or "WARNING"
11776
+ message = _normalize_optional_text(warning.get("message")) or ""
11777
+ lines.append(f"- `{code}`: {message}")
11778
+ lines.extend(
11779
+ [
11780
+ "",
11781
+ "## Usage Notes",
11782
+ "",
11783
+ "- Use `records-*.csv` for Python or pandas processing; column names are stable and field-id based.",
11784
+ "- Use `schema.json` or this README to map `field_<field_id>` columns back to Qingflow field titles and types.",
11785
+ "",
11786
+ ]
11787
+ )
11788
+ return "\n".join(lines)
11789
+
11790
+
11791
+ def _markdown_escape(value: str) -> str:
11792
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
11793
+
11794
+
11506
11795
  def _record_access_field_type(field: FormField) -> str:
11507
11796
  if field.que_type in DATE_QUE_TYPES:
11508
11797
  return "datetime"