@danceiny/gotry 0.0.1-rc.10 → 0.0.1-rc.12
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 +35 -24
- package/cordis.gotry-patch.yml +14 -7
- package/dist/capabilities/flyai.js +135 -30
- package/dist/capabilities/hbcli.js +14 -2
- package/dist/capabilities/session/benchmark.js +252 -0
- package/dist/capabilities/session/transport.js +14 -9
- package/dist/capabilities/session-consent.js +82 -0
- package/dist/capabilities/session-login.js +103 -0
- package/dist/capabilities/session-search.js +5 -3
- package/dist/capabilities/weather.js +72 -17
- package/dist/scripts/agent-reach-wrapper-tests.js +4 -0
- package/dist/scripts/async-collect.js +33 -5
- package/dist/scripts/hbcli-tests.js +25 -4
- package/dist/scripts/i18n-tests.js +34 -0
- package/dist/scripts/ledger-tests.js +134 -4
- package/dist/scripts/memory-value-report.js +379 -0
- package/dist/scripts/product-metrics.js +569 -0
- package/dist/scripts/realtime-pricing-tests.js +115 -0
- package/dist/scripts/replay-real.js +3 -1
- package/dist/scripts/session-benchmark.js +261 -0
- package/dist/scripts/session-login.js +28 -0
- package/dist/scripts/session-tests.js +263 -22
- package/dist/scripts/smoke.js +112 -14
- package/dist/scripts/weather-tests.js +8 -1
- package/dist/scripts/z3-race-tests.js +35 -0
- package/dist/src/engine.js +185 -116
- package/dist/src/i18n.js +77 -0
- package/dist/src/index.js +124 -5
- package/dist/src/journey.js +78 -82
- package/dist/src/loop.js +65 -14
- package/dist/src/realtime-pricing.js +105 -0
- package/dist/src/state-ledger.js +30 -4
- package/dist/src/unified.js +158 -105
- package/dist/src/z3-shared.js +18 -0
- package/package.json +5 -3
- package/ts/capabilities/flyai.ts +177 -22
- package/ts/capabilities/hbcli.ts +16 -4
- package/ts/capabilities/session/benchmark.ts +273 -0
- package/ts/capabilities/session/transport.ts +19 -8
- package/ts/capabilities/session-consent.ts +127 -0
- package/ts/capabilities/session-login.ts +130 -0
- package/ts/capabilities/session-search.ts +11 -4
- package/ts/capabilities/weather.ts +84 -19
- package/ts/scripts/async-collect.ts +48 -7
- package/ts/src/index.ts +107 -11
- package/ts/src/loop.ts +85 -12
- package/ts/src/state-ledger.ts +48 -4
- package/ts/src/unified.ts +40 -36
package/dist/scripts/smoke.js
CHANGED
|
@@ -7,6 +7,7 @@ async function main() {
|
|
|
7
7
|
const smokeRoot = mkdtempSync(join(tmpdir(), 'gotry-smoke-'));
|
|
8
8
|
const registered = [];
|
|
9
9
|
const variables = {};
|
|
10
|
+
const preExecutes = [];
|
|
10
11
|
const ctx = {
|
|
11
12
|
tools: {
|
|
12
13
|
register: (t)=>registered.push(t)
|
|
@@ -15,13 +16,19 @@ async function main() {
|
|
|
15
16
|
variable: (name, provider)=>{
|
|
16
17
|
variables[name] = provider;
|
|
17
18
|
}
|
|
19
|
+
},
|
|
20
|
+
on: (event, fn)=>{
|
|
21
|
+
if (event === 'tools/pre-execute') preExecutes.push(fn);
|
|
22
|
+
return ()=>{};
|
|
18
23
|
}
|
|
19
24
|
};
|
|
20
|
-
|
|
25
|
+
const cfg = {
|
|
21
26
|
stateRoot: smokeRoot,
|
|
22
27
|
timeoutMs: 30_000,
|
|
23
|
-
hbcliBin: 'hbcli-not-on-path'
|
|
24
|
-
|
|
28
|
+
hbcliBin: 'hbcli-not-on-path',
|
|
29
|
+
sessionAccess: 'ask'
|
|
30
|
+
};
|
|
31
|
+
apply(ctx, cfg);
|
|
25
32
|
console.log(`registered tools: ${registered.map((t)=>t.name).join(', ')}`);
|
|
26
33
|
const byName = (n)=>{
|
|
27
34
|
const t = registered.find((t)=>t.name === n);
|
|
@@ -290,27 +297,118 @@ async function main() {
|
|
|
290
297
|
}
|
|
291
298
|
}, null);
|
|
292
299
|
const faBlocked = fa.verdict === 'error' && /sentinel|block/i.test(fa.error ?? '');
|
|
300
|
+
const faErrTerminal = fa.ok === false && fa.verdict === 'error' && /^flyai-error$/.test(String(fa.via ?? '')) && /\[实时API:flyai@error@/.test(String(fa.evidence ?? ''));
|
|
293
301
|
if (faBlocked) {
|
|
294
302
|
console.log(' WARN - flyai Sentinel 限流中,降级合同通过(hit 断言跳过)');
|
|
303
|
+
} else if (faErrTerminal) {
|
|
304
|
+
console.log(' WARN - flyai 端点不可达(超时/降级),证据链合同通过(hit 断言跳过)');
|
|
295
305
|
} else if (fa.ok !== true || fa.verdict !== 'hit' || (fa.options?.length ?? 0) < 1 || !/\[实时API:flyai@/.test(fa.evidence ?? '')) {
|
|
296
306
|
throw new Error(`FAIL: flyai 工具应 live hit,实际:${JSON.stringify(fa).slice(0, 200)}`);
|
|
297
307
|
}
|
|
298
308
|
const prof = mkdtempSync(join(smokeRoot, 'sess-'));
|
|
299
|
-
const
|
|
309
|
+
const faPast = await byName('gotry_flyai_search').execute({
|
|
300
310
|
query: {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
311
|
+
kind: 'flight',
|
|
312
|
+
from: '深圳',
|
|
313
|
+
to: '普吉',
|
|
314
|
+
date: '2026-01-01'
|
|
315
|
+
}
|
|
316
|
+
}, null);
|
|
317
|
+
if (!(faPast.ok === false && /已是过去/.test(String(faPast.summary ?? '')))) {
|
|
318
|
+
throw new Error(`FAIL: flyai 过去日期应代码层预校验拒绝,实际:${JSON.stringify(faPast).slice(0, 200)}`);
|
|
319
|
+
}
|
|
320
|
+
const previousChromeUserDataDir = process.env.CHROME_USER_DATA_DIR;
|
|
321
|
+
process.env.CHROME_USER_DATA_DIR = prof;
|
|
322
|
+
let ss;
|
|
323
|
+
try {
|
|
324
|
+
ss = await byName('gotry_session_search').execute({
|
|
325
|
+
query: {
|
|
326
|
+
from: '上海',
|
|
327
|
+
to: '丽江',
|
|
328
|
+
date: '2026-10-01'
|
|
329
|
+
}
|
|
330
|
+
}, null);
|
|
331
|
+
} finally{
|
|
332
|
+
if (previousChromeUserDataDir === undefined) delete process.env.CHROME_USER_DATA_DIR;
|
|
333
|
+
else process.env.CHROME_USER_DATA_DIR = previousChromeUserDataDir;
|
|
334
|
+
rmSync(prof, {
|
|
335
|
+
recursive: true,
|
|
336
|
+
force: true
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const ssErrTerminal = ss.ok === false && /^session-[a-z0-9-]+-error$/.test(String(ss.via ?? '')) && !!ss.evidence;
|
|
340
|
+
if (!(ss.verdict === 'needs-login' || ss.verdict === 'needs-attach' || ss.verdict === 'hit' || ss.verdict === 'cooldown' || ss.verdict === 'challenged') && !ssErrTerminal) {
|
|
341
|
+
throw new Error(`FAIL: session 工具终态应属 {needs-attach,needs-login,hit,cooldown,challenged} 或带证据的 error 终态,实际:${JSON.stringify(ss).slice(0, 200)}`);
|
|
342
|
+
}
|
|
343
|
+
console.log(`session-face tools: flyai ${faBlocked ? 'sentinel-限流降级' : `live hit(${fa.options?.length ?? 0} 条)`}; session 终态=${ss.verdict ?? ss.via}(登录态存在前提合同)`);
|
|
344
|
+
const fh = await byName('gotry_flyai_search').execute({
|
|
345
|
+
query: {
|
|
346
|
+
kind: 'hotel',
|
|
347
|
+
to: '大理',
|
|
348
|
+
checkIn: '2026-10-01',
|
|
349
|
+
checkOut: '2026-10-03'
|
|
304
350
|
}
|
|
305
351
|
}, null);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
throw new Error(`FAIL:
|
|
352
|
+
const fhBlocked = fh.verdict === 'error' && /sentinel|block/i.test(fh.error ?? '');
|
|
353
|
+
const fhErrTerminal = fh.ok === false && fh.verdict === 'error' && /^flyai-error$/.test(String(fh.via ?? '')) && /\[实时API:flyai@error@/.test(String(fh.evidence ?? ''));
|
|
354
|
+
if (fhBlocked || fhErrTerminal) {
|
|
355
|
+
console.log(' WARN - flyai hotel 限流/端点降级,证据链合同通过(hit 断言跳过)');
|
|
356
|
+
} else if (fh.ok !== true || fh.verdict !== 'hit' || (fh.hotels?.length ?? 0) < 1 || !/\[实时API:flyai@/.test(fh.evidence ?? '')) {
|
|
357
|
+
throw new Error(`FAIL: flyai hotel 应 live hit,实际:${JSON.stringify(fh).slice(0, 220)}`);
|
|
358
|
+
}
|
|
359
|
+
const fhDest = await byName('gotry_flyai_search').execute({
|
|
360
|
+
query: {
|
|
361
|
+
kind: 'hotel'
|
|
362
|
+
}
|
|
363
|
+
}, null);
|
|
364
|
+
if (fhDest.ok !== false) throw new Error('FAIL: hotel 缺目的地应参数闸拒绝');
|
|
365
|
+
const fhPast = await byName('gotry_flyai_search').execute({
|
|
366
|
+
query: {
|
|
367
|
+
kind: 'hotel',
|
|
368
|
+
to: '大理',
|
|
369
|
+
checkIn: '2026-01-01',
|
|
370
|
+
checkOut: '2026-01-03'
|
|
371
|
+
}
|
|
372
|
+
}, null);
|
|
373
|
+
if (!(fhPast.ok === false && /不是未来合法区间/.test(String(fhPast.summary ?? '')))) {
|
|
374
|
+
throw new Error(`FAIL: 酒店过去入住日应代码层预校验拒绝,实际:${JSON.stringify(fhPast).slice(0, 200)}`);
|
|
375
|
+
}
|
|
376
|
+
console.log(` hotel channel: ${fhBlocked ? 'sentinel-限流降级' : `${fh.hotels?.length ?? 0} 家`}; 参数闸/过去日闸生效`);
|
|
377
|
+
}
|
|
378
|
+
{
|
|
379
|
+
const gate = preExecutes.at(-1);
|
|
380
|
+
if (!gate) throw new Error('FAIL: tools/pre-execute 授权闸未注册');
|
|
381
|
+
const next = async ()=>({
|
|
382
|
+
kind: 'allow'
|
|
383
|
+
});
|
|
384
|
+
const ask = await gate({
|
|
385
|
+
name: 'gotry_session_search'
|
|
386
|
+
}, next);
|
|
387
|
+
if (ask.kind !== 'ask' || !/只读检索/.test(String(ask.reason ?? ''))) {
|
|
388
|
+
throw new Error(`FAIL: 会话工具无审批通道时应交 ask(运行时原生结算),实际:${JSON.stringify(ask)}`);
|
|
389
|
+
}
|
|
390
|
+
const pass = await gate({
|
|
391
|
+
name: 'gotry_anything_search'
|
|
392
|
+
}, next);
|
|
393
|
+
if (pass.kind !== 'allow') throw new Error(`FAIL: 非会话工具应原样放行,实际:${JSON.stringify(pass)}`);
|
|
394
|
+
cfg.sessionAccess = 'off';
|
|
395
|
+
const deny = await gate({
|
|
396
|
+
name: 'gotry_session_search'
|
|
397
|
+
}, next);
|
|
398
|
+
if (deny.kind !== 'deny' || !/sessionAccess=off/.test(String(deny.reason ?? ''))) {
|
|
399
|
+
throw new Error(`FAIL: sessionAccess=off 应 fail-closed deny,实际:${JSON.stringify(deny)}`);
|
|
400
|
+
}
|
|
401
|
+
cfg.sessionAccess = 'ask';
|
|
402
|
+
console.log('consent gate: session tool → approval-card ask(每会话一次/拒绝即会话内吊销); off → fail-closed deny; other tools pass through');
|
|
403
|
+
}
|
|
404
|
+
{
|
|
405
|
+
const lo = byName('gotry_session_login');
|
|
406
|
+
if (typeof lo.presentResult !== 'function') throw new Error('FAIL: gotry_session_login 缺 presentResult');
|
|
407
|
+
const desc = String(lo.description ?? '');
|
|
408
|
+
if (!/NEVER collects, stores, or transmits credentials/.test(desc)) {
|
|
409
|
+
throw new Error(`FAIL: 登录工具描述缺「不经手凭证」语义红线,实际 ${desc.slice(0, 120)}`);
|
|
312
410
|
}
|
|
313
|
-
console.log(
|
|
411
|
+
console.log('login tool: registered; 登录=外部网站+用户自己的浏览器,gotry 只读票据名(0 值过手)语义钉死');
|
|
314
412
|
}
|
|
315
413
|
rmSync(smokeRoot, {
|
|
316
414
|
recursive: true,
|
|
@@ -9,6 +9,13 @@ const dali = geo.results.find((r)=>(r.admin1 ?? '').includes('云南')) ?? geo.r
|
|
|
9
9
|
assert.ok(Math.abs(dali.latitude - 25.6) < 0.5, `纬度应≈25.6,实际 ${dali.latitude}(${dali.name},${dali.admin1})`);
|
|
10
10
|
assert.match(geo.evidence, /open-meteo-geo@2/, '证据链带时间戳');
|
|
11
11
|
console.log(`1. geocode 大理市 → ${dali.latitude},${dali.longitude} (${dali.name},${dali.admin1}) OK`);
|
|
12
|
+
const phuket = await geocodePlace('普吉岛');
|
|
13
|
+
assert.equal(phuket.ok, true, `普吉岛 geocode ok: ${phuket.error ?? ''}`);
|
|
14
|
+
assert.equal(phuket.via, 'nominatim', 'open-meteo 0 结果应走 nominatim 兜底层');
|
|
15
|
+
assert.ok(Math.abs(phuket.results[0].latitude - 8.0) < 0.5, `普吉岛纬度应≈8,实际 ${phuket.results[0].latitude}(${phuket.results[0].name})`);
|
|
16
|
+
assert.match(phuket.results[0].country ?? '', /泰国/, '国家标签应为泰国');
|
|
17
|
+
assert.match(phuket.evidence, /\[实时API:nominatim@2/, '兜底层证据链带时间戳');
|
|
18
|
+
console.log(`1b. geocode 普吉岛(兜底层)→ ${phuket.results[0].name}(${phuket.results[0].admin1},${phuket.results[0].country}) OK`);
|
|
12
19
|
const fc = await getForecast({
|
|
13
20
|
latitude: dali.latitude,
|
|
14
21
|
longitude: dali.longitude
|
|
@@ -42,7 +49,7 @@ assert.equal(wmoLabel(0), '晴');
|
|
|
42
49
|
assert.equal(wmoLabel(95), '雷暴');
|
|
43
50
|
assert.match(wmoLabel(999), /天气码999/, '未知码回退');
|
|
44
51
|
console.log('5. WMO 码映射 OK');
|
|
45
|
-
console.log('\nWEATHER TESTS:
|
|
52
|
+
console.log('\nWEATHER TESTS: 6/6 OK(Open-Meteo 真实 API,免费无 key;普吉岛走 Nominatim 兜底层)');
|
|
46
53
|
|
|
47
54
|
|
|
48
55
|
//# sourceURL=/Users/bytedance/work/gotry/ts/scripts/weather-tests.ts
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { solve } from '../src/engine.js';
|
|
5
|
+
import { solveJourney, parseFlightPackLegs } from '../src/journey.js';
|
|
6
|
+
import { parseFlightPackToSpec, solveUnified } from '../src/unified.js';
|
|
7
|
+
import { parseCandidate, parseRequest } from '../src/model.js';
|
|
8
|
+
const erhai = JSON.parse(await readFile(join('..', 'data', 'golden_erhai.json'), 'utf-8'));
|
|
9
|
+
const pack = JSON.parse(await readFile(join('..', 'data', 'flights_2026.json'), 'utf-8'));
|
|
10
|
+
const req = parseRequest(erhai['request']);
|
|
11
|
+
const candidates = erhai['candidates'].map(parseCandidate);
|
|
12
|
+
const legs = parseFlightPackLegs(pack);
|
|
13
|
+
const baseEngine = await solve(req, candidates);
|
|
14
|
+
const baseJourney = await solveJourney({
|
|
15
|
+
legs
|
|
16
|
+
});
|
|
17
|
+
const ROUNDS = 12;
|
|
18
|
+
for(let round = 0; round < ROUNDS; round++){
|
|
19
|
+
const [eng, jny, uni] = await Promise.all([
|
|
20
|
+
solve(req, candidates),
|
|
21
|
+
solveJourney({
|
|
22
|
+
legs
|
|
23
|
+
}),
|
|
24
|
+
solveUnified(parseFlightPackToSpec(pack))
|
|
25
|
+
]);
|
|
26
|
+
void jny;
|
|
27
|
+
assert.equal(eng['recommended'], baseEngine['recommended'], `r${round}: engine recommended 漂移`);
|
|
28
|
+
assert.equal(jny['feasible'], baseJourney['feasible'], `r${round}: journey feasible 漂移`);
|
|
29
|
+
assert.equal((uni['unsat_core'] ?? []).includes('wasm_runtime_error'), false, `r${round}: unified 会话触发 WASM 降级——race 复发`);
|
|
30
|
+
assert.equal(uni['feasible'], true, `r${round}: unified 判定漂移(基线应为可行)`);
|
|
31
|
+
}
|
|
32
|
+
console.log(`Z3 RACE TESTS OK(进程内三形态并发 ×${ROUNDS}:判定与顺序基线一致,零 WASM 降级)`);
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
//# sourceURL=/Users/bytedance/work/gotry/ts/scripts/z3-race-tests.ts
|
package/dist/src/engine.js
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseCandidate, parseRequest } from './model.js';
|
|
2
2
|
import { evaluateChoice, minToHhmm, requiredUsableHours, trueCostToDict, LATEST_ARRIVE_STAY_MIN } from './model.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
if (!z3Promise) {
|
|
6
|
-
z3Promise = (async ()=>(await init()).Context('main'))();
|
|
7
|
-
}
|
|
8
|
-
return z3Promise;
|
|
9
|
-
}
|
|
3
|
+
import { withZ3 } from './z3-shared.js';
|
|
4
|
+
import { t } from './i18n.js';
|
|
10
5
|
const CONSTRAINT_LABEL_KEYS = [
|
|
11
6
|
'wake_floor',
|
|
12
7
|
'energy_floor',
|
|
@@ -111,8 +106,7 @@ async function unsatCoreOf(s) {
|
|
|
111
106
|
for(let i = 0; i < v.length(); i++)out.push(String(v.get(i)));
|
|
112
107
|
return out;
|
|
113
108
|
}
|
|
114
|
-
async function cheapestPlan(enc, skip) {
|
|
115
|
-
const z3 = await getZ3();
|
|
109
|
+
async function cheapestPlan(enc, skip, z3) {
|
|
116
110
|
const opt = new z3.Optimize();
|
|
117
111
|
opt.add(enc.structure);
|
|
118
112
|
for (const name of CONSTRAINT_LABEL_KEYS){
|
|
@@ -133,109 +127,138 @@ async function cheapestPlan(enc, skip) {
|
|
|
133
127
|
function suggestText(relax, cand, req, choice, cost) {
|
|
134
128
|
const mot = req.motivation;
|
|
135
129
|
const parts = [];
|
|
136
|
-
if (relax.includes('duration')) parts.push(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (relax.includes('
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
if (relax.includes('duration')) parts.push(t('sg.duration', {
|
|
131
|
+
days: choice.days,
|
|
132
|
+
name: cand.name
|
|
133
|
+
}));
|
|
134
|
+
if (relax.includes('budget')) parts.push(t('sg.budget', {
|
|
135
|
+
cost: cost.moneyCny,
|
|
136
|
+
budget: req.budgetCny
|
|
137
|
+
}));
|
|
138
|
+
if (relax.includes('wake_floor')) parts.push(t('sg.wake_floor', {
|
|
139
|
+
wake: minToHhmm(cost.wakeMin)
|
|
140
|
+
}));
|
|
141
|
+
if (relax.includes('energy_floor')) parts.push(t('sg.energy_floor', {
|
|
142
|
+
pct: cost.energyArrivalPct,
|
|
143
|
+
min: mot.minArrivalEnergyPct
|
|
144
|
+
}));
|
|
145
|
+
if (relax.includes('usable_hours')) parts.push(t('sg.usable_hours', {
|
|
146
|
+
hours: cost.usableHours.toFixed(1),
|
|
147
|
+
need: requiredUsableHours(mot).toFixed(1)
|
|
148
|
+
}));
|
|
149
|
+
if (relax.includes('arrival_before_evening')) parts.push(t('sg.arrival', {
|
|
150
|
+
time: minToHhmm(cost.arriveStayMin)
|
|
151
|
+
}));
|
|
152
|
+
const plan = t('sg.plan', {
|
|
153
|
+
id: choice.outService.id,
|
|
154
|
+
dep: minToHhmm(choice.outService.depMin),
|
|
155
|
+
mode: choice.outTransfer.mode,
|
|
156
|
+
money: cost.moneyCny,
|
|
157
|
+
wake: minToHhmm(cost.wakeMin),
|
|
158
|
+
arrive: minToHhmm(cost.arriveStayMin),
|
|
159
|
+
percent: cost.energyArrivalPct
|
|
160
|
+
});
|
|
143
161
|
return parts.join(' + ') + `;${plan}`;
|
|
144
162
|
}
|
|
145
163
|
export async function solveCandidate(cand, req) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
const core = (await unsatCoreOf(s)).sort();
|
|
164
|
-
verdict.unsatCore = core;
|
|
165
|
-
for (const name of core){
|
|
166
|
-
const skip = new Set([
|
|
167
|
-
name
|
|
168
|
-
]);
|
|
169
|
-
const enc2 = name === 'duration' ? new Encoding(z3, cand, req, cand.minDaysForPurpose) : enc;
|
|
170
|
-
const s2 = await enc2.solverWith(skip);
|
|
171
|
-
if (!await isUnsat(s2)) {
|
|
172
|
-
const choice = await enc2.extract(s2.model());
|
|
173
|
-
const cost = evaluateChoice(cand, req, choice);
|
|
174
|
-
verdict.suggestions.push({
|
|
175
|
-
relax: [
|
|
176
|
-
name
|
|
177
|
-
],
|
|
178
|
-
text: suggestText([
|
|
179
|
-
name
|
|
180
|
-
], cand, req, choice, cost),
|
|
181
|
-
resulting: {
|
|
182
|
-
days: choice.days,
|
|
183
|
-
...trueCostToDict(cost)
|
|
184
|
-
}
|
|
185
|
-
});
|
|
164
|
+
return withZ3('engine.solveCandidate', async (z3)=>{
|
|
165
|
+
const enc = new Encoding(z3, cand, req);
|
|
166
|
+
const verdict = {
|
|
167
|
+
candidateId: cand.id,
|
|
168
|
+
name: cand.name,
|
|
169
|
+
feasible: false,
|
|
170
|
+
imageryMatch: cand.imageryMatch,
|
|
171
|
+
unsatCore: [],
|
|
172
|
+
suggestions: []
|
|
173
|
+
};
|
|
174
|
+
const s = await enc.solverWith();
|
|
175
|
+
if (!await isUnsat(s)) {
|
|
176
|
+
verdict.feasible = true;
|
|
177
|
+
verdict.choice = await enc.extract(s.model());
|
|
178
|
+
verdict.trueCost = evaluateChoice(cand, req, verdict.choice);
|
|
179
|
+
return verdict;
|
|
186
180
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
];
|
|
199
|
-
for (const name of joint.filter((n)=>n !== 'duration')){
|
|
200
|
-
const trial = new Set(joint.filter((n)=>n !== name));
|
|
201
|
-
if (!await isUnsat(await longEnc.solverWith(trial))) joint = [
|
|
202
|
-
...trial
|
|
203
|
-
];
|
|
204
|
-
}
|
|
205
|
-
const plan = await cheapestPlan(longEnc, new Set(joint));
|
|
206
|
-
if (plan) {
|
|
181
|
+
const core = (await unsatCoreOf(s)).sort();
|
|
182
|
+
verdict.unsatCore = core;
|
|
183
|
+
for (const name of core){
|
|
184
|
+
const skip = new Set([
|
|
185
|
+
name
|
|
186
|
+
]);
|
|
187
|
+
const enc2 = name === 'duration' ? new Encoding(z3, cand, req, cand.minDaysForPurpose) : enc;
|
|
188
|
+
const s2 = await enc2.solverWith(skip);
|
|
189
|
+
if (!await isUnsat(s2)) {
|
|
190
|
+
const choice = await enc2.extract(s2.model());
|
|
191
|
+
const cost = evaluateChoice(cand, req, choice);
|
|
207
192
|
verdict.suggestions.push({
|
|
208
|
-
relax:
|
|
209
|
-
|
|
193
|
+
relax: [
|
|
194
|
+
name
|
|
195
|
+
],
|
|
196
|
+
text: suggestText([
|
|
197
|
+
name
|
|
198
|
+
], cand, req, choice, cost),
|
|
210
199
|
resulting: {
|
|
211
|
-
days:
|
|
212
|
-
...trueCostToDict(
|
|
200
|
+
days: choice.days,
|
|
201
|
+
...trueCostToDict(cost)
|
|
213
202
|
}
|
|
214
203
|
});
|
|
215
204
|
}
|
|
216
205
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
206
|
+
if (core.includes('duration')) {
|
|
207
|
+
const longEnc = new Encoding(z3, cand, req, cand.minDaysForPurpose);
|
|
208
|
+
const s2 = await longEnc.solverWith(new Set([
|
|
209
|
+
'duration'
|
|
210
|
+
]));
|
|
211
|
+
if (await isUnsat(s2)) {
|
|
212
|
+
const core2 = await unsatCoreOf(s2);
|
|
213
|
+
let joint = [
|
|
214
|
+
'duration',
|
|
215
|
+
...core2.filter((n)=>n !== 'duration')
|
|
216
|
+
];
|
|
217
|
+
for (const name of joint.filter((n)=>n !== 'duration')){
|
|
218
|
+
const trial = new Set(joint.filter((n)=>n !== name));
|
|
219
|
+
if (!await isUnsat(await longEnc.solverWith(trial))) joint = [
|
|
220
|
+
...trial
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
const plan = await cheapestPlan(longEnc, new Set(joint), z3);
|
|
224
|
+
if (plan) {
|
|
225
|
+
verdict.suggestions.push({
|
|
226
|
+
relax: joint,
|
|
227
|
+
text: suggestText(joint, cand, req, plan.choice, plan.cost),
|
|
228
|
+
resulting: {
|
|
229
|
+
days: plan.choice.days,
|
|
230
|
+
...trueCostToDict(plan.cost)
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (verdict.suggestions.some((sg)=>sg.relax.includes('duration'))) {
|
|
237
|
+
const longEnc = new Encoding(z3, cand, req, cand.minDaysForPurpose);
|
|
238
|
+
const plan = await cheapestPlan(longEnc, new Set([
|
|
239
|
+
'duration',
|
|
240
|
+
'budget'
|
|
241
|
+
]), z3);
|
|
242
|
+
const conditions = {
|
|
243
|
+
days: cand.minDaysForPurpose
|
|
244
|
+
};
|
|
245
|
+
if (plan) conditions['budget_cny'] = plan.cost.moneyCny;
|
|
246
|
+
if (cand.bestMonths.length) conditions['best_months'] = cand.bestMonths;
|
|
247
|
+
verdict.wishPool = {
|
|
248
|
+
name: cand.name,
|
|
249
|
+
conditions,
|
|
250
|
+
reason: t('sg.wish_reason_engine', {
|
|
251
|
+
weights: JSON.stringify(req.motivation.weights),
|
|
252
|
+
days: req.windowDays
|
|
253
|
+
})
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return verdict;
|
|
257
|
+
});
|
|
236
258
|
}
|
|
237
259
|
export async function solve(req, candidates) {
|
|
238
|
-
const verdicts =
|
|
260
|
+
const verdicts = [];
|
|
261
|
+
for (const c of candidates)verdicts.push(await solveCandidate(c, req));
|
|
239
262
|
const feasible = verdicts.filter((v)=>v.feasible).sort((a, b)=>b.imageryMatch - a.imageryMatch);
|
|
240
263
|
const recommended = feasible[0]?.candidateId ?? null;
|
|
241
264
|
return {
|
|
@@ -284,45 +307,91 @@ function renderMarkdown(req, verdicts, recommended) {
|
|
|
284
307
|
const feasible = verdicts.filter((v)=>v.feasible).sort((a, b)=>b.imageryMatch - a.imageryMatch);
|
|
285
308
|
const parked = verdicts.filter((v)=>!v.feasible);
|
|
286
309
|
const lines = [];
|
|
287
|
-
lines.push(
|
|
288
|
-
|
|
310
|
+
lines.push(t('md.header', {
|
|
311
|
+
note: req.note
|
|
312
|
+
}));
|
|
313
|
+
lines.push(t('md.constraints', {
|
|
314
|
+
days: req.windowDays,
|
|
315
|
+
budget: req.budgetCny,
|
|
316
|
+
hours: requiredUsableHours(req.motivation).toFixed(1)
|
|
317
|
+
}));
|
|
289
318
|
lines.push('');
|
|
290
319
|
for (const v of parked){
|
|
291
|
-
lines.push(
|
|
320
|
+
lines.push(t('md.infeasible', {
|
|
321
|
+
name: v.name,
|
|
322
|
+
core: v.unsatCore.join('、')
|
|
323
|
+
}));
|
|
292
324
|
for (const sg of v.suggestions.slice(0, 1))lines.push(`- ${sg.text}`);
|
|
293
325
|
if (v.wishPool) {
|
|
294
326
|
const conds = v.wishPool.conditions;
|
|
295
|
-
const budgetNote = conds['budget_cny'] ?
|
|
327
|
+
const budgetNote = conds['budget_cny'] ? t('md.wish_budget', {
|
|
328
|
+
cny: String(conds['budget_cny'])
|
|
329
|
+
}) : '';
|
|
296
330
|
const months = conds['best_months'];
|
|
297
|
-
const season = months ?
|
|
298
|
-
|
|
331
|
+
const season = months ? t('md.wish_season', {
|
|
332
|
+
months: months.join(',')
|
|
333
|
+
}) : '';
|
|
334
|
+
lines.push(t('md.wish_pool', {
|
|
335
|
+
days: String(conds['days']),
|
|
336
|
+
budgetNote,
|
|
337
|
+
season
|
|
338
|
+
}));
|
|
299
339
|
}
|
|
300
340
|
}
|
|
301
341
|
lines.push('');
|
|
302
342
|
for (const v of feasible){
|
|
303
|
-
const c = v.choice,
|
|
304
|
-
lines.push(
|
|
343
|
+
const c = v.choice, cost = v.trueCost;
|
|
344
|
+
lines.push(t('md.feasible_engine', {
|
|
345
|
+
name: v.name,
|
|
346
|
+
id: c.outService.id,
|
|
347
|
+
dep: minToHhmm(c.outService.depMin),
|
|
348
|
+
mode: c.outTransfer.mode,
|
|
349
|
+
arrive: minToHhmm(cost.arriveStayMin),
|
|
350
|
+
wake: minToHhmm(cost.wakeMin),
|
|
351
|
+
energy: cost.energyArrivalPct,
|
|
352
|
+
d2d: `${Math.floor(cost.doorToDoorOutMin / 60)}h${String(cost.doorToDoorOutMin % 60).padStart(2, '0')}m`,
|
|
353
|
+
hours: cost.usableHours.toFixed(1),
|
|
354
|
+
money: cost.moneyCny
|
|
355
|
+
}));
|
|
305
356
|
}
|
|
306
357
|
if (feasible.length) {
|
|
307
358
|
lines.push('');
|
|
308
359
|
const best = feasible[0];
|
|
309
360
|
const alt = feasible[1];
|
|
310
|
-
lines.push(
|
|
361
|
+
lines.push(t('md.recommend', {
|
|
362
|
+
best: best.name,
|
|
363
|
+
match: (best.imageryMatch * 100).toFixed(0)
|
|
364
|
+
}) + (alt ? t('md.alt', {
|
|
365
|
+
name: alt.name,
|
|
366
|
+
money: alt.trueCost.moneyCny,
|
|
367
|
+
match: (alt.imageryMatch * 100).toFixed(0)
|
|
368
|
+
}) : ''));
|
|
311
369
|
}
|
|
312
370
|
lines.push('');
|
|
313
|
-
lines.push('
|
|
371
|
+
lines.push(t('md.decide_two'));
|
|
314
372
|
if (feasible.length >= 2) {
|
|
315
|
-
lines.push(
|
|
373
|
+
lines.push(t('md.q_choice', {
|
|
374
|
+
a: feasible[0].name,
|
|
375
|
+
b: feasible[1].name
|
|
376
|
+
}));
|
|
316
377
|
} else if (feasible.length === 1) {
|
|
317
|
-
lines.push(
|
|
378
|
+
lines.push(t('md.q_single', {
|
|
379
|
+
name: feasible[0].name
|
|
380
|
+
}));
|
|
318
381
|
} else {
|
|
319
|
-
lines.push('
|
|
382
|
+
lines.push(t('md.q_none'));
|
|
320
383
|
}
|
|
321
384
|
const p0 = parked[0];
|
|
322
385
|
if (p0?.wishPool) {
|
|
323
|
-
lines.push(
|
|
386
|
+
lines.push(t('md.q_wish', {
|
|
387
|
+
name: p0.name,
|
|
388
|
+
days: String(p0.wishPool.conditions['days'])
|
|
389
|
+
}));
|
|
324
390
|
} else if (feasible.length) {
|
|
325
|
-
lines.push(
|
|
391
|
+
lines.push(t('md.q_depart', {
|
|
392
|
+
id: feasible[0].choice.outService.id,
|
|
393
|
+
dep: minToHhmm(feasible[0].choice.outService.depMin)
|
|
394
|
+
}));
|
|
326
395
|
}
|
|
327
396
|
return lines.join('\n');
|
|
328
397
|
}
|