nodejs-store 2.6.0 → 3.0.0

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/src/ask.js ADDED
@@ -0,0 +1,358 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AI 问数(L1)—— `ask()` 唯一入口 + `describeForAi` schema 摘要生成器
5
+ *
6
+ * 《AI能力接入设计-L1问数档.md》§4 的宿主侧两个新组件(M2 nodejs-store parity,
7
+ * 与 py_store/ask.py 逐函数同构),把既有能力接线:
8
+ *
9
+ * 用户问题 → ① describeForAi(ctx) 权限过滤摘要
10
+ * → ② LLM(注入式客户端)翻译为 {"gql","params"}
11
+ * → ③ text2query() 档内规划期校验(core 判决:语法/档位/权限/硬限)
12
+ * → ④ crud.query 执行(只读)
13
+ * → ⑤ 失败结构化回喂 LLM 重试(≤ maxRetries 次),耗尽抛 AskExhausted
14
+ *
15
+ * 护栏面(D5,服务端硬编码,LLM 零可触):
16
+ * - 档位 = text2query:本模块硬编码 `text2query()` 包裹全部执行;
17
+ * - 用户上下文 = ctx:服务端注入参数,经 permission.scopedContext 进执行面,
18
+ * 绝不进入任何 LLM 消息;core 档位门禁强制无 ctx 即拒(fail-secure,A4);
19
+ * - routeOverride = null:硬编码(CWE-639;Host 兜底 _guardRouteOverride 双保险);
20
+ * - 行数/深度/联邦硬限:core 常量(T2Q_MAX_ROWS=1000 / T2Q_MAX_DEPTH=3 / ...,A5);
21
+ * - 只读:编排面仅 crud.query(mutation/remove 属 L2,禁入)。
22
+ *
23
+ * LLM 输出永远当不可信输入:唯一产出形状 `{"gql","params"}` 单 JSON 对象(D3),
24
+ * 严格 JSON 解析、禁正则容错提取;幻觉最坏后果是「规划失败 + 结构化错误回喂」,
25
+ * 不可能变成不受控命令。一切失败结构化显式暴露(no-error-masking:是错就是错,
26
+ * 禁降级、禁返回空结果——「问数失败」就是失败,交上层裁决,D4)。
27
+ *
28
+ * 并发限制(如实声明):text2query 档位(core 单例)与反馈 sink 为进程级全局,
29
+ * 同一进程内并发调用 ask() 会互相串扰,宿主需串行化(或每任务独享进程/事件循环)。
30
+ */
31
+
32
+ const fs = require('node:fs');
33
+ const path = require('node:path');
34
+
35
+ const crud = require('./crud');
36
+ const feedback = require('./feedback');
37
+ const permission = require('./permission');
38
+ const schema = require('./schema');
39
+ const { text2query } = require('./profile');
40
+ const { getLlm } = require('./llm');
41
+
42
+ // ─── 轨迹载体 ──────────────────────────────────────────────────
43
+
44
+ /** 问数成功结果:查询数据 + 全部尝试轨迹 + 反馈事件(对齐 py AskResult) */
45
+ class AskResult {
46
+ /**
47
+ * - `data`:查询结果数组(crud.query 原样返回);
48
+ * - `attempts`:每次尝试一条(`llmRaw` / `gql` / `params` / `rows`),
49
+ * 最后一轮为成功轮——**不含任何 error 键**(成功态无错误字段,
50
+ * no-error-masking 正向断言);
51
+ * - `events`:执行期接管到的反馈事件(拦截/降级告警;无拦截时为空数组)。
52
+ */
53
+ constructor(data, attempts, events) {
54
+ this.data = data;
55
+ this.attempts = attempts;
56
+ this.events = events;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 重试耗尽:maxRetries 次回喂重试后仍失败(D4:显式失败,不降级、不返回空结果)
62
+ *
63
+ * `attempts` / `events` 携带全部尝试轨迹;消息内嵌最后一轮结构化错误,
64
+ * 上游不读属性也能看到失败原因。
65
+ */
66
+ class AskExhausted extends Error {
67
+ constructor(attempts, events) {
68
+ const lastError = (attempts.length && attempts[attempts.length - 1].error)
69
+ || { code: 'unknown' };
70
+ super(`ask() 重试耗尽(共 ${attempts.length} 次尝试全部失败),不降级、不返回空结果;`
71
+ + `最后一轮错误: ${JSON.stringify(lastError)};完整轨迹见 .attempts / .events`);
72
+ this.name = 'AskExhausted';
73
+ this.attempts = attempts;
74
+ this.events = events;
75
+ }
76
+ }
77
+
78
+ // ─── 组件 A:schema 摘要生成器 ─────────────────────────────────
79
+
80
+ // 计算列收窄告警去重(同 (model, compute) 只告警一次,对齐 py _COMPUTE_SKIP_SIGS)
81
+ const _computeSkipSigs = new Set();
82
+
83
+ /** 字段类型字符串(str 形态原样;dict 形态取 type;其余显式 null,不伪造) */
84
+ function _fieldType(fdef) {
85
+ if (typeof fdef === 'string') return fdef;
86
+ if (fdef && typeof fdef === 'object') return fdef.type;
87
+ return null;
88
+ }
89
+
90
+ function _emitComputeSkipped(model, compute) {
91
+ const sig = `${model}\u0000${compute}`;
92
+ if (_computeSkipSigs.has(sig)) return;
93
+ _computeSkipSigs.add(sig);
94
+ feedback.emit({
95
+ type: 'ask_summary_compute_skipped',
96
+ code: 'askSummaryComputeSkipped',
97
+ layer: 'host',
98
+ message: `AI 摘要收窄:模型 ${model} 的计算列 ${compute} 配置了 read 白名单,`
99
+ + 'core-node 绑定未导出 readableComputes 判决,为不越权暴露已从摘要排除',
100
+ hint: '去掉该计算列的 read 配置可进摘要;或在 core-node 导出 readableComputes '
101
+ + '后接入 describeForAi(执行面权限判决始终在 core,此处仅摘要暴露面收窄)',
102
+ model,
103
+ compute,
104
+ });
105
+ }
106
+
107
+ /**
108
+ * 输出 LLM 可读的 schema 摘要(紧凑 JSON 数组,每模型一条;对齐 py describe_for_ai)。
109
+ *
110
+ * 过滤规则(顺序固定,设计文档 §4.1):
111
+ * 1. 排除归档表(名称以 `Deleted` 结尾,对齐 store-api 派生路由先例);
112
+ * 2. 模型级按 core `canRead`;字段/关系按 core 角色可读集(`readableFields` /
113
+ * `readableRelations`,列级白名单);计算列按 core `readableComputes` 角色判决——
114
+ * 该判决在 core-node 已导出(rust-store「导出 readableComputes」任务合入后),
115
+ * 旧绑定未导出时按声明 read 白名单**保守收窄**并 emit 告警(宁缺勿泄,
116
+ * 对齐 py 现状;执行面判决始终在 core,收窄只影响摘要暴露面);
117
+ * 无 ctx → 仅暴露模型名与字段名,不暴露类型细节(防探针);
118
+ * 3. 不输出 indexes / datasource / namespace(运维细节不进 prompt)。
119
+ */
120
+ function describeForAi(ctx = null) {
121
+ const summaries = [];
122
+ for (const name of schema.list()) {
123
+ if (name.endsWith('Deleted')) continue;
124
+ const mirror = schema.get(name);
125
+ if (ctx == null) {
126
+ summaries.push({ name, fields: Object.keys(mirror.fields).sort() });
127
+ continue;
128
+ }
129
+ if (!permission.canReadSchema(name, ctx)) continue;
130
+ const readable = new Set(permission.getReadableFields(name, ctx));
131
+ const fields = {};
132
+ for (const [fname, fdef] of Object.entries(mirror.fields)) {
133
+ if (readable.has(fname)) fields[fname] = _fieldType(fdef);
134
+ }
135
+ for (const fname of [...readable]
136
+ .filter((f) => !(f in mirror.fields))
137
+ .sort()) {
138
+ // core 自动补的时间戳字段(createdAt/updatedAt)不在 Host 镜像——类型显式留白
139
+ fields[fname] = null;
140
+ }
141
+ const relations = {};
142
+ const readableRels = new Set(permission.getReadableRelations(name, ctx));
143
+ for (const [rname, rdef] of Object.entries(mirror.relations)) {
144
+ if (readableRels.has(rname)) {
145
+ relations[rname] = { model: rdef.model, type: rdef.type };
146
+ }
147
+ }
148
+ const computes = {};
149
+ // 新绑定:core 角色判决;旧绑定(core-node 未导出 readableComputes,如 npm
150
+ // rust-store-node 2.0.0):能力探测降级为「配 read 一律收窄 + 告警」(对齐 py 现状)
151
+ const readableComputes = typeof schema.core.readableComputes === 'function'
152
+ ? new Set(permission.getReadableComputes(name, ctx))
153
+ : null;
154
+ for (const [cname, cdef] of Object.entries(mirror.computes)) {
155
+ if (readableComputes !== null) {
156
+ if (!readableComputes.has(cname)) continue;
157
+ } else if (cdef.read !== undefined && cdef.read !== null) {
158
+ _emitComputeSkipped(name, cname);
159
+ continue;
160
+ }
161
+ const entry = {};
162
+ if (cdef.agg !== undefined && cdef.agg !== null) entry.agg = cdef.agg;
163
+ if (cdef.type !== undefined && cdef.type !== null) entry.type = cdef.type;
164
+ computes[cname] = entry;
165
+ }
166
+ summaries.push({ name, fields, relations, computes });
167
+ }
168
+ return summaries;
169
+ }
170
+
171
+ // ─── 组件 B:ask() 编排器 ──────────────────────────────────────
172
+
173
+ let _knowledgeCache = null;
174
+
175
+ /** 知识文本:传参优先(测试/自定义);缺省读包内 ask_knowledge.md(text-to-query 裁剪版) */
176
+ function _loadKnowledge(knowledge) {
177
+ if (knowledge !== undefined && knowledge !== null) return knowledge;
178
+ if (_knowledgeCache === null) {
179
+ _knowledgeCache = fs.readFileSync(path.join(__dirname, 'ask_knowledge.md'), 'utf8');
180
+ }
181
+ return _knowledgeCache;
182
+ }
183
+
184
+ /** system prompt = 翻译知识 + 摘要 + 输出契约(D3;含 "json" 字样满足 json_mode 预检) */
185
+ function _buildSystemPrompt(summary, knowledge) {
186
+ return `${knowledge}\n\n`
187
+ + '## 可用模型摘要(当前用户可见;字段/关系/计算列已按权限过滤)\n'
188
+ + `${JSON.stringify(summary)}\n\n`
189
+ + '## 输出契约(唯一产出形状)\n'
190
+ + '只输出一个 json 对象:{"gql": "<GQL 查询串>", "params": {<参数对象>}}。\n'
191
+ + '- 条件值一律参数化:GQL 内用 @key 引用,真实值放 params(键 = 去掉 @ 的引用名);\n'
192
+ + ' 禁止内联值进 GQL 串。\n'
193
+ + '- 禁止输出解释文字、markdown 代码围栏、或除 gql/params 外的任何键。\n';
194
+ }
195
+
196
+ /** LLM 输出未通过严格解析(携带结构化 detail 供回喂)——内部控制流,不外穿 */
197
+ class BadLlmOutput extends Error {
198
+ constructor(detail) {
199
+ super(detail.detail || 'badLlmOutput');
200
+ this.name = 'BadLlmOutput';
201
+ this.detail = detail;
202
+ }
203
+ }
204
+
205
+ /** 严格 JSON 解析 + 形状校验(D3);失败抛 BadLlmOutput(禁正则容错提取) */
206
+ function _parseLlmOutput(raw) {
207
+ const fail = (detail) => new BadLlmOutput({
208
+ code: 'badLlmOutput',
209
+ message: 'LLM 输出不是合法的 {"gql","params"} 单 JSON 对象',
210
+ detail,
211
+ raw: String(raw ?? '').slice(0, 500),
212
+ });
213
+
214
+ if (typeof raw !== 'string') throw fail(`输出不是字符串: ${typeof raw}`);
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(raw);
218
+ } catch (e) {
219
+ throw fail(`JSON 解析失败: ${(e && e.message) || e}`);
220
+ }
221
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
222
+ throw fail(`顶层不是 JSON 对象: ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
223
+ }
224
+ const keys = Object.keys(parsed).sort();
225
+ if (keys.length !== 2 || keys[0] !== 'gql' || keys[1] !== 'params') {
226
+ throw fail(`键集合必须恰为 gql/params,实际: ${keys}`);
227
+ }
228
+ const { gql, params } = parsed;
229
+ if (typeof gql !== 'string' || !gql.trim()) throw fail('gql 必须为非空字符串');
230
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
231
+ throw fail('params 必须为 JSON 对象');
232
+ }
233
+ return { gql, params };
234
+ }
235
+
236
+ /**
237
+ * 执行失败 → 回喂错误对象:core/Host 拦截事件**原样透传**(禁改写禁摘要);
238
+ * 无事件兜底构造时只给确证字段(feature/layer 留白不伪造)
239
+ */
240
+ function _errorFromException(e, roundEvents) {
241
+ for (const ev of roundEvents) {
242
+ if (ev && ev.type === 'profile_blocked') return { ...ev };
243
+ }
244
+ if (e instanceof permission.PermissionError) {
245
+ return { code: 'permissionDenied', message: String(e.message) };
246
+ }
247
+ if (e instanceof crud.ProfileViolation) {
248
+ return { code: 'profileBlocked', message: String(e.message) };
249
+ }
250
+ return { code: 'planError', message: String((e && e.message) || e) };
251
+ }
252
+
253
+ /**
254
+ * AI 问数唯一入口(L1 只读):自然语言 → LLM 翻译 → 受控沙箱执行 → 结构化回喂。
255
+ *
256
+ * 参数(options 对象,对齐 py keyword-only 形参):
257
+ * question : 自然语言问题(非空字符串)。
258
+ * llm : 注册名(string,经 llm.getLlm)或现成客户端
259
+ * (协议 `async (messages) => string`,D2/D6 注入式,零 SDK)。
260
+ * ctx : 服务端构造的用户上下文(`{userId: ..., roles: [...]}`);
261
+ * null/undefined 直接拒绝(fail-secure);LLM 永远碰不到本参数(D5)。
262
+ * maxRetries : 失败后的最大**重试**次数(总尝试 ≤ 1 + maxRetries);耗尽抛 AskExhausted。
263
+ * knowledge : 覆盖 system prompt 知识文本(缺省用包内 ask_knowledge.md)。
264
+ *
265
+ * 返回 AskResult(data, attempts, events);LLM 客户端自身的异常(网络/HTTP/空 content,
266
+ * LlmError 结构化)**原样穿透**——回喂循环只裁决「翻译质量/查询合法性」,
267
+ * 链路故障显式失败不重试。
268
+ *
269
+ * 轨迹契约:成功轮 attempt 不含 error 键;每次失败以 user 消息追加
270
+ * `{"error": {...}}`(core/Host 结构化错误原样透传,§4.3)。
271
+ */
272
+ async function ask(question, { llm, ctx, maxRetries = 3, knowledge = null } = {}) {
273
+ if (typeof question !== 'string' || !question.trim()) {
274
+ throw new TypeError('ask(question) 需要非空自然语言问题字符串');
275
+ }
276
+ if (ctx == null) {
277
+ // fail-secure:core text2query 档强制 ctx(ensure_profile_ctx)——入口先拒,
278
+ // 免一次注定失败的 LLM 调用;空对象等其余边界交 core/权限层如实判决
279
+ throw new TypeError(
280
+ 'ask() 需要服务端构造的用户上下文 ctx(如 { userId: ..., roles: [...] });'
281
+ + 'ctx 属受信参数,LLM 永远碰不到(D5)');
282
+ }
283
+ if (!Number.isInteger(maxRetries) || maxRetries < 0) {
284
+ throw new TypeError(`maxRetries 必须为非负整数,实际: ${JSON.stringify(maxRetries)}`);
285
+ }
286
+ if (llm === undefined) {
287
+ throw new TypeError('ask() 需要 LLM 客户端 llm(注册名或符合协议的 async (messages) => string 函数)');
288
+ }
289
+ const client = typeof llm === 'function' ? llm : getLlm(llm);
290
+ const system = _buildSystemPrompt(describeForAi(ctx), _loadKnowledge(knowledge));
291
+
292
+ const messages = [
293
+ { role: 'system', content: system },
294
+ { role: 'user', content: question },
295
+ ];
296
+ const attempts = [];
297
+ const events = [];
298
+ const roundEvents = [];
299
+
300
+ const collect = (event) => {
301
+ events.push(event);
302
+ roundEvents.push(event);
303
+ };
304
+
305
+ const prevSink = feedback.getSink();
306
+ feedback.setSink(collect);
307
+ let result;
308
+ try {
309
+ // 成功轮在内层闭包 return AskResult;循环走完(耗尽)内层返回 undefined
310
+ result = await text2query(() => permission.scopedContext(ctx, async () => {
311
+ for (let i = 0; i < 1 + maxRetries; i++) {
312
+ roundEvents.length = 0;
313
+ // LLM 客户端异常(llmNetworkError/llmHttpError/llmEmptyContent)原样穿透
314
+ const raw = await client(messages);
315
+ const attempt = { llmRaw: raw };
316
+ let parsed;
317
+ try {
318
+ parsed = _parseLlmOutput(raw);
319
+ } catch (e) {
320
+ if (!(e instanceof BadLlmOutput)) throw e;
321
+ attempt.error = e.detail;
322
+ attempts.push(attempt);
323
+ messages.push({ role: 'assistant', content: raw });
324
+ messages.push({ role: 'user', content: JSON.stringify({ error: e.detail }) });
325
+ continue;
326
+ }
327
+ attempt.gql = parsed.gql;
328
+ attempt.params = parsed.params;
329
+ try {
330
+ // routeOverride 硬编码 null(受信参数,禁 AI 侧指定,D5/CWE-639)
331
+ const data = await crud.query(parsed.gql, parsed.params, null);
332
+ attempt.rows = data.length;
333
+ attempts.push(attempt);
334
+ return new AskResult(data, attempts, events);
335
+ } catch (e) {
336
+ attempt.error = _errorFromException(e, roundEvents);
337
+ attempts.push(attempt);
338
+ messages.push({ role: 'assistant', content: raw });
339
+ messages.push({ role: 'user', content: JSON.stringify({ error: attempt.error }) });
340
+ }
341
+ }
342
+ }));
343
+ } finally {
344
+ feedback.setSink(prevSink);
345
+ }
346
+ if (result !== undefined) return result;
347
+ throw new AskExhausted(attempts, events);
348
+ }
349
+
350
+ module.exports = {
351
+ AskResult,
352
+ AskExhausted,
353
+ ask,
354
+ describeForAi,
355
+ // 内部件(测试取证用;下划线惯例对齐 crud/_ctx 等先例)
356
+ _computeSkipSigs,
357
+ _parseLlmOutput,
358
+ };
@@ -0,0 +1,250 @@
1
+ <!-- ask() system prompt 知识源(text-to-query 通用部分裁剪版)。
2
+
3
+ 来源:common-store/.agents/skills/text-to-query/SKILL.md
4
+ 裁剪:剔除 frontmatter / 触发时机 / 分语言路由 / 「前置:拿到 Schema」(由
5
+ describe_for_ai 摘要承担)/ 「交付物格式」(由 ask() 输出契约承担);
6
+ 其余 GQL 契约逐段保留。SKILL.md 更新 GQL 契约时须同步本文件(漂移以 SKILL.md
7
+ 为准,发现即修)。发布形态:本文件随 nodejs-store 包分发(fs 读取,src/ 目录整体随 npm files 分发),
8
+ npm 用户零外部依赖。 -->
9
+
10
+ 把一句自然语言 / 业务问题,翻译成 **GQL 查询串 + params 参数**。只做「产出查询」,
11
+ 不做任何执行/IO;产出的 GQL + params 会被数据层 core 解析、按当前档位与权限判决后执行。
12
+
13
+ ## GQL 语法(唯一产出形状)
14
+
15
+ ```
16
+ Model($condition:@c0 [,$sort:@s0] [,$skip:@sk] [,$limit:@l] [,$group:@g0] [,$having:@h0]){
17
+ 标量字段, 计算列, 关系名($condition:@c1 [,$sort:@s1] [,$skip:@sk1] [,$limit:@l1]){ 子字段… }
18
+ }
19
+ ```
20
+
21
+ - `Model(...)`:顶层入口;字段块 `{ ... }` 决定返回列与关系子查询。
22
+ - **参数段命名固定**:`$condition` / `$sort` / `$skip` / `$limit`(根级另有 `$group` / `$having`;
23
+ 关系块内只用前四个)。参数值是**引用** `@xxx`,真实值放 `params` 对象(键 = 去掉 `@` 的引用名)。
24
+ ```json
25
+ { "c0": { <Mongo filter> }, "s0": { <字段: 1|-1> }, "sk": 20, "l": 10,
26
+ "g0": { "by": [...], "agg": {...} }, "h0": { <分组条件> } }
27
+ ```
28
+ - **不要**把条件值内联进 GQL 串;一律参数化。
29
+ - 字段块可以是:标量字段名、计算列名、关系名(带 `()` 或 `{}` 即为关系,见下)。
30
+ - 无投影时也至少选 `_id`:`Post{_id}`。无条件/无排序/无分页时省略对应参数段:`Post{title}`。
31
+
32
+ ### 关系子查询
33
+
34
+ - `many` 关系:`关系名{子字段}` → 结果为**数组**(无匹配 → `[]`)。
35
+ - `one` 关系:子查询结果并入父文档(无匹配 → `null`)。
36
+ - 关系名后「参数 + 选择集」可同时出现:`items($condition:@c1,$sort:@s1,$limit:@l1){ sku, qty }`;
37
+ 也可只带参数不写选择集。
38
+ - 关系级 `$condition` / `$sort` / `$skip` / `$limit` 的**作用域 = 本级集合**;`$skip/$limit` 恒为
39
+ **每父 top-N**(每父独立取前 N 条)。子查询块内可按需再嵌套子关系。
40
+ - **别名规则**:**不支持** `alias: relName` 别名语法;**一切引用(`$condition` 键、
41
+ `$group.agg`、`$sort` 关系路径、计算列 agg 路径)一律写 schema 关系名**。
42
+ - 关系级 `$condition` / `$sort` 的字段形态与根级**同规**:U1~U4 按档(`standard` 放行 /
43
+ `text2query` Err);数组索引路径(`tags.0`)两档一律 Err。详见 §档位清单。
44
+
45
+ ### 条件 `$condition`:Mongo 算子
46
+
47
+ 顶层必须是**对象或 `null`**(`null`/`{}` = 无过滤);其它类型 → Err。
48
+
49
+ | 算子 | 含义 | 示例 |
50
+ |---|---|---|
51
+ | 顶级键值 | 相等(implicit `$eq`) | `{ "status": "draft" }` |
52
+ | `$eq` / `$ne` | 相等/不等(`null` 走 `IS [NOT] NULL` + 存在性判定) | `{ "status": { "$ne": "deleted" } }` |
53
+ | `$gt` / `$gte` / `$lt` / `$lte` | 范围 | `{ "amount": { "$gte": 10 } }` |
54
+ | `$in` / `$nin` | 属于/不属于列表 | `{ "views": { "$in": [5, 10] } }` |
55
+ | `$exists: true/false` | 字段显式存在(含显式 `null`) | `{ "paidAt": { "$exists": true } }` |
56
+ | `$regex` / `$options` | 字符串正则 | `{ "title": { "$regex": "^你好", "$options": "i" } }` |
57
+ | `$and` / `$or` / `$nor`(数组) | 逻辑组合 | `{ "$or": [ {…}, {…} ] }` |
58
+ | `$not` | 字段级取反包裹 | `{ "views": { "$not": { "$gt": 100 } } }` |
59
+
60
+ 边界(**规划期显式 Err,绝不静默**):
61
+
62
+ - 未识别算子(`$expr` / `$elemMatch` / `$all` …)与拒绝名单 `$where` / `$function` / `$accumulator` → Err。
63
+ - 空逻辑组 `$and:[]` / `$or:[]` / `$nor:[]` → Err。
64
+ - **数组字段条件(U1)、对象字段整值条件(U2)、对象点号路径条件(U3)**:`text2query` 档 Err。
65
+ **数组索引路径(`tags.0`)两档一律 Err**。跨表语义应建模为 `relations`(见关系聚合谓词)。
66
+ - 嵌套 **object 子字段的投影**(`meta{title}`)会被展平为点号字段(`meta.title`)后输出;作为过滤 /
67
+ 排序键时按 U3 / U4 分档(`text2query` 档 Err)。
68
+
69
+ ### `$sort` / `$skip` / `$limit`
70
+
71
+ - `$sort` 值形状 `{ 字段: 1|-1 }`;键可为标量字段,或**关系路径**(`bidders.amount`,逐层解析)。
72
+ - **对象点号路径排序(U4)**:`text2query` 档 Err。
73
+ - `$skip` / `$limit` 值形状为整数。
74
+ - 边界:`$sort` 键应使用标量字段或已建模关系路径(未知字段 / object·array 字段 / 关系名本身
75
+ 会导致该键不下推或 Err)。
76
+
77
+ ### 计算列请求
78
+
79
+ 在选择集里直接写**计算列名**即可,按定义形态分两类:
80
+
81
+ | 形态 | 求值位置 | 说明 |
82
+ |---|---|---|
83
+ | `fn` / `asyncFn` | 应用层 | `depends` 依赖字段会自动并入投影 |
84
+ | `agg` | **引擎内联** | schema 声明如 `{ "$count": "lessons" }` / `{ "$sum": "lessons.duration" }`;被请求时才发射聚合 |
85
+
86
+ - `agg` 白名单:`$count / $sum / $avg / $min / $max`;路径 = 关系名(`$count`)或 `关系名.子字段`。
87
+ - 空集语义:`$count` → `0`;`$sum/$avg/$min/$max` → `null`。
88
+
89
+ ### 根级 `$group` + `$having`(成组聚合,终结路径)
90
+
91
+ ```
92
+ Course($condition:@c0, $group:@g0, $having:@h0, $sort:@s0, $skip:@sk, $limit:@l0){
93
+ status, n, total # 选择集 ⊆ by 键 ∪ agg 别名
94
+ }
95
+ ```
96
+ ```json
97
+ {
98
+ "c0": { "publishedAt": { "$exists": true } },
99
+ "g0": {
100
+ "by": ["status", "meta.level"],
101
+ "agg": {
102
+ "n": { "$count": "*" },
103
+ "paid": { "$count": "paidAt" },
104
+ "total": { "$sum": "price" },
105
+ "avgRate": { "$avg": "rating" },
106
+ "maxRate": { "$max": "rating" },
107
+ "minRate": { "$min": "rating" }
108
+ }
109
+ },
110
+ "h0": { "n": { "$gt": 1 } },
111
+ "s0": { "total": -1 },
112
+ "l0": 10
113
+ }
114
+ ```
115
+
116
+ **固定执行序**:`$condition`(WHERE) → `$group`(GROUP BY) → `$having`(HAVING) →
117
+ `$sort` → `$skip/$limit` → 投影。有 `$group` 时,排序/分页作用于**分组结果**。
118
+
119
+ 合法性规则(全部**规划期 Err**,不静默补空):
120
+
121
+ - `by`:字段名数组,**仅标量域**(可含 object 点号路径;`text2query` 档 U3 收缩);重复键 → Err;
122
+ 引用关系名 → Err;引用 schema 外字段 → Err;数组字段 → Err;裸对象字段(无点号)→ Err。
123
+ 省略 / `[]` = **全表单组**(无 `GROUP BY`;空输入仍返回 1 行)。
124
+ - `agg`:`{ 别名: { 算子: 参数 } }`,每个别名**恰有一个算子键**;别名不得为空、不得与 `by` 键冲突;
125
+ 算子须在白名单内。`$count` 参数可为 `"*"`(行数)或**标量字段名**(非空计数);
126
+ `$sum/$avg/$min/$max` 必须带**标量字段名**(不可点号路径、不可关系/数组/对象)。
127
+ - **选择集**:必须显式非空,且 ⊆ `by` 键 ∪ `agg` 别名(引用未声明字段 → Err)。
128
+ - `$having`:**必须与 `$group` 同用**(无 `$group` → Err);须为条件对象,**仅可引用 `by` 键 / `agg` 别名**,
129
+ 支持 `$and/$or/$nor`;引用其它字段或其它 `$` 算子 → Err。
130
+ - `$sort` 键域 = `by` 键 ∪ `agg` 别名(越界 → Err)。
131
+ - **不支持关系字段**:带关系块(子查询)→ Err;与关系聚合谓词(§9.6)同时使用 → Err。
132
+ - 输出:每组一行(`by` 键 + 被请求的 `agg` 别名);**不输出 `_id`**(除非 `_id` 在 `by` 里)。
133
+
134
+ ### §9.6 关系聚合谓词(跨表条件过滤 / semi-join)
135
+
136
+ **入口**:`$condition` 的键命中 schema 的**关系名**(而非 array/object 字段名)。
137
+ 它是 `$condition` 的一种键型,在根 `$group` 之前执行,**不新增阶段/执行序**。
138
+
139
+ **主形式**(谓词多、需 AND 组合时;键仅允许 `filter` / `agg` / `having`):
140
+
141
+ ```json
142
+ { "c0": {
143
+ "$and": [
144
+ { "status": "onSale" },
145
+ { "orders": {
146
+ "filter": { "status": "paid" },
147
+ "agg": { "n": { "$count": "*" }, "amt": { "$sum": "amount" } },
148
+ "having": { "n": { "$gt": 3 }, "amt": { "$gte": 1000 } }
149
+ } }
150
+ ]
151
+ } }
152
+ ```
153
+
154
+ - `filter`:可选,子级过滤(仅标量域,U1~U4 → Err)。
155
+ - `agg`:**主形式必填**(省略无法推导 `having` 引用的聚合 → Err),复用 `$condition` 算子集。
156
+ - `having`:**必填**,只可引用本块 `agg` 别名;未引用任何 agg 别名 → Err。
157
+
158
+ **简写形式**(关系名下 = 可选 `$filter` + **恰好一个**聚合谓词;谓词值形状 `{ "$of"?: field, "<比较算子>": value }`):
159
+
160
+ | 意图 | 简写 |
161
+ |---|---|
162
+ | 有 / 无该关系 | `{ "orders": { "$exists": true } }` / `false` |
163
+ | 数量 > 3 | `{ "orders": { "$count": { "$gt": 3 } } }` |
164
+ | 非空字段计数 ≥ 4 | `{ "orders": { "$count": { "$of": "paidAt", "$gte": 4 } } }` |
165
+ | 求和 / 均值 / 极值 | `{ "orders": { "$sum": { "$of": "amount", "$gt": 1000 } } }`、`{"$avg"/"$min"/"$max": { "$of": …, "<比较算子>": … } }` |
166
+ | 带子过滤的计数 | `{ "orders": { "$filter": { "status": "paid" }, "$count": { "$gt": 3 } } }` |
167
+
168
+ - 比较算子白名单:`$gt / $gte / $lt / $lte / $eq / $ne`(仅一个)。
169
+ - `$sum/$avg/$min/$max` **必须**带 `$of`;`$count` 的 `$of` 可选(省略 = 对行数)。
170
+ - 多谓词 → 用**主形式 `having`**(简写不支持多算子)。
171
+
172
+ **语义与边界**:
173
+
174
+ - 语义 = **semi-join**(父行数量与文档形状都不变,**不扇出**);
175
+ `{ "$not": { "关系名": { … } } }` 或 `$exists:false` → **anti-join**(`$not` 仅可包裹**单个**关系谓词)。
176
+ - 可与标量条件、`$and/$or/$not` 任意组合;**多个关系谓词可并存**。
177
+ - **关系聚合谓词只能引用一层关系,谓词字段只能是一层字段**;二级关系路径
178
+ (如 `orders.items.price` 的下钻)→ **Err**(仅支持一级关系,不可 `关系.字段` 再下钻)。
179
+ - 关系**不可读**(权限)→ **Err**(不得静默当 `false`)。
180
+ - 与根级 `$group` **不可同时使用**(→ Err);与分组后的 `$having` 语义不同,勿混淆。
181
+
182
+ ## 翻译步骤(从需求 → 查询)
183
+
184
+ 1. 从「可用模型摘要」定位目标模型;识别需求里出现的**意图**:
185
+ - 「查/找/列出」 → find;字段块内挑展示字段。
186
+ - 「统计/有多少/计数/求和/平均/最大最小/按 X 分组」 → **根级 `$group`**(+ `$having` 过滤分组结果)。
187
+ - 「有/无某关系的记录」「某关系的**数量/求和/均值** 大于/等于 N」 → **§9.6 关系聚合谓词**。
188
+ - 「每条 XX 的某关系聚合值(如课程数、总时长)」 → **关系滚动聚合计算列**(摘要中带 `agg` 的计算列)。
189
+ - 「最新/最热/前 N 条」 → `$sort` + `$limit`;「跳过前 K 条」 → `$skip`。
190
+ - 「附带/展示它的 XX / 每条订单的明细」 → 关系子查询(关系块)。
191
+ 2. 把条件里的自然语言词映射到算子上(「大于…」「不超过」「至少」「不在…中」「有/没有…」等)。
192
+ 3. 字段名、关系名、计算列名**必须与摘要完全一致**(不确定就只用摘要里有的名字,禁止臆造)。
193
+
194
+ ## 示例(自然语言 → GQL + params)
195
+
196
+ **① 筛选 + 排序 + 分页**
197
+ - 「views 超过 100 或状态为 draft 的帖子,按 views 降序取前 10」 →
198
+ `Post($condition:@c0,$sort:@s0,$limit:@l){ _id, title, views, status }`、
199
+ `{ "c0": { "$or": [ { "views": { "$gt": 100 } }, { "status": "draft" } ] }, "s0": { "views": -1 }, "l": 10 }`
200
+
201
+ **② 关系子查询(many + 每父 top-N)**
202
+ - 「最近到账、金额大于 10 的订单,带前 5 条明细条目」 →
203
+ `Order($condition:@c0,$sort:@s0,$limit:@l){ code, amount, items($sort:@s1,$limit:@l1){ sku, qty } }`、
204
+ `{ "c0": { "paidAt": { "$exists": true }, "amount": { "$gt": 10 } }, "s0": { "paidAt": -1 }, "l": 50, "s1": { "qty": -1 }, "l1": 5 }`
205
+
206
+ **③ 根级分组聚合(group by + having)**
207
+ - 「按状态分组,统计每个状态的课程数与总课时;只要课程数大于 1 的组,按课程数降序取前 20」 →
208
+ `Course($condition:@c0,$group:@g0,$having:@h0,$sort:@s0,$limit:@l0){ status, n, total }`、
209
+ ```json
210
+ { "c0": { "status": { "$ne": "deleted" } },
211
+ "g0": { "by": ["status"], "agg": { "n": { "$count": "*" }, "total": { "$sum": "price" } } },
212
+ "h0": { "n": { "$gt": 1 } },
213
+ "s0": { "n": -1 },
214
+ "l0": 20 }
215
+ ```
216
+
217
+ **④ 关系聚合谓词(semi-join)**
218
+ - 「订单数大于 3 的在售商品,按名称排序」 →
219
+ `Product($condition:@c0,$sort:@s0){ _id, name }`、
220
+ `{ "c0": { "$and": [ { "status": "onSale" }, { "orders": { "$count": { "$gt": 3 } } } ] }, "s0": { "name": 1 } }`
221
+
222
+ **⑤ 关系滚动聚合计算列**
223
+ - 「查已发布课程及其课时总数(`lessonCount`)」 →
224
+ `Course($condition:@c0){ _id, title, lessonCount }`、
225
+ `{ "c0": { "status": "published" } }`
226
+ (`lessonCount` 为摘要中带 `agg` 的计算列,如 `{ "$count": "lessons" }`。)
227
+
228
+ ## 后端无关性与边界
229
+
230
+ - 此 GQL 是**方言无关**的:数据层 core 统一解析后落各后端;只产出 GQL+params,不手写 SQL。
231
+ - **显式 Err(跨后端归一)**:未识别/被拒绝的算子;数组/对象字段过滤与对象点号路径过滤/排序
232
+ (U1~U4,**仅 `text2query` 档**);二级关系路径;关系不可读;`$group` 带关系字段或与关系聚合
233
+ 谓词并用;`$having` 无 `$group`;`$group` 选择集越出 `by ∪ agg`;`compute.agg` 算子越出白名单。
234
+ - **已移除 / 不存在的能力(不要产出)**:`$exclude` 排除式投影(不存在,投影仅包含式)。
235
+ - `object/array` 字段:`text2query` 档禁用点式筛选/排序(U1~U4);跨表语义应走已建模的
236
+ `relations`,不要直接对 object/array 字段做深层点式筛选。
237
+
238
+ ### 当前档位:text2query(功能收缩,禁产出下列任何一项)
239
+
240
+ 本次查询固定在 **text2query 档**执行(AI 问数沙箱:单次 ≤1000 行 / 关系深度 ≤3 / 强制用户上下文 /
241
+ 禁 route_override)。下列项**一律显式 Err**,**不要产出**:
242
+
243
+ - U1~U4:object/array 字段的点式筛选或排序(整值条件、点号路径);
244
+ - `$pipeline` 直通(用户自带聚合管道);
245
+ - `$group.by` 的 object 点号路径;
246
+ - `$where` / `$function` / `$accumulator`;未识别算子;空逻辑组;
247
+ - 数组索引路径(`tags.0`)。
248
+
249
+ 其余(标量字段筛选/排序/分页、关系子查询、计算列、根级 `$group`/`$having`、关系聚合谓词)均可正常产出;
250
+ 单次取数行数上限 1000(省略 `$limit` 也按 1000 封顶,无需因此改写查询)。