e10-data 0.1.33 → 0.1.35

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/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.1.35(2026-11-02)—— `scene search` 改为「后端 keyword 过滤优先」(召回修复)
4
+
5
+ ### 1. 背景
6
+
7
+ `scene search` 自 0.1.33 引入起一直是「本地字面打分」补丁,代码注释与 0.1.33 变更记录
8
+ 均写「后端无对应接口」。**该说法有误**:`GET /api/dw/newetl/chatBi/getSceneGroups`
9
+ 支持 `keyword`(**单数**)query 参数,且返回体里直接带回已过滤的 `datasets`。
10
+
11
+ 因为关键词从未发给后端,本地打分只能看 `groupName` / `groupDesc`,
12
+ **完全丢掉了「关键词只落在条目名/条目描述里」的场景**,典型症状:
13
+ `--keywords 未结案`、`--keywords 请假` 直接 0 候选;问「excel 表」时凭「表」字硬凑无关分组。
14
+
15
+ ### 2. 后端 `keyword` 的命中规则(实测)
16
+
17
+ | 情形 | 判据 | 返回 |
18
+ |---|---|---|
19
+ | ① 分组级命中 | 关键词在 `groupName` 或 `groupDesc` | 该分组 **+ 全部条目** |
20
+ | ② 仅条目级命中 | 分组名/描述都不含,关键词在 `datasetName` 或 `datasetDesc` | **只返命中条目 + 父分组** |
21
+ | ③ 全不命中 | — | **空数组**(不回落全量骨架) |
22
+
23
+ 多个关键词逗号分隔,后端按 **OR** 取并集;整句不切词(`keyword=整句话` → 0 组)。
24
+
25
+ ### 3. 改动
26
+
27
+ - `cmdSceneSearch` 重写:
28
+ - 默认先发 **1 次** `getSceneGroups?datasetClient=…&keyword=词1,词2`,命中则直接使用响应
29
+ 里的 `datasets`,**不再逐组拉 items**(请求数从 `1+maxGroups` 降到 `1`);
30
+ - 未命中 / 请求失败且 `--server-filter=auto` 时回退原本地打分逻辑(老服务端可用);
31
+ - 条目层:后端已经给过的条目**只排序、不再按 `score>0` 过滤**(分组级命中时组内条目
32
+ 未必含关键词字面,过滤会丢量:`业绩` 实测 57 条只剩 20 条);后端未做条目级过滤
33
+ (`datasets` 条数 = `itemCount`)时按 `--max-items` 截断,避免整组几十条 `datasetDesc` 撑爆上下文;
34
+ - stderr 增印「请求 N 次 | 过滤方式 后端 keyword / 本地打分」,便于排查召回来源。
35
+ - 新增 `--server-filter auto|on|off`(默认 `auto`):`on` = 只认后端、不回退;`off` = 只走旧本地打分。
36
+ - 更正文件头注释、`cmdSceneSearch` 文档注释、`usage()` 中「后端无此接口/本地补丁」的过时说法。
37
+
38
+ ### 4. 效果(同一账号、同一环境实测)
39
+
40
+ | 命令 | 0.1.34 | 本改动 |
41
+ |---|---|---|
42
+ | `--keywords 未结案` | 0 候选 | 项目收支 **18 条**(PMO 项目情况系列),1 次请求 |
43
+ | `--keywords 请假` | 0 候选 | 2 组 2 条(人事绩效 + 人事考勤),1 次请求 |
44
+ | `--keywords 业绩` | 12 条(本地打分+截断) | **57 条全量**(`--max-items 999`),1 次请求 |
45
+ | `--keywords excel,表,字段` | 3 个无关组 ×12 条 | 3 组 25 条(后端命中条目),1 次请求 |
46
+
47
+ ### 5. 兼容
48
+
49
+ - 默认 `auto`:后端不可用/未命中时行为与 0.1.34 一致;`--server-filter off` 完全等价旧版。
50
+ - `--max-groups` / `--max-items` / `--scope-group-id` 语义不变。
51
+ - 后端场景目录会变(同一天 `keyword=excel` 命中的分组从「全部」→「专项经营」→ 整条消失),
52
+ 故代码与文档**不得写死 groupId**,一律按 keyword 召回复核。
53
+
54
+ ## 0.1.34(2026-10-29)—— `ds fields` / `ds describe` 改走 `getFields`(扁平返回)
55
+
56
+ ### 1. 背景
57
+
58
+ 取字段端点在后端已有统一实现 `GET /api/dw/newetl/chatBi/ds/getFields`(与 `ds/listByPage`、
59
+ `getListDatas` 同族),旧的 `POST /api/ebuilder/common/ds/fields` 不再是唯一入口。本次按用户
60
+ 指定切到新端点,并适配其**扁平返回结构**。
61
+
62
+ ### 2. 改动(**输出结构变化**)
63
+
64
+ - **`ds fields` 端点**:`POST /api/ebuilder/common/ds/fields`(multipart 表单)→
65
+ **`GET /api/dw/newetl/chatBi/ds/getFields`**(query 参数),四个参数全部取自本次调用:
66
+
67
+ | 参数 | 来源 |
68
+ | --- | --- |
69
+ | `groupId` | `--group` |
70
+ | `sourceId` | `--table` |
71
+ | `sourceType` | `--source-type`(缺省 `LOGIC`) |
72
+ | `appId` | `--app-id`(显式传入才拼;`auto` / 空则不出现) |
73
+
74
+ - **返回结构(不兼容)**:`data` 由「表节点数组」变为**扁平字段数组** ——
75
+ 不再有 `data[0].fields[]` 这一层,也不再有 `isDetailtable` 子表节点与表名。
76
+ 字段本身不丢(实测同一流程表新旧均为 126 个字段),子表归属只能看字段的
77
+ `config.formTableId`。
78
+ - **`ds describe`**:字段段同步走 `getFields`;`fields.main` = 全部字段、`fields.detail` 恒为空,
79
+ 并新增 `fields.shapeNote` 说明该端点不返回表名与子表分组。
80
+ - **文本模式**:`ds fields` 不加 `--json` 时按扁平数组打印(`字段 N 个(getFields 扁平返回)`)。
81
+ - **`getUrl()` 支持 `E10_DEBUG_URL`**:与 `postJson` 对齐,设 `E10_DEBUG_URL=1` 时打印真实
82
+ `GET` 路径(含 query),便于核对参数是否按预期拼装。
83
+
84
+ ### 3. 影响 / 兼容
85
+
86
+ - 只读字段清单的调用方无需改命令;**自己解析 `data[0].fields` 的脚本必须改成遍历 `data`**。
87
+ - `ds fields-dsl` **未改**,仍走 `/api/dw/newetl/chatBi/ds/fieldsForDSL`(返回仍为嵌套结构,
88
+ DSL 需要密文列名 `f_*` 与 `compType` 回填)。
89
+
90
+ ### 4. 实测
91
+
92
+ - EB 表单(`846297265322573830`):137 个字段;需求库(`840720984867880961`):106 个字段;
93
+ 流程表(`100003460000000478`):126 个字段(主表 62 + 7 张子表 20/7/6/6/5/5/2 + 13 个无 formTableId)。
94
+ - 新旧字段集合逐项比对一致,`compType` / `browserParams` 等属性比旧端点更全。
95
+
3
96
  ## 0.1.33(2026-10-23)—— 移除 `login xiaoe-env`(登录交 weaver-work-cli)+ 新增 `scene search`
