dsh-math-calculator 2.1.1 → 2.2.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/CHANGELOG.md +921 -0
- package/README.md +64 -50
- package/dsh.plugin.json +2 -2
- package/lib/__wtest.txt +1 -0
- package/lib/awareness.js +69 -20
- package/lib/format.js +522 -13
- package/lib/formula-library-em.js +428 -0
- package/lib/formulas.js +28 -1
- package/lib/guards.js +653 -0
- package/lib/help.js +509 -0
- package/lib/index.js +1357 -212
- package/lib/ops/shared.js +547 -6
- package/lib/ops/units-op.js +116 -10
- package/lib/ops/workspace-op.js +10 -0
- package/lib/playbook.js +32 -7
- package/lib/types/format.d.ts +31 -1
- package/lib/types/formula-library-em.d.ts +28 -0
- package/lib/types/formulas.d.ts +2 -0
- package/lib/types/guards.d.ts +255 -0
- package/lib/types/help.d.ts +70 -0
- package/lib/types/index.d.ts +34 -1
- package/lib/types/ops/shared.d.ts +87 -3
- package/lib/types/playbook.d.ts +28 -0
- package/lib/types/units-data.d.ts +253 -0
- package/lib/types/units.d.ts +54 -4
- package/lib/types/workspace.d.ts +50 -0
- package/lib/units-data.js +1037 -0
- package/lib/units.js +82 -117
- package/lib/workspace.js +54 -0
- package/package.json +1 -1
- package/src/awareness.ts +69 -20
- package/src/format.ts +520 -12
- package/src/formula-library-em.ts +435 -0
- package/src/formulas.ts +35 -4
- package/src/guards.ts +673 -0
- package/src/help.ts +543 -0
- package/src/index.ts +1492 -213
- package/src/ops/shared.ts +589 -9
- package/src/ops/units-op.ts +125 -9
- package/src/ops/workspace-op.ts +10 -0
- package/src/playbook.ts +32 -7
- package/src/units-data.ts +1128 -0
- package/src/units.ts +149 -130
- package/src/workspace.ts +65 -0
package/lib/index.js
CHANGED
|
@@ -23,20 +23,19 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
23
23
|
import { nullspaceOf, rankOf, singularValuesOf } from './numeric.js';
|
|
24
24
|
import { ambiguousSymbols, explainSymbol, symbolMeanings } from './symbols.js';
|
|
25
25
|
import { DOMAINS, DOMAIN_NAMES, LAYERS, domainCounts, domainsOf, formulasByDomain, formulasByLayer, layerCounts, layerOf, } from './taxonomy.js';
|
|
26
|
-
import { containsNonFinite, formatValue, toFiniteNumber, toJsonValue, unwrapResult, } from './format.js';
|
|
26
|
+
import { containsNonFinite, formatValue, isNonFiniteValue, toFiniteNumber, toJsonValue, unwrapResult, } from './format.js';
|
|
27
27
|
import { describe, binomialCdf, binomialPmf, chiSquareCdf, exponentialCdf, linearRegression, normalCdf, normalInv, normalPdf, poissonCdf, poissonPmf, polynomialRegression, quantile, studentTCdf, } from './stats.js';
|
|
28
28
|
import { SymbolicError, symbolicDerivative, symbolicExpand, symbolicIntegral, symbolicRootsWithComplex, symbolicSimplify, } from './symbolic.js';
|
|
29
29
|
import { parseLinearEquation, solveLinearSystem_, splitEquations, } from './systems.js';
|
|
30
30
|
import { FormulaError, buildFormulaScope, describeFormula, listFormulasByFrequency, listFormulaCategories, resolveExpression, resolveParams, resolveFormulaStrict, scoreFormulas, } from './formulas.js';
|
|
31
31
|
import { BUILTINS } from './formula-builtins.js';
|
|
32
32
|
import { getActiveConstants, } from './constants-graph.js';
|
|
33
|
-
import { currentWorkspace, loadWorkspace, bindHostWorkspace, cwdOfExec, cwdOfSession, displayPrecisionOf, scopeFromCwd, setLastExecShape, switchWorkspace, } from './workspace.js';
|
|
33
|
+
import { currentWorkspace, loadWorkspace, bindHostWorkspace, cwdOfExec, cwdOfSession, displayPrecisionOf, scopeFromCwd, setLastExecShape, switchWorkspace, workspaceVariablesOf, } from './workspace.js';
|
|
34
34
|
import { buildApiRoutes } from './api.js';
|
|
35
35
|
import { materializeFormulas, } from './formula-table.js';
|
|
36
|
-
import { FORMULAS } from './formulas.js';
|
|
37
36
|
import { TOOL_DESCRIPTION, buildSystemPromptSection, setPromptMode, suggestOperation, } from './awareness.js';
|
|
38
37
|
import { buildReminderMessage, recordPrompt, shouldPromptNow, } from './detector.js';
|
|
39
|
-
import { CalcError, bignum, currentDisplayPrecision, fast, normalizeSource, safeNumberForJson, setActiveDisplayPrecision, trimNumber, } from './ops/shared.js';
|
|
38
|
+
import { CalcError, bignum, currentDisplayPrecision, fast, normalizeSource, normalizeChineseUnits, rational, safeNumberForJson, setActiveDisplayPrecision, trimNumber, } from './ops/shared.js';
|
|
40
39
|
import { PRECISION_LOSS_THRESHOLD, consumePrecisionLoss, isDisplayPrecisionExplicit, setDisplayPrecisionExplicit, } from './precision-report.js';
|
|
41
40
|
import { opOde } from './ops/ode-op.js';
|
|
42
41
|
import { opWorld } from './ops/world-op.js';
|
|
@@ -47,6 +46,9 @@ import { opQuantities } from './ops/quantities-op.js';
|
|
|
47
46
|
import { opFormulaTable } from './ops/formula-table-op.js';
|
|
48
47
|
import { opFeasibility } from './ops/feasibility-op.js';
|
|
49
48
|
import { opDerivation } from './ops/derivation-op.js';
|
|
49
|
+
import { WORLD_KEYS } from './world-params.js';
|
|
50
|
+
import { opHelp } from './help.js';
|
|
51
|
+
import { annotateMagnitude, annotateMetricPrefix, checkAnchors, checkAngleUnit, checkLabelMagnitude, explainUnitResult, findBatchAnomalies } from './guards.js';
|
|
50
52
|
import { opCustom } from './ops/custom-op.js';
|
|
51
53
|
import { opUnits } from './ops/units-op.js';
|
|
52
54
|
import { opBalance } from './ops/balance-op.js';
|
|
@@ -172,7 +174,207 @@ const RISKY_FUNCTION_LIMITS = new Map([
|
|
|
172
174
|
// 错误
|
|
173
175
|
// ────────────────────────────────────────────────────────────────────────────
|
|
174
176
|
/** 把任意异常翻译成带类型的 CalcError。 */
|
|
175
|
-
|
|
177
|
+
/**
|
|
178
|
+
* 给括号类语法错补一句「在哪一层、哪一对对不上」。
|
|
179
|
+
*
|
|
180
|
+
* ## 为什么需要它
|
|
181
|
+
*
|
|
182
|
+
* 原来的提示是一句固定文案「检查括号是否配对、运算符是否连续」——
|
|
183
|
+
* 对长公式等于没说。实测反馈原话:char 25 指向的是多余的右括号,
|
|
184
|
+
* 真正缺的那个左括号在更前面,人得自己数。
|
|
185
|
+
*
|
|
186
|
+
* 这里做的是**最小可用的定位**,不是完整解析器:
|
|
187
|
+
* · 扫一遍得到每个括号的层级与配对状态
|
|
188
|
+
* · 报出第一处不配对的位置(缺左 / 多右)
|
|
189
|
+
* · 回显表达式并在下方标出各层的起止
|
|
190
|
+
*
|
|
191
|
+
* ## 为什么不做「自动补括号」
|
|
192
|
+
*
|
|
193
|
+
* 猜意图补全括号,猜错时会把报错变成一个**算错的数** ——
|
|
194
|
+
* 比直接报错糟得多。这里只指出位置,补不补、补在哪,由调用方决定。
|
|
195
|
+
*
|
|
196
|
+
* @param source - 归一化后的表达式
|
|
197
|
+
* @returns 提示文本;括号本身平衡时返回空串(说明不是括号问题)
|
|
198
|
+
*/
|
|
199
|
+
function diagnoseParentheses(source) {
|
|
200
|
+
if (typeof source !== 'string' || source === '')
|
|
201
|
+
return '';
|
|
202
|
+
const openAt = [];
|
|
203
|
+
const pairs = [];
|
|
204
|
+
let depth = 0;
|
|
205
|
+
let firstUnexpectedClose = -1;
|
|
206
|
+
/** 引号内的括号不算结构(例如 5 cm to "inch(2)" 这类)。 */
|
|
207
|
+
let inQuote = null;
|
|
208
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
209
|
+
const ch = source[i];
|
|
210
|
+
if (inQuote !== null) {
|
|
211
|
+
if (ch === inQuote)
|
|
212
|
+
inQuote = null;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (ch === '"' || ch === "'") {
|
|
216
|
+
inQuote = ch;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (ch === '(') {
|
|
220
|
+
openAt.push({ index: i, depth });
|
|
221
|
+
depth += 1;
|
|
222
|
+
}
|
|
223
|
+
else if (ch === ')') {
|
|
224
|
+
depth -= 1;
|
|
225
|
+
const opened = openAt.pop();
|
|
226
|
+
if (opened === undefined) {
|
|
227
|
+
if (firstUnexpectedClose < 0)
|
|
228
|
+
firstUnexpectedClose = i;
|
|
229
|
+
depth = 0;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
pairs.push({ open: opened.index, close: i, depth: opened.depth });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const lines = [];
|
|
237
|
+
if (firstUnexpectedClose >= 0) {
|
|
238
|
+
lines.push('第 ' + (firstUnexpectedClose + 1) + ' 个字符处的 ")" 没有对应的左括号');
|
|
239
|
+
if (openAt.length > 0) {
|
|
240
|
+
lines.push('这几个左括号始终没配上: ' + openAt.map(function (o) { return '第 ' + (o.index + 1) + ' 字符'; }).join('、'));
|
|
241
|
+
}
|
|
242
|
+
lines.push('多数情况是它前面漏了一个 "("');
|
|
243
|
+
}
|
|
244
|
+
else if (openAt.length > 0) {
|
|
245
|
+
lines.push('这几个左括号没有闭合: ' + openAt.map(function (o) { return '第 ' + (o.index + 1) + ' 字符'; }).join('、'));
|
|
246
|
+
lines.push('在末尾补上 ' + openAt.length + ' 个 ")" 即可(若本意不是这样,删掉多余的 "(")');
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
/** 回显表达式并标出层级(超过 120 字符只给结论,避免糊成一片)。 */
|
|
252
|
+
if (source.length <= 120) {
|
|
253
|
+
const marks = [];
|
|
254
|
+
for (let i = 0; i < source.length; i += 1)
|
|
255
|
+
marks.push(' ');
|
|
256
|
+
for (const pair of pairs) {
|
|
257
|
+
marks[pair.open] = String(Math.min(9, pair.depth));
|
|
258
|
+
marks[pair.close] = String(Math.min(9, pair.depth));
|
|
259
|
+
}
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push(source);
|
|
262
|
+
lines.push(marks.join(''));
|
|
263
|
+
lines.push('(数字标的是括号层级,同层级的左右括号应该一一对应)');
|
|
264
|
+
}
|
|
265
|
+
return lines.join('\n');
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* 语法错误的具体解释:**先认常见误因,再退到通用诊断**。
|
|
269
|
+
*
|
|
270
|
+
* ## 为什么需要分类
|
|
271
|
+
*
|
|
272
|
+
* 实测反馈(2026-11-06 第二份)列了三种同 `syntax` 但原因完全不同的情况:
|
|
273
|
+
*
|
|
274
|
+
* ```
|
|
275
|
+
* / 112.5e8 → 「检查括号是否配对、运算符是否连续」
|
|
276
|
+
* 2.66亿 * 330 → 没有任何提示
|
|
277
|
+
* 1,000 * 2 → 仍说「检查括号」 ← 真因是千分位逗号
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* 第三条最糟:**提示指向了错误的方向**。用户会去数括号,而问题是逗号。
|
|
281
|
+
*
|
|
282
|
+
* ## 顺序:具体 → 通用
|
|
283
|
+
*
|
|
284
|
+
* 越具体的误因越先判,因为它们能被可靠识别;都认不出才用括号诊断兜底。
|
|
285
|
+
*
|
|
286
|
+
* @param source - 归一化后的表达式
|
|
287
|
+
* @param engineText - 引擎给的原始消息(含 char 位置)
|
|
288
|
+
* @returns 解释文本
|
|
289
|
+
*/
|
|
290
|
+
function explainSyntax(source, engineText) {
|
|
291
|
+
const at = engineText.match(/char ([0-9]+)/i);
|
|
292
|
+
const position = at ? Number(at[1]) : 0;
|
|
293
|
+
/** 出错字符附近的一小段,便于用户对照。 */
|
|
294
|
+
const snippet = position > 0 && source.length > 0
|
|
295
|
+
? source.slice(Math.max(0, position - 1), Math.min(source.length, position + 6))
|
|
296
|
+
: '';
|
|
297
|
+
/**
|
|
298
|
+
* ① 千分位逗号 —— 中文语料里极常见(`1,000`、`139,000,000`)。
|
|
299
|
+
* 引擎把逗号当分隔符,报语法错。
|
|
300
|
+
*/
|
|
301
|
+
if (/[0-9],[0-9]{3}/.test(source)) {
|
|
302
|
+
return '无法解析:表达式里有**千分位逗号**(如 1,000)。去掉逗号即可 —— 千分位只是书写习惯,不参与计算。';
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* ② 中文/未知字符 —— 量级词与中文单位若无对应展开,会落到这里。
|
|
306
|
+
* 给出可操作的下一步(而不是「检查括号」)。
|
|
307
|
+
*/
|
|
308
|
+
if (/[\u4e00-\u9fff]/.test(source)) {
|
|
309
|
+
return '无法解析:表达式里有中文。中文单位(里/丈/亩/斤/吨)与量级词(万/亿/万亿)可直接写;' +
|
|
310
|
+
'其它中文标识符请改用英文变量名,或先存进工作区。';
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* ③ 运算符位置 —— 开头/连续运算符。
|
|
314
|
+
*/
|
|
315
|
+
if (/^[*/+^]|\/\/|[*/+^]{2,}/.test(source)) {
|
|
316
|
+
return '无法解析:运算符位置不对' + (position > 0 ? '(第 ' + position + ' 个字符附近「' + snippet + '」)' : '') +
|
|
317
|
+
'。检查是否以运算符开头,或出现连续运算符。';
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* ④ 兜底:括号诊断(它会给出层级图),再退到通用建议。
|
|
321
|
+
*/
|
|
322
|
+
const diagnostic = diagnoseParentheses(source);
|
|
323
|
+
if (diagnostic)
|
|
324
|
+
return diagnostic;
|
|
325
|
+
return '检查括号是否配对、运算符是否连续。' +
|
|
326
|
+
(position > 0 ? '(引擎报错位置:第 ' + position + ' 个字符「' + snippet + '」)' : '');
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* 把 mathjs `Unexpected type of argument in function to` 这类内部报错
|
|
330
|
+
* 翻译成调用方能行动的说法。
|
|
331
|
+
*
|
|
332
|
+
* ## 为什么它必须单独处理
|
|
333
|
+
*
|
|
334
|
+
* 实测(1107 报告问题 6):
|
|
335
|
+
*
|
|
336
|
+
* ```
|
|
337
|
+
* (0.907 kg / (10 hp * 0.475)) to min
|
|
338
|
+
* → 错误[internal]: Unexpected type of argument in function to
|
|
339
|
+
* (expected: Unit or string or ... actual: function, index: 1)
|
|
340
|
+
* ```
|
|
341
|
+
*
|
|
342
|
+
* `internal` 在本项目的约定里意思是**工具自己坏了** —— 会让人去重试,
|
|
343
|
+
* 而真实原因有四种,全部是写法问题:
|
|
344
|
+
*
|
|
345
|
+
* ```
|
|
346
|
+
* ① 目标单位名撞上内置函数 1 s to min → actual: function
|
|
347
|
+
* (min/max/mean/sum/abs/round… 在 mathjs 里是函数,不是单位)
|
|
348
|
+
* ② 值根本不是单位 0.5 to kW → actual: Unit | identifier
|
|
349
|
+
* (`to` 只能把**带单位的量**换算,裸数字没有单位可换)
|
|
350
|
+
* ③ 值算了半天不是有限数 (1/0) to kW
|
|
351
|
+
* ④ 目标单位拼错 2 m to cmm
|
|
352
|
+
* ```
|
|
353
|
+
*
|
|
354
|
+
* 四类的修复方向完全不同,所以不能笼统说「检查写法」。
|
|
355
|
+
*
|
|
356
|
+
* @param message - mathjs 的原始消息
|
|
357
|
+
* @returns 翻译后的错误;认不出时返回 null(交给上层兜底)
|
|
358
|
+
*/
|
|
359
|
+
function translateToError(message) {
|
|
360
|
+
if (!/in function to\b/i.test(message))
|
|
361
|
+
return null;
|
|
362
|
+
/** ① 目标单位名是内置函数:消息里 `actual: function` 就是这个信号。 */
|
|
363
|
+
if (/actual: function/i.test(message)) {
|
|
364
|
+
const known = ['min', 'max', 'mean', 'median', 'mode', 'sum', 'prod', 'std', 'variance', 'abs', 'sqrt', 'exp', 'log', 'floor', 'ceil', 'round', 'sign', 'norm', 'size', 'index', 'count', 'map', 'filter'];
|
|
365
|
+
return new CalcError('unit_name_conflict', '目标单位名与内置函数同名,mathjs 把它当函数而不是单位', '常见撞名: ' + known.slice(0, 10).join(' / ') + ' …' +
|
|
366
|
+
'。时间单位写全称(minute / hour / second),或改用 operation="units"' +
|
|
367
|
+
'(units 的单位表与函数命名空间互不干扰)。');
|
|
368
|
+
}
|
|
369
|
+
/** ② 值不是单位:`actual: Unit | identifier` 说明左边根本没解析成带单位的量。 */
|
|
370
|
+
if (/actual: Unit \| identifier/i.test(message)) {
|
|
371
|
+
return new CalcError('not_a_quantity', 'to 只能给「带单位的量」换算,左边这个值不带单位', '要么写成带单位的形态(1 hp、2 kg),要么先去算出一个带单位的结果;' +
|
|
372
|
+
'纯数字换单位请用 operation="units"(给 unitValue/unitFrom/unitTo)。');
|
|
373
|
+
}
|
|
374
|
+
/** ③ 剩下的是「左边算出来不是有限数量」,最典型是除以零。(按 4 类) */
|
|
375
|
+
return new CalcError('not_a_quantity', 'to 的目标单位没被识别,或左边的值不构成可换算的量', '检查目标单位拼写(cm / cm^3 / MJ/kg);若左边算式含除法,先确认它没除以零。');
|
|
376
|
+
}
|
|
377
|
+
function translateError(raw, fallbackType = 'internal', source = '') {
|
|
176
378
|
if (raw instanceof CalcError)
|
|
177
379
|
return raw;
|
|
178
380
|
if (raw instanceof SymbolicError) {
|
|
@@ -180,11 +382,36 @@ function translateError(raw, fallbackType = 'internal') {
|
|
|
180
382
|
}
|
|
181
383
|
const message = raw instanceof Error ? raw.message : String(raw ?? '未知错误');
|
|
182
384
|
const text = message.trim() || '未知错误';
|
|
385
|
+
/** `to` 的四种写法错误优先翻译 —— 它原本一律落进 `internal`。 */
|
|
386
|
+
const toError = translateToError(text);
|
|
387
|
+
if (toError)
|
|
388
|
+
return toError;
|
|
183
389
|
if (/Undefined symbol/i.test(text)) {
|
|
184
390
|
return new CalcError('unknown_symbol', text, '先定义变量,或通过 variables 参数传入,例如 {"m": 500}。');
|
|
185
391
|
}
|
|
186
|
-
|
|
187
|
-
|
|
392
|
+
/**
|
|
393
|
+
* 未定义的函数也归 `unknown_symbol`,不归 `internal`。
|
|
394
|
+
*
|
|
395
|
+
* 实测反馈:`nosuchfunc(1)` 报 `internal`,而 `internal` 的含义是
|
|
396
|
+
* 「工具自己坏了」—— 会误导用户去重试同一个错。函数名打错是**输入问题**,
|
|
397
|
+
* 该给可操作的提示。
|
|
398
|
+
*/
|
|
399
|
+
if (/Undefined function/i.test(text)) {
|
|
400
|
+
const name = text.match(/Undefined function\s+(\S+)/i)?.[1] ?? '';
|
|
401
|
+
return new CalcError('unknown_symbol', name ? '没有名为 "' + name + '" 的函数' : text, '可用 operation="help" 查看支持的全部函数与公式。');
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* ⚠️ `SyntaxError` 与 `Syntax error` **不是同一个字符串**:一个是标识符名、一个是消息文本。
|
|
405
|
+
* mathjs 抛的是后者(`Syntax error in part "某中文*2" (char 1)`)。
|
|
406
|
+
*
|
|
407
|
+
* 踩过:正则只写了前者,于是这类错误落到了末尾的兜底分支 ——
|
|
408
|
+
* 表现为「错误类型对(syntax)、但没有任何 hint」。这类漏判不报错、不显眼,
|
|
409
|
+
* 靠的是对照实验(同样是语法错,`1+*2` 有提示而 `某中文*2` 没有)才揪出来。
|
|
410
|
+
*
|
|
411
|
+
* 所以两种写法都收,并且用 `\s*` 容忍中间空格差异。
|
|
412
|
+
*/
|
|
413
|
+
if (/Unexpected end of expression|Value expected|Parenthesis|Invalid left hand side|Syntax\s*Error|Unexpected operator/i.test(text)) {
|
|
414
|
+
return new CalcError('syntax', text, explainSyntax(source, text));
|
|
188
415
|
}
|
|
189
416
|
if (/Units do not match|Cannot convert|not found.*unit/i.test(text)) {
|
|
190
417
|
return new CalcError('unit_mismatch', text, '两侧单位必须同量纲。');
|
|
@@ -209,6 +436,152 @@ function translateError(raw, fallbackType = 'internal') {
|
|
|
209
436
|
// ────────────────────────────────────────────────────────────────────────────
|
|
210
437
|
// AST 安全校验
|
|
211
438
|
// ────────────────────────────────────────────────────────────────────────────
|
|
439
|
+
/**
|
|
440
|
+
* mathjs 内置的**单字母单位符号**。
|
|
441
|
+
*
|
|
442
|
+
* 实测确认的完整清单(`math.evaluate('1 X')` 能得到 Unit 的那些)。
|
|
443
|
+
* 注意 `i`(虚数单位)与 `e`(自然常数)**不在**此列 —— 它们是常量不是单位。
|
|
444
|
+
*/
|
|
445
|
+
const BARE_UNIT_LETTERS = new Set('ABCFHJKLNSTVWbghlmst'.split(''));
|
|
446
|
+
/**
|
|
447
|
+
* 拦下「未定义的单字母变量被当成单位」这一类**静默错误**。
|
|
448
|
+
*
|
|
449
|
+
* ## 为什么这是最危险的一类
|
|
450
|
+
*
|
|
451
|
+
* 实测反馈(`math_execute_实测问题与改进建议.md` 的 P0-2):
|
|
452
|
+
*
|
|
453
|
+
* ```
|
|
454
|
+
* rho=2035; rho*V → 2.035 kV ← V 被当成伏特
|
|
455
|
+
* rho=2035; rho*m → 2.035 km ← m 被当成米
|
|
456
|
+
* rho=2035; rho*g → 2.035 kg ← g 被当成克
|
|
457
|
+
* ```
|
|
458
|
+
*
|
|
459
|
+
* 不报错、不警告,而且数字看着正常(2.035 是个"合理"的数)。
|
|
460
|
+
* 而物理推演里最规范的变量名恰恰都是单字母:m/V/W/T/L/P/A/C/h/g。
|
|
461
|
+
* **工具在惩罚最规范的命名习惯。**
|
|
462
|
+
*
|
|
463
|
+
* ## 为什么不能简单地「未定义就报错」
|
|
464
|
+
*
|
|
465
|
+
* 因为 mathjs 把 `5 V`(单位)与 `rho*V`(变量)解析成**完全相同**的 AST:
|
|
466
|
+
*
|
|
467
|
+
* ```
|
|
468
|
+
* 5 V → OperatorNode(*) [ConstantNode(5), SymbolNode(V)]
|
|
469
|
+
* rho*V → OperatorNode(*) [SymbolNode(rho), SymbolNode(V)]
|
|
470
|
+
* ```
|
|
471
|
+
*
|
|
472
|
+
* 语法层无法区分,只能看**上下文**。所以判据是:
|
|
473
|
+
*
|
|
474
|
+
* · 放行:符号处「单位位置」—— 父是乘法且兄弟里有数字(`5 V`、`9.81 m`),
|
|
475
|
+
* 或父是幂且指数是数字(`s^2`)。这些是真单位用法。
|
|
476
|
+
* · 报错:符号与**已定义变量同级**(`rho*V` 的兄弟 rho 已定义),
|
|
477
|
+
* 或它**单独出现**(`V` 一个字母单独求值,语义含糊)。
|
|
478
|
+
* · 其它:放行(如纯单位表达式 `9.81 m/s^2`)。
|
|
479
|
+
*
|
|
480
|
+
* ## 已知的取舍
|
|
481
|
+
*
|
|
482
|
+
* 批内混用「变量 + 单位」会被拦(`["v=30", "9.81 m/s^2 * v"]` 里的 m/s)。
|
|
483
|
+
* 这是有意的:那种写法本身含糊,报错比猜更安全,且提示会说明两种写法。
|
|
484
|
+
*
|
|
485
|
+
* @param node - 已解析的 AST
|
|
486
|
+
* @param scope - 当前作用域(含已定义变量)
|
|
487
|
+
*/
|
|
488
|
+
function guardBareUnitSymbols(node, scope) {
|
|
489
|
+
if (!node || typeof node !== 'object')
|
|
490
|
+
return;
|
|
491
|
+
const defined = new Set(Object.keys(scope ?? {}));
|
|
492
|
+
const problems = [];
|
|
493
|
+
const visit = (current, parent) => {
|
|
494
|
+
if (!current || typeof current !== 'object')
|
|
495
|
+
return;
|
|
496
|
+
if (current.type === 'SymbolNode') {
|
|
497
|
+
const name = String(current.name ?? '');
|
|
498
|
+
if (name.length === 1 && !defined.has(name) && BARE_UNIT_LETTERS.has(name)) {
|
|
499
|
+
const siblings = Array.isArray(parent?.args) ? parent.args : [];
|
|
500
|
+
const others = siblings.filter((a) => a !== current);
|
|
501
|
+
const hasConstantSibling = others.some((a) => a?.type === 'ConstantNode');
|
|
502
|
+
const hasDefinedSibling = others.some((a) => a?.type === 'SymbolNode' && defined.has(String(a.name)));
|
|
503
|
+
/**
|
|
504
|
+
* ★ 顺序是判据的一部分,不能只看「同级有没有数字」。
|
|
505
|
+
*
|
|
506
|
+
* mathjs 保留了书写顺序,于是:
|
|
507
|
+
*
|
|
508
|
+
* 2 b → OperatorNode(*) [0:ConstantNode(2), 1:SymbolNode(b)] ← 单位
|
|
509
|
+
* b*2 → OperatorNode(*) [0:SymbolNode(b), 1:ConstantNode(2)] ← 变量
|
|
510
|
+
*
|
|
511
|
+
* 两者兄弟都含数字,旧判据一律放过 —— 结果 `b*2` 被算成
|
|
512
|
+
* 「2 b(单位)」,静默给出 `2 b`。实测踩到过:
|
|
513
|
+
* 批量里 `b=oops*2` 失败之后,后续 `d=b*2` 不报错、算出 `2 b`,
|
|
514
|
+
* 一个依赖失败步骤的下游悄悄变成了合法结果(虚成功)。
|
|
515
|
+
*
|
|
516
|
+
* 单位写法是「数字在前、符号在后」;符号在前是**变量乘数字**。
|
|
517
|
+
*/
|
|
518
|
+
const symbolIndex = siblings.indexOf(current);
|
|
519
|
+
const constantIndex = siblings.findIndex((a) => a?.type === 'ConstantNode');
|
|
520
|
+
const constantBefore = constantIndex >= 0 && constantIndex < symbolIndex;
|
|
521
|
+
// 单位位置:数字 × 单位(数字在前)、或 数字 × 单位的幂
|
|
522
|
+
const isUnitPosition = (parent?.type === 'OperatorNode' && parent.op === '*' && constantBefore) ||
|
|
523
|
+
(parent?.type === 'OperatorNode' && parent.op === '^' && hasConstantSibling);
|
|
524
|
+
/**
|
|
525
|
+
* 三个条件里**任一**成立就报:都不是「数字在前的单位写法」时,
|
|
526
|
+
* 这个单字母就不该被静默当成单位。
|
|
527
|
+
*
|
|
528
|
+
* 踩过的坑:旧版只报了「有已定义兄弟」与「单独出现」两种情况,
|
|
529
|
+
* 于是 `b*2`(符号在前、没有已定义兄弟、又不是根)三条全不命中,
|
|
530
|
+
* 被放过去算成 `2 b` —— 批量里一个失败步骤的下游就这样变成了
|
|
531
|
+
* 「成功」,而且是合法单位结果,最难看出来的那种。
|
|
532
|
+
*/
|
|
533
|
+
if (!isUnitPosition) {
|
|
534
|
+
const isBareSymbolChild = parent?.type === 'OperatorNode' &&
|
|
535
|
+
parent.op === '*' &&
|
|
536
|
+
siblings.length === 2 &&
|
|
537
|
+
siblings.every((a) => a === current || a?.type === 'ConstantNode');
|
|
538
|
+
if (hasDefinedSibling || parent === null || isBareSymbolChild)
|
|
539
|
+
problems.push(name);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
for (const key of ['args', 'items']) {
|
|
545
|
+
const list = current[key];
|
|
546
|
+
if (Array.isArray(list))
|
|
547
|
+
for (const child of list)
|
|
548
|
+
visit(child, current);
|
|
549
|
+
}
|
|
550
|
+
if (current.content)
|
|
551
|
+
visit(current.content, current);
|
|
552
|
+
if (current.object)
|
|
553
|
+
visit(current.object, current);
|
|
554
|
+
if (current.index)
|
|
555
|
+
visit(current.index, current);
|
|
556
|
+
/**
|
|
557
|
+
* ★ 赋值语句的右半部分挂在 `value` 上,不在 `args` 里。
|
|
558
|
+
*
|
|
559
|
+
* 踩过的坑:`d=b*2` 的 AST 是 `AssignmentNode(name=d, value=OperatorNode(*))`,
|
|
560
|
+
* 而这里原本只走 `args/items/content/object/index` —— 赋值右侧**整个不被遍历**。
|
|
561
|
+
* 于是批量里 `b=oops*2` 失败之后,`d=b*2` 从未被判据看到,
|
|
562
|
+
* 直接被 mathjs 当成「2 b」算出一个合法单位结果。
|
|
563
|
+
*
|
|
564
|
+
* 这类「下游悄悄变成虚成功」比报错难发现得多:批量汇总里它是「成功」。
|
|
565
|
+
*/
|
|
566
|
+
if (current.value && typeof current.value === 'object')
|
|
567
|
+
visit(current.value, current);
|
|
568
|
+
};
|
|
569
|
+
visit(node, null);
|
|
570
|
+
if (problems.length > 0) {
|
|
571
|
+
const uniq = [...new Set(problems)];
|
|
572
|
+
const list = uniq.map((n) => '"' + n + '"').join('、');
|
|
573
|
+
/**
|
|
574
|
+
* 「为什么报错」这句写在 `hint` 里,不写 `message`。
|
|
575
|
+
*
|
|
576
|
+
* 批量模式的失败行格式是 `失败[type]:<hint>`,**只显示 hint**;
|
|
577
|
+
* 单条模式两者都回。把「既是变量名也是单位符号」放进 hint,
|
|
578
|
+
* 两种模式都能看到 —— 这是用户理解报错原因的唯一线索。
|
|
579
|
+
*/
|
|
580
|
+
throw new CalcError('unknown_symbol', '未定义的标识符 ' + list, list + ' 既是变量名也是单位符号。若要当单位用,请写成带数字的形式(如 "5 V");' +
|
|
581
|
+
'若要当变量,请先在本次调用里赋值,或通过 variables 参数传入(如 {"' +
|
|
582
|
+
uniq[0] + '": 100})。');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
212
585
|
function guardAst(node) {
|
|
213
586
|
if (!node || typeof node.traverse !== 'function')
|
|
214
587
|
return;
|
|
@@ -240,6 +613,65 @@ function guardAst(node) {
|
|
|
240
613
|
}
|
|
241
614
|
});
|
|
242
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* 判断表达式里有没有**显式要求的目标单位**。
|
|
618
|
+
*
|
|
619
|
+
* mathjs 里三种写法都表示「换成这个单位」:
|
|
620
|
+
*
|
|
621
|
+
* ```
|
|
622
|
+
* 5 cm to inch OperatorNode(op=to)
|
|
623
|
+
* to(5 cm, inch) FunctionNode(fn=to)
|
|
624
|
+
* (10 hp/0.475).to('kW') FunctionNode(fn=AccessorNode(property=to))
|
|
625
|
+
* ```
|
|
626
|
+
*
|
|
627
|
+
* ## 为什么需要这个判断
|
|
628
|
+
*
|
|
629
|
+
* 输出层的单位归位(`normalizeUnitPrefix`)会在「没人指定单位」时挑一个顺口的档。
|
|
630
|
+
* 但调用方一旦写了 `.to(...)`,选择权就该归他 —— 否则会出现
|
|
631
|
+
* `2 m to cm` 被改成 `2 m` 这种**把用户的换算悄悄撤回**的回归。
|
|
632
|
+
* 数字仍然合法(`2 m` 是个正经长度),所以这类错误不会被怀疑。
|
|
633
|
+
*
|
|
634
|
+
* @param node - 已解析的 AST;解析失败时调用方本来就走不到这里
|
|
635
|
+
* @returns 有显式目标单位时为 true
|
|
636
|
+
*/
|
|
637
|
+
function usesExplicitUnitConversion(node) {
|
|
638
|
+
let found = false;
|
|
639
|
+
if (!node || typeof node !== 'object')
|
|
640
|
+
return false;
|
|
641
|
+
const visit = (current, depth) => {
|
|
642
|
+
if (found || !current || typeof current !== 'object' || depth > 64)
|
|
643
|
+
return;
|
|
644
|
+
if (current.type === 'OperatorNode' && String(current.op ?? '') === 'to') {
|
|
645
|
+
found = true;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (current.type === 'FunctionNode') {
|
|
649
|
+
if (String(current.fn?.name ?? '') === 'to') {
|
|
650
|
+
found = true;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
/** `.to('kW')` 的 fn 是 AccessorNode,属性名挂在 index 上。 */
|
|
654
|
+
const indexNode = current.fn?.index;
|
|
655
|
+
const property = typeof indexNode?.value === 'string' ? indexNode.value : indexNode?.name;
|
|
656
|
+
if (typeof property === 'string' && property.replace(/^["']|["']$/g, '') === 'to') {
|
|
657
|
+
found = true;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
for (const key of ['args', 'items']) {
|
|
662
|
+
const list = current[key];
|
|
663
|
+
if (Array.isArray(list))
|
|
664
|
+
for (const child of list)
|
|
665
|
+
visit(child, depth + 1);
|
|
666
|
+
}
|
|
667
|
+
for (const key of ['content', 'object', 'index', 'value']) {
|
|
668
|
+
if (current[key])
|
|
669
|
+
visit(current[key], depth + 1);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
visit(node, 0);
|
|
673
|
+
return found;
|
|
674
|
+
}
|
|
243
675
|
function guardComplexity(node) {
|
|
244
676
|
if (!node || typeof node.traverse !== 'function')
|
|
245
677
|
return;
|
|
@@ -261,17 +693,124 @@ function guardComplexity(node) {
|
|
|
261
693
|
// 输入处理
|
|
262
694
|
// ────────────────────────────────────────────────────────────────────────────
|
|
263
695
|
/** 把 variables 字典转成 mathjs 可用的 scope。 */
|
|
696
|
+
/**
|
|
697
|
+
* 是否把工作区里登记的命名变量并入作用域。
|
|
698
|
+
*
|
|
699
|
+
* ## 这是显式开关,默认关闭
|
|
700
|
+
*
|
|
701
|
+
* 打开后同一个调用在不同工作区状态下会给出不同结果 ——
|
|
702
|
+
* 那会削弱「同输入同输出」。所以:
|
|
703
|
+
* · 默认关闭,**不改变任何现有调用的行为**
|
|
704
|
+
* · 要开就在工作区里固化:`{action2:"set", useWorkspaceVariables:true}`
|
|
705
|
+
*
|
|
706
|
+
* 关掉后 `workspaceVariables` 里存的东西仍然存着(能 show、能 remove),
|
|
707
|
+
* 只是不参与计算 —— 与旧版行为完全一致。
|
|
708
|
+
*
|
|
709
|
+
* @param name - 工作区名;省略用当前工作区
|
|
710
|
+
* @returns 是否启用
|
|
711
|
+
*/
|
|
712
|
+
function useWorkspaceVariables(name) {
|
|
713
|
+
try {
|
|
714
|
+
const data = loadWorkspace(name ?? activeWorkspaceNameForCache());
|
|
715
|
+
return data.useWorkspaceVariables === true;
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* 把工作区变量与本次传入的变量合成最终作用域输入。
|
|
723
|
+
*
|
|
724
|
+
* ## 优先级:本次参数 > 工作区
|
|
725
|
+
*
|
|
726
|
+
* 临时覆盖长期,符合「显式优先」。同名的键以本次传入为准 ——
|
|
727
|
+
* 这样「临时想换个值算一下」不必先去改工作区。
|
|
728
|
+
*
|
|
729
|
+
* ## 两个来源都要规范化
|
|
730
|
+
*
|
|
731
|
+
* `buildScope` 会校验变量名与取值,所以这里不能只做浅合并:
|
|
732
|
+
* 否则工作区里存了非法名字(历史上可能写进去过),
|
|
733
|
+
* 会绕过校验直接进 mathjs 作用域。
|
|
734
|
+
*
|
|
735
|
+
* @param passed - 本次调用传的 `variables` 参数
|
|
736
|
+
* @returns 合并后的变量字典;开关关闭时原样返回 passed
|
|
737
|
+
*/
|
|
738
|
+
function mergeWorkspaceVariables(passed) {
|
|
739
|
+
if (!useWorkspaceVariables())
|
|
740
|
+
return passed;
|
|
741
|
+
const stored = workspaceVariablesOf();
|
|
742
|
+
const incoming = passed && typeof passed === 'object' && !Array.isArray(passed)
|
|
743
|
+
? passed
|
|
744
|
+
: {};
|
|
745
|
+
if (Object.keys(stored).length === 0 && Object.keys(incoming).length === 0)
|
|
746
|
+
return passed;
|
|
747
|
+
return { ...stored, ...incoming };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* 收集上一次 `buildScope` 跳过的非法变量键。
|
|
751
|
+
*
|
|
752
|
+
* 模块级、每次调用开头清空 —— 与 `activeDisplayPrecision` 同样的理由:
|
|
753
|
+
* 逐层传参要改四十多个函数签名,而这个信息只在**入口**用来组装一条提示。
|
|
754
|
+
*/
|
|
755
|
+
let skippedVariableKeys = [];
|
|
756
|
+
/**
|
|
757
|
+
* 取本次调用中被跳过的非法变量键(组装提示用)。
|
|
758
|
+
*
|
|
759
|
+
* @returns 被跳过的键与原因
|
|
760
|
+
*/
|
|
761
|
+
export function takeSkippedVariableKeys() {
|
|
762
|
+
const taken = skippedVariableKeys;
|
|
763
|
+
skippedVariableKeys = [];
|
|
764
|
+
return taken;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* 清理被跳过的变量键记录(每次工具调用开头)。
|
|
768
|
+
*/
|
|
769
|
+
export function resetSkippedVariableKeys() {
|
|
770
|
+
skippedVariableKeys = [];
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* 把变量字典装进 mathjs 作用域。
|
|
774
|
+
*
|
|
775
|
+
* ## 非法键只跳过,不中止整批
|
|
776
|
+
*
|
|
777
|
+
* 实测反馈(2026-11-06 第二份):
|
|
778
|
+
*
|
|
779
|
+
* ```
|
|
780
|
+
* expressions: ["P*330", "Q+1"], variables: {"P":1000, "Q":5, "人口":1}
|
|
781
|
+
* → ok:false,连合法的 P*330 都没算
|
|
782
|
+
* ```
|
|
783
|
+
*
|
|
784
|
+
* 一个键名写错,前面排队的算式全白写的 —— 而这类语料的**概念名几乎全是中文**
|
|
785
|
+
* (人口/粮食/石料),用户第一反应就是用中文命名。
|
|
786
|
+
*
|
|
787
|
+
* 现在:合法的键照常进作用域,非法的记进 `skippedVariableKeys`,
|
|
788
|
+
* 由入口在结果里列出来。**算出来的数仍然是对的** —— 只是那个中文键没生效。
|
|
789
|
+
*
|
|
790
|
+
* ## 为什么不做「支持中文变量名」
|
|
791
|
+
*
|
|
792
|
+
* 那要放宽 mathjs 的词法,而词法宽松会带来新的歧义
|
|
793
|
+
* (中文字符与单位名、量级词的边界更难判)。
|
|
794
|
+
* 相比之下「跳过 + 明确告知」既解决了痛点,又不引入不确定性。
|
|
795
|
+
*
|
|
796
|
+
* @param engine - mathjs 实例
|
|
797
|
+
* @param variables - 变量字典
|
|
798
|
+
* @returns 可用的作用域对象
|
|
799
|
+
*/
|
|
264
800
|
function buildScope(engine, variables) {
|
|
265
801
|
const scope = {};
|
|
266
802
|
if (!variables || typeof variables !== 'object' || Array.isArray(variables))
|
|
267
803
|
return scope;
|
|
268
804
|
for (const [key, raw] of Object.entries(variables)) {
|
|
269
805
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
270
|
-
|
|
806
|
+
skippedVariableKeys.push({ name: key, reason: '标识符只能含字母、数字、下划线' });
|
|
807
|
+
continue;
|
|
271
808
|
}
|
|
272
809
|
if (typeof raw === 'number') {
|
|
273
|
-
if (!Number.isFinite(raw))
|
|
274
|
-
|
|
810
|
+
if (!Number.isFinite(raw)) {
|
|
811
|
+
skippedVariableKeys.push({ name: key, reason: '不是有限数字' });
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
275
814
|
scope[key] = engine.bignumber(String(raw));
|
|
276
815
|
}
|
|
277
816
|
else if (typeof raw === 'string') {
|
|
@@ -280,14 +819,14 @@ function buildScope(engine, variables) {
|
|
|
280
819
|
scope[key] = engine.bignumber(raw.trim());
|
|
281
820
|
}
|
|
282
821
|
catch {
|
|
283
|
-
|
|
822
|
+
skippedVariableKeys.push({ name: key, reason: '无法解析为数字' });
|
|
284
823
|
}
|
|
285
824
|
}
|
|
286
825
|
else if (typeof raw === 'boolean') {
|
|
287
826
|
scope[key] = raw;
|
|
288
827
|
}
|
|
289
828
|
else {
|
|
290
|
-
|
|
829
|
+
skippedVariableKeys.push({ name: key, reason: '必须是数字或数字字符串' });
|
|
291
830
|
}
|
|
292
831
|
}
|
|
293
832
|
return scope;
|
|
@@ -335,6 +874,24 @@ function opEvaluate(engine, expression, scope, precision) {
|
|
|
335
874
|
if (expression === '') {
|
|
336
875
|
throw new CalcError('invalid_argument', '表达式为空');
|
|
337
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* 数学记号归一化(`**` → `^`、`π` → `pi`、`ln(` → `log(`、`×`/`÷`)。
|
|
879
|
+
*
|
|
880
|
+
* 为什么必须在这里:其它 op(derivative / integral / stats / solve…)
|
|
881
|
+
* 都在内部调了 `normalizeSource`,**只有 evaluate 漏了** ——
|
|
882
|
+
* 于是 `2**10`、`π`、`2 × 3` 这些直觉写法在 evaluate 上全部报错,
|
|
883
|
+
* 而同一套写法在 formula 的 overrides 里却能过(实测反馈的语法糖问题)。
|
|
884
|
+
*
|
|
885
|
+
* 幂等:这些替换重复执行结果不变,所以不会因多次归一化而出错。
|
|
886
|
+
*/
|
|
887
|
+
expression = normalizeSource(expression);
|
|
888
|
+
/**
|
|
889
|
+
* 中文单位展开:把「1 里 + 3 米」变成 mathjs 能算的形态。
|
|
890
|
+
*
|
|
891
|
+
* 必须放在 normalizeSource **之后**:前者处理数学记号(× ÷ π **),
|
|
892
|
+
* 后者处理单位名;顺序反了会让 `2 × 3 里` 这类混合写法漏掉。
|
|
893
|
+
*/
|
|
894
|
+
expression = normalizeChineseUnits(expression);
|
|
338
895
|
if (expression.length > MAX_INPUT_LENGTH) {
|
|
339
896
|
throw new CalcError('too_large', `表达式过长(${expression.length} > ${MAX_INPUT_LENGTH})`);
|
|
340
897
|
}
|
|
@@ -343,10 +900,12 @@ function opEvaluate(engine, expression, scope, precision) {
|
|
|
343
900
|
node = engine.parse(expression);
|
|
344
901
|
}
|
|
345
902
|
catch (error) {
|
|
346
|
-
throw translateError(error, 'syntax');
|
|
903
|
+
throw translateError(error, 'syntax', expression);
|
|
347
904
|
}
|
|
348
905
|
guardAst(node);
|
|
349
906
|
guardComplexity(node);
|
|
907
|
+
// 拦「未定义单字母被当单位」的静默错误(实测反馈 P0-2)
|
|
908
|
+
guardBareUnitSymbols(node, scope);
|
|
350
909
|
let raw;
|
|
351
910
|
try {
|
|
352
911
|
raw = node.evaluate(scope);
|
|
@@ -355,8 +914,49 @@ function opEvaluate(engine, expression, scope, precision) {
|
|
|
355
914
|
throw translateError(error);
|
|
356
915
|
}
|
|
357
916
|
const value = unwrapResult(raw);
|
|
917
|
+
/**
|
|
918
|
+
* ## 非有限值必须在**这里**拦,不能指望顶层
|
|
919
|
+
*
|
|
920
|
+
* 顶层的 `containsNonFinite(result.value)` 拿到的是**序列化后**的值:
|
|
921
|
+
* 带单位的结果被压成字符串 `"Infinity g"`,而那个判据只认恰好 `"Infinity"`。
|
|
922
|
+
* 于是:
|
|
923
|
+
*
|
|
924
|
+
* ```
|
|
925
|
+
* 1/0 → 错误[non_finite] ✓ 拦住
|
|
926
|
+
* (1/0)*2 kg → ok=true, "Infinity g" ✗ 放过
|
|
927
|
+
* ```
|
|
928
|
+
*
|
|
929
|
+
* 而后者更危险:它看起来**像个带单位的正经结果**(`Infinity g` 读起来
|
|
930
|
+
* 像「无穷克」,不像「算错了」),而且会顺着链式推演传下去。
|
|
931
|
+
*
|
|
932
|
+
* 所以判在原始对象上 —— `isNonFiniteValue` 会下钻到 `Unit.value`。
|
|
933
|
+
* 注意 `1e400`、`2^1024` 这类**有限的极大数**不能误判:
|
|
934
|
+
* 它们转成双精度才溢出,而 BigNumber 自己知道自己是有限的。
|
|
935
|
+
*/
|
|
936
|
+
if (isNonFiniteValue(value)) {
|
|
937
|
+
const shown = String(raw);
|
|
938
|
+
throw new CalcError('non_finite', `计算结果非有限(${shown})`, '常见于除以零、tan(π/2)、log(0)。若要符号形式可改用 operation="simplify"。');
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* 表达式里写了目标单位(`.to('cm')`、`5 cm to inch`)时,**不归位**。
|
|
942
|
+
*
|
|
943
|
+
* 归位的职责是「调用方没指定时挑一个顺口的档」;指定了就不该被改写。
|
|
944
|
+
* 详见 `formatValue` 的 `respectExplicitUnit` 参数说明 —— 那里记着
|
|
945
|
+
* `2 m to cm` 被改回 `2 m` 这条回归。
|
|
946
|
+
*/
|
|
947
|
+
const explicitUnit = usesExplicitUnitConversion(node);
|
|
948
|
+
const text = formatValue(engine, value, precision, currentDisplayPrecision(), explicitUnit);
|
|
358
949
|
return {
|
|
359
|
-
|
|
950
|
+
/**
|
|
951
|
+
* 带 SI 质量前缀时补一句中文量级参照(如 `87.78 Tg(= 8778 万吨)`)。
|
|
952
|
+
*
|
|
953
|
+
* 只对「质量 × 克」这一族生效 —— 它是中文语料里量级混用最严重的场景,
|
|
954
|
+
* 也是实测反馈里唯一真实出过事故的地方(两次把「吨」当成「亿吨」)。
|
|
955
|
+
*
|
|
956
|
+
* 其它单位、复合单位一律原样返回:量级词在那个位置不适用,
|
|
957
|
+
* 硬套只会制造噪音。
|
|
958
|
+
*/
|
|
959
|
+
result: annotateMetricPrefix(text),
|
|
360
960
|
value: toJsonValue(value),
|
|
361
961
|
};
|
|
362
962
|
}
|
|
@@ -382,48 +982,280 @@ function opEvaluate(engine, expression, scope, precision) {
|
|
|
382
982
|
* @param precision - 计算精度
|
|
383
983
|
* @returns 结果文本与结构化值
|
|
384
984
|
*/
|
|
385
|
-
function opEvaluateBatch(engine, expressions, scope, precision) {
|
|
985
|
+
function opEvaluateBatch(engine, expressions, scope, precision, terse = false) {
|
|
386
986
|
const items = expressions.slice(0, MAX_BATCH_EXPRESSIONS);
|
|
387
987
|
if (items.length === 0) {
|
|
388
988
|
throw new CalcError('invalid_argument', 'expressions 为空', '例如 ["1/7","2^0.5"]');
|
|
389
989
|
}
|
|
390
990
|
const shown = [];
|
|
391
991
|
const values = [];
|
|
992
|
+
/** 量级类提示(不影响成败,只提醒核对)。 */
|
|
993
|
+
const magnitudeWarnings = [];
|
|
392
994
|
let successCount = 0;
|
|
393
995
|
let failCount = 0;
|
|
394
996
|
for (const raw of items) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
997
|
+
/**
|
|
998
|
+
* 两种条目形态:
|
|
999
|
+
* 字符串 `"1/7"`
|
|
1000
|
+
* 带标签对象 `{label:"储耗比-中值", expr:"139e8/152e4"}`
|
|
1001
|
+
*
|
|
1002
|
+
* ## 为什么要 label
|
|
1003
|
+
*
|
|
1004
|
+
* 实测反馈:同一批算两个口径(`["139e8/1743500","139e8/152e4"]` → 7972 / 9144),
|
|
1005
|
+
* 输出里**只有顺序号区分**。后续引用时得回翻表达式原文,
|
|
1006
|
+
* 报告里「一次差点引错」。
|
|
1007
|
+
*
|
|
1008
|
+
* 批内条目一多(该会话单批最多 14 条),纯靠位置对齐的心智负担很大。
|
|
1009
|
+
*/
|
|
1010
|
+
/**
|
|
1011
|
+
* ## 纯数字元素自动转成表达式
|
|
1012
|
+
*
|
|
1013
|
+
* 实测反馈(2026-11-06 第二份):`expressions: [0.5, 9.9, 10]` 五条全被当成
|
|
1014
|
+
* **「空表达式」跳过**,汇总「成功 0,失败 5」。
|
|
1015
|
+
*
|
|
1016
|
+
* 这是**类型错误被伪装成内容错误** —— 用户传的是合法数字,
|
|
1017
|
+
* 工具说「空表达式」,会让人去翻数据源而不是检查类型。
|
|
1018
|
+
*
|
|
1019
|
+
* 转成字符串是零风险的:`0.5` → `"0.5"` → 正常求值得 0.5。
|
|
1020
|
+
* 布尔与 null 不转换(那不是「一个数」,是调用方写错了,该报错)。
|
|
1021
|
+
*/
|
|
1022
|
+
const entry = typeof raw === 'string'
|
|
1023
|
+
? { expr: raw }
|
|
1024
|
+
: typeof raw === 'number' && Number.isFinite(raw)
|
|
1025
|
+
? { expr: String(raw) }
|
|
1026
|
+
: raw ?? {};
|
|
1027
|
+
/**
|
|
1028
|
+
* ## 元素里的未知键也要点名
|
|
1029
|
+
*
|
|
1030
|
+
* 顶层的未知参数有 `findUnknownArguments` 拦着,但**数组元素内部没查**。
|
|
1031
|
+
* 实测(2026-11-06):
|
|
1032
|
+
*
|
|
1033
|
+
* ```
|
|
1034
|
+
* {expressions:[{label:'a', expr:'1+1', unit:'kg/day'}]} → ok=true,unit 被静默丢弃
|
|
1035
|
+
* {expression:'1+1', unit:'kg/day'} → 报 unknown_argument ✓
|
|
1036
|
+
* ```
|
|
1037
|
+
*
|
|
1038
|
+
* 危险在于:将来若要加 `unit` / `assumption` 这类**按条**的参数,
|
|
1039
|
+
* 它们必然写在这个位置 —— 而现有行为会让它们**静默不生效**,
|
|
1040
|
+
* 调用方以为生效了。这正是本项目一直在打击的那类问题。
|
|
1041
|
+
*
|
|
1042
|
+
* 处理方式:认不出的键当作**该条失败**(而不是整批失败),
|
|
1043
|
+
* 这样一条写错不影响其余条目 —— 与「逐条独立报错」的既有约定一致。
|
|
1044
|
+
*/
|
|
1045
|
+
/** 元素级允许的键。新增一条按条参数时记得加在这里,否则会被自己的校验拦下。 */
|
|
1046
|
+
const allowedEntryKeys = ['label', 'expr', 'sanity'];
|
|
1047
|
+
const unknownEntryKeys = typeof raw === 'string'
|
|
1048
|
+
? []
|
|
1049
|
+
: Object.keys(raw).filter(function (k) { return !allowedEntryKeys.includes(k); });
|
|
1050
|
+
if (unknownEntryKeys.length > 0) {
|
|
1051
|
+
const echoName = entry.label || String(entry.expr ?? '');
|
|
1052
|
+
values.push({ expression: '', label: String(echoName), ok: false, error: 'unknown_argument' });
|
|
1053
|
+
shown.push(' ' + echoName + ' → 失败[unknown_argument]:这一条里有认不出的键 ' +
|
|
1054
|
+
unknownEntryKeys.join('、') + '(只接受 ' + allowedEntryKeys.join(' / ') + ')');
|
|
1055
|
+
failCount++;
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
|
1059
|
+
const rawExpression = String(entry.expr ?? '').trim();
|
|
1060
|
+
if (rawExpression === '') {
|
|
1061
|
+
values.push({ expression: rawExpression, label, ok: false, error: 'empty' });
|
|
398
1062
|
shown.push(' (空表达式)→ 跳过');
|
|
399
1063
|
failCount++;
|
|
400
1064
|
continue;
|
|
401
1065
|
}
|
|
1066
|
+
/**
|
|
1067
|
+
* 归一化只用于**计算**,回显仍用用户原文。
|
|
1068
|
+
*
|
|
1069
|
+
* 否则用户写 `π`、`2**10` 会看到自己被回显成 `pi`、`2^10` ——
|
|
1070
|
+
* 那会让人怀疑「我传的到底被改成了什么」。
|
|
1071
|
+
*/
|
|
1072
|
+
const expression = normalizeChineseUnits(normalizeSource(rawExpression));
|
|
402
1073
|
try {
|
|
403
|
-
|
|
1074
|
+
/**
|
|
1075
|
+
* 解析单独包一层:给 `translateError` 传 `'syntax'` 作为兜底类型。
|
|
1076
|
+
*
|
|
1077
|
+
* 为什么:单条求值里写的是 `translateError(error, 'syntax')`,
|
|
1078
|
+
* 而批量这里原来是 `translateError(error)` —— 默认兜底是 `internal`。
|
|
1079
|
+
* 于是 `不存在xyz(1)` 在单条里报 syntax、在批量里报 internal。
|
|
1080
|
+
* `internal` 的含义是「工具自己坏了」,会误导人去重试同一个错,必须对齐。
|
|
1081
|
+
*/
|
|
1082
|
+
let node;
|
|
1083
|
+
try {
|
|
1084
|
+
node = engine.parse(expression);
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
throw translateError(error, 'syntax', expression);
|
|
1088
|
+
}
|
|
404
1089
|
guardAst(node);
|
|
405
1090
|
guardComplexity(node);
|
|
1091
|
+
// 拦「未定义单字母被当单位」的静默错误(实测反馈 P0-2)
|
|
1092
|
+
guardBareUnitSymbols(node, scope);
|
|
406
1093
|
const value = unwrapResult(node.evaluate(scope));
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
1094
|
+
/**
|
|
1095
|
+
* ## 批量也必须拦非有限值
|
|
1096
|
+
*
|
|
1097
|
+
* 实测(1107 报告问题 2、3):单条 `1/0` 报错,批量却宣告
|
|
1098
|
+
* 「算式全部求值成功(4 条)」,其中就包括 `1/0` 与 `log(0)`。
|
|
1099
|
+
*
|
|
1100
|
+
* 这构成一条危险的对称破坏:**同一个式子,换个调用方式就从「错」
|
|
1101
|
+
* 变成「对」**。而批量恰恰是 AI 最常用的形态(一次算 8—14 条),
|
|
1102
|
+
* 于是最该拦住的地方恰好不拦。
|
|
1103
|
+
*
|
|
1104
|
+
* 归入 `failed` 而不是单独一类,理由是与单条 `non_finite` 对齐:
|
|
1105
|
+
* 调用方的分流逻辑只需要认 `ok/partial/failed` 三个字段。
|
|
1106
|
+
*/
|
|
1107
|
+
if (isNonFiniteValue(value)) {
|
|
1108
|
+
throw new CalcError('non_finite', `计算结果非有限(${String(value)})`, '常见于除以零、tan(π/2)、log(0)。若要符号形式可改用 operation="simplify"。');
|
|
1109
|
+
}
|
|
1110
|
+
/** 批量同样要尊重显式目标单位(见 `usesExplicitUnitConversion`)。 */
|
|
1111
|
+
const text = formatValue(engine, value, precision, currentDisplayPrecision(), usesExplicitUnitConversion(node));
|
|
1112
|
+
values.push({ expression, label: label || undefined, ok: true, result: text });
|
|
1113
|
+
/**
|
|
1114
|
+
* 回显格式:有 label 用 label,否则用用户原文。
|
|
1115
|
+
*
|
|
1116
|
+
* 回显原文(`π` 而不是 `pi`)是刻意的 —— 否则用户会怀疑「我传的到底被改成了什么」。
|
|
1117
|
+
*/
|
|
1118
|
+
const echo = label ? label : rawExpression;
|
|
1119
|
+
/**
|
|
1120
|
+
* 量级标注:给裸数字配上「这是 10 的几次方」。
|
|
1121
|
+
*
|
|
1122
|
+
* 实测复盘里最贵的一次错误是:`/1e9` 算出 2133 却贴上「万吨」标签,
|
|
1123
|
+
* 据此指控原文「错了 10 倍」。返回体当时只有一行裸数字,看不出量级。
|
|
1124
|
+
* 标上 `(~10³)` 之后,「2133 与万吨差一个量级」这件事人眼即可发现。
|
|
1125
|
+
*/
|
|
1126
|
+
/**
|
|
1127
|
+
* 量级标注:给裸数字配上「这是 10 的几次方」。
|
|
1128
|
+
*
|
|
1129
|
+
* 实测复盘里最贵的一次错误是:`/1e9` 算出 2133 却贴上「万吨」标签,
|
|
1130
|
+
* 据此指控原文「错了 10 倍」。返回体当时只有一行裸数字,看不出量级。
|
|
1131
|
+
*
|
|
1132
|
+
* ## 带单位的结果另走一条
|
|
1133
|
+
*
|
|
1134
|
+
* `annotateMagnitude` 内部做 `Number(text)`,而 `87.78 Mt` 会得到 NaN ——
|
|
1135
|
+
* 带单位时它静默失效。所以这里按结果形态分派:
|
|
1136
|
+
* 纯数字走 `(~10^N)`,带单位走 `explainUnitResult`(给出基准值与中文量级)。
|
|
1137
|
+
*/
|
|
1138
|
+
const shownValue = annotateMagnitude(text);
|
|
1139
|
+
const unitExplain = explainUnitResult(text);
|
|
1140
|
+
shown.push(' ' + echo + (label ? '(' + rawExpression + ')' : '') + ' = ' + shownValue +
|
|
1141
|
+
(unitExplain ? '|' + unitExplain : ''));
|
|
1142
|
+
/**
|
|
1143
|
+
* label 与结果的量级核对:只有 label 里**明确写了量级词**(万/亿/千…)才查,
|
|
1144
|
+
* 且结果本身已跨过那个量级时才提示 —— 宁漏勿误。
|
|
1145
|
+
*/
|
|
1146
|
+
if (label) {
|
|
1147
|
+
const warn = checkLabelMagnitude(label, text);
|
|
1148
|
+
if (warn)
|
|
1149
|
+
magnitudeWarnings.push(warn);
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* ③ 锚点比对:调用方给了 `sanity` 就拿它的判据去比。
|
|
1153
|
+
*
|
|
1154
|
+
* 三条来源共用同一个 `magnitudeWarnings` 通道 —— 它的下游
|
|
1155
|
+
* (terse 分支、汇总行、结构化计数)全部自动生效,不必改三处。
|
|
1156
|
+
*
|
|
1157
|
+
* 为什么在这里而不是在缓存层:批量路径**根本不走缓存**(见上方注释),
|
|
1158
|
+
* 所以不存在「换锚点却命中旧提示」的问题。
|
|
1159
|
+
*/
|
|
1160
|
+
if (typeof entry === 'object' && entry !== null) {
|
|
1161
|
+
const sanity = entry.sanity;
|
|
1162
|
+
const anchorWarn = checkAnchors(sanity, text);
|
|
1163
|
+
if (anchorWarn) {
|
|
1164
|
+
const anchorEcho = label || rawExpression;
|
|
1165
|
+
magnitudeWarnings.push(anchorEcho + ' → ' + anchorWarn);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
410
1168
|
successCount++;
|
|
411
1169
|
}
|
|
412
1170
|
catch (error) {
|
|
413
1171
|
const translated = translateError(error);
|
|
414
|
-
values.push({ expression, ok: false, error: translated.type });
|
|
1172
|
+
values.push({ expression, label: label || undefined, ok: false, error: translated.type });
|
|
415
1173
|
// 失败要显眼,但不能长篇大论 —— 一行说清哪条、什么类型
|
|
416
|
-
|
|
1174
|
+
const echo = label ? label : rawExpression;
|
|
1175
|
+
shown.push(' ' + echo + ' → 失败[' + translated.type + ']' +
|
|
417
1176
|
(translated.hint ? ':' + translated.hint : ''));
|
|
418
1177
|
failCount++;
|
|
419
1178
|
}
|
|
420
1179
|
}
|
|
1180
|
+
/**
|
|
1181
|
+
* `terse` 模式:只回「表达式 = 结果」,去掉标题与统计。
|
|
1182
|
+
*
|
|
1183
|
+
* 实测反馈:批量 8—14 条时,标题与统计行本身也是固定开销;
|
|
1184
|
+
* 报告估算「若全程 terse,省 40%+ 工具输出 token」。
|
|
1185
|
+
*
|
|
1186
|
+
* 但**失败信息不能省**:省掉失败行会让调用方以为「全成功了」。
|
|
1187
|
+
*/
|
|
1188
|
+
if (terse) {
|
|
1189
|
+
/**
|
|
1190
|
+
* terse 的承诺是「省掉标题与统计」,**不是**省掉风险提示。
|
|
1191
|
+
*
|
|
1192
|
+
* 踩过:第一版把 ⚠ 量级提示一起省了,于是 terse 用户看到的是一行干净数字,
|
|
1193
|
+
* 而干净正是最危险的 —— 他会以为没问题。
|
|
1194
|
+
*/
|
|
1195
|
+
const terseWarnings = [];
|
|
1196
|
+
const terseBatch = findBatchAnomalies(values
|
|
1197
|
+
.filter(function (v) { return v.ok && typeof v.result === 'string'; })
|
|
1198
|
+
.map(function (v) { return { label: v.label, result: String(v.result) }; }));
|
|
1199
|
+
if (terseBatch)
|
|
1200
|
+
magnitudeWarnings.push(terseBatch);
|
|
1201
|
+
if (magnitudeWarnings.length > 0) {
|
|
1202
|
+
terseWarnings.push('');
|
|
1203
|
+
for (const warning of magnitudeWarnings)
|
|
1204
|
+
terseWarnings.push(warning);
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
result: [...shown.map((s) => s.trim()), ...terseWarnings].join('\n'),
|
|
1208
|
+
value: toJsonValue({
|
|
1209
|
+
count: items.length,
|
|
1210
|
+
success: successCount,
|
|
1211
|
+
failed: failCount,
|
|
1212
|
+
magnitudeWarnings: magnitudeWarnings.length,
|
|
1213
|
+
items: values,
|
|
1214
|
+
}),
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
421
1217
|
const header = items.length < expressions.length
|
|
422
1218
|
? `═══ 批量求值(前 ${items.length} 条,共给了 ${expressions.length} 条)═══`
|
|
423
1219
|
: `═══ 批量求值(${items.length} 条)═══`;
|
|
1220
|
+
/**
|
|
1221
|
+
* 同批次量级一致性比对。
|
|
1222
|
+
*
|
|
1223
|
+
* 真实案例:四条「年耗/年产(万吨)」全部用了 /1e9(实际是千吨),
|
|
1224
|
+
* 全部偏大 10 倍、全部报「成功」。单条看不出问题,四条一起就该看出来。
|
|
1225
|
+
*/
|
|
1226
|
+
const batchWarning = findBatchAnomalies(values
|
|
1227
|
+
.filter(function (v) { return v.ok && typeof v.result === 'string'; })
|
|
1228
|
+
.map(function (v) { return { label: v.label, result: String(v.result) }; }));
|
|
1229
|
+
if (batchWarning)
|
|
1230
|
+
magnitudeWarnings.push(batchWarning);
|
|
1231
|
+
/**
|
|
1232
|
+
* 分级语义:把「算式没塌」与「结论已验证」分开。
|
|
1233
|
+
*
|
|
1234
|
+
* 实测反馈原话:「成功 7,失败 0」会让调用方跳过复核 ——
|
|
1235
|
+
* 因为「成功」两个字读起来像「对了」。这里显式说明它只表示什么。
|
|
1236
|
+
*/
|
|
1237
|
+
const summary = [];
|
|
1238
|
+
if (failCount === 0) {
|
|
1239
|
+
summary.push('算式全部求值成功(' + successCount + ' 条,只表示算式无语法/符号错,不含结论正确性判断)');
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
summary.push('成功 ' + successCount + ',失败 ' + failCount);
|
|
1243
|
+
}
|
|
1244
|
+
if (magnitudeWarnings.length > 0) {
|
|
1245
|
+
summary.push('');
|
|
1246
|
+
summary.push('⚠ 量级待核(' + magnitudeWarnings.length + ' 条):');
|
|
1247
|
+
for (const warning of magnitudeWarnings)
|
|
1248
|
+
summary.push(warning);
|
|
1249
|
+
}
|
|
424
1250
|
return {
|
|
425
|
-
result: [header, '', ...shown, '',
|
|
426
|
-
value: toJsonValue({
|
|
1251
|
+
result: [header, '', ...shown, '', ...summary].join('\n'),
|
|
1252
|
+
value: toJsonValue({
|
|
1253
|
+
count: items.length,
|
|
1254
|
+
success: successCount,
|
|
1255
|
+
failed: failCount,
|
|
1256
|
+
magnitudeWarnings: magnitudeWarnings.length,
|
|
1257
|
+
items: values,
|
|
1258
|
+
}),
|
|
427
1259
|
};
|
|
428
1260
|
}
|
|
429
1261
|
function opDerivative(expression, variable, precision) {
|
|
@@ -454,11 +1286,68 @@ function opDerivative(expression, variable, precision) {
|
|
|
454
1286
|
}
|
|
455
1287
|
return { result: symbolic, value: symbolic };
|
|
456
1288
|
}
|
|
457
|
-
function opIntegral(expression, variable, precision) {
|
|
1289
|
+
function opIntegral(expression, variable, precision, from, to) {
|
|
458
1290
|
const text = normalizeSource(expression);
|
|
459
1291
|
if (text === '')
|
|
460
1292
|
throw new CalcError('invalid_argument', '表达式为空');
|
|
1293
|
+
// 只给一端是含糊的输入:要么两端都给,要么都不给
|
|
1294
|
+
if ((from === undefined) !== (to === undefined)) {
|
|
1295
|
+
throw new CalcError('invalid_argument', '定积分需要同时给 from 与 to', '需要同时给两端(如 from=0, to=3);只给一端无法确定积分区间。');
|
|
1296
|
+
}
|
|
461
1297
|
const primitive = symbolicIntegral(text, variable);
|
|
1298
|
+
/**
|
|
1299
|
+
* ── 给了上下限:算定积分 ──
|
|
1300
|
+
*
|
|
1301
|
+
* 实测反馈(P1-3):以前传 `from`/`to` 会被**静默忽略**,
|
|
1302
|
+
* 只返回不定积分 `1/3*x^3 + C` —— 调用者以为拿到了定积分。
|
|
1303
|
+
* 文档的原话:「绝不要静默忽略已传入的参数,这是最伤信任的行为。」
|
|
1304
|
+
*
|
|
1305
|
+
* 现在按 Newton-Leibniz 算 F(to) − F(from)。
|
|
1306
|
+
*/
|
|
1307
|
+
if (from !== undefined && to !== undefined) {
|
|
1308
|
+
const engine = bignum(precision);
|
|
1309
|
+
/**
|
|
1310
|
+
* 用**有理数引擎**代入,而不是 BigNumber。
|
|
1311
|
+
*
|
|
1312
|
+
* 实测对比(原函数 1/3·x³ 在 x=3 处):
|
|
1313
|
+
* BigNumber → 8.99999999999999999999999999999999999999…
|
|
1314
|
+
* Fraction → 9
|
|
1315
|
+
*
|
|
1316
|
+
* BigNumber 的 `1/3` 是截断小数,乘 27 回不到整数。
|
|
1317
|
+
* 定积分出现的多半是有理系数多项式,Fraction 能约分回精确值。
|
|
1318
|
+
*/
|
|
1319
|
+
const rationalEngine = rational();
|
|
1320
|
+
let exact;
|
|
1321
|
+
try {
|
|
1322
|
+
const F = rationalEngine.parse(primitive);
|
|
1323
|
+
const upper = F.evaluate({ [variable]: to });
|
|
1324
|
+
const lower = F.evaluate({ [variable]: from });
|
|
1325
|
+
exact = rationalEngine.subtract(upper, lower);
|
|
1326
|
+
}
|
|
1327
|
+
catch (error) {
|
|
1328
|
+
throw new CalcError('unsupported', '原函数 ' + primitive + ' 无法代入上下限求值', '这是符号引擎的能力边界。可改用 operation="evaluate" 自己做数值积分(如黎曼和)。');
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* 显示:优先精确形式(整数或分数),并给出 BigNumber 小数近似。
|
|
1332
|
+
*
|
|
1333
|
+
* 为什么两个都给:`1/3` 这种系数在定积分里很常见,
|
|
1334
|
+
* 化为 `6.75` 会丢「精确等于 27/4」这个信息;而工程场景又常需要小数。
|
|
1335
|
+
*/
|
|
1336
|
+
const exactTextRaw = rationalEngine.format(exact);
|
|
1337
|
+
// 分母为 1 时不该写成 `9/1`(mathjs 的 ratio 格式会保留 /1)
|
|
1338
|
+
const exactText = exactTextRaw.replace(/\/1$/, '');
|
|
1339
|
+
const asNumber = Number(rationalEngine.number(exact));
|
|
1340
|
+
const decimalText = Number.isFinite(asNumber)
|
|
1341
|
+
? formatValue(engine, engine.bignumber(String(asNumber)), precision, currentDisplayPrecision())
|
|
1342
|
+
: exactText;
|
|
1343
|
+
const isFraction = exactText.includes('/');
|
|
1344
|
+
return {
|
|
1345
|
+
result: '原函数:' + primitive + ' + C\n' +
|
|
1346
|
+
'定积分 ∫[' + from + '→' + to + '] ' + text + ' d' + variable + ' = ' +
|
|
1347
|
+
exactText + (isFraction ? '(= ' + decimalText + ')' : ''),
|
|
1348
|
+
value: toJsonValue(exactText),
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
462
1351
|
return { result: `${primitive} + C`, value: primitive };
|
|
463
1352
|
}
|
|
464
1353
|
function opSimplify(expression) {
|
|
@@ -1302,6 +2191,16 @@ function opFormula(args, engine, precision) {
|
|
|
1302
2191
|
const required = Object.entries(formula.params).map(([name, spec]) => `${name}: ${spec.desc}`);
|
|
1303
2192
|
throw new CalcError('missing_params', `公式 "${formula.name}" 缺少参数: ${resolved.missing.join(', ')}`, `完整参数表 —— ${required.join(';')}。用 overrides 传入,例如 {"${resolved.missing[0]}": 100}。`);
|
|
1304
2193
|
}
|
|
2194
|
+
/**
|
|
2195
|
+
* ── 未知参数名:明确告知,不静默丢弃 ──
|
|
2196
|
+
*
|
|
2197
|
+
* 实测反馈:overrides 的 key 拼错时公式**照常出数**,只是那个参数用了默认值。
|
|
2198
|
+
* 报告里那次「236 倍缺口」误判就是这么来的 —— 而结果看着完全正常。
|
|
2199
|
+
*
|
|
2200
|
+
* 这里不抛错(可能是调用方故意传常数名,或用法上无伤),
|
|
2201
|
+
* 而是把「你没生效的参数」写成一条提示,让调用方能自己判断。
|
|
2202
|
+
*/
|
|
2203
|
+
const unknownOverrideKeys = resolved.unknownKeys ?? [];
|
|
1305
2204
|
const expression = resolveExpression(formula, args.customExpression);
|
|
1306
2205
|
const scope = buildFormulaScope(engine, resolved.values);
|
|
1307
2206
|
// 走 JS 实现的公式(数组逐元素运算这类 mathjs 表达不好的)
|
|
@@ -1340,7 +2239,7 @@ function opFormula(args, engine, precision) {
|
|
|
1340
2239
|
node = engine.parse(expression);
|
|
1341
2240
|
}
|
|
1342
2241
|
catch (error) {
|
|
1343
|
-
throw translateError(error, 'syntax');
|
|
2242
|
+
throw translateError(error, 'syntax', expression);
|
|
1344
2243
|
}
|
|
1345
2244
|
guardAst(node);
|
|
1346
2245
|
guardComplexity(node);
|
|
@@ -1353,6 +2252,19 @@ function opFormula(args, engine, precision) {
|
|
|
1353
2252
|
}
|
|
1354
2253
|
const parts = [describeFormula(formula, expression, resolved.values, resolved.overridden)];
|
|
1355
2254
|
parts.push(`结果: ${formatValue(engine, raw, precision, currentDisplayPrecision())}${formula.unit ? ` ${formula.unit}` : ''}`);
|
|
2255
|
+
/**
|
|
2256
|
+
* 未知参数名提示:把「你没生效的 key」明确说出来。
|
|
2257
|
+
*
|
|
2258
|
+
* 紧跟在结果后面(而不是压到输出末尾),因为这类问题会直接改变结论,
|
|
2259
|
+
* 读者必须第一眼就看到。
|
|
2260
|
+
*/
|
|
2261
|
+
if (unknownOverrideKeys.length > 0) {
|
|
2262
|
+
parts.push('');
|
|
2263
|
+
parts.push('⚠ 以下 overrides 的 key 不在公式「' + formula.name + '」的参数里,**已被忽略**:' +
|
|
2264
|
+
unknownOverrideKeys.join('、') +
|
|
2265
|
+
'。该公式的参数是:' + Object.keys(formula.params).join('、') +
|
|
2266
|
+
'。若这是拼写笔误,结果可能已偏离——请核对后重算。');
|
|
2267
|
+
}
|
|
1356
2268
|
/**
|
|
1357
2269
|
* 经验公式的适用性提示。
|
|
1358
2270
|
*
|
|
@@ -1555,122 +2467,6 @@ function opFormulas(args) {
|
|
|
1555
2467
|
}
|
|
1556
2468
|
return { result: lines.join('\n'), value: { query, matches: matches.map((m) => m.id) } };
|
|
1557
2469
|
}
|
|
1558
|
-
/** 能力清单。 */
|
|
1559
|
-
/** 能力清单。 */
|
|
1560
|
-
function opHelp() {
|
|
1561
|
-
return {
|
|
1562
|
-
result: [
|
|
1563
|
-
'══════ math_execute 完整能力清单 ══════',
|
|
1564
|
-
'',
|
|
1565
|
-
'【求值与代数】',
|
|
1566
|
-
' evaluate 任意 mathjs 表达式;BigNumber 64 位精度',
|
|
1567
|
-
' 例:10^20+5、factorial(50)、(1+2i)*(3-i)、5 cm to inch、sin(30 deg)',
|
|
1568
|
-
' 批量:给 expressions=["1/7","2^0.5","log(10)"] 一次算多条,',
|
|
1569
|
-
' 逐条独立报错;比多次调用省往返',
|
|
1570
|
-
' solve 解方程。多方程逗号分隔 = 方程组',
|
|
1571
|
-
' 例:3x + 2y = 10, x - y = 1 / x^2 - 5x + 6 = 0',
|
|
1572
|
-
' system 显式线性系统(matrix + vector)',
|
|
1573
|
-
' derivative 符号求导(order 可指定高阶)',
|
|
1574
|
-
' integral 符号不定积分',
|
|
1575
|
-
' simplify 符号化简',
|
|
1576
|
-
' expand 符号展开',
|
|
1577
|
-
' roots 多项式求根(含复数根)',
|
|
1578
|
-
'',
|
|
1579
|
-
'【扩展函数】(可直接写在表达式里)',
|
|
1580
|
-
' rank(A) 矩阵的秩;nullspace(A) 零空间基;svd(A) 奇异值',
|
|
1581
|
-
' factorize(n) 整数质因数分解',
|
|
1582
|
-
' nsolve("表达式", 初值) 数值求根(牛顿法 + 二分兜底)',
|
|
1583
|
-
'',
|
|
1584
|
-
'【线性代数】',
|
|
1585
|
-
' matrix 矩阵乘法/求逆/行列式/转置/迹;只给 matrix 时输出完整画像',
|
|
1586
|
-
' (秩/行列式/迹/奇异值/零空间/逆)',
|
|
1587
|
-
' eigen 特征值与特征向量',
|
|
1588
|
-
'',
|
|
1589
|
-
'【统计与概率】',
|
|
1590
|
-
' stats 描述统计(均值/中位数/众数/方差/标准差/分位数/偏度/峰度)',
|
|
1591
|
-
' dist 概率分布:normal/binomial/poisson/exponential/chisquare/t/normalinv',
|
|
1592
|
-
' regression 线性与多项式回归(含 R²)',
|
|
1593
|
-
'',
|
|
1594
|
-
'【微分方程】',
|
|
1595
|
-
' ode 数值求解 ODE(RK4):一阶方程组(SIR/捕食模型)、二阶方程自动降阶',
|
|
1596
|
-
' 返回轨迹、终点值、极值、发散警告',
|
|
1597
|
-
'',
|
|
1598
|
-
'【公式库】',
|
|
1599
|
-
' formula 调用已收录的 ' + FORMULAS.length + ' 条自然科学公式',
|
|
1600
|
-
' 例:formula="escape_velocity", overrides={M,r}',
|
|
1601
|
-
' 任何参数可覆盖 —— 架空世界改造的关键',
|
|
1602
|
-
' formulas 检索与分类:',
|
|
1603
|
-
' query="关键词" 按名称/关键词',
|
|
1604
|
-
' query="layer:derived" 按规则层级(fundamental=基本规则、mathematical=数学基础(与基本同级)、derived/phenomenological/engineering/conventional)',
|
|
1605
|
-
' query="domain:物理" 按学科领域(多标签)',
|
|
1606
|
-
' query="freq:high" 按主角感知频率',
|
|
1607
|
-
' query="symbol:m" 按符号含义',
|
|
1608
|
-
' formula-table 两级扩展:工作区临时公式(覆盖/自定义/停用)+ 全局公式包',
|
|
1609
|
-
' (tableAction: global-list / global-add / global-remove)',
|
|
1610
|
-
' 字段与校验规则见 docs/formula-authoring.md',
|
|
1611
|
-
'',
|
|
1612
|
-
'【世界观推演】',
|
|
1613
|
-
' world 输入星球参数 → 体感画像:重力/昼夜/季节/纬度气候/地形上限 +',
|
|
1614
|
-
' 天空配色/恒星结构与寿命/卫星系统/化学窗口/生命闸门/跨模块一致性检查',
|
|
1615
|
-
' 【world 完整字段表】(全部可选,默认地球)',
|
|
1616
|
-
' 恒星:starMass(太阳质量)、starRadius(太阳半径)、starTemp(K,默认5778)',
|
|
1617
|
-
' 轨道:orbitAU(AU,默认1)、eccentricity(默认0.0167)',
|
|
1618
|
-
' 行星:planetMass(地球质量)、planetRadius(地球半径)、rotationHours(默认23.93)、',
|
|
1619
|
-
' obliquityDeg(默认23.44)、albedo(默认0.306)',
|
|
1620
|
-
' 大气:pressure(Pa,默认101325)、molarMass(kg/mol,默认0.02896)、',
|
|
1621
|
-
' gamma(默认1.4)、greenhouse(增温K,默认33)',
|
|
1622
|
-
' 地表:materialStrength(Pa,默认1.3e8花岗岩)、materialDensity(默认2700)',
|
|
1623
|
-
' 地形 terrain(附带):kind=coastal/plain/plateau/mountain/valley/desert/',
|
|
1624
|
-
' forest/tundra/island/lake;elevation(海拔m)、distanceToSea(km)、',
|
|
1625
|
-
' latitudeDeg、annualPrecipMm、humidity(0-1)、windward(1迎风/-1背风)、',
|
|
1626
|
-
' reliefHeight(m)、oceanCurrent=warm/cold/none、',
|
|
1627
|
-
' surface=soil/sand/rock/ice/water/vegetation',
|
|
1628
|
-
' constants 改变物理常数,自动推导派生量并评估世界偏离度',
|
|
1629
|
-
' 【可改的基础旋钮】c(光速) / G(引力常数) / h(普朗克) / kB(玻尔兹曼) /',
|
|
1630
|
-
' e_charge(元电荷) / me(电子质量) / mp(质子质量) / NA(阿伏伽德罗) /',
|
|
1631
|
-
' epsilon0(真空介电常数)',
|
|
1632
|
-
' 【派生量,自动跟随,不能直接改】hbar / R / mu0 / kCoulomb / sigma /',
|
|
1633
|
-
' bWien / alpha_fs / Ry / muB',
|
|
1634
|
-
' 例:{c:1.5e8} → mu0/sigma/alpha_fs 等自动重算并评估偏离度',
|
|
1635
|
-
' quantities 看哪些物理量该跟着常数变、哪些不该(量纲分析 + 缩放指数)',
|
|
1636
|
-
' impact 改常数后跑全部公式并与现实对比,算每条偏差',
|
|
1637
|
-
' units 单位换算:把「三里」「两丈」「五百斤」换成标准单位',
|
|
1638
|
-
' 不带单位参数 → 列出全部可用单位(含中文古制与天文单位)',
|
|
1639
|
-
' 例:unitValue=30, unitFrom="里", unitTo="km" → 15 km',
|
|
1640
|
-
' 自创单位用 customUnits 覆盖(如把「里」定成 576 m)',
|
|
1641
|
-
' balance 收支盘点:粮/水/能/地/财政/军费/信息 多项一起看,给最短板',
|
|
1642
|
-
' 例:balance={grain:{demand:2.7e6,supply:3.1e6,unit:"kg/年"}}',
|
|
1643
|
-
' 回答「这个王国养不养得起二十万兵」这类问题(feasibility 只管单项上限)',
|
|
1644
|
-
' bodies 天体目录:太阳系 8 大行星 + 卫星 + 代表性系外行星 + 各亮度级恒星',
|
|
1645
|
-
' 例:bodyName="mars" / bodyKind="moon" / 不带参数列全部',
|
|
1646
|
-
' bodyConstant="alpha_fs", bodyFactor=2 → 看改常数后哪些天体数据要重算',
|
|
1647
|
-
' (关键:行星质量/半径/轨道与 α 无关,绝不跟着改)',
|
|
1648
|
-
'',
|
|
1649
|
-
'【数据管理】',
|
|
1650
|
-
' workspace 工作区管理(一个世界观 = 一套常数表 + 公式表 + 地点 + 变量)',
|
|
1651
|
-
' action2: list/show/create/switch/save/set/add-terrain/delete/reset',
|
|
1652
|
-
' stats/cleanup 可查看存储与清理;不同工作区完全隔离',
|
|
1653
|
-
' 注意:本插件没有图形界面(2026-10-25 已移除),全部经工具与 HTTP 路由操作',
|
|
1654
|
-
'',
|
|
1655
|
-
'【其他】',
|
|
1656
|
-
' help 本清单',
|
|
1657
|
-
' prompt-mode 调整系统提示注入档位(off/brief/full)',
|
|
1658
|
-
'',
|
|
1659
|
-
'【常用参数】',
|
|
1660
|
-
' expression 表达式/方程(多方程用逗号)',
|
|
1661
|
-
' variables 变量字典,如 {"m":500,"v":30}',
|
|
1662
|
-
' operation 上面的值;不填=auto',
|
|
1663
|
-
' precision 有效数字位数,默认 64,范围 1-256',
|
|
1664
|
-
' variable 求导/求根的自变量,默认 x',
|
|
1665
|
-
' overrides 公式参数覆盖',
|
|
1666
|
-
' constants 要改的物理常数',
|
|
1667
|
-
' world 星球参数',
|
|
1668
|
-
' terrain 局部地形参数',
|
|
1669
|
-
'',
|
|
1670
|
-
'【失败时】返回 ok=false + error.type/message/hint,按 hint 修正即可,不会崩溃。',
|
|
1671
|
-
].join('\n'),
|
|
1672
|
-
};
|
|
1673
|
-
}
|
|
1674
2470
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1675
2471
|
// 主流程
|
|
1676
2472
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -1695,6 +2491,123 @@ function normalizeOperation(value) {
|
|
|
1695
2491
|
return lowered;
|
|
1696
2492
|
}
|
|
1697
2493
|
/** 工具主入口:永不抛异常。 */
|
|
2494
|
+
/**
|
|
2495
|
+
* 承认的参数名白名单 —— 从 `MathArgs` 接口派生,不手写。
|
|
2496
|
+
*
|
|
2497
|
+
* ## 为什么需要这份白名单
|
|
2498
|
+
*
|
|
2499
|
+
* 踩过的坑:`{expression:'1+3', foo_bar_baz:123}` 会**静默成功**并返回 3。
|
|
2500
|
+
* 调用方以为参数生效了,实际被整个吞掉。这类「以为做了、其实没做」
|
|
2501
|
+
* 比报错危险得多 —— 与 overrides 里 key 拼错被丢弃是同一类问题。
|
|
2502
|
+
*
|
|
2503
|
+
* ## 为什么不靠 JSON Schema 的 additionalProperties
|
|
2504
|
+
*
|
|
2505
|
+
* 工具的 input schema 是宿主侧契约,加严会影响所有历史调用;
|
|
2506
|
+
* 而「参数名拼错」这件事在本工具内部就能判,判得更早、提示更具体。
|
|
2507
|
+
*
|
|
2508
|
+
* ## 白名单从哪来
|
|
2509
|
+
*
|
|
2510
|
+
* 不手写清单(手写必然与接口漂移)。用 TypeScript 的类型约束保证
|
|
2511
|
+
* `KNOWN_ARGUMENT_KEYS` 覆盖 `MathArgs` 的全部键:少一个键 tsconfig 会报错。
|
|
2512
|
+
* 见文件末尾的 `_AssertKeysCoverMathArgs`。
|
|
2513
|
+
*/
|
|
2514
|
+
const KNOWN_ARGUMENT_KEYS = new Set([
|
|
2515
|
+
// 基础计算
|
|
2516
|
+
'expression', 'expressions', 'terse', 'variables', 'operation', 'variable',
|
|
2517
|
+
'matrix', 'vector', 'precision', 'displayPrecision',
|
|
2518
|
+
// 数据与统计
|
|
2519
|
+
'data', 'dataY', 'distribution', 'params', 'x', 'degree',
|
|
2520
|
+
// 公式
|
|
2521
|
+
'formula', 'overrides', 'query', 'customExpression',
|
|
2522
|
+
// 微分方程 / 物理
|
|
2523
|
+
'initial', 'from', 'to', 'steps', 'samples',
|
|
2524
|
+
// 星球与推演
|
|
2525
|
+
'world', 'terrain', 'constants', 'derivation', 'layer',
|
|
2526
|
+
// 工作区
|
|
2527
|
+
'action2', 'workspace', 'name', 'description', 'terrainName', 'workspaceVariables',
|
|
2528
|
+
'useWorkspaceVariables',
|
|
2529
|
+
// 设定一致性
|
|
2530
|
+
'claims', 'listConstraints',
|
|
2531
|
+
// 单位
|
|
2532
|
+
'unitValue', 'unitFrom', 'unitTo', 'customUnits', 'unitScales', 'unitScale',
|
|
2533
|
+
// 收支
|
|
2534
|
+
'balance',
|
|
2535
|
+
// 天体
|
|
2536
|
+
'bodyName', 'bodyKind', 'bodyConstant', 'bodyFactor', 'bodyType', 'bodyEvidence',
|
|
2537
|
+
// 逆推校准
|
|
2538
|
+
'anchor', 'anchorValue', 'compareFrom', 'compareTo',
|
|
2539
|
+
// 幻想层
|
|
2540
|
+
'fantasyAbility', 'fantasyDemand', 'fantasyPhysical', 'fantasyBodyArea',
|
|
2541
|
+
// 混沌 / 继承 / 对比
|
|
2542
|
+
'lyapunov', 'inheritDerived', 'inheritBase', 'inheritSync', 'diffA', 'diffB',
|
|
2543
|
+
// 观测者 / 图 / 时间轴 / 白皮书 / 溯源 / 算力
|
|
2544
|
+
'observerKind', 'graphDepths', 'timelineFrom', 'timelineTo', 'timelineSteps',
|
|
2545
|
+
'whitepaperMode', 'traceTarget', 'costKind', 'costX', 'costY',
|
|
2546
|
+
'costIterations', 'costCombos', 'costWithPipeline',
|
|
2547
|
+
// 提示档位与帮助分节
|
|
2548
|
+
'promptMode',
|
|
2549
|
+
'helpTopic',
|
|
2550
|
+
// 公式表
|
|
2551
|
+
'tableAction', 'formulaId', 'newExpression', 'newParams', 'customFormula',
|
|
2552
|
+
// 自定义常数
|
|
2553
|
+
'customAction', 'customConstant', 'customConstantName', 'expectedDimension',
|
|
2554
|
+
// 结论文翻转
|
|
2555
|
+
'flipClaim', 'flipEpsilon', 'flipBase',
|
|
2556
|
+
// 杂项
|
|
2557
|
+
'threshold', 'worstLimit', 'variableName', 'constantName',
|
|
2558
|
+
'emptyOnly', 'dryRun', 'olderThanDays', 'keep', 'tempPrefixes',
|
|
2559
|
+
'sensitivityBase', 'sensitivityExponent',
|
|
2560
|
+
]);
|
|
2561
|
+
/**
|
|
2562
|
+
* world 的**顶层简写参数**(`{operation:'world', starMass:10}` 这种写法)。
|
|
2563
|
+
*
|
|
2564
|
+
* 这些键**不在 `MathArgs` 接口里**,但它们是合法输入 —— 由 `WORLD_PARAMS` 决定,
|
|
2565
|
+
* `opWorld` 会遍历它把顶层值并入 `world`。
|
|
2566
|
+
*
|
|
2567
|
+
* 踩过的坑:白名单第一版只抄了接口里的 4 个(planetRadius/planetMass/rotationHours/obliquityDeg),
|
|
2568
|
+
* 于是 `{operation:'world', starMass:10}` 被新加的校验拦下 —— **world 套件当场红**。
|
|
2569
|
+
* 教训:白名单不能只认 TypeScript 接口,接口落后于实现的部分会变成假阳性。
|
|
2570
|
+
* 所以这里**从真实数据源 `WORLD_KEYS` 派生**,接口补不全也不影响。
|
|
2571
|
+
*/
|
|
2572
|
+
const WORLD_SHORTHAND_KEYS = WORLD_KEYS;
|
|
2573
|
+
/**
|
|
2574
|
+
* 查有没有拼错/多余的参数名。
|
|
2575
|
+
*
|
|
2576
|
+
* ## 为什么大小写变体也一律报错
|
|
2577
|
+
*
|
|
2578
|
+
* 做过一版「容忍大小写差异」(把 `DisplayPrecision` 归一到 `displayPrecision`),
|
|
2579
|
+
* 实测发现更糟:**参数被接受,却因为代码只读小写键而完全不生效**,
|
|
2580
|
+
* 输出静默退回 8 位。那不是宽容,那是另一种静默 ——
|
|
2581
|
+
* 比直接报错更难排查,因为连错误信息都没有。
|
|
2582
|
+
*
|
|
2583
|
+
* 所以这里只做**精确匹配**:名字必须是白名单里那个写法,一个字母都不差。
|
|
2584
|
+
*
|
|
2585
|
+
* @param rawArguments - 调用方原样的参数对象
|
|
2586
|
+
* @returns 认不出的键;空数组表示全部可识别
|
|
2587
|
+
*/
|
|
2588
|
+
function findUnknownArguments(rawArguments) {
|
|
2589
|
+
if (rawArguments === null || typeof rawArguments !== 'object' || Array.isArray(rawArguments))
|
|
2590
|
+
return [];
|
|
2591
|
+
const unknown = [];
|
|
2592
|
+
for (const key of Object.keys(rawArguments)) {
|
|
2593
|
+
if (KNOWN_ARGUMENT_KEYS.has(key))
|
|
2594
|
+
continue;
|
|
2595
|
+
if (WORLD_SHORTHAND_KEYS.includes(key))
|
|
2596
|
+
continue;
|
|
2597
|
+
unknown.push(key);
|
|
2598
|
+
}
|
|
2599
|
+
return unknown.sort();
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* 工具主入口:永不抛异常。
|
|
2603
|
+
*
|
|
2604
|
+
* 所有失败都转成 `{ ok:false, error:{ type, message, hint } }`,
|
|
2605
|
+
* 因为宿主侧把抛异常当成「工具坏了」,而这里的失败几乎都是**输入问题**,
|
|
2606
|
+
* 调用方按 hint 改一下就能过。
|
|
2607
|
+
*
|
|
2608
|
+
* @param rawArguments - 宿主传进来的参数对象(可能是任意形态,这里先做白名单校验)
|
|
2609
|
+
* @returns 永远是一个 MathResult,不会 throw
|
|
2610
|
+
*/
|
|
1698
2611
|
export function runMathTool(rawArguments) {
|
|
1699
2612
|
const args = (rawArguments ?? {});
|
|
1700
2613
|
const input = typeof args.expression === 'string' ? args.expression.trim() : '';
|
|
@@ -1703,6 +2616,18 @@ export function runMathTool(rawArguments) {
|
|
|
1703
2616
|
/** 显示有效位数;undefined = 未设定(沿用计算精度,即完整输出)。 */
|
|
1704
2617
|
let displayPrecision;
|
|
1705
2618
|
try {
|
|
2619
|
+
/**
|
|
2620
|
+
* 先把「认不出的参数名」拦下来。
|
|
2621
|
+
*
|
|
2622
|
+
* 不静默继续算 —— 那样调用方会以为某个参数已经生效
|
|
2623
|
+
* (实测见过:`displayPrecision:0`、`format`、`sigfigs` 都是这样被吞的)。
|
|
2624
|
+
*/
|
|
2625
|
+
const unknownArguments = findUnknownArguments(rawArguments);
|
|
2626
|
+
if (unknownArguments.length > 0) {
|
|
2627
|
+
throw new CalcError('unknown_argument', '这些参数名认不出来,已经被忽略(本次没有生效): ' + unknownArguments.join('、'), '按名字传给 help 可查用法: {operation:"help"}。' +
|
|
2628
|
+
'常见原因:拼写笔误、大小写、把某个 operation 的子字段提到了顶层' +
|
|
2629
|
+
'(如 terrain 的字段要放在 terrain 对象里)。');
|
|
2630
|
+
}
|
|
1706
2631
|
precision = clampPrecision(args.precision);
|
|
1707
2632
|
/**
|
|
1708
2633
|
* 显示精度:本次显式给 > 工作区固化值 > 沿用计算精度。
|
|
@@ -1769,9 +2694,26 @@ export function runMathTool(rawArguments) {
|
|
|
1769
2694
|
setActiveDisplayPrecision(displayPrecision);
|
|
1770
2695
|
const variable = normalizeVariable(args.variable);
|
|
1771
2696
|
const requested = normalizeOperation(args.operation);
|
|
1772
|
-
|
|
2697
|
+
/**
|
|
2698
|
+
* 作用域输入按「本次参数 > 工作区变量」合并。
|
|
2699
|
+
*
|
|
2700
|
+
* 合并只发生在**计算路径**(这一处),且受工作区开关 `useWorkspaceVariables` 控制;
|
|
2701
|
+
* 开关关闭时 `mergeWorkspaceVariables` 原样返回 `args.variables`,
|
|
2702
|
+
* 行为与旧版完全一致。
|
|
2703
|
+
*
|
|
2704
|
+
* 为什么只在这一处合并、而不是每个 operation 各自去读工作区:
|
|
2705
|
+
* 那样每个 op 都要自己处理「合并 + 优先级 + 缓存指纹」,
|
|
2706
|
+
* 迟早有一处漏掉(缓存指纹漏掉就是静默返回旧值)。
|
|
2707
|
+
*/
|
|
2708
|
+
const scopeVariables = mergeWorkspaceVariables(args.variables);
|
|
2709
|
+
const known = knownNumbers(scopeVariables);
|
|
1773
2710
|
const engine = bignum(precision);
|
|
1774
|
-
|
|
2711
|
+
/**
|
|
2712
|
+
* 每次调用开头清空「被跳过的变量键」记录,避免串到上一次调用。
|
|
2713
|
+
* 与 `activeDisplayPrecision` 同理:模块级状态必须每次开头复位。
|
|
2714
|
+
*/
|
|
2715
|
+
resetSkippedVariableKeys();
|
|
2716
|
+
const scope = buildScope(engine, scopeVariables);
|
|
1775
2717
|
operation = requested === 'auto' ? detectOperation(input, args) : requested;
|
|
1776
2718
|
// 提示档位切换(不影响计算,只影响注入给模型的内容)
|
|
1777
2719
|
if (args.promptMode !== undefined) {
|
|
@@ -1859,6 +2801,11 @@ export function runMathTool(rawArguments) {
|
|
|
1859
2801
|
stableArgs(restForCache),
|
|
1860
2802
|
activeWorkspaceNameForCache(),
|
|
1861
2803
|
constantsFingerprint(),
|
|
2804
|
+
/**
|
|
2805
|
+
* 变量内容必须进键:见 `workspaceVariablesFingerprint` 的说明。
|
|
2806
|
+
* 开关关闭时这一项是常量 'disabled',等价于旧行为。
|
|
2807
|
+
*/
|
|
2808
|
+
workspaceVariablesFingerprint(),
|
|
1862
2809
|
]);
|
|
1863
2810
|
const cached = getCached(key);
|
|
1864
2811
|
if (cached !== undefined) {
|
|
@@ -1880,9 +2827,37 @@ export function runMathTool(rawArguments) {
|
|
|
1880
2827
|
* 缓存它们只会挤占单条计算的位置。
|
|
1881
2828
|
*/
|
|
1882
2829
|
if (Array.isArray(args.expressions) && args.expressions.length > 0) {
|
|
1883
|
-
outcome = opEvaluateBatch(engine, args.expressions, scope, precision);
|
|
2830
|
+
outcome = opEvaluateBatch(engine, args.expressions, scope, precision, args.terse === true);
|
|
2831
|
+
/**
|
|
2832
|
+
* 批量里**有失败**时,顶层 `ok` 必须为 false。
|
|
2833
|
+
*
|
|
2834
|
+
* ## 修的是什么
|
|
2835
|
+
*
|
|
2836
|
+
* 实测反馈(2026-11-06):批量里一条 `unknown_symbol` 失败时,
|
|
2837
|
+
* `result` 里写着「→ 失败[unknown_symbol]」,但 `ok` 仍是 `true`、
|
|
2838
|
+
* `error` 是 `undefined`。于是任何写 `if (r.ok)` 的调用方
|
|
2839
|
+
* **都会把失败当成成功**,再把失败文本当数值用。
|
|
2840
|
+
*
|
|
2841
|
+
* 原话:「我在本次任务中多次写 `if (!r.ok)` 来判断成败,
|
|
2842
|
+
* 得到的结果是『永远成功』——因为 `ok` 永远是 `true`。」
|
|
2843
|
+
*
|
|
2844
|
+
* ## 为什么不是「一条失败就整批失败」
|
|
2845
|
+
*
|
|
2846
|
+
* 「一条失败不影响其它条」是这个批量模式的核心优点,不能丢。
|
|
2847
|
+
* 所以这里分开表达:
|
|
2848
|
+
* · `ok: false` —— 告诉自动化流程「本批**不是**全好」
|
|
2849
|
+
* · `partial: true` —— 告诉它「但有成功的条目可用」
|
|
2850
|
+
* · `failed / succeeded` —— 给出具体数量,便于分流处理
|
|
2851
|
+
*
|
|
2852
|
+
* 顶层 `error` 仍是 `undefined`(这是**批量**失败,不是某个单条的错误;
|
|
2853
|
+
* 每条的原因都在 `value.items` 里)。若硬塞一个 error,
|
|
2854
|
+
* 反而会让「只有第 3 条错」被读成「整个调用都错了」。
|
|
2855
|
+
*/
|
|
2856
|
+
const batchValue = (outcome.value ?? {});
|
|
2857
|
+
const failedCount = typeof batchValue.failed === 'number' ? batchValue.failed : 0;
|
|
2858
|
+
const succeededCount = typeof batchValue.success === 'number' ? batchValue.success : 0;
|
|
1884
2859
|
const batchResult = {
|
|
1885
|
-
ok:
|
|
2860
|
+
ok: failedCount === 0,
|
|
1886
2861
|
operation: 'evaluate',
|
|
1887
2862
|
input: args.expressions.join(' ; ').slice(0, 200),
|
|
1888
2863
|
result: outcome.result,
|
|
@@ -1890,17 +2865,37 @@ export function runMathTool(rawArguments) {
|
|
|
1890
2865
|
};
|
|
1891
2866
|
if (outcome.value !== undefined)
|
|
1892
2867
|
batchResult.value = outcome.value;
|
|
2868
|
+
if (failedCount > 0) {
|
|
2869
|
+
batchResult.partial = succeededCount > 0;
|
|
2870
|
+
batchResult.failed = failedCount;
|
|
2871
|
+
batchResult.succeeded = succeededCount;
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* 被跳过的变量键在**批量路径**也要告知。
|
|
2875
|
+
*
|
|
2876
|
+
* 踩过:第一版只在函数末尾的单条路径上组装这条提示,
|
|
2877
|
+
* 而批量在 3078 行就 return 了 —— 于是批量调用永远看不到它。
|
|
2878
|
+
* 这正是实测反馈里的原始场景(一批算式 + 一个中文变量名)。
|
|
2879
|
+
*/
|
|
2880
|
+
const skippedInBatch = takeSkippedVariableKeys();
|
|
2881
|
+
if (skippedInBatch.length > 0) {
|
|
2882
|
+
const parts = skippedInBatch.map(function (item) { return item.name + '(' + item.reason + ')'; });
|
|
2883
|
+
batchResult.notice =
|
|
2884
|
+
'这些变量名没被采用,已跳过(其余照算): ' + parts.join(';') +
|
|
2885
|
+
'。变量名只能含字母、数字、下划线;中文名可先存进工作区再引用。';
|
|
2886
|
+
}
|
|
1893
2887
|
return batchResult;
|
|
1894
2888
|
}
|
|
1895
2889
|
switch (operation) {
|
|
1896
2890
|
case 'help':
|
|
1897
|
-
|
|
2891
|
+
/** 分节帮助:给了 helpTopic 就回那一节,否则回目录。详见 `src/help.ts`。 */
|
|
2892
|
+
outcome = opHelp(typeof args.helpTopic === 'string' ? args.helpTopic : undefined);
|
|
1898
2893
|
break;
|
|
1899
2894
|
case 'derivative':
|
|
1900
2895
|
outcome = opDerivative(input, variable, precision);
|
|
1901
2896
|
break;
|
|
1902
2897
|
case 'integral':
|
|
1903
|
-
outcome = opIntegral(input, variable, precision);
|
|
2898
|
+
outcome = opIntegral(input, variable, precision, typeof args.from === 'number' ? args.from : undefined, typeof args.to === 'number' ? args.to : undefined);
|
|
1904
2899
|
break;
|
|
1905
2900
|
case 'simplify':
|
|
1906
2901
|
outcome = opSimplify(input);
|
|
@@ -2007,6 +3002,52 @@ export function runMathTool(rawArguments) {
|
|
|
2007
3002
|
const hintNote = maybeDisplayPrecisionHint(result.result);
|
|
2008
3003
|
if (hintNote)
|
|
2009
3004
|
result.notice = hintNote;
|
|
3005
|
+
/**
|
|
3006
|
+
* 三角函数按弧度算时补一句说明。
|
|
3007
|
+
*
|
|
3008
|
+
* 走 `notice` 而不是拼进 `result`:`result` 是数据通道,
|
|
3009
|
+
* 往里拼提示会让依赖精确相等(`sin(30) = -0.98803162`)的调用方失配 ——
|
|
3010
|
+
* 这与「显示精度提示走 notice」是同一条既有约定。
|
|
3011
|
+
*
|
|
3012
|
+
* 实测反馈(2026-11-06 第二份):中文工程语境里 `sin(30)` 的默认读法是 30 度,
|
|
3013
|
+
* 而工具按弧度给出 `-0.988` —— 数字看着正常,用户不会察觉。
|
|
3014
|
+
*/
|
|
3015
|
+
if (operation === 'evaluate' && input !== '') {
|
|
3016
|
+
const angleNote = checkAngleUnit(input);
|
|
3017
|
+
if (angleNote) {
|
|
3018
|
+
result.notice = result.notice ? result.notice + '\n' + angleNote : angleNote;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* 带单位的结果补说明:「这个数有多大、等价于什么」。
|
|
3023
|
+
*
|
|
3024
|
+
* 实测:`evaluate` 带单位的结果只有裸文本(`87.78 Mt`,notice 为空),
|
|
3025
|
+
* 而 `units` 操作有【代入过程】【注意】——两者信息量差得远。
|
|
3026
|
+
* 模型看到 `87.78 Mt` 并不知道它是 8778 万吨。
|
|
3027
|
+
*
|
|
3028
|
+
* 用户原话:「面向 AI 的运算信息最好把信息注释写到位。」
|
|
3029
|
+
*/
|
|
3030
|
+
{
|
|
3031
|
+
const unitNote = explainUnitResult(result.result);
|
|
3032
|
+
if (unitNote) {
|
|
3033
|
+
result.notice = result.notice ? result.notice + '\n' + unitNote : unitNote;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* 被跳过的变量键要告知,不能静默。
|
|
3038
|
+
*
|
|
3039
|
+
* 实测反馈(2026-11-06 第二份):`variables` 里写了一个中文键,
|
|
3040
|
+
* 整批中止、合法算式一条没算。现在改成「跳过 + 告知」——
|
|
3041
|
+
* 算出来的数是对的,但调用方必须知道哪个键没生效,
|
|
3042
|
+
* 否则它会以为 `人口` 已经传进去了。
|
|
3043
|
+
*/
|
|
3044
|
+
const skippedVars = takeSkippedVariableKeys();
|
|
3045
|
+
if (skippedVars.length > 0) {
|
|
3046
|
+
const parts = skippedVars.map(function (item) { return item.name + '(' + item.reason + ')'; });
|
|
3047
|
+
const skipNote = '这些变量名没被采用,已跳过(其余照算): ' + parts.join(';') +
|
|
3048
|
+
'。变量名只能含字母、数字、下划线;中文名可先存进工作区再引用。';
|
|
3049
|
+
result.notice = result.notice ? result.notice + '\n' + skipNote : skipNote;
|
|
3050
|
+
}
|
|
2010
3051
|
/**
|
|
2011
3052
|
* ── 精度不足提醒 ──
|
|
2012
3053
|
*
|
|
@@ -2025,23 +3066,37 @@ export function runMathTool(rawArguments) {
|
|
|
2025
3066
|
*
|
|
2026
3067
|
* 取「两条提示取更强的」:精度损失提示优先于泛泛的「可以调」提示。
|
|
2027
3068
|
*/
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
3069
|
+
/**
|
|
3070
|
+
* ── 精度损失:只在**真的丢精度**时提示一句 ──
|
|
3071
|
+
*
|
|
3072
|
+
* 与旧实现的关键区别:旧版只要「未固化 + 结果有小数」就附一段说明,
|
|
3073
|
+
* 一次 60+ 次调用的会话里出现 60+ 次(用户实测反馈:纯噪音)。
|
|
3074
|
+
* 现在判据是**相对误差超过万分之一**,普通计算(`1/7` 误差 1e-9)不提示。
|
|
3075
|
+
*
|
|
3076
|
+
* 两种情况的说法不同:
|
|
3077
|
+
* · 未显式指定 → 已自动改用科学计数法,说明为什么换格式
|
|
3078
|
+
* · 显式指定 → 格式按你要求的保留,但告知这个位数丢了精度
|
|
3079
|
+
*/
|
|
3080
|
+
const loss = consumePrecisionLoss();
|
|
3081
|
+
/**
|
|
3082
|
+
* 显式指定时用**更宽松**的提示阈值。
|
|
3083
|
+
*
|
|
3084
|
+
* 理由:调用方既然写了 `displayPrecision:4`,就已经接受「4 位小数的精度」。
|
|
3085
|
+
* `1/7` → `0.1429` 的相对误差是 0.03% —— 那是他要求的结果,不值得提示。
|
|
3086
|
+
* 只有丢到 1% 以上(如 `disp:4` 下的 σ 直接归零)才该说一句。
|
|
3087
|
+
*
|
|
3088
|
+
* 未显式指定时保持万分之一:那时代码替用户做了取舍,要说明。
|
|
3089
|
+
*/
|
|
3090
|
+
const noticeThreshold = isDisplayPrecisionExplicit() ? 1e-2 : PRECISION_LOSS_THRESHOLD;
|
|
3091
|
+
if (loss > noticeThreshold) {
|
|
3092
|
+
const percent = loss >= 1 ? '>100' : (loss * 100).toFixed(loss > 0.1 ? 0 : 2);
|
|
3093
|
+
result.notice = isDisplayPrecisionExplicit()
|
|
3094
|
+
? '按你指定的 ' + displayPrecision + ' 位小数定点显示会丢约 ' + percent +
|
|
3095
|
+
'% 的精度,结果已按该位数给出。需要精确值请传 displayPrecision:' +
|
|
3096
|
+
Math.min(64, displayPrecision + 8) + '。'
|
|
3097
|
+
: '本次结果已改用科学计数法:按 ' + displayPrecision + ' 位小数定点显示会丢约 ' +
|
|
3098
|
+
percent + '% 的精度。要定点显示请传 displayPrecision:' +
|
|
3099
|
+
Math.min(64, displayPrecision + 8) + '。';
|
|
2045
3100
|
}
|
|
2046
3101
|
/**
|
|
2047
3102
|
* 非有限结果的处理:**数值型** operation 才算错误,分析型的不算。
|
|
@@ -2142,17 +3197,23 @@ function clampPrecision(value) {
|
|
|
2142
3197
|
*/
|
|
2143
3198
|
function maybeDisplayPrecisionHint(resultText) {
|
|
2144
3199
|
try {
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
3200
|
+
/**
|
|
3201
|
+
* ── 这条通用提示已被移除(2026-11-02,依据实测反馈)──
|
|
3202
|
+
*
|
|
3203
|
+
* 原实现:「未固化显示精度 + 结果里有小数」就附一行说明。
|
|
3204
|
+
* 实测代价:一次 60+ 次调用的会话里,这条提示出现 **60+ 次** —— 纯噪音。
|
|
3205
|
+
*
|
|
3206
|
+
* 为什么不能改成「只提示前 3 次」:那会破坏「同输入同输出」的确定性
|
|
3207
|
+
* (第一次带提示、第二次不带,输出不再字节一致),本项目把它当硬不变量。
|
|
3208
|
+
*
|
|
3209
|
+
* 现在改为**只在真的丢精度时**提示一句具体的说明(见下方的精度损失分支),
|
|
3210
|
+
* 而且格式本身已自动换成科学计数法,所以那种情况也很少出现。
|
|
3211
|
+
*
|
|
3212
|
+
* 「显示精度可调」这件事已经写在工具描述、系统提示与 playbook 三处,
|
|
3213
|
+
* 不需要每次调用重复提醒。
|
|
3214
|
+
*/
|
|
3215
|
+
void resultText;
|
|
3216
|
+
return '';
|
|
2156
3217
|
}
|
|
2157
3218
|
catch {
|
|
2158
3219
|
return '';
|
|
@@ -2183,6 +3244,45 @@ function constantsFingerprint() {
|
|
|
2183
3244
|
return 'unknown-' + Math.random();
|
|
2184
3245
|
}
|
|
2185
3246
|
}
|
|
3247
|
+
/**
|
|
3248
|
+
* 工作区变量指纹(缓存键用)。
|
|
3249
|
+
*
|
|
3250
|
+
* ## 为什么必须有这一条
|
|
3251
|
+
*
|
|
3252
|
+
* 缓存键原本只含**工作区名**与**常数指纹**,不含变量内容。于是:
|
|
3253
|
+
*
|
|
3254
|
+
* ```
|
|
3255
|
+
* 存 BTU_LB=1260000 → 算 30*BTU_LB → 得数,进缓存
|
|
3256
|
+
* 改成 BTU_LB=126000 → 再算 → 命中旧缓存 → 还是按 1260000 的数
|
|
3257
|
+
* ```
|
|
3258
|
+
*
|
|
3259
|
+
* 这是**静默返回改之前的数**,比不缓存危险得多 ——
|
|
3260
|
+
* 与「改常数后必须失效」是同一条道理,只是状态源换成了变量。
|
|
3261
|
+
*
|
|
3262
|
+
* ## 开关关闭时返回固定串(而不是空)
|
|
3263
|
+
*
|
|
3264
|
+
* 空串与「有变量但指纹为空」无法区分。返回一个明确的常量,
|
|
3265
|
+
* 让键的行为在「开关关闭」这件事上也保持稳定。
|
|
3266
|
+
*
|
|
3267
|
+
* @returns 稳定的指纹字符串
|
|
3268
|
+
*/
|
|
3269
|
+
function workspaceVariablesFingerprint() {
|
|
3270
|
+
if (!useWorkspaceVariables())
|
|
3271
|
+
return 'disabled';
|
|
3272
|
+
try {
|
|
3273
|
+
const stored = workspaceVariablesOf();
|
|
3274
|
+
const keys = Object.keys(stored).sort();
|
|
3275
|
+
if (keys.length === 0)
|
|
3276
|
+
return 'empty';
|
|
3277
|
+
return keys
|
|
3278
|
+
.map(function (key) { return key + '=' + stored[key].toPrecision(15); })
|
|
3279
|
+
.join(';');
|
|
3280
|
+
}
|
|
3281
|
+
catch {
|
|
3282
|
+
// 读不到就给随机值 —— 宁可慢,不可错(与 constantsFingerprint 同策略)
|
|
3283
|
+
return 'unknown-' + Math.random();
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
2186
3286
|
/**
|
|
2187
3287
|
* 当前工作区名(缓存键用)。
|
|
2188
3288
|
*
|
|
@@ -2232,20 +3332,30 @@ function stableArgs(value) {
|
|
|
2232
3332
|
* 解析**显示**精度。
|
|
2233
3333
|
*
|
|
2234
3334
|
* 与 `clampPrecision` 的区别:这个不影响计算,只影响写出来的位数,
|
|
2235
|
-
*
|
|
3335
|
+
* 所以允许更宽的范围(**0—64**)并且**允许返回 undefined**(= 没给)。
|
|
3336
|
+
*
|
|
3337
|
+
* ## 0 在这里是「0 位小数」,不是「清除」
|
|
3338
|
+
*
|
|
3339
|
+
* 踩过的坑:这里原先把 `<= 0` 当作未给,于是 `displayPrecision: 0`
|
|
3340
|
+
* 被静默丢弃、照旧输出 8 位小数 —— 而 schema 上白纸黑字写着「0-64」。
|
|
3341
|
+
* 文档与实现对不上,正是这个项目一直要避免的那类问题。
|
|
2236
3342
|
*
|
|
2237
|
-
*
|
|
3343
|
+
* **两处 0 的语义刻意不同,各有各的理由**:
|
|
3344
|
+
* · 本次参数 `displayPrecision: 0` → `3.14159265` 显示成 `3`
|
|
3345
|
+
* (问的是「这次显示几位」,0 是合法答案)
|
|
3346
|
+
* · 工作区 `set displayPrecision: 0` → 清除固化值
|
|
3347
|
+
* (问的是「要不要保存」,0 表示不要)
|
|
2238
3348
|
*
|
|
2239
3349
|
* @param value - 原始参数
|
|
2240
|
-
* @returns
|
|
3350
|
+
* @returns 显示位数(0—64);未给或非法返回 undefined
|
|
2241
3351
|
*/
|
|
2242
3352
|
function clampDisplayPrecision(value) {
|
|
2243
3353
|
if (value === undefined || value === null || value === '')
|
|
2244
3354
|
return undefined;
|
|
2245
3355
|
const numeric = typeof value === 'number' ? value : Number(value);
|
|
2246
|
-
if (!Number.isFinite(numeric) || numeric
|
|
3356
|
+
if (!Number.isFinite(numeric) || numeric < 0)
|
|
2247
3357
|
return undefined;
|
|
2248
|
-
return Math.min(64, Math.max(
|
|
3358
|
+
return Math.min(64, Math.max(0, Math.round(numeric)));
|
|
2249
3359
|
}
|
|
2250
3360
|
function normalizeVariable(value) {
|
|
2251
3361
|
if (typeof value !== 'string' || value.trim() === '')
|
|
@@ -2295,6 +3405,17 @@ const OUTPUT_SCHEMA = {
|
|
|
2295
3405
|
notice: { type: 'string', description: '附加提示(显示精度/精度不足等)' },
|
|
2296
3406
|
/** 结果来自缓存(未重算)。同样必须声明,否则命中缓存时调用失败。 */
|
|
2297
3407
|
cached: { type: 'boolean', description: '是否命中缓存' },
|
|
3408
|
+
/**
|
|
3409
|
+
* 批量模式下「部分成功」。
|
|
3410
|
+
*
|
|
3411
|
+
* 语义:本批**有失败条目**(所以 `ok` 是 false),但**也有成功的**可用。
|
|
3412
|
+
* 没这个字段时,调用方无法区分「整批全废」与「废一条、其余可用」。
|
|
3413
|
+
*/
|
|
3414
|
+
partial: { type: 'boolean', description: '批量部分成功(有失败条目但仍有成功结果)' },
|
|
3415
|
+
/** 批量模式:失败条目数。 */
|
|
3416
|
+
failed: { type: 'integer', description: '批量中失败条数' },
|
|
3417
|
+
/** 批量模式:成功条目数。 */
|
|
3418
|
+
succeeded: { type: 'integer', description: '批量中成功条数' },
|
|
2298
3419
|
error: {
|
|
2299
3420
|
type: 'object',
|
|
2300
3421
|
additionalProperties: false,
|
|
@@ -2306,6 +3427,10 @@ const OUTPUT_SCHEMA = {
|
|
|
2306
3427
|
},
|
|
2307
3428
|
},
|
|
2308
3429
|
};
|
|
3430
|
+
/** 运行期再兜一次:白名单里不许有接口没有的键(防止手滑多写)。 */
|
|
3431
|
+
const _KEY_SET_MATCHES_INTERFACE = KNOWN_ARGUMENT_KEYS;
|
|
3432
|
+
void 0;
|
|
3433
|
+
void _KEY_SET_MATCHES_INTERFACE;
|
|
2309
3434
|
function renderResult(value) {
|
|
2310
3435
|
const payload = (value ?? {});
|
|
2311
3436
|
if (payload.ok === false) {
|
|
@@ -2331,13 +3456,40 @@ const PARAMETER_SPEC = {
|
|
|
2331
3456
|
},
|
|
2332
3457
|
expressions: {
|
|
2333
3458
|
type: 'array',
|
|
2334
|
-
|
|
2335
|
-
|
|
3459
|
+
/**
|
|
3460
|
+
* ## 这里声明 items,是推翻了之前「刻意不声明」的决定
|
|
3461
|
+
*
|
|
3462
|
+
* 之前的理由是「不声明也收得进来 + 省 schema 长度」。但那是错的:
|
|
3463
|
+
* **不声明等于模型看不见元素级字段**。上一轮修「元素里的未知键被静默忽略」时
|
|
3464
|
+
* 就撞上了这个 —— 校验现在会拒绝未声明的键,而模型不知道能写什么。
|
|
3465
|
+
*
|
|
3466
|
+
* 加了 `sanity` 之后更明显:一个模型看不见的参数等于不存在。
|
|
3467
|
+
* 所以把 items 加回来,长度从别处腾(`operation` 与 `customExpression` 各压一段)。
|
|
3468
|
+
*/
|
|
3469
|
+
items: {
|
|
3470
|
+
type: 'json',
|
|
3471
|
+
description: '字符串("1/7")或对象 {label?, expr, sanity?}。' +
|
|
3472
|
+
'sanity 给锚点:{max,min,expect,note},例 {max:1e10,note:"帝国总兵力"}。',
|
|
3473
|
+
},
|
|
3474
|
+
description: '批量求值,逐条独立报错。',
|
|
3475
|
+
},
|
|
3476
|
+
terse: {
|
|
3477
|
+
type: 'boolean',
|
|
3478
|
+
description: '省 token:只回「表达式 = 结果」。失败条照常标出原因。',
|
|
2336
3479
|
},
|
|
2337
3480
|
variables: {
|
|
2338
3481
|
type: 'object',
|
|
2339
3482
|
additionalProperties: true,
|
|
2340
|
-
description: '变量字典,注入表达式求值的环境。例:{"m": 500, "v": 30
|
|
3483
|
+
description: '变量字典,注入表达式求值的环境。例:{"m": 500, "v": 30}。值可为数字或数字字符串。',
|
|
3484
|
+
},
|
|
3485
|
+
workspaceVariables: {
|
|
3486
|
+
type: 'object',
|
|
3487
|
+
additionalProperties: true,
|
|
3488
|
+
description: 'workspace/action2=set:把命名常数存进工作区,免去重打。默认不参与计算。',
|
|
3489
|
+
},
|
|
3490
|
+
useWorkspaceVariables: {
|
|
3491
|
+
type: 'boolean',
|
|
3492
|
+
description: 'workspace/action2=set:命名变量是否参与计算(默认 false)。详见 help。',
|
|
2341
3493
|
},
|
|
2342
3494
|
operation: {
|
|
2343
3495
|
type: 'string',
|
|
@@ -2384,15 +3536,12 @@ const PARAMETER_SPEC = {
|
|
|
2384
3536
|
},
|
|
2385
3537
|
bodyKind: {
|
|
2386
3538
|
type: 'string',
|
|
2387
|
-
description: '
|
|
2388
|
-
'planet-mini-neptune / planet-gas-giant / moon / dwarf-planet / asteroid。',
|
|
3539
|
+
description: 'bodies:按类别筛。star / planet-terrestrial / planet-gas-giant / moon / dwarf-planet 等,全文见 help。',
|
|
2389
3540
|
},
|
|
2390
3541
|
bodyConstant: {
|
|
2391
3542
|
type: 'string',
|
|
2392
|
-
description: '
|
|
2393
|
-
'
|
|
2394
|
-
'输出分「必须重算的量」与「绝不该动的量」两块。' +
|
|
2395
|
-
'后者尤其重要:行星质量、半径、轨道与 α 无关,跟着改就是篡改设定。',
|
|
3543
|
+
description: 'bodies:改常数后看哪些天体数据该重算。例 bodyConstant="alpha_fs", bodyFactor=2。' +
|
|
3544
|
+
'输出分「必须重算」与「绝不该动」两块(行星质量/半径/轨道与 α 无关)。',
|
|
2396
3545
|
},
|
|
2397
3546
|
bodyFactor: {
|
|
2398
3547
|
type: 'number',
|
|
@@ -2400,14 +3549,11 @@ const PARAMETER_SPEC = {
|
|
|
2400
3549
|
},
|
|
2401
3550
|
bodyType: {
|
|
2402
3551
|
type: 'string',
|
|
2403
|
-
description: '
|
|
2404
|
-
'bodyType 查类型学条目(中子星、热木星、流浪行星、戴森球)。' +
|
|
2405
|
-
'每个类型都标了证据等级,并列明「物理锁死项」(编不了)与「可自由发挥项」。',
|
|
3552
|
+
description: 'bodies:查天体类型学(中子星/热木星/戴森球等),每类标证据等级与物理锁死项。',
|
|
2406
3553
|
},
|
|
2407
3554
|
bodyEvidence: {
|
|
2408
3555
|
type: 'string',
|
|
2409
|
-
description: '
|
|
2410
|
-
'theoretical(理论预言,物理必然但未直接观测)/ speculative(纯思辨)。',
|
|
3556
|
+
description: 'bodies:按证据等级筛:observed(已观测)/ theoretical(理论预言)/ speculative(纯思辨)。',
|
|
2411
3557
|
},
|
|
2412
3558
|
matrix: {
|
|
2413
3559
|
type: 'array',
|
|
@@ -2419,16 +3565,13 @@ const PARAMETER_SPEC = {
|
|
|
2419
3565
|
},
|
|
2420
3566
|
precision: {
|
|
2421
3567
|
type: 'integer',
|
|
2422
|
-
description: '
|
|
2423
|
-
'
|
|
2424
|
-
'`(10^20+1)-(10^20)` 在 precision=20 时会得 0(应为 1)。' +
|
|
2425
|
-
'只想让输出短一些,用 displayPrecision。',
|
|
3568
|
+
description: '**计算精度**(BigNumber 有效位,默认 64,1-256)。⚠️ 调低会算错大数:' +
|
|
3569
|
+
'`(10^20+1)-(10^20)` 在 precision=20 得 0。只想输出短些用 displayPrecision。',
|
|
2426
3570
|
},
|
|
2427
3571
|
displayPrecision: {
|
|
2428
3572
|
type: 'integer',
|
|
2429
|
-
description: '**显示精度:小数点后几位**(0-64,默认 8
|
|
2430
|
-
'
|
|
2431
|
-
'别用 precision 达此目的(会算错大数)。',
|
|
3573
|
+
description: '**显示精度:小数点后几位**(0-64,默认 8)。只截小数、整数永远完整,0 = 只留整数。' +
|
|
3574
|
+
'可用 workspace set displayPrecision=N 固化(那里的 0 = 清除)。',
|
|
2432
3575
|
},
|
|
2433
3576
|
data: {
|
|
2434
3577
|
type: 'array',
|
|
@@ -2462,14 +3605,11 @@ const PARAMETER_SPEC = {
|
|
|
2462
3605
|
overrides: {
|
|
2463
3606
|
type: 'object',
|
|
2464
3607
|
additionalProperties: true,
|
|
2465
|
-
description: '
|
|
2466
|
-
'例如把重力改成 1.5 倍地球:{"g": 14.71};把光速改成 1e8:{"c": 100000000}。' +
|
|
2467
|
-
'任何带默认值的参数都能这样覆盖。',
|
|
3608
|
+
description: '覆盖公式任意参数 —— 架空世界改造的关键。例:重力 1.5 倍 {"g": 14.71}、光速 {"c": 1e8}。',
|
|
2468
3609
|
},
|
|
2469
3610
|
customExpression: {
|
|
2470
3611
|
type: 'string',
|
|
2471
|
-
description: 'structure
|
|
2472
|
-
'例如 operation="formula", formula="weight", customExpression="m * g * 1.3" 表示该世界重力异常 +30%。',
|
|
3612
|
+
description: 'structure 级改造:临时替换公式表达式,沿用其参数名。例 formula="weight", customExpression="m*g*1.3" 表示重力 +30%。',
|
|
2473
3613
|
},
|
|
2474
3614
|
query: {
|
|
2475
3615
|
type: 'string',
|
|
@@ -2503,8 +3643,8 @@ const PARAMETER_SPEC = {
|
|
|
2503
3643
|
// 字段清单不写在 schema 里:schema 每轮都要付费,而这些字段只在
|
|
2504
3644
|
// 真用 world 时才需要。全文见 help(operation="help", query="world")。
|
|
2505
3645
|
description: '星球参数(operation=world),全部可选,默认地球。' +
|
|
2506
|
-
'常用键:planetMass/planetRadius
|
|
2507
|
-
'完整字段表见 help
|
|
3646
|
+
'常用键:planetMass/planetRadius、starMass/starTemp、rotationHours、obliquityDeg。' +
|
|
3647
|
+
'完整字段表见 help。',
|
|
2508
3648
|
},
|
|
2509
3649
|
planetRadius: {
|
|
2510
3650
|
type: 'number',
|
|
@@ -2534,7 +3674,12 @@ const PARAMETER_SPEC = {
|
|
|
2534
3674
|
additionalProperties: true,
|
|
2535
3675
|
// 旋钮清单移进 help:只有要改常数时才需要,不该每轮付费
|
|
2536
3676
|
description: '要改的物理常数(operation=constants),只能改基础旋钮,派生量自动重算。' +
|
|
2537
|
-
'例:{c:1.5e8}。可用旋钮清单见 help 的 constants
|
|
3677
|
+
'例:{c:1.5e8}。可用旋钮清单见 help 的 constants 节。',
|
|
3678
|
+
},
|
|
3679
|
+
helpTopic: {
|
|
3680
|
+
type: 'string',
|
|
3681
|
+
description: 'operation="help" 时要取哪一节,省略则返回目录。目录里每节都有简介,' +
|
|
3682
|
+
'按需逐节取比一次读全文省得多(单节约 400 字符,全文 5900)。',
|
|
2538
3683
|
},
|
|
2539
3684
|
};
|
|
2540
3685
|
/**
|