@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +47 -3
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/doctor.js +11 -4
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +71 -1
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +37 -1
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +603 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +37 -1
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +160 -1
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +175 -2
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* migrate [root] one-shot 迁移:旧 JSON/JSONL → 账本 events
|
|
5
5
|
* (导入前自动快照到 gotry-state/pre-ledger-backup/)
|
|
6
6
|
* export [root] local-only:账本 → 旧文件名视图(红线 6:可见可导出;单向,不回流)
|
|
7
|
+
* 另含会话双区派生视图(P4-2):hot-context.jsonl /
|
|
8
|
+
* notebook.json,从事件 fold 渲染——视图永不是写路径
|
|
7
9
|
* log [root] [--limit N] 事件账本尾部(append-only 审计面)
|
|
8
10
|
* stats [root] 各面计数
|
|
9
11
|
* rebuild [root] [toSeq] DROP 投影 → fold 重放(可截到 toSeq;账本/投影
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
48
50
|
import { dirname, join } from 'node:path';
|
|
49
51
|
import { ensureLedger, ledgerDbPath, ledgerExists, openLedgerIfExists } from '../src/state-ledger.js';
|
|
52
|
+
import { ZONE_EXPORT_HOT_FILE, ZONE_EXPORT_NOTEBOOK_FILE, readZoneExportViews, } from '../src/session-zone-ledger.js';
|
|
50
53
|
import { formatRepairPlan, loadEvidenceMappingFile, planLedgerRepair } from '../src/ledger-repair-plan.js';
|
|
51
54
|
import { applyLedgerRepair, formatRepairApplyResult, restoreRepairBackup, } from '../src/ledger-repair-apply.js';
|
|
52
55
|
import { collectDeepPlanning, makeJournaledSolvePort, settleAsyncTicket } from '../src/loop.js';
|
|
@@ -382,6 +385,19 @@ switch (cmd) {
|
|
|
382
385
|
const trips = ledger.readTrips();
|
|
383
386
|
if (trips.length)
|
|
384
387
|
atomicWrite(join(dir, 'trips.jsonl'), trips.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
388
|
+
// 会话双区派生视图(P4-2,design/session-dual-zone-memory-design.md §2):
|
|
389
|
+
// 从六个事件 kind 的确定性 fold 渲染;空分区不产生空文件(同既有视图纪律)。
|
|
390
|
+
// 日志触读上界即拒:导出残缺视图比不导出更坏(读者会当它是全量)。
|
|
391
|
+
const zone = readZoneExportViews(ledger);
|
|
392
|
+
if (!zone.ok) {
|
|
393
|
+
console.error(`${zone.code}: ${zone.detail}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
const zoneViews = zone.views;
|
|
397
|
+
if (zoneViews.hotContextJsonl)
|
|
398
|
+
atomicWrite(join(dir, ZONE_EXPORT_HOT_FILE), zoneViews.hotContextJsonl);
|
|
399
|
+
if (zoneViews.notebookJson !== '[]')
|
|
400
|
+
atomicWrite(join(dir, ZONE_EXPORT_NOTEBOOK_FILE), zoneViews.notebookJson);
|
|
385
401
|
console.log(`视图已导出(单向,DB→文件;红线 6):${dir}/`);
|
|
386
402
|
break;
|
|
387
403
|
}
|
|
@@ -26,6 +26,8 @@ import { newState, runTurn } from '../src/loop.js';
|
|
|
26
26
|
import { parseRequest } from '../src/model.js';
|
|
27
27
|
import { solveChoiceSegment } from '../src/unified.js';
|
|
28
28
|
import { parsePlanningWindow } from '../src/time-anchor.js';
|
|
29
|
+
// issue #617:确定性部分(第 6 节)经 runTurn,其 PoI 探针缺省不得 spawn 本机真实 hbcli(显式设置的值不覆盖)
|
|
30
|
+
process.env['GOTRY_HBCLI_LIVE'] ??= '0';
|
|
29
31
|
const dataset = JSON.parse(readFileSync(join('..', 'data', 'time-slot-eval.json'), 'utf-8'));
|
|
30
32
|
const [ay, am, ad] = dataset.anchor_date.split('-').map(Number);
|
|
31
33
|
const ANCHOR_NOW = new Date(ay, am - 1, ad, 12); // 锚点日中午,避免午夜边界
|
|
@@ -50,4 +50,40 @@ off.budgetCny = 9000;
|
|
|
50
50
|
off.workWindow = undefined;
|
|
51
51
|
const r4 = await solveUnified(off);
|
|
52
52
|
assert.deepEqual(r4.work_window_exclusions, []); // 关掉窗口,周五班恢复可选
|
|
53
|
-
|
|
53
|
+
// 5. Issue #620:yunnan-pack 的 yn0 段缺 buffer_min/origin_transfer_min/dest_transfer_min
|
|
54
|
+
// → 旧实现 Number(undefined)=NaN → Int.val(NaN) 在 z3 WASM 里 `Assertion failed`
|
|
55
|
+
// → 被兜底翻译成「不可行 + unsat_core:['wasm_runtime_error']」。三条红线:
|
|
56
|
+
// ① parse 边界必须点名拒收(不默认补 0);② 绕过 parse 的 NaN spec 必须以
|
|
57
|
+
// solver_error 返回而**绝不**是 infeasible;③ 补齐那三个字段后整包必须真能判定。
|
|
58
|
+
const yunnanRaw = JSON.parse(await readFile(join('..', 'data', 'yunnan-pack.json'), 'utf-8'));
|
|
59
|
+
// ① 真实数据包:yn0 的三个缺字段 → NaN → 必须是显式 solver_error,绝不是「不可行」
|
|
60
|
+
const yunnanSpecRaw = parseFlightPackToSpec(structuredClone(yunnanRaw));
|
|
61
|
+
yunnanSpecRaw.budgetCny = 9000;
|
|
62
|
+
assert.ok(Number.isNaN(yunnanSpecRaw.segments[0].options[0].move.bufferMin), 'yn0 缺 buffer_min 的事实前提');
|
|
63
|
+
const rYunnanRaw = await solveUnified(yunnanSpecRaw);
|
|
64
|
+
assert.equal(rYunnanRaw.solver_error?.code, 'solver_input_not_integer', `yunnan 包必须以 solver_error 返回(got ${JSON.stringify(rYunnanRaw)})`);
|
|
65
|
+
assert.ok(rYunnanRaw.solver_error.message.includes('yn0.bufferMin'), `solver_error 必须点名出问题的段与字段(got ${rYunnanRaw.solver_error.message})`);
|
|
66
|
+
assert.equal(rYunnanRaw.feasible, false, 'solver_error 轮次没有产出可行解');
|
|
67
|
+
assert.deepEqual(rYunnanRaw.unsat_core, undefined, '求解器失败不得伪造 unsat_core(机器 token 不进冲突位)');
|
|
68
|
+
assert.ok(!JSON.stringify(rYunnanRaw).includes('wasm_runtime_error'), '不得再出现 wasm_runtime_error 这个伪冲突 token');
|
|
69
|
+
// ② 最小输入:健康包里塞一个 NaN 接驳分钟 → 同样是 solver_error,点名到字段
|
|
70
|
+
const nanSpec = parseFlightPackToSpec(pack);
|
|
71
|
+
nanSpec.budgetCny = 9000;
|
|
72
|
+
nanSpec.segments[0].options[0].move.bufferMin = Number.NaN;
|
|
73
|
+
const rNan = await solveUnified(nanSpec);
|
|
74
|
+
assert.equal(rNan.solver_error?.code, 'solver_input_not_integer', `NaN 接驳必须以 solver_error 返回(got ${JSON.stringify(rNan)})`);
|
|
75
|
+
assert.ok(rNan.solver_error.message.includes('f1.bufferMin'), `solver_error 必须点名字段(got ${rNan.solver_error.message})`);
|
|
76
|
+
assert.equal(rNan.feasible, false, 'solver_error 轮次没有产出可行解');
|
|
77
|
+
// ③ 补齐 yn0 的三个字段(由测试提供,不改数据包:真值待 founder 校准)→ 整包真能判定,
|
|
78
|
+
// 证明断言失败的最小输入正是这三个字段,其余四段编码本身没问题
|
|
79
|
+
const yunnanFixed = structuredClone(yunnanRaw);
|
|
80
|
+
Object.assign(yunnanFixed['legs'][0], {
|
|
81
|
+
buffer_min: 60, origin_transfer_min: 30, dest_transfer_min: 30,
|
|
82
|
+
});
|
|
83
|
+
const yunnanSpec = parseFlightPackToSpec(yunnanFixed);
|
|
84
|
+
yunnanSpec.budgetCny = 9000;
|
|
85
|
+
const rYunnan = await solveUnified(yunnanSpec);
|
|
86
|
+
assert.equal(rYunnan.solver_error, undefined, `补齐三字段后不得再有求解器失败(got ${JSON.stringify(rYunnan.solver_error)})`);
|
|
87
|
+
assert.equal(rYunnan.feasible, true, `yunnan 整包应可行(unsat_core=${JSON.stringify(rYunnan.unsat_core)})`);
|
|
88
|
+
assert.equal(rYunnan.legs.length, 5, '五段全判定');
|
|
89
|
+
console.log(`TS UNIFIED TESTS: 5/5 OK(工作窗口生效,f2=VZ303,¥${r1.money_cny};#620 yunnan NaN→solver_error 点名 yn0,补齐三字段后可行 ¥${rYunnan.money_cny})`);
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* (run-all §1 重试止血与 release-notes rc.2-rc.4 的实录根因)。
|
|
7
7
|
* 修复(z3-shared.ts):单一 WASM 实例 + 单一 Context + 会话级互斥门。
|
|
8
8
|
*
|
|
9
|
-
* 断言:① 并发轮次无 unified
|
|
9
|
+
* 断言:① 并发轮次无 unified 求解器失败(issue #620 后形态:`solver_error`,
|
|
10
|
+
* 旧形态是伪造的 `unsat_core:['wasm_runtime_error']`——两条都断言不出现);
|
|
11
|
+
* ② 判定与顺序基线一致。
|
|
10
12
|
* 运行(在 ts/ 下):npx tsx scripts/z3-race-tests.ts
|
|
11
13
|
*/
|
|
12
14
|
import assert from 'node:assert/strict';
|
|
@@ -35,6 +37,9 @@ for (let round = 0; round < ROUNDS; round++) {
|
|
|
35
37
|
void jny;
|
|
36
38
|
assert.equal(eng['recommended'], baseEngine['recommended'], `r${round}: engine recommended 漂移`);
|
|
37
39
|
assert.equal(jny['feasible'], baseJourney['feasible'], `r${round}: journey feasible 漂移`);
|
|
40
|
+
// issue #620:求解器失败现在是 solver_error(显式类别),不再伪装成 unsat_core 里的
|
|
41
|
+
// 机器 token。两种形态都断言不出现——旧形态回归也会被这条抓住。
|
|
42
|
+
assert.equal(uni['solver_error'], undefined, `r${round}: unified 会话求解器失败——race 复发(${JSON.stringify(uni['solver_error'])})`);
|
|
38
43
|
assert.equal((uni['unsat_core'] ?? []).includes('wasm_runtime_error'), false, `r${round}: unified 会话触发 WASM 降级——race 复发`);
|
|
39
44
|
assert.equal(uni['feasible'], true, `r${round}: unified 判定漂移(基线应为可行)`);
|
|
40
45
|
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* City × scenario tiering MECHANISM v1 (issue #339 contract slice,
|
|
3
|
+
* SIMULATED-TRIGGER DRILL — default OFF, zero callers, trigger flag frozen false,
|
|
4
|
+
* registry frozen EMPTY).
|
|
5
|
+
*
|
|
6
|
+
* ⚠ Evidence boundary: this module ships the MECHANISM only. #339's trigger is
|
|
7
|
+
* "reviewable real usage samples with a stated scenario vocabulary and success
|
|
8
|
+
* metric"; it has NOT fired. The tracker is explicit that a profile must never be
|
|
9
|
+
* fabricated from fixtures (「没有样本时保持研究/设计状态,不凭 fixture 造画像」),
|
|
10
|
+
* so there is deliberately NO taxonomy CONTENT here:
|
|
11
|
+
* - `CITY_SCENARIO_TIER_REGISTRY` is frozen `[]`;
|
|
12
|
+
* - `CITY_SCENARIO_TIER_TRIGGER_FIRED` is frozen `false`, and `admitTierEntry`
|
|
13
|
+
* refuses every admission before validating anything while it is false, so no
|
|
14
|
+
* tier can enter the registry without real samples and a founder decision;
|
|
15
|
+
* - every resolution therefore returns the NEUTRAL modifier today.
|
|
16
|
+
* Any scenario vocabulary proposal lives in the drill report as a clearly labelled
|
|
17
|
+
* unvalidated hypothesis, never in this file.
|
|
18
|
+
*
|
|
19
|
+
* Shape (mirrors ts/capabilities/fx-contract.ts #344 / ts/capabilities/geo-atlas.ts
|
|
20
|
+
* #342 / ts/src/session-zones.ts P4-1): pure functions, zero IO / network / timers,
|
|
21
|
+
* closed error-code set, typed results, deterministic, versioned, fail-closed.
|
|
22
|
+
*
|
|
23
|
+
* Frozen upstream constraints (READ-ONLY reuse):
|
|
24
|
+
* - docs/design/memory-design.md §1 item 3: the profile only feeds ranking, never
|
|
25
|
+
* hard filters — future shape `semantic × bounded_modifier`, "otherwise memory
|
|
26
|
+
* would filter the search down to nothing";
|
|
27
|
+
* - docs/rfc/loopx-inspired-upgrades-rfc.md S2: the ranking form
|
|
28
|
+
* `rank = semantic × bounded_modifier` and the evidence-grade ordering
|
|
29
|
+
* `owner_correction > controlled_replay > deterministic_effect > evaluator_inference`;
|
|
30
|
+
* - the ranking-disclosure precedent in `ts/capabilities/` (the sponsored-inventory
|
|
31
|
+
* ranker): a non-semantic weight never silently rewrites the sort key, and a
|
|
32
|
+
* tie-break bias is disclosed per candidate;
|
|
33
|
+
* - ts/src/memory-decay.ts: decay touches behavioural events only and exposes no
|
|
34
|
+
* motivation-profile API; this module likewise touches no profile and no store.
|
|
35
|
+
*
|
|
36
|
+
* Red lines encoded structurally (each has a falsification test in
|
|
37
|
+
* ts/scripts/city-scenario-tier-tests.ts, run-all §86):
|
|
38
|
+
* 1. ranking only — `applyTierRanking` has NO filter/exclude surface at all, and
|
|
39
|
+
* the output always contains exactly the input candidates: a modifier may
|
|
40
|
+
* reorder, it can never remove a candidate (not even at the lower bound with
|
|
41
|
+
* a zero semantic score);
|
|
42
|
+
* 2. bounded — the modifier is an integer ppm inside declared [min,max] bounds;
|
|
43
|
+
* 0, negative, non-integer and out-of-range are refused at admission, so no
|
|
44
|
+
* tier can zero a candidate out;
|
|
45
|
+
* 3. neutral fallback, never a guess — unknown city, unknown scenario, conflicting
|
|
46
|
+
* evidence, empty registry and trigger-not-fired all return the NEUTRAL
|
|
47
|
+
* modifier with a stated reason; conflicting entries never elect a winner;
|
|
48
|
+
* 4. per-tier provenance (evidence grade + real sample refs + reviewer + freeze
|
|
49
|
+
* instant) and a retirement condition + review deadline are MANDATORY;
|
|
50
|
+
* 5. deterministic and versioned: same inputs → byte-identical result; ties keep
|
|
51
|
+
* declaration order.
|
|
52
|
+
*
|
|
53
|
+
* Only the real #339 trigger can supply: the taxonomy members themselves, the
|
|
54
|
+
* scenario vocabulary, the success metric, the numeric bounds justified by data,
|
|
55
|
+
* and the before/after ranking hypotheses with real counterexamples.
|
|
56
|
+
*
|
|
57
|
+
* @module src/city-scenario-tier
|
|
58
|
+
*/
|
|
59
|
+
/** Versioned contract schema id (readers validate it). */
|
|
60
|
+
export const CITY_SCENARIO_TIER_SCHEMA = 'gotry_city_scenario_tier.v1';
|
|
61
|
+
/**
|
|
62
|
+
* #339 implementation trigger gate, frozen false. Flipping it true requires
|
|
63
|
+
* reviewable real usage samples plus an explicit scenario vocabulary and success
|
|
64
|
+
* metric. While false, `admitTierEntry` refuses every admission.
|
|
65
|
+
*/
|
|
66
|
+
export const CITY_SCENARIO_TIER_TRIGGER_FIRED = false;
|
|
67
|
+
/**
|
|
68
|
+
* Declared modifier bounds, in ppm (1_000_000 = neutral ×1.0).
|
|
69
|
+
*
|
|
70
|
+
* NOT data-derived: RFC S2 fixes the FORM (`semantic × bounded_modifier`) but the
|
|
71
|
+
* repo states no numeric interval anywhere. These values are drill-declared so the
|
|
72
|
+
* bound CHECK can be proven; the interval itself must be re-frozen from real
|
|
73
|
+
* samples when #339's trigger fires. The lower bound is deliberately > 0 so no
|
|
74
|
+
* tier can ever annihilate a candidate's semantic score.
|
|
75
|
+
*/
|
|
76
|
+
export const TIER_MODIFIER_PPM_NEUTRAL = 1_000_000;
|
|
77
|
+
export const TIER_MODIFIER_PPM_MIN = 850_000;
|
|
78
|
+
export const TIER_MODIFIER_PPM_MAX = 1_150_000;
|
|
79
|
+
/** The only admitted application surface. There is no hard-filter surface. */
|
|
80
|
+
export const TIER_APPLICATION_SURFACES = ['ranking'];
|
|
81
|
+
/** Evidence grades, strongest first (RFC S2 ordering, read-only reuse). */
|
|
82
|
+
export const TIER_EVIDENCE_GRADES = ['owner_correction', 'controlled_replay', 'deterministic_effect', 'evaluator_inference'];
|
|
83
|
+
/**
|
|
84
|
+
* Admitted sample sources. Exactly one member: a reviewable real usage sample.
|
|
85
|
+
* A fixture / synthetic / model-authored sample is outside the closed set and is
|
|
86
|
+
* refused by name — this is #339's「不凭 fixture 造画像」as code.
|
|
87
|
+
*/
|
|
88
|
+
export const TIER_SAMPLE_SOURCES = ['real_usage_sample'];
|
|
89
|
+
/** Bounded shapes: keys are vocabulary members, never free prose. */
|
|
90
|
+
export const TIER_KEY_MAX_CHARS = 48;
|
|
91
|
+
export const TIER_TEXT_MAX_CHARS = 200;
|
|
92
|
+
export const TIER_MAX_REGISTRY_ENTRIES = 512;
|
|
93
|
+
function fail(code, detail) {
|
|
94
|
+
return { ok: false, code, detail };
|
|
95
|
+
}
|
|
96
|
+
function pass(value) {
|
|
97
|
+
return { ok: true, value };
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Key normalization (deterministic; never a guess)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Normalize a city / scenario key: trim, collapse internal whitespace, lowercase.
|
|
104
|
+
* No transliteration, no alias expansion, no fuzzy matching — an unrecognized key
|
|
105
|
+
* stays unrecognized and falls back to neutral (ADR-10: nothing is fabricated).
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeTierKey(value, label) {
|
|
108
|
+
if (typeof value !== 'string') {
|
|
109
|
+
return fail('bad_key', `${label} must be a string, got ${JSON.stringify(value)}`);
|
|
110
|
+
}
|
|
111
|
+
const normalized = value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
112
|
+
if (normalized === '')
|
|
113
|
+
return fail('bad_key', `${label} must not be blank (a blank key is not an unknown key; callers must pass the key they actually have)`);
|
|
114
|
+
if (normalized.length > TIER_KEY_MAX_CHARS) {
|
|
115
|
+
return fail('bad_key', `${label} exceeds ${TIER_KEY_MAX_CHARS} chars (${normalized.length}) — a key is a vocabulary member, never prose`);
|
|
116
|
+
}
|
|
117
|
+
return pass(normalized);
|
|
118
|
+
}
|
|
119
|
+
function requiredText(label, value, code) {
|
|
120
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
121
|
+
return fail(code, `${label} must be a non-empty string, got ${JSON.stringify(value)}`);
|
|
122
|
+
}
|
|
123
|
+
if (value.length > TIER_TEXT_MAX_CHARS) {
|
|
124
|
+
return fail('bound_exceeded', `${label} exceeds ${TIER_TEXT_MAX_CHARS} chars (${value.length})`);
|
|
125
|
+
}
|
|
126
|
+
return pass(value.trim());
|
|
127
|
+
}
|
|
128
|
+
function requiredInstant(label, value) {
|
|
129
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
130
|
+
return fail('bad_ts', `${label} must be a non-empty ISO-8601 instant, got ${JSON.stringify(value)}`);
|
|
131
|
+
}
|
|
132
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
|
|
133
|
+
return fail('bad_ts', `${label} must carry a full time-of-day (date-only '${value}' cannot order a freeze/review)`);
|
|
134
|
+
}
|
|
135
|
+
const ms = Date.parse(value);
|
|
136
|
+
if (!Number.isFinite(ms))
|
|
137
|
+
return fail('bad_ts', `${label} is not a parseable ISO-8601 instant: ${JSON.stringify(value)}`);
|
|
138
|
+
return pass(new Date(ms).toISOString());
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Runtime taxonomy registry, frozen EMPTY — #339 forbids fabricating city-scenario
|
|
142
|
+
* tiers from fixtures. A non-empty value requires reviewable real samples and is a
|
|
143
|
+
* founder decision; the focused suite asserts this declaration stays `[]` in source.
|
|
144
|
+
*/
|
|
145
|
+
export const CITY_SCENARIO_TIER_REGISTRY = [];
|
|
146
|
+
/** Minimum reviewable real samples behind one tier (a single anecdote is not a tier). */
|
|
147
|
+
export const TIER_MIN_SAMPLE_REFS = 3;
|
|
148
|
+
/**
|
|
149
|
+
* Admit one taxonomy entry.
|
|
150
|
+
*
|
|
151
|
+
* While `CITY_SCENARIO_TIER_TRIGGER_FIRED` is false this refuses BEFORE validating
|
|
152
|
+
* anything, so no tier can be fabricated from fixtures. Tests and a future admitted
|
|
153
|
+
* slice pass `{ triggerFired: true }` explicitly to exercise the validation shape.
|
|
154
|
+
*/
|
|
155
|
+
export function admitTierEntry(input, options) {
|
|
156
|
+
const triggerFired = options?.triggerFired ?? CITY_SCENARIO_TIER_TRIGGER_FIRED;
|
|
157
|
+
if (!triggerFired) {
|
|
158
|
+
return fail('trigger_not_fired', '#339 trigger has not fired (reviewable real usage samples + an explicit scenario vocabulary and success metric are required); zero tiers admitted — 不凭 fixture 造画像');
|
|
159
|
+
}
|
|
160
|
+
const registry = options?.registry ?? CITY_SCENARIO_TIER_REGISTRY;
|
|
161
|
+
if (registry.length >= TIER_MAX_REGISTRY_ENTRIES) {
|
|
162
|
+
return fail('bound_exceeded', `registry already holds ${registry.length} entries (bound ${TIER_MAX_REGISTRY_ENTRIES}); an unbounded taxonomy is not explainable`);
|
|
163
|
+
}
|
|
164
|
+
const tierId = requiredText('tier_id', input?.tier_id, 'binding_incomplete');
|
|
165
|
+
if (!tierId.ok)
|
|
166
|
+
return tierId;
|
|
167
|
+
const cityKey = normalizeTierKey(input?.city_key, 'city_key');
|
|
168
|
+
if (!cityKey.ok)
|
|
169
|
+
return cityKey;
|
|
170
|
+
const scenarioKey = normalizeTierKey(input?.scenario_key, 'scenario_key');
|
|
171
|
+
if (!scenarioKey.ok)
|
|
172
|
+
return scenarioKey;
|
|
173
|
+
const version = requiredText('taxonomy_version', input?.taxonomy_version, 'binding_incomplete');
|
|
174
|
+
if (!version.ok)
|
|
175
|
+
return version;
|
|
176
|
+
const modifier = input?.modifier_ppm;
|
|
177
|
+
if (typeof modifier !== 'number' || !Number.isInteger(modifier)) {
|
|
178
|
+
return fail('modifier_bounds', `modifier_ppm must be an integer ppm, got ${JSON.stringify(modifier)} (floats drift; ppm integers are exact)`);
|
|
179
|
+
}
|
|
180
|
+
if (modifier < TIER_MODIFIER_PPM_MIN || modifier > TIER_MODIFIER_PPM_MAX) {
|
|
181
|
+
return fail('modifier_bounds', `modifier_ppm ${modifier} is outside the declared bounds [${TIER_MODIFIER_PPM_MIN},${TIER_MODIFIER_PPM_MAX}] (lower bound is > 0 by design: a tier may reorder, never annihilate a candidate)`);
|
|
182
|
+
}
|
|
183
|
+
const surface = input?.applies_to ?? 'ranking';
|
|
184
|
+
if (!TIER_APPLICATION_SURFACES.includes(surface)) {
|
|
185
|
+
return fail('closed_set', `applies_to must be one of [${TIER_APPLICATION_SURFACES.join('/')}], got ${JSON.stringify(surface)} — memory never enters a hard filter (memory-design §1.3)`);
|
|
186
|
+
}
|
|
187
|
+
const prov = input?.provenance;
|
|
188
|
+
if (prov === null || typeof prov !== 'object') {
|
|
189
|
+
return fail('provenance_required', 'provenance is mandatory: a tier without a stated evidence source is not evidence');
|
|
190
|
+
}
|
|
191
|
+
if (!TIER_EVIDENCE_GRADES.includes(prov['evidence_grade'])) {
|
|
192
|
+
return fail('closed_set', `provenance.evidence_grade must be one of [${TIER_EVIDENCE_GRADES.join('/')}], got ${JSON.stringify(prov['evidence_grade'])}`);
|
|
193
|
+
}
|
|
194
|
+
if (!TIER_SAMPLE_SOURCES.includes(prov['sample_source'])) {
|
|
195
|
+
return fail('closed_set', `provenance.sample_source must be one of [${TIER_SAMPLE_SOURCES.join('/')}], got ${JSON.stringify(prov['sample_source'])} — fixture/synthetic/model-authored samples are refused (#339: 不凭 fixture 造画像)`);
|
|
196
|
+
}
|
|
197
|
+
const refs = prov['sample_refs'];
|
|
198
|
+
if (!Array.isArray(refs) || refs.length < TIER_MIN_SAMPLE_REFS || !refs.every(r => typeof r === 'string' && r.trim() !== '')) {
|
|
199
|
+
return fail('provenance_required', `provenance.sample_refs must list at least ${TIER_MIN_SAMPLE_REFS} non-empty reviewable sample references, got ${JSON.stringify(refs)} (a single anecdote is not a tier)`);
|
|
200
|
+
}
|
|
201
|
+
const reviewer = requiredText('provenance.reviewer', prov['reviewer'], 'provenance_required');
|
|
202
|
+
if (!reviewer.ok)
|
|
203
|
+
return reviewer;
|
|
204
|
+
const frozenAt = requiredInstant('provenance.frozen_at', prov['frozen_at']);
|
|
205
|
+
if (!frozenAt.ok)
|
|
206
|
+
return frozenAt;
|
|
207
|
+
const ret = input?.retirement;
|
|
208
|
+
if (ret === null || typeof ret !== 'object') {
|
|
209
|
+
return fail('retirement_required', 'retirement is mandatory: a tier that cannot expire is not evidence (#339 acceptance 1 requires a retirement condition per class)');
|
|
210
|
+
}
|
|
211
|
+
const condition = requiredText('retirement.condition', ret['condition'], 'retirement_required');
|
|
212
|
+
if (!condition.ok)
|
|
213
|
+
return condition;
|
|
214
|
+
const reviewBy = requiredInstant('retirement.review_by', ret['review_by']);
|
|
215
|
+
if (!reviewBy.ok)
|
|
216
|
+
return reviewBy;
|
|
217
|
+
if (reviewBy.value <= frozenAt.value) {
|
|
218
|
+
return fail('retirement_required', `retirement.review_by ${reviewBy.value} must postdate provenance.frozen_at ${frozenAt.value}`);
|
|
219
|
+
}
|
|
220
|
+
if (registry.some(e => e.tier_id === tierId.value)) {
|
|
221
|
+
return fail('duplicate_tier', `tier_id ${tierId.value} already exists in the registry`);
|
|
222
|
+
}
|
|
223
|
+
if (registry.some(e => e.city_key === cityKey.value && e.scenario_key === scenarioKey.value)) {
|
|
224
|
+
return fail('duplicate_tier', `(city ${cityKey.value}, scenario ${scenarioKey.value}) already carries a tier; a second weight for the same pair is conflicting evidence, not an additional tier`);
|
|
225
|
+
}
|
|
226
|
+
return pass(Object.freeze({
|
|
227
|
+
schema: CITY_SCENARIO_TIER_SCHEMA,
|
|
228
|
+
tier_id: tierId.value,
|
|
229
|
+
city_key: cityKey.value,
|
|
230
|
+
scenario_key: scenarioKey.value,
|
|
231
|
+
modifier_ppm: modifier,
|
|
232
|
+
applies_to: surface,
|
|
233
|
+
provenance: Object.freeze({
|
|
234
|
+
evidence_grade: prov['evidence_grade'],
|
|
235
|
+
sample_source: prov['sample_source'],
|
|
236
|
+
sample_refs: Object.freeze([...refs]),
|
|
237
|
+
reviewer: reviewer.value,
|
|
238
|
+
frozen_at: frozenAt.value,
|
|
239
|
+
}),
|
|
240
|
+
retirement: Object.freeze({ condition: condition.value, review_by: reviewBy.value }),
|
|
241
|
+
taxonomy_version: version.value,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Resolution: neutral fallback, never a guess
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
export const TIER_NEUTRAL_REASONS = [
|
|
248
|
+
'trigger_not_fired',
|
|
249
|
+
'empty_registry',
|
|
250
|
+
'unknown_city',
|
|
251
|
+
'unknown_scenario',
|
|
252
|
+
'conflicting_evidence',
|
|
253
|
+
];
|
|
254
|
+
function neutral(reason, explanation, conflicting = []) {
|
|
255
|
+
return Object.freeze({
|
|
256
|
+
status: 'neutral',
|
|
257
|
+
modifier_ppm: TIER_MODIFIER_PPM_NEUTRAL,
|
|
258
|
+
tier_id: null,
|
|
259
|
+
reason,
|
|
260
|
+
conflicting_tier_ids: Object.freeze([...conflicting]),
|
|
261
|
+
explanation,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the bounded modifier for one (city, scenario) pair.
|
|
266
|
+
*
|
|
267
|
+
* Returns NEUTRAL (×1.0) with a stated reason for: trigger not fired, empty
|
|
268
|
+
* registry, unknown city, unknown scenario, and conflicting evidence. Conflicting
|
|
269
|
+
* entries never elect a winner (geo-atlas `ambiguous` discipline). A malformed key
|
|
270
|
+
* is a caller error and is reported as such rather than silently neutralized.
|
|
271
|
+
*/
|
|
272
|
+
export function resolveTier(query, options) {
|
|
273
|
+
const cityKey = normalizeTierKey(query?.cityKey, 'cityKey');
|
|
274
|
+
if (!cityKey.ok)
|
|
275
|
+
return cityKey;
|
|
276
|
+
const scenarioKey = normalizeTierKey(query?.scenarioKey, 'scenarioKey');
|
|
277
|
+
if (!scenarioKey.ok)
|
|
278
|
+
return scenarioKey;
|
|
279
|
+
const triggerFired = options?.triggerFired ?? CITY_SCENARIO_TIER_TRIGGER_FIRED;
|
|
280
|
+
if (!triggerFired) {
|
|
281
|
+
return pass(neutral('trigger_not_fired', '#339 trigger has not fired; every city×scenario pair resolves to the neutral modifier ×1.0 (no tier is guessed)'));
|
|
282
|
+
}
|
|
283
|
+
const registry = options?.registry ?? CITY_SCENARIO_TIER_REGISTRY;
|
|
284
|
+
if (registry.length === 0) {
|
|
285
|
+
return pass(neutral('empty_registry', 'the taxonomy registry is empty; neutral ×1.0 (an empty taxonomy is not a zero weight)'));
|
|
286
|
+
}
|
|
287
|
+
const cityMatches = registry.filter(e => e.city_key === cityKey.value);
|
|
288
|
+
if (cityMatches.length === 0) {
|
|
289
|
+
return pass(neutral('unknown_city', `city '${cityKey.value}' carries no tier in taxonomy; neutral ×1.0 (an unknown city is never guessed from a neighbour)`));
|
|
290
|
+
}
|
|
291
|
+
const matches = cityMatches.filter(e => e.scenario_key === scenarioKey.value);
|
|
292
|
+
if (matches.length === 0) {
|
|
293
|
+
return pass(neutral('unknown_scenario', `scenario '${scenarioKey.value}' carries no tier for city '${cityKey.value}'; neutral ×1.0 (an unknown scenario is never approximated by another scenario)`));
|
|
294
|
+
}
|
|
295
|
+
if (matches.length > 1) {
|
|
296
|
+
return pass(neutral('conflicting_evidence', `city '${cityKey.value}' × scenario '${scenarioKey.value}' carries ${matches.length} conflicting tiers; neutral ×1.0 and no winner is elected (conflict is surfaced, never resolved by guessing)`, matches.map(e => e.tier_id)));
|
|
297
|
+
}
|
|
298
|
+
const entry = matches[0];
|
|
299
|
+
if (entry.modifier_ppm < TIER_MODIFIER_PPM_MIN || entry.modifier_ppm > TIER_MODIFIER_PPM_MAX) {
|
|
300
|
+
return fail('modifier_bounds', `registry entry ${entry.tier_id} carries modifier_ppm ${entry.modifier_ppm} outside the declared bounds [${TIER_MODIFIER_PPM_MIN},${TIER_MODIFIER_PPM_MAX}] — a defective registry entry is refused, never clamped silently`);
|
|
301
|
+
}
|
|
302
|
+
return pass(Object.freeze({
|
|
303
|
+
status: 'hit',
|
|
304
|
+
modifier_ppm: entry.modifier_ppm,
|
|
305
|
+
tier_id: entry.tier_id,
|
|
306
|
+
taxonomy_version: entry.taxonomy_version,
|
|
307
|
+
evidence_grade: entry.provenance.evidence_grade,
|
|
308
|
+
explanation: `tier ${entry.tier_id} (${entry.taxonomy_version}, evidence ${entry.provenance.evidence_grade}) applies ×${(entry.modifier_ppm / 1_000_000).toFixed(6)} to the ranking score of city '${entry.city_key}' under scenario '${entry.scenario_key}'; retire when: ${entry.retirement.condition}`,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Apply tier modifiers to a candidate ranking.
|
|
313
|
+
*
|
|
314
|
+
* There is deliberately NO filter, exclude, drop or threshold parameter and no
|
|
315
|
+
* such field in the result: the returned array always contains exactly the input
|
|
316
|
+
* candidates (same ids, same count), only reordered. `semantic` is preserved on
|
|
317
|
+
* every output row so a caller can always recover the unmodified ranking.
|
|
318
|
+
* Ties keep declaration order (stable).
|
|
319
|
+
*/
|
|
320
|
+
export function applyTierRanking(candidates, context, options) {
|
|
321
|
+
if (!Array.isArray(candidates))
|
|
322
|
+
return fail('bad_candidate', 'candidates must be an array');
|
|
323
|
+
const seen = new Set();
|
|
324
|
+
for (const c of candidates) {
|
|
325
|
+
if (c === null || typeof c !== 'object' || typeof c.id !== 'string' || c.id.trim() === '') {
|
|
326
|
+
return fail('bad_candidate', `every candidate needs a non-empty id, got ${JSON.stringify(c)}`);
|
|
327
|
+
}
|
|
328
|
+
if (seen.has(c.id))
|
|
329
|
+
return fail('bad_candidate', `duplicate candidate id ${c.id} (ranking inputs must be a set)`);
|
|
330
|
+
seen.add(c.id);
|
|
331
|
+
if (typeof c.semantic !== 'number' || !Number.isFinite(c.semantic)) {
|
|
332
|
+
return fail('bad_candidate', `candidate ${c.id} semantic score must be finite, got ${String(c.semantic)}`);
|
|
333
|
+
}
|
|
334
|
+
if (c.semantic < 0) {
|
|
335
|
+
return fail('bad_candidate', `candidate ${c.id} semantic score must be non-negative, got ${String(c.semantic)} — the ranking contract is multiplicative, so a negative score would invert every modifier (a boost would demote)`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const ranked = [];
|
|
339
|
+
for (const c of candidates) {
|
|
340
|
+
const resolution = resolveTier({ cityKey: c.cityKey, scenarioKey: context?.scenarioKey }, options);
|
|
341
|
+
if (!resolution.ok)
|
|
342
|
+
return resolution;
|
|
343
|
+
const r = resolution.value;
|
|
344
|
+
ranked.push(Object.freeze({
|
|
345
|
+
id: c.id,
|
|
346
|
+
semantic: c.semantic,
|
|
347
|
+
modifier_ppm: r.modifier_ppm,
|
|
348
|
+
score: (c.semantic * r.modifier_ppm) / 1_000_000,
|
|
349
|
+
tier_id: r.status === 'hit' ? r.tier_id : null,
|
|
350
|
+
explanation: r.explanation,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
const ordered = ranked
|
|
354
|
+
.map((row, index) => ({ row, index }))
|
|
355
|
+
.sort((a, b) => (b.row.score - a.row.score) || (a.index - b.index))
|
|
356
|
+
.map(({ row }) => row);
|
|
357
|
+
// Structural invariant: ranking never removes a candidate.
|
|
358
|
+
if (ordered.length !== candidates.length || !candidates.every(c => ordered.some(r => r.id === c.id))) {
|
|
359
|
+
return fail('bad_candidate', 'internal invariant violated: tier ranking must preserve the candidate set exactly (memory never enters a hard filter)');
|
|
360
|
+
}
|
|
361
|
+
return pass(Object.freeze([...ordered]));
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Mechanical no-hard-filter check a caller (or a gate) can run: the ranked output
|
|
365
|
+
* must contain exactly the input candidate ids. Returns a violation string, or null.
|
|
366
|
+
*/
|
|
367
|
+
export function tierRankingFilterViolation(before, after) {
|
|
368
|
+
if (before.length !== after.length) {
|
|
369
|
+
return `tier ranking changed the candidate count ${before.length} → ${after.length}: memory may reorder, never filter (memory-design §1.3)`;
|
|
370
|
+
}
|
|
371
|
+
const missing = before.filter(c => !after.some(r => r.id === c.id)).map(c => c.id);
|
|
372
|
+
if (missing.length > 0)
|
|
373
|
+
return `tier ranking dropped candidates [${missing.join(',')}]: memory may reorder, never filter`;
|
|
374
|
+
const added = after.filter(r => !before.some(c => c.id === r.id)).map(r => r.id);
|
|
375
|
+
if (added.length > 0)
|
|
376
|
+
return `tier ranking invented candidates [${added.join(',')}]`;
|
|
377
|
+
return null;
|
|
378
|
+
}
|
package/dist/src/dsh-llm.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 真 LlmPort:OpenAI 兼容接口的 provider 中立适配器(DeepSeek/MiniMax-M2 实测)。
|
|
3
3
|
* 零新依赖(node 内建 fetch)。环境变量:LLM_API_KEY/LLM_BASE_URL/LLM_MODEL
|
|
4
|
-
* (兼容旧 DEEPSEEK_* 别名)
|
|
4
|
+
* (兼容旧 DEEPSEEK_* 别名);GOTRY_LLM_TIMEOUT_MS = 单次请求总预算(默认 300000ms,
|
|
5
|
+
* 非正整数忽略)。MiniMax-M2 是推理模型:输出带 <think> 块,
|
|
5
6
|
* 必须先剥离再解析——JSON 藏在 think 里是常见失败模式。
|
|
6
7
|
* 无 key 时抛出明确错误,replay-real 自动回退 mock(ADR-8)。
|
|
7
8
|
* 责任铁律不变:本适配器只做翻译/润色/解释,判定与算术在确定性组件。
|
|
@@ -17,12 +18,73 @@ const base = () => (process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_UR
|
|
|
17
18
|
function emptyUsage() {
|
|
18
19
|
return { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 };
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
/** 单次请求(连接→响应头→正文读完)的默认总预算:对推理模型足够宽松,只兜「连上了却永不应答」的卡死。 */
|
|
22
|
+
export const DEFAULT_LLM_TIMEOUT_MS = 300_000;
|
|
23
|
+
/**
|
|
24
|
+
* 超时上限 = 2^31-1 ms(≈24.8 天)。Node 对更大的延迟会溢出并**悄悄改成 1ms**
|
|
25
|
+
* (TimeoutOverflowWarning),即「配得越大越立刻超时」——所以必须夹住,绝不原样透传。
|
|
26
|
+
*/
|
|
27
|
+
const MAX_LLM_TIMEOUT_MS = 2_147_483_647;
|
|
28
|
+
/** 只认十进制正整数(数值或纯数字串);其余(0/负数/小数/NaN/带单位/空串)一律 undefined——绝不退化成「无超时」。 */
|
|
29
|
+
function parseTimeoutMs(raw) {
|
|
30
|
+
const n = typeof raw === 'string' ? (/^\d+$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN) : raw;
|
|
31
|
+
return typeof n === 'number' && Number.isSafeInteger(n) && n > 0 ? Math.min(n, MAX_LLM_TIMEOUT_MS) : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** 解析顺序:本次调用显式值 > 环境变量 GOTRY_LLM_TIMEOUT_MS(调用时取值,同 base()/model())> 默认值。 */
|
|
34
|
+
export function resolveLlmTimeoutMs(callMs) {
|
|
35
|
+
return parseTimeoutMs(callMs) ?? parseTimeoutMs(process.env['GOTRY_LLM_TIMEOUT_MS']) ?? DEFAULT_LLM_TIMEOUT_MS;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* chat() 请求被截断(超时 / 调用方取消)的类型化失败——不是挂起,也不会被吞。
|
|
39
|
+
* 与既有错误形态一致,而非另起一套:
|
|
40
|
+
* - message 沿用本模块 `llm <kind>: <detail>` 约定(同 `llm 429: …`),只读 .message 的调用方
|
|
41
|
+
* (nightly-evidence / persona-sim / time-eval --real)原样可用;
|
|
42
|
+
* - name 取平台既有名 TimeoutError / AbortError(AbortSignal.timeout 与手动 abort 抛的同名),
|
|
43
|
+
* persona-sim、channel-probe 等按 name 分类的代码零改动即可识别;
|
|
44
|
+
* - cause 保留 fetch 抛出的原始错误(调用方自定义的 abort reason 也在其中)。
|
|
45
|
+
* HTTP 非 2xx(`llm <status>`)、DNS/连接重置、坏 JSON 仍是原样的普通 Error——只改写「被我们的 signal 终止」的失败。
|
|
46
|
+
*/
|
|
47
|
+
export class LlmRequestError extends Error {
|
|
48
|
+
code;
|
|
49
|
+
/** 本次生效的超时预算;仅 code === 'timeout' 有值 */
|
|
50
|
+
timeoutMs;
|
|
51
|
+
constructor(code, detail, options = {}) {
|
|
52
|
+
super(`llm ${code}: ${detail}`, options.cause === undefined ? undefined : { cause: options.cause });
|
|
53
|
+
this.name = code === 'timeout' ? 'TimeoutError' : 'AbortError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.timeoutMs = options.timeoutMs;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 发一次请求并读完正文。**同一个 signal 贯穿 fetch 与 res.json()/res.text()**,所以超时覆盖整个请求
|
|
60
|
+
* (含「响应头已到、正文卡住」与「非 2xx 的错误正文卡住」),调用方取消同理。
|
|
61
|
+
* 只有「被我们的 signal 终止」才改写成 LlmRequestError;其余错误原样上抛。
|
|
62
|
+
*/
|
|
63
|
+
async function requestCompletion(url, init, request) {
|
|
64
|
+
const timeoutMs = resolveLlmTimeoutMs(request.timeoutMs);
|
|
65
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
66
|
+
const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(url, { method: 'POST', headers: init.headers, body: init.body, signal });
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
return { ok: false, status: res.status, text: await res.text() };
|
|
71
|
+
return { ok: true, data: await res.json() };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!signal.aborted)
|
|
75
|
+
throw error;
|
|
76
|
+
// AbortSignal.any 的 reason 是最先触发的那个源的 reason:等于超时信号的 reason 即超时先到,否则是调用方取消
|
|
77
|
+
if (timeout.aborted && signal.reason === timeout.reason) {
|
|
78
|
+
throw new LlmRequestError('timeout', `no complete response within ${timeoutMs}ms (GOTRY_LLM_TIMEOUT_MS)`, { cause: error, timeoutMs });
|
|
79
|
+
}
|
|
80
|
+
throw new LlmRequestError('aborted', "request cancelled by the caller's AbortSignal", { cause: error });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function chat(messages, json, usage, request = {}) {
|
|
21
84
|
const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY'];
|
|
22
85
|
if (!key)
|
|
23
86
|
throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)');
|
|
24
|
-
const
|
|
25
|
-
method: 'POST',
|
|
87
|
+
const reply = await requestCompletion(`${base()}/chat/completions`, {
|
|
26
88
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
27
89
|
body: JSON.stringify({
|
|
28
90
|
model: model(),
|
|
@@ -30,10 +92,10 @@ async function chat(messages, json, usage) {
|
|
|
30
92
|
...(json ? { response_format: { type: 'json_object' } } : {}),
|
|
31
93
|
temperature: json ? 0 : 0.7,
|
|
32
94
|
}),
|
|
33
|
-
});
|
|
34
|
-
if (!
|
|
35
|
-
throw new Error(`llm ${
|
|
36
|
-
const data =
|
|
95
|
+
}, request);
|
|
96
|
+
if (!reply.ok)
|
|
97
|
+
throw new Error(`llm ${reply.status}: ${reply.text.slice(0, 300)}`);
|
|
98
|
+
const data = reply.data;
|
|
37
99
|
if (usage) {
|
|
38
100
|
usage.calls += 1;
|
|
39
101
|
const u = data.usage;
|
|
@@ -75,7 +137,11 @@ const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程
|
|
|
75
137
|
输出 JSON:{"scenario":"erhai|workation|yunnan|generic","segments":[{"id":"f1","role":"choice|fixed","route":"HKG->HKT","dateHint":"2026-07-18","anchors":{"arriveByMin":885}}]}
|
|
76
138
|
规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
|
|
77
139
|
scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`;
|
|
78
|
-
|
|
140
|
+
/**
|
|
141
|
+
* request:本端口每次 chat() 共用的请求级选项(timeoutMs / signal)。signal 是端口级取消——
|
|
142
|
+
* 调用方可借它在会话/批次截止时真正掐断在途请求,而不是放着它继续挂在 provider 上。
|
|
143
|
+
*/
|
|
144
|
+
export function createOpenAICompatLlm(flightPackPath, clock = () => new Date(), request = {}) {
|
|
79
145
|
const pack = flightPackPath;
|
|
80
146
|
const usage = emptyUsage();
|
|
81
147
|
const historyText = (h) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n');
|
|
@@ -84,7 +150,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
84
150
|
const anchorContext = () => `时间锚点卡:\n${buildTimeAnchor(clock()).card}`;
|
|
85
151
|
return {
|
|
86
152
|
async extractFacts(history) {
|
|
87
|
-
const out = await chat([{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }], true, usage);
|
|
153
|
+
const out = await chat([{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }], true, usage, request);
|
|
88
154
|
const obj = parseJsonBlock(out);
|
|
89
155
|
if (!obj)
|
|
90
156
|
return { assumptions: [] };
|
|
@@ -96,7 +162,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
96
162
|
async extractSpec(history, state) {
|
|
97
163
|
// 架构(ADR-10):LLM 只产骨架与锚点;班次数据永远来自能力层(数据包/未来实时API)
|
|
98
164
|
const context = `${anchorContext()}\n已断言日历:${JSON.stringify(state.calendar.assertedWeekdays)}\nprofile:${JSON.stringify(state.profile)}`;
|
|
99
|
-
const out = await chat([{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }], true, usage);
|
|
165
|
+
const out = await chat([{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }], true, usage, request);
|
|
100
166
|
const skeleton = parseJsonBlock(out);
|
|
101
167
|
if (!skeleton || !Array.isArray(skeleton['segments']) || skeleton['segments'].length === 0)
|
|
102
168
|
return null;
|
|
@@ -126,7 +192,11 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
126
192
|
}
|
|
127
193
|
packSpec = parseFlightPackToSpec(JSON.parse(await readFile(packPath, 'utf-8')));
|
|
128
194
|
}
|
|
129
|
-
catch {
|
|
195
|
+
catch (e) {
|
|
196
|
+
// issue #620:数据包被 parse 边界拒收时,原因必须留痕——否则用户只看到
|
|
197
|
+
// 「骨架还不完整」,运维看不到是哪个包的哪个字段把求解面挡住了。
|
|
198
|
+
const err = e;
|
|
199
|
+
console.error(`[gotry] extractSpec: 数据包不可用 ${packPath}(${err.code ?? 'parse_failed'}):`, (err.message ?? '').slice(0, 200));
|
|
130
200
|
return null;
|
|
131
201
|
}
|
|
132
202
|
const anchorsById = new Map(skeleton['segments'].map(s => [String(s['id'] ?? ''), (s['anchors'] ?? {})]));
|
|
@@ -142,7 +212,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
142
212
|
async extractSlots(history, now) {
|
|
143
213
|
// now 可注入(评测固定锚点);过期判定在 flagExpiredSlots(代码层),模型只管逐字抽取
|
|
144
214
|
const anchor = buildTimeAnchor(now ?? clock());
|
|
145
|
-
const out = await chat([{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }], true, usage);
|
|
215
|
+
const out = await chat([{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }], true, usage, request);
|
|
146
216
|
const obj = parseJsonBlock(out);
|
|
147
217
|
if (!obj)
|
|
148
218
|
return null;
|
|
@@ -152,7 +222,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
152
222
|
},
|
|
153
223
|
async polishQuestion(q) {
|
|
154
224
|
const out = await chat([{ role: 'system', content: '把旅行规划的追问润色得更自然,保留全部信息(含为什么问),一两句话,不要加表情。' },
|
|
155
|
-
{ role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }], false, usage);
|
|
225
|
+
{ role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }], false, usage, request);
|
|
156
226
|
return out.trim() || `【${q.key}】${q.text}`;
|
|
157
227
|
},
|
|
158
228
|
usage,
|