4
97
 
5
98
  ### 1. 背景
package/README.md CHANGED
@@ -460,6 +460,20 @@ e10-data scene groups [--client agent|all|connector] [--json]
460
460
  ] }]
461
461
  ```
462
462
 
463
+ **关键词召回(`scene search`,未发布改动起改为后端过滤)**:
464
+
465
+ ```bash
466
+ e10-data scene search --keywords "未结案" [--client agent] [--json]
467
+ [--max-groups 3] [--max-items 12] [--scope-group-id <gid>]
468
+ [--server-filter auto|on|off]
469
+ ```
470
+
471
+ 后端 `getSceneGroups` **支持 `keyword`(单数)**,命中规则:分组名/描述命中 ⇒ **整组全返**;
472
+ 仅条目名/描述命中 ⇒ **只返命中条目 + 父分组**;都不命中 ⇒ **空数组**;多词逗号分隔按 OR。
473
+ 因此本命令默认把关键词直接交给后端,**命中时全程只发 1 次请求**并直接使用返回的 `datasets`;
474
+ 未命中才回退本地字面打分(`--server-filter auto` 默认 / `on` 只走后端 / `off` 只走本地)。
475
+ stderr 会打印「请求 N 次 | 过滤方式 后端 keyword / 本地打分」,可用于判断召回是否走了后端。
476
+
463
477
  **分流(拿到条目后)**:
464
478
 
465
479
  - `datasetType=interface` → `e10-data module call --conf-key <sourceId> --group <moduleName>`(`moduleName` 随条目下发,勿用 confKey 拆段猜);
package/bin/e10-data.mjs CHANGED
@@ -745,6 +745,9 @@ async function postJson(endpoint, bodyObj, extraHeaders = {}) {
745
745
  return j;
746
746
  }
747
747
  async function getUrl(url) {
748
+ if (process.env.E10_DEBUG_URL) {
749
+ console.error("[http] GET " + url);
750
+ }
748
751
  const resp = await fetch(BASE + url, {
749
752
  method: "GET",
750
753
  headers: {
@@ -1098,6 +1101,24 @@ function parseOrder(val) {
1098
1101
  function dsFieldsForm(gid, tid, st) {
1099
1102
  return { groupId: gid, sourceId: tid, sourceType: st, detailFieldsGroup: "true", enableMerge: "true", needTableName: "true", params: "[]" };
1100
1103
  }
1104
+ // ---- 获取字段端点(2026-10-29 用户指定):GET /api/dw/newetl/chatBi/ds/getFields ----
1105
+ // 四个参数全部取自本次调用,不写死:
1106
+ // groupId ← --group
1107
+ // sourceId ← --table(数据集 / EB 表 id)
1108
+ // sourceType ← --source-type(缺省 LOGIC)
1109
+ // appId ← --app-id(仅显式传入时才带;EB 建模表单才需要)
1110
+ const DS_FIELDS_PATH = "/api/dw/newetl/chatBi/ds/getFields";
1111
+ function dsFieldsQuery(gid, tid, st, appId) {
1112
+ const q = new URLSearchParams();
1113
+ q.set("groupId", String(gid));
1114
+ q.set("sourceId", String(tid));
1115
+ q.set("sourceType", String(st || "LOGIC"));
1116
+ if (typeof appId === "string" && appId !== "" && appId !== "auto") q.set("appId", appId);
1117
+ return q.toString();
1118
+ }
1119
+ async function dsGetFields(gid, tid, st, appId) {
1120
+ return getUrl(DS_FIELDS_PATH + "?" + dsFieldsQuery(gid, tid, st, appId));
1121
+ }
1101
1122
  // ---- ds listByPage 端点(唯一):/api/dw/newetl/chatBi/ds/listByPage ----
1102
1123
  // ⚠️ 该端点**当前会忽略 name**(传 name/tableName/keyword/title 等任意字段名,都返回该分组全量,实测 8531 条)。
1103
1124
  // 后端后续会升级支持 name 过滤(用户 2026-09-24 确认)——升级后 `ds find` / `ds tables --name` **无需改代码**
@@ -1124,16 +1145,17 @@ async function cmdTables(args, json) {
1124
1145
  const recs = ((r.data || {}).records) || [];
1125
1146
  printHuman("分组表清单 (total=" + ((r.data || {}).total) + ")", recs, ["id", "name", "appId", "tableText", "detailTable", "usage"]);
1126
1147
  }
1148
+ // 取字段:GET /api/dw/newetl/chatBi/ds/getFields(2026-10-29 用户指定端点)
1149
+ // 返回形态 = **扁平字段数组**(data[] 里每个元素就是一个字段,没有 table node、没有子表节点)
1150
+ // ⇒ 与旧 /api/ebuilder/common/ds/fields 的 data[0].fields[] 不同,故 --json 原样透传、
1151
+ // 文本模式按扁平数组打印;下游(describe / 过滤脚本)按扁平形态归一。
1127
1152
  async function cmdFields(args, json) {
1128
1153
  const gid = need("group", args), tid = need("table", args);
1129
- const form = dsFieldsForm(gid, tid, args["source-type"] || "LOGIC");
1130
- if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1131
- const r = await postMultipart("/api/ebuilder/common/ds/fields", form);
1154
+ const r = await dsGetFields(gid, tid, args["source-type"] || "LOGIC", args["app-id"]);
1132
1155
  if (json) return r;
1133
- for (const d of (r.data || [])) {
1134
- console.log("== 表: " + d.name + " (id=" + d.id + ") 字段 " + (d.fields || []).length + " 个 ==");
1135
- for (const f of (d.fields || [])) console.log([f.name, f.text, f.type, f.mainPrimaryKey ? "PK" : ""].filter(Boolean).join("\t"));
1136
- }
1156
+ const fields = Array.isArray(r.data) ? r.data : [];
1157
+ console.log("== 字段 " + fields.length + " 个(getFields 扁平返回)==");
1158
+ for (const f of fields) console.log([f.name, f.text, f.type || f.compType, f.mainPrimaryKey ? "PK" : ""].filter(Boolean).join("\t"));
1137
1159
  }
1138
1160
  // fieldsForDSL:参数与返回结构同 cmdFields,仅接口换为 /api/dw/newetl/chatBi/ds/fieldsForDSL
1139
1161
  async function cmdFieldsForDSL(args, json) {
@@ -1555,7 +1577,24 @@ function collectFieldNodes(list, out, subName) {
1555
1577
  }
1556
1578
  }
1557
1579
  function normalizeFieldsResp(r) {
1558
- const node = (r.data || [])[0] || {};
1580
+ const data = Array.isArray(r.data) ? r.data : [];
1581
+ // ④ getFields 扁平形态(2026-10-29):data 直接是字段数组 —— 没有 table node、没有子表节点、没有表名。
1582
+ // 字段本身不丢(与旧端点字段名集合一致),但 isDetailtable 无从判断,只能靠 config.formTableId 区分归属。
1583
+ if (data.length && !data.some((d) => Array.isArray(d && d.fields))) {
1584
+ const all = data.map((f) => ({
1585
+ name: f.name ?? null, text: f.text ?? null, type: f.type || f.compType || null,
1586
+ isPK: !!f.mainPrimaryKey, subTable: null,
1587
+ dataKey: (f.config && f.config.dataKey) || null,
1588
+ formTableId: (f.config && f.config.formTableId) || null,
1589
+ }));
1590
+ return {
1591
+ tableName: null, tableId: null,
1592
+ mainCount: all.length, detailCount: 0,
1593
+ main: all, detail: [],
1594
+ shapeNote: "端点 getFields 返回扁平字段数组:不含表名与子表分组(isDetailtable),子表归属只能看 formTableId",
1595
+ };
1596
+ }
1597
+ const node = data[0] || {};
1559
1598
  const flat = [];
1560
1599
  collectFieldNodes(node.fields, flat, null);
1561
1600
  const main = flat.filter((x) => !x.__detail);
@@ -1586,9 +1625,7 @@ async function cmdDsDescribe(args, json) {
1586
1625
  const tasks = [
1587
1626
  (async () => {
1588
1627
  try {
1589
- const form = dsFieldsForm(gid, tid, sourceType);
1590
- if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1591
- return { key: "fields", value: normalizeFieldsResp(await postMultipart("/api/ebuilder/common/ds/fields", form)) };
1628
+ return { key: "fields", value: normalizeFieldsResp(await dsGetFields(gid, tid, sourceType, args["app-id"])) };
1592
1629
  } catch (e) { return { key: "fields", error: String((e && e.message) || e) }; }
1593
1630
  })(),
1594
1631
  (async () => {
@@ -3019,7 +3056,7 @@ async function cmdRefusalRules(args, json) {
3019
3056
  // 端点:/api/dw/newetl/chatBi/getSceneGroups(别名 /getAgentSceneGroups,等价)
3020
3057
  // ① scene groups → 分组骨架(不传 groupId;每个分组带 groupDesc 三段式)
3021
3058
  // ② scene items --group <groupId> → 该分组的条目(传 groupId;datasetType = interface / datasource)
3022
- // ③ scene search --keywords <词> → 关键词候选召回(2026-10-23 本地补丁;本地打分,非后端接口)
3059
+ // ③ scene search --keywords <词> → 关键词候选召回(把 keyword 交给后端过滤;未命中回退本地打分)
3023
3060
  // 来源:由技能 `references/scene-locate/scripts/locate.mjs` **整体迁入**(scene-locate.md §6 原计划的 CLI 替换),
3024
3061
  // 迁入后技能侧不再直连接口。参数与输出口径与脚本保持一致(`--client` 默认 agent、精简字段集、计数走 stderr)。
3025
3062
  // 形状口径(2026-10-14 修订):**默认输出顶层一律为数组** —— ① groups = 分组数组;② items = 分组数组
@@ -3127,15 +3164,23 @@ async function cmdSceneItems(args, json) {
3127
3164
  console.log(JSON.stringify(out, null, 1));
3128
3165
  }
3129
3166
 
3130
- /** ③ 关键词候选召回(2026-10-23 本地补丁;后端无对应接口,故在本地打分实现)
3131
- * 数据源 = getSceneGroups 元数据:
3132
- * ① 拉一次分组骨架,按 groupName 命中×3 + groupDesc 命中×1 打分,取前 --max-groups(默认 3);
3133
- * ② 仅对入选分组再各拉一次条目(1 个分组 = 1 次请求);
3134
- * ③ 条目按 datasetName 命中×3 + datasetDesc 命中×1 打分;组命中但条目全 0 分时,
3135
- * 回退「条目原序前 --max-items 条」(覆盖「关键词只落在组描述里」的情况)。
3167
+ /** ③ 关键词候选召回(2026-10-29 改为「后端 keyword 过滤优先,本地打分兜底」)
3168
+ * 数据源 = getSceneGroups 元数据。后端**支持** `keyword`(单数)query 参数,命中规则:
3169
+ * ① 分组级命中(关键词落在 groupName / groupDesc)→ 返回该分组**及其全部条目**;
3170
+ * ② 仅条目级命中(分组名/描述都不含,关键词落在 datasetName / datasetDesc)→ 只返回命中条目 + 父分组;
3171
+ * ③ 全不命中 → 空数组(不返回全量骨架);多个关键词逗号分隔,后端按 OR 取并集。
3172
+ * 因此本命令的正确姿势是**把关键词交给后端**,只有后端未命中/老服务端不支持时才回退本地打分:
3173
+ * ① server-filter≠off 时先发 1 次 `getSceneGroups?datasetClient=…&keyword=词1,词2`,
3174
+ * 命中则直接采用响应里的 datasets(不再逐组拉 items)→ 全程 1 次请求;
3175
+ * ② 未命中或请求失败且 --server-filter=auto → 回退旧逻辑:拉骨架 + 入选组各拉一次 items,
3176
+ * 按 groupName×3 + groupDesc×1 选组(前 --max-groups),条目按 datasetName×3 + datasetDesc×1 排序,
3177
+ * 组命中但条目全 0 分时回退「条目原序前 --max-items 条」;
3178
+ * ③ 条目裁剪:后端已做条目级过滤(组内 datasets 条数 < itemCount)时全保留;
3179
+ * 分组级命中(后端回了全量)才按 --max-items 截断,避免整组几十条 datasetDesc 撑爆上下文。
3136
3180
  * 形状:顶层数组,与 `scene items` 同构(分组 + datasets);`--json` 输出同一结构
3137
3181
  * (本命令无「原样透传对象」可言,--json 与默认输出一致)。
3138
- * 注意:关键词是**字面包含**匹配(忽略大小写),不做语义裁决 —— 候选仍需模型判断。
3182
+ * 注意:后端与本地判据都是**字面包含**(不做语义裁决);仍建议调用方对候选做一次语义判断。
3183
+ * stderr 会打印一行「请求 N 次 | 过滤方式 后端 keyword / 本地打分」,便于排查召回来源。
3139
3184
  */
3140
3185
  function sceneKeywords(raw) {
3141
3186
  return String(raw ?? "")
@@ -3178,40 +3223,98 @@ async function cmdSceneSearch(args, json) {
3178
3223
  const maxGroups = Math.max(1, Number(args["max-groups"] ?? 3) || 3);
3179
3224
  const maxItems = Math.max(1, Number(args["max-items"] ?? 12) || 12);
3180
3225
  const scope = args["scope-group-id"] == null ? "" : String(args["scope-group-id"]).trim();
3226
+ const serverFilter = String(args["server-filter"] ?? "auto").trim().toLowerCase();
3227
+ if (!["auto", "on", "off"].includes(serverFilter)) {
3228
+ throw new Error("--server-filter 值必须为 auto|on|off(当前: " + args["server-filter"] + ")");
3229
+ }
3230
+
3231
+ const inScope = (g) => !scope || String(g.groupId) === scope;
3232
+ const kw = keywords.join(",");
3233
+
3234
+ let pool = [];
3235
+ let requests = 0;
3236
+ let usedServer = false;
3181
3237
 
3182
- const raw = await sceneRequest({ datasetClient: client });
3183
- let requests = 1;
3184
- const all = Array.isArray(raw) ? raw : (raw == null ? [] : [raw]);
3185
- const pool = all.filter((g) => g && g.groupId !== undefined);
3238
+ // ① 优先把关键词交给后端过滤:GET getSceneGroups?datasetClient=…&keyword=词1,词2
3239
+ if (serverFilter !== "off") {
3240
+ try {
3241
+ const raw = await sceneRequest({ datasetClient: client, keyword: kw });
3242
+ requests += 1;
3243
+ const arr = Array.isArray(raw) ? raw : (raw == null ? [] : [raw]);
3244
+ const hit = arr.filter((g) => g && g.groupId !== undefined && inScope(g));
3245
+ if (hit.length) {
3246
+ pool = hit;
3247
+ usedServer = true;
3248
+ } else if (serverFilter === "auto") {
3249
+ process.stderr.write("# 后端 keyword 未命中,回退本地打分(keyword=" + kw + ")\n");
3250
+ }
3251
+ } catch (e) {
3252
+ if (serverFilter === "on") throw e;
3253
+ process.stderr.write("# [warn] 带 keyword 的召回失败,回退本地打分:" + ((e && e.message) || e) + "\n");
3254
+ }
3255
+ }
3186
3256
 
3187
- const scored = pool
3188
- .filter((g) => !scope || String(g.groupId) === scope)
3189
- .map((g) => ({ g, score: sceneHitScore(g.groupName, keywords) * 3 + sceneHitScore(g.groupDesc, keywords) }))
3257
+ // ② 决定候选分组:后端命中 → 直接用(只排序不筛);否则回退旧的本地打分
3258
+ let scored;
3259
+ if (usedServer) {
3260
+ scored = pool
3261
+ .map((g) => ({
3262
+ g,
3263
+ score: sceneHitScore(g.groupName, keywords) * 3 + sceneHitScore(g.groupDesc, keywords),
3264
+ items: Array.isArray(g.datasets) ? g.datasets : null,
3265
+ }))
3266
+ .sort((a, b) => (b.score - a.score) || ((b.items || []).length - (a.items || []).length))
3267
+ .slice(0, maxGroups);
3268
+ } else if (serverFilter === "on") {
3269
+ scored = [];
3270
+ } else {
3271
+ // 回退本地打分:必须拿到完整分组骨架(后端未命中时 pool 为空,不能复用)
3272
+ if (!pool.length) {
3273
+ const raw = await sceneRequest({ datasetClient: client });
3274
+ requests += 1;
3275
+ const arr = Array.isArray(raw) ? raw : (raw == null ? [] : [raw]);
3276
+ pool = arr.filter((g) => g && g.groupId !== undefined && inScope(g));
3277
+ }
3278
+ scored = pool
3279
+ .map((g) => ({
3280
+ g,
3281
+ score: sceneHitScore(g.groupName, keywords) * 3 + sceneHitScore(g.groupDesc, keywords),
3282
+ items: null,
3283
+ }))
3190
3284
  .filter((x) => x.score > 0)
3191
3285
  .sort((a, b) => b.score - a.score)
3192
3286
  .slice(0, maxGroups);
3287
+ }
3193
3288
 
3194
3289
  const out = [];
3195
- for (const { g } of scored) {
3196
- let items = Array.isArray(g.datasets) ? g.datasets : null;
3290
+ for (const cand of scored) {
3291
+ const g = cand.g;
3292
+ let items = cand.items;
3197
3293
  if (!items) {
3198
- const one = await sceneRequest({ datasetClient: client, groupId: String(g.groupId) });
3199
- requests += 1;
3200
- const arr = Array.isArray(one) ? one : (one == null ? [] : [one]);
3201
- const hit = arr.find((x) => x && String(x.groupId) === String(g.groupId)) || arr[0] || {};
3202
- items = Array.isArray(hit.datasets) ? hit.datasets : [];
3294
+ const one = await sceneRequest({ datasetClient: client, groupId: String(g.groupId) });
3295
+ requests += 1;
3296
+ const arr = Array.isArray(one) ? one : (one == null ? [] : [one]);
3297
+ const hit = arr.find((x) => x && String(x.groupId) === String(g.groupId)) || arr[0] || {};
3298
+ items = Array.isArray(hit.datasets) ? hit.datasets : [];
3203
3299
  }
3204
3300
  const ranked = items
3205
- .map((d) => ({ d, score: sceneHitScore(d.datasetName, keywords) * 3 + sceneHitScore(d.datasetDesc, keywords) }))
3206
- .sort((a, b) => b.score - a.score);
3207
- const matched = ranked.filter((x) => x.score > 0);
3208
- const keep = (matched.length ? matched : ranked).slice(0, maxItems);
3301
+ .map((d) => ({ d, score: sceneHitScore(d.datasetName, keywords) * 3 + sceneHitScore(d.datasetDesc, keywords) }))
3302
+ .sort((a, b) => b.score - a.score);
3303
+ // 后端过滤过 ⇒ 条目是「后端给的」,本地只排序不筛:
3304
+ // ① 仅条目级命中(serverPartial)时后端已筛好,全部保留;
3305
+ // ② 分组级命中时后端返回整组,其中未命中关键词的条目**同样可能相关**(关键词只是落在组描述里),
3306
+ // 不能再按 datasetName/datasetDesc 字面分把它们剔掉,否则会出现「57 条只剩 20 条」的丢量。
3307
+ // 只有「回退本地打分」这条路径才需要 score>0 过滤。
3308
+ const ordered = usedServer ? ranked
3309
+ : (() => { const m = ranked.filter((x) => x.score > 0); return m.length ? m : ranked; })();
3310
+ const serverPartial = usedServer && Number(g.itemCount ?? items.length) > items.length;
3311
+ const keep = usedServer && serverPartial ? ordered : ordered.slice(0, maxItems);
3209
3312
  const group = {
3210
- groupId: String(g.groupId),
3211
- groupName: g.groupName ?? "",
3212
- groupDesc: g.groupDesc ?? "",
3213
- itemCount: Number(g.itemCount ?? items.length),
3214
- datasets: keep.map((x) => sceneSlimItem(x.d)),
3313
+ groupId: String(g.groupId),
3314
+ groupName: g.groupName ?? "",
3315
+ groupDesc: g.groupDesc ?? "",
3316
+ itemCount: Number(g.itemCount ?? items.length),
3317
+ datasets: keep.map((x) => sceneSlimItem(x.d)),
3215
3318
  };
3216
3319
  // 前向兼容:后端下发 groupLogic 时自动带出
3217
3320
  if (g.groupLogic !== undefined) group.groupLogic = g.groupLogic;
@@ -3219,11 +3322,14 @@ async function cmdSceneSearch(args, json) {
3219
3322
  }
3220
3323
 
3221
3324
  const itemTotal = out.reduce((n, g) => n + g.datasets.length, 0);
3325
+ const filterMode = usedServer ? "后端 keyword" : (serverFilter === "on" ? "后端 keyword(无命中)" : "本地打分");
3222
3326
  if (!out.length) {
3223
- process.stderr.write("# 未命中任何分组(关键词:" + keywords.join(",") + ";候选池 " + pool.length + " 组)\n");
3327
+ process.stderr.write("# 未命中任何分组(关键词:" + keywords.join(",") + ";"
3328
+ + (serverFilter === "on" ? "后端 keyword 无命中,已按 --server-filter on 不回退" : "候选池 " + pool.length + " 组") + ")\n");
3224
3329
  }
3225
3330
  process.stderr.write("# 关键词 " + keywords.join(",")
3226
- + " | 候选分组 " + out.length + " 个 | 条目 " + itemTotal + " 条 | 请求 " + requests + " 次\n");
3331
+ + " | 候选分组 " + out.length + " 个 | 条目 " + itemTotal + " 条 | 请求 " + requests + " 次"
3332
+ + " | 过滤方式 " + filterMode + "\n");
3227
3333
  return out;
3228
3334
  }
3229
3335
 
@@ -3359,9 +3465,12 @@ refusal 拒答话术(chatBi,2026-10-13 新增)
3359
3465
  scene 场景定位元数据(chatBi getSceneGroups,2026-10-14 新增;**不取业务数据**,只做「分组 → 条目」定位)
3360
3466
  scene groups [--client agent|all|connector] # ① 分组骨架(groupDesc =【场景】【触发词】【取数顺序】)
3361
3467
  scene items --group <groupId> [--client …] # ② 组内条目(每组带 datasetDesc;datasetType 决定分流)
3362
- scene search --keywords <词1,词2> [--client …] # ③ 关键词候选召回(本地打分,2026-10-23 本地补丁)
3363
- [--max-groups 3] [--max-items 12] [--scope-group-id <gid>]
3364
- # 后端无此接口:拉一次 groups + 每个入选分组各拉一次 items,本地按关键词打分
3468
+ scene search --keywords <词1,词2> [--client …] # ③ 关键词候选召回(后端 keyword 过滤优先,miss 回退本地打分)
3469
+ [--max-groups 3] [--max-items 12] [--scope-group-id <gid>] [--server-filter auto|on|off]
3470
+ # 后端支持 keyword(单数,逗号分隔多词按 OR):分组级命中→整组全返;
3471
+ # 仅条目级命中→只返命中条目 + 父分组;全不命中→空数组
3472
+ # auto(默认)= 先发 keyword,未命中再回退本地打分;on = 只走后端;off = 只走本地
3473
+ # 命中后端时全程 1 次请求(不再逐组拉 items);stderr 会注明「过滤方式」
3365
3474
  # 输出与 ② items 同构(分组 + datasets);--json 同结构(非原样透传)
3366
3475
  # 端点 /api/dw/newetl/chatBi/getSceneGroups(别名 getAgentSceneGroups,等价)
3367
3476
  # 默认输出**精简 JSON**(groups: groupId/groupName/itemCount/groupDesc;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e10-data",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "泛微 E10 数据接口统一 CLI(data-assistant 专家配套):DSL 查询/数据源/文档/词库/关联字段翻译/环境参数配置中心(conf,支持 confGroup 分组)/专家包版本管理;ds 取数(list/chart/stat/count)的 appId 由调用方显式传入 --app-id(v0.1.14 起 CLI 不再自动反查;EB 建模表单 weaver-ebuilder-form-service 必须带,appId 取自 ebdf_obj.app_id;2026-10-09 起不再做 EB 权限前置校验);认证凭据**唯一来源 = weaver-work-cli**(通过 `auth export-weaver-env` 命令出口取明文凭据,**不设 e10-login 回退**;加解密由该 CLI 自行负责,本 CLI 不实现任何加解密;登录命令不在本 CLI 内,一律用 weaver-work-cli auth login)。v0.1.17 性能改造:免凭据命令(--version/--help/未知命令)提前返回、直连 e10-login JS 入口(去掉 shell:true 的 cmd.exe 开销)、新增凭据进程间 TTL 缓存(不落明文);v0.1.19(2026-09-24)新增 ds 发现层聚合 groups/find/describe(进程内并发,全 58 分组扫表实测 ~5.5s),并修复 ds tables --name 因 listByPage 端点切换导致的关键词过滤静默失效;v0.1.18(2026-09-24)新增 ds fields-dsl(接口 /api/dw/newetl/chatBi/ds/fieldsForDSL),并移除凭据明文镜像同步(不再写 ~/.workbuddy/e10-login-cache.md);v0.1.20(2026-09-24)凭据来源切换为 weaver-work-cli 唯一来源、移除 e10-login 回退,并补齐 userId/tenantKey 传递(修复 attend statis 等命令报「缺少查询人员」的问题)。v0.1.21(2026-09-28)新增 module call —— 模块配置接口调用(配置中心驱动):已确定「哪个模块的哪条具体接口」之后的一条命令取数入口(精取该条 confAttrs → 按 http_method/input_params 组参 → 发请求(自动禁代理 + Cookie/eteamsid/UA 三件套)→ 提取精简结果),与技能自带 script/call_module.py 等价(参数名/退出码/提取口径全对齐),复用本 CLI 凭据缓存与 conf 通道、零临时文件;同时 auth 归一化与凭据缓存补齐 userAgent(供 AgentType UA 使用,缺失时从 cookie 的 agentType= 推导)。v0.1.22(2026-10-10):移除 `eb perm-page` 命令(EB 建模表单取数不再做权限前置校验,appId 直接取 ebdf_obj.app_id);`--fields`/`--group-by`/`--order` 等逗号列表支持 `@文件`(逗号字符串或 JSON 数组,与 --where/--stat/--dsl 一致,规避 shell 拆分导致的静默丢列/0 行)。v0.1.23(2026-10-13)新增 `refusal get` —— 拒答话术 getRefusalSpeechTechnique(入参仅 type,返回对象的 data = 话术字符串;默认 auto 通道 = POST multipart 表单优先、返回不含 data 时自动回退 GET ?type=,可用 --http post|get 锁定单通道)。v0.1.25(2026-10-14)新增 `scene groups|items` —— 场景定位元数据 getSceneGroups(① groups 分组骨架 / ② items --group <groupId> 组内条目),由技能 references/scene-locate/scripts/locate.mjs 整体迁入(属元数据定位接口、不取业务数据):默认输出精简 JSON(groups: groupId/groupName/itemCount/groupDesc;items: id/datasetName/datasetType/sourceId[/moduleName/sourceType/sourceGroupId/appId]/datasetDesc),--json 原样输出接口返回,计数走 stderr;--client agent|all|connector 默认 agent。v0.1.26(2026-10-15)新增 `refusal rules` —— 拒答话术规则 getRefusalSpeechRules(**无入参**,返回对象的 data = 对象数组;调用方式与 refusal get 同口径:默认 auto 通道 = POST 空 multipart 表单优先、返回不含 data 时回退 GET,可用 --http post|get 锁定单通道;人读直接打印 data 数组,--json 原样输出整个响应对象)。v0.1.27(2026-10-15)新增 `noun scope --ids <id1,id2|@ids.json>` —— 词库**按生效范围批量检索**(端点 /api/dw/newetl/chatBi/doc/getChatBiDocByScopeId,POST body = 数据集 id 的 JSON 数组;入参 = 数据集 id 列表 = `scene items --group <分组id>` 里每个条目的 `id`;命中 = allScope=1 全局词库 ∪ scopeEffectId 含任一传入 id;id 按字符串下发,避免 19 位雪花号超 2^53 丢精度)。属**新增通道**,原 `noun list --skill <skillKey>`(getChatDocsBySkillKey)行为**完全不变**。 v0.1.28(2026-10-17):`module call` 支持 `--type <confType>`(自定义 confType / 域级配置分组,默认 `api`,不传时行为与旧版一致)与 `--show-params` 精简模式(默认省略 `request_example`,`--full` / `--with-example` 回显,`--no-example` 显式精简),`--show-params` 输出新增 `conf_type` 字段。v0.1.29(2026-10-19)本地增强:`module call` 新增 `--conf-file <文件|@文件>`——直接读本地 confAttrs 跳过 `conf get`(同一 confKey 一次问数只取一次配置),兼容模板缓存与 `conf get --json` 原样输出,带该参数时 `--group`/`--conf-key` 可从文件回填,stderr 提示「未请求配置中心」;缓存缺失/过期时去掉该参数即回退原路径。 v0.1.30(2026-10-19):query 透传后端 executeQueryDsl 新增的 4 个 @RequestParam(dimension=后端 confKey / confGroup / username / startDate,恒定发送、值允许为空,缺键即失败),并显式声明 Content-Type=application/json;charset=utf-8。v0.1.31(2026-10-21)凭据直读快路径(providerAuthFileDirect 直读 weaver-work-cli auth 文件解密,省 2 个子进程约 5.6s→~0.1s)+ cred-helper stale socket 自愈。v0.1.32(2026-10-22)新增 `field options` —— Select/下拉框字段的选项值清单 {id,name,archive}(GET /api/ebuilder/common/ds/options?fieldName=&objId=&sourceType=&groupId=,通用下拉框选项方法,不限 DSL/宽表:EB 表单与 ETEAMS 宽表均适用;--field-name 用 ds fields 的 name,--source-type 需显式传参 LOGIC|ETEAMS、代码缺省 LOGIC 兜底;Select 列存 value_key 时用它做显示映射,替代已停用的 id2name/字典表直查)。v0.1.33(2026-10-23):**移除 `login xiaoe-env`**(含别名 `xiaoe-env`)——登录链路统一交 weaver-work-cli(`auth login/status/export-weaver-env`),本 CLI 不再提供任何登录命令,未登录时提示执行 `weaver-work-cli auth login --base-url <域名>`;同版本新增 `scene search --keywords <词1,词2>` —— 场景关键词候选召回(本地打分:拉一次 groups + 每个入选分组各拉一次 items,按 groupName/datasetName 命中×3 + 描述命中×1 排序,输出与 `scene items` 同构,支持 `--max-groups 3` / `--max-items 12` / `--scope-group-id`)。",
5
5
  "author": "e10-dw",
6
6
  "license": "MIT",