@danceiny/gotry 0.0.1-rc.5
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/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/gotry-inner.js +124 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +28 -0
- package/package.json +53 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +145 -0
- package/ts/cordis.gotry-patch.yml +36 -0
- package/ts/package.json +23 -0
- package/ts/scripts/skeleton-check.ts +45 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +135 -0
- package/ts/src/index.ts +620 -0
- package/ts/src/loop.ts +336 -0
- package/ts/src/mock-llm.ts +88 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/unified.ts +577 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 真 LlmPort:OpenAI 兼容接口的 provider 中立适配器(DeepSeek/MiniMax-M2 实测)。
|
|
3
|
+
* 零新依赖(node 内建 fetch)。环境变量:LLM_API_KEY/LLM_BASE_URL/LLM_MODEL
|
|
4
|
+
* (兼容旧 DEEPSEEK_* 别名)。MiniMax-M2 是推理模型:输出带 <think> 块,
|
|
5
|
+
* 必须先剥离再解析——JSON 藏在 think 里是常见失败模式。
|
|
6
|
+
* 无 key 时抛出明确错误,replay-real 自动回退 mock(ADR-8)。
|
|
7
|
+
* 责任铁律不变:本适配器只做翻译/润色/解释,判定与算术在确定性组件。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LlmPort } from './loop.ts'
|
|
11
|
+
import type { InterviewQuestion, TravelerProfile, TripState, Turn, CalendarState } from './contracts.ts'
|
|
12
|
+
import { parseFlightPackToSpec, type JourneySpecTS } from './unified.ts'
|
|
13
|
+
|
|
14
|
+
const MODEL = process.env['LLM_MODEL'] ?? process.env['DEEPSEEK_MODEL'] ?? 'MiniMax-M2'
|
|
15
|
+
const BASE = (process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_URL'] ?? 'https://api.minimax.io/v1').replace(/\/$/, '')
|
|
16
|
+
|
|
17
|
+
async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean): Promise<string> {
|
|
18
|
+
const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY']
|
|
19
|
+
if (!key) throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)')
|
|
20
|
+
const res = await fetch(`${BASE}/chat/completions`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
model: MODEL,
|
|
25
|
+
messages,
|
|
26
|
+
...(json ? { response_format: { type: 'json_object' } } : {}),
|
|
27
|
+
temperature: json ? 0 : 0.7,
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
if (!res.ok) throw new Error(`llm ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
31
|
+
const data = await res.json() as { choices: Array<{ message: { content: string } }> }
|
|
32
|
+
const raw = data.choices[0]?.message?.content ?? ''
|
|
33
|
+
// 推理模型(MiniMax-M2 等):剥 <think> 块,只留正文;未闭合时留全文由上层容错
|
|
34
|
+
return raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || raw
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 从模型输出稳健地抠出 JSON 对象(容忍围栏/前后文) */
|
|
38
|
+
function parseJsonBlock(text: string): Record<string, unknown> | null {
|
|
39
|
+
const m = text.match(/\{[\s\S]*\}/)
|
|
40
|
+
if (!m) return null
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(m[0]) as Record<string, unknown>
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FACTS_SYSTEM = `你是旅行规划的事实抽取器。从对话中抽取两类事实并以 JSON 返回:
|
|
49
|
+
{"calendar": {"year": 数字, "assertedWeekdays": {"YYYY-MM-DD": "mon|tue|wed|thu|fri|sat|sun"}},
|
|
50
|
+
"profile": {"workWindow": {"homeTzOffsetMin": 数字, "startMin": 数字, "endMin": 数字, "workdays": [0,1,2,3,4], "evidence": "用户原话"},
|
|
51
|
+
"companions": ["..."], "budgetTier": "economy|comfort|convenience",
|
|
52
|
+
"bookedResources": [{"kind": "flight|hotel", "ref": "...", "window": "..."}]}}
|
|
53
|
+
只放用户明确说过的事实;没有的字段省略。分钟数从 HH:MM 换算;UTC+4 → homeTzOffsetMin=240。
|
|
54
|
+
**休假语义(关键)**:用户说「请假/年假/不用办公/休假」→ workWindow 输出 {"vacation": true}(不是省略!省略会触发重复追问);只有用户明确给了工作时间才输出完整 workWindow 对象。只输出 JSON。`
|
|
55
|
+
|
|
56
|
+
const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程的**骨架**——段(移动)与锚点,不包含任何班次数据(班次来自数据层,你不要编造时刻/价格/航班号)。
|
|
57
|
+
输出 JSON:{"scenario":"erhai|workation|yunnan|generic","segments":[{"id":"f1","role":"choice|fixed","route":"HKG->HKT","dateHint":"2026-07-18","anchors":{"arriveByMin":885}}]}
|
|
58
|
+
规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
|
|
59
|
+
scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`
|
|
60
|
+
|
|
61
|
+
export function createOpenAICompatLlm(flightPackPath?: string): LlmPort {
|
|
62
|
+
const pack = flightPackPath
|
|
63
|
+
const historyText = (h: Turn[]) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n')
|
|
64
|
+
return {
|
|
65
|
+
async extractFacts(history) {
|
|
66
|
+
const out = await chat(
|
|
67
|
+
[{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: historyText(history) }],
|
|
68
|
+
true,
|
|
69
|
+
)
|
|
70
|
+
const obj = parseJsonBlock(out)
|
|
71
|
+
if (!obj) return { assumptions: [] }
|
|
72
|
+
const calendar = obj['calendar'] as Partial<CalendarState> | undefined
|
|
73
|
+
const profile = obj['profile'] as Partial<TravelerProfile> | undefined
|
|
74
|
+
const assumptions = Object.keys(profile ?? {}).map(f => ({ field: f, source: 'user-verbatim' as const }))
|
|
75
|
+
return { calendar, profile, assumptions }
|
|
76
|
+
},
|
|
77
|
+
async extractSpec(history, state) {
|
|
78
|
+
// 架构(ADR-10):LLM 只产骨架与锚点;班次数据永远来自能力层(数据包/未来实时API)
|
|
79
|
+
const context = `已断言日历:${JSON.stringify(state.calendar.assertedWeekdays)}\nprofile:${JSON.stringify(state.profile)}`
|
|
80
|
+
const out = await chat(
|
|
81
|
+
[{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }],
|
|
82
|
+
true,
|
|
83
|
+
)
|
|
84
|
+
const skeleton = parseJsonBlock(out)
|
|
85
|
+
if (!skeleton || !Array.isArray(skeleton['segments']) || (skeleton['segments'] as unknown[]).length === 0) return null
|
|
86
|
+
// 能力层装数据:航班包提供 services;骨架按段 id 合并锚点
|
|
87
|
+
if (!pack) return null
|
|
88
|
+
const { readFile } = await import('node:fs/promises')
|
|
89
|
+
const scenario = String(skeleton['scenario'] ?? 'generic')
|
|
90
|
+
// 场景→数据包路由(薄壳段3:意图决定装哪个包,而非永远装通用包)
|
|
91
|
+
// generic 不装包——意图不明确时不进求解,让循环继续访谈(ADR-10:翻译不造数)
|
|
92
|
+
if (scenario === 'generic') return null
|
|
93
|
+
const packByScenario: Record<string, string> = {
|
|
94
|
+
erhai: pack.replace('flights_2026.json', 'golden_erhai.json'),
|
|
95
|
+
workation: pack, // 五段链
|
|
96
|
+
yunnan: pack.replace('flights_2026.json', 'yunnan-pack.json'),
|
|
97
|
+
}
|
|
98
|
+
const packPath = packByScenario[scenario]
|
|
99
|
+
if (!packPath) return null
|
|
100
|
+
let packSpec: JourneySpecTS
|
|
101
|
+
try {
|
|
102
|
+
if (scenario === 'erhai') {
|
|
103
|
+
// 洱海 = 候选集场景:不装五段链,返回洱海候选 spec 的轻量标记(由引擎的候选求解处理;
|
|
104
|
+
// 循环层看到 scenario=erhai 时走 solveChoiceSegment 而非 solveUnified)
|
|
105
|
+
return { segments: [], note: 'erhai-candidates', budgetCny: 3000 } as unknown as JourneySpecTS
|
|
106
|
+
}
|
|
107
|
+
packSpec = parseFlightPackToSpec(JSON.parse(await readFile(packPath, 'utf-8')))
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
const anchorsById = new Map<string, Record<string, unknown>>(
|
|
112
|
+
(skeleton['segments'] as Array<Record<string, unknown>>).map(s => [String(s['id'] ?? ''), (s['anchors'] ?? {}) as Record<string, unknown>]))
|
|
113
|
+
for (const seg of packSpec.segments) {
|
|
114
|
+
const a = anchorsById.get(seg.id) as { arriveByMin?: number } | undefined
|
|
115
|
+
if (a?.arriveByMin !== undefined) seg.anchors = { arriveByMin: a.arriveByMin }
|
|
116
|
+
}
|
|
117
|
+
packSpec.workWindow = state.profile.workWindow ? {
|
|
118
|
+
homeTzOffsetMin: state.profile.workWindow.homeTzOffsetMin,
|
|
119
|
+
startMin: state.profile.workWindow.startMin,
|
|
120
|
+
endMin: state.profile.workWindow.endMin,
|
|
121
|
+
workdays: state.profile.workWindow.workdays,
|
|
122
|
+
} : undefined
|
|
123
|
+
packSpec.budgetCny = 9000
|
|
124
|
+
return packSpec
|
|
125
|
+
},
|
|
126
|
+
async polishQuestion(q: InterviewQuestion) {
|
|
127
|
+
const out = await chat(
|
|
128
|
+
[{ role: 'system', content: '把旅行规划的追问润色得更自然,保留全部信息(含为什么问),一两句话,不要加表情。' },
|
|
129
|
+
{ role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }],
|
|
130
|
+
false,
|
|
131
|
+
)
|
|
132
|
+
return out.trim() || `【${q.key}】${q.text}`
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|