@danceiny/gotry 0.0.1-rc.10
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 +267 -0
- package/bin/gotry-inner.js +214 -0
- package/bin/gotry-stdio-ask.js +59 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +86 -0
- package/data/flights_2026.json +218 -0
- package/data/golden_erhai.json +92 -0
- package/data/golden_trip_2026.json +75 -0
- package/data/hotels_2026.json +151 -0
- package/data/openflights-skeleton.json +895 -0
- package/data/time-slot-eval.json +384 -0
- package/data/yunnan-pack.json +202 -0
- package/dist/capabilities/agent-reach-bridge.py +90 -0
- package/dist/capabilities/agent-reach-deep.js +172 -0
- package/dist/capabilities/agent-reach.js +204 -0
- package/dist/capabilities/anything.js +139 -0
- package/dist/capabilities/flyai.js +131 -0
- package/dist/capabilities/hbcli.js +143 -0
- package/dist/capabilities/incident-log.js +100 -0
- package/dist/capabilities/opensky.js +74 -0
- package/dist/capabilities/session/action-cache.js +120 -0
- package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
- package/dist/capabilities/session/adapters/meituan-local.js +73 -0
- package/dist/capabilities/session/extract.js +40 -0
- package/dist/capabilities/session/read-guard.js +99 -0
- package/dist/capabilities/session/transport.js +101 -0
- package/dist/capabilities/session-search.js +93 -0
- package/dist/capabilities/weather.js +180 -0
- package/dist/scripts/action-cache-tests.js +107 -0
- package/dist/scripts/agent-reach-deep-tests.js +54 -0
- package/dist/scripts/agent-reach-tests.js +38 -0
- package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
- package/dist/scripts/anything-tests.js +95 -0
- package/dist/scripts/async-collect.js +37 -0
- package/dist/scripts/companion-tests.js +112 -0
- package/dist/scripts/diff-test.js +39 -0
- package/dist/scripts/engine-run.js +14 -0
- package/dist/scripts/engine-tests.js +45 -0
- package/dist/scripts/hbcli-tests.js +101 -0
- package/dist/scripts/incident-tests.js +96 -0
- package/dist/scripts/journey-tests.js +45 -0
- package/dist/scripts/ledger-tests.js +365 -0
- package/dist/scripts/ledger-workflow-crash.js +42 -0
- package/dist/scripts/memory-capture-tests.js +77 -0
- package/dist/scripts/memory-decay-tests.js +83 -0
- package/dist/scripts/memory-metrics.js +24 -0
- package/dist/scripts/nudge-digest.js +83 -0
- package/dist/scripts/opensky-check.js +20 -0
- package/dist/scripts/opensky-tests.js +52 -0
- package/dist/scripts/probe-poi-tests.js +88 -0
- package/dist/scripts/publish-preverify.js +68 -0
- package/dist/scripts/replay-async.js +44 -0
- package/dist/scripts/replay-real.js +38 -0
- package/dist/scripts/replay.js +116 -0
- package/dist/scripts/session-attach-diagnose.js +27 -0
- package/dist/scripts/session-attach-poc.js +65 -0
- package/dist/scripts/session-extract-tests.js +81 -0
- package/dist/scripts/session-tests.js +162 -0
- package/dist/scripts/skeleton-check.js +37 -0
- package/dist/scripts/skeleton-integration-test.js +22 -0
- package/dist/scripts/skills-contract-tests.js +85 -0
- package/dist/scripts/smoke.js +328 -0
- package/dist/scripts/state-cli-tests.js +136 -0
- package/dist/scripts/state-cli.js +248 -0
- package/dist/scripts/time-eval-tests.js +318 -0
- package/dist/scripts/travel-timeline-tests.js +123 -0
- package/dist/scripts/unified-tests.js +49 -0
- package/dist/scripts/weather-tests.js +48 -0
- package/dist/src/bridge.js +34 -0
- package/dist/src/companions.js +112 -0
- package/dist/src/contracts.js +64 -0
- package/dist/src/dsh-llm.js +174 -0
- package/dist/src/engine.js +331 -0
- package/dist/src/index.js +1239 -0
- package/dist/src/journey.js +147 -0
- package/dist/src/loop.js +360 -0
- package/dist/src/memory-capture.js +40 -0
- package/dist/src/memory-decay.js +31 -0
- package/dist/src/memory-utility.js +55 -0
- package/dist/src/mock-llm.js +103 -0
- package/dist/src/model.js +134 -0
- package/dist/src/slot-spec.js +165 -0
- package/dist/src/state-ledger.js +901 -0
- package/dist/src/time-anchor.js +100 -0
- package/dist/src/tool-packet.js +12 -0
- package/dist/src/travel-slots.js +144 -0
- package/dist/src/travel-timeline.js +78 -0
- package/dist/src/unified.js +538 -0
- package/dist/src/wish-pool.js +27 -0
- package/package.json +84 -0
- package/ts/capabilities/agent-reach-deep.ts +114 -0
- package/ts/capabilities/agent-reach.ts +158 -0
- package/ts/capabilities/anything.ts +201 -0
- package/ts/capabilities/flyai.ts +151 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +148 -0
- package/ts/capabilities/opensky.ts +121 -0
- package/ts/capabilities/session/action-cache.ts +148 -0
- package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
- package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
- package/ts/capabilities/session/extract.ts +54 -0
- package/ts/capabilities/session/read-guard.ts +141 -0
- package/ts/capabilities/session/transport.ts +114 -0
- package/ts/capabilities/session-search.ts +129 -0
- package/ts/capabilities/weather.ts +176 -0
- package/ts/package.json +27 -0
- package/ts/scripts/async-collect.ts +50 -0
- package/ts/scripts/skeleton-check.ts +49 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/scripts/state-cli.ts +201 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/companions.ts +113 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +155 -0
- package/ts/src/index.ts +968 -0
- package/ts/src/loop.ts +411 -0
- package/ts/src/memory-capture.ts +59 -0
- package/ts/src/memory-decay.ts +55 -0
- package/ts/src/memory-utility.ts +70 -0
- package/ts/src/mock-llm.ts +100 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/slot-spec.ts +172 -0
- package/ts/src/state-ledger.ts +849 -0
- package/ts/src/time-anchor.ts +134 -0
- package/ts/src/tool-packet.ts +32 -0
- package/ts/src/travel-slots.ts +219 -0
- package/ts/src/travel-timeline.ts +83 -0
- package/ts/src/unified.ts +582 -0
- package/ts/src/wish-pool.ts +66 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同行人档案(memory-design M5 层,P2):同行人及其约束(行动/健康/偏好)的持久化。
|
|
3
|
+
*
|
|
4
|
+
* 立场(memory-design §1):
|
|
5
|
+
* - 溯源 P0:每条约束伴 evidence(用户原话),追加不删史;
|
|
6
|
+
* - 约束只进排序与行程结构建议,永不进硬过滤;
|
|
7
|
+
* - **负面清单守卫**:证件号/电话/地址等敏感字段拒收入库(需要时后端从登录态
|
|
8
|
+
* 填充,fail-closed)——health 存的是「高血压/晕车」这类行为约束,不是病历;
|
|
9
|
+
* - upsert 合并语义同 mergeProfile:追加不删史/同内容幂等。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface CompanionConstraints {
|
|
13
|
+
/** 行动/体力:如「走不了一段」「坐轮椅」 */
|
|
14
|
+
mobility?: string
|
|
15
|
+
/** 健康类行为约束:如「轻度高血压」「晕车」(非病历,禁止诊断性描述) */
|
|
16
|
+
health?: string[]
|
|
17
|
+
/** 偏好:如「怕吵」「爱吃辣」 */
|
|
18
|
+
prefs?: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompanionProfile {
|
|
22
|
+
schema: 'companion_profile.v1'
|
|
23
|
+
/** 稳定主键:label 语义派生(去空白) */
|
|
24
|
+
companion_id: string
|
|
25
|
+
label: string
|
|
26
|
+
constraints: CompanionConstraints
|
|
27
|
+
/** 用户原话,追加不删史 */
|
|
28
|
+
evidence: string[]
|
|
29
|
+
ts: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 负面清单:命中即拒收(红线 6 + 隐私立场) */
|
|
33
|
+
const SENSITIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
|
34
|
+
{ re: /\d{15,17}[Xx]?/, label: '疑似证件号/卡号' },
|
|
35
|
+
{ re: /1[3-9]\d{9}/, label: '疑似手机号' },
|
|
36
|
+
{ re: /(护照|身份证|证件号|通行证)\s*号?\s*[::]?\s*\w+/i, label: '证件信息' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** 守门:内容命中负面清单 → 返回拒收理由;否则 null */
|
|
40
|
+
export function sensitiveViolation(text: string): string | null {
|
|
41
|
+
for (const { re, label } of SENSITIVE_PATTERNS) {
|
|
42
|
+
if (re.test(text)) return `负面清单拒收:${label}不入库(需要时由后端从登录态填充)`
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 稳定主键:label 去空白 */
|
|
48
|
+
export function companionId(label: string): string {
|
|
49
|
+
return label.replace(/\s+/g, '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mergeStrArray(oldArr: string[] | undefined, newArr: string[] | undefined): { merged: string[]; added: boolean } {
|
|
53
|
+
const a = oldArr ?? []
|
|
54
|
+
let added = false
|
|
55
|
+
for (const x of newArr ?? []) {
|
|
56
|
+
if (!a.includes(x)) {
|
|
57
|
+
a.push(x)
|
|
58
|
+
added = true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { merged: a, added }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* upsert 合并(纯函数,不改入参):同 companion_id 按 label 归并——
|
|
66
|
+
* constraints 逐字段取新值(新值覆盖旧行为约束,追加数组项),evidence 追加不删史,
|
|
67
|
+
* 全量无新增 → 幂等 no-op(appended:false)。
|
|
68
|
+
*/
|
|
69
|
+
export function upsertCompanion(
|
|
70
|
+
profiles: CompanionProfile[],
|
|
71
|
+
patch: { label: string; constraints: CompanionConstraints; evidence: string },
|
|
72
|
+
): { profiles: CompanionProfile[]; appended: boolean; companionId: string; reason?: string } {
|
|
73
|
+
if (!patch.label?.trim()) return { profiles, appended: false, companionId: '', reason: 'label 必填' }
|
|
74
|
+
const violation = sensitiveViolation(JSON.stringify(patch))
|
|
75
|
+
if (violation) return { profiles, appended: false, companionId: '', reason: violation }
|
|
76
|
+
const id = companionId(patch.label)
|
|
77
|
+
const existing = profiles.find(p => p.companion_id === id)
|
|
78
|
+
if (!existing) {
|
|
79
|
+
const full: CompanionProfile = {
|
|
80
|
+
schema: 'companion_profile.v1',
|
|
81
|
+
companion_id: id,
|
|
82
|
+
label: patch.label.trim(),
|
|
83
|
+
constraints: {
|
|
84
|
+
mobility: patch.constraints.mobility,
|
|
85
|
+
health: patch.constraints.health ? [...patch.constraints.health] : undefined,
|
|
86
|
+
prefs: patch.constraints.prefs ? [...patch.constraints.prefs] : undefined,
|
|
87
|
+
},
|
|
88
|
+
evidence: [patch.evidence],
|
|
89
|
+
ts: new Date().toISOString(),
|
|
90
|
+
}
|
|
91
|
+
return { profiles: [...profiles, full], appended: true, companionId: id }
|
|
92
|
+
}
|
|
93
|
+
const mergedConstraints: CompanionConstraints = {
|
|
94
|
+
mobility: patch.constraints.mobility ?? existing.constraints.mobility,
|
|
95
|
+
health: mergeStrArray(existing.constraints.health, patch.constraints.health).merged,
|
|
96
|
+
prefs: mergeStrArray(existing.constraints.prefs, patch.constraints.prefs).merged,
|
|
97
|
+
}
|
|
98
|
+
const ev = mergeStrArray(existing.evidence, [patch.evidence])
|
|
99
|
+
const healthLen = mergedConstraints.health?.length ?? 0
|
|
100
|
+
const prefsLen = mergedConstraints.prefs?.length ?? 0
|
|
101
|
+
const changed =
|
|
102
|
+
mergedConstraints.mobility !== existing.constraints.mobility ||
|
|
103
|
+
healthLen !== (existing.constraints.health?.length ?? 0) ||
|
|
104
|
+
prefsLen !== (existing.constraints.prefs?.length ?? 0) ||
|
|
105
|
+
ev.added
|
|
106
|
+
if (!changed) return { profiles, appended: false, companionId: id }
|
|
107
|
+
const next = profiles.map(p =>
|
|
108
|
+
p.companion_id === id
|
|
109
|
+
? { ...p, constraints: mergedConstraints, evidence: ev.merged, ts: new Date().toISOString() }
|
|
110
|
+
: p,
|
|
111
|
+
)
|
|
112
|
+
return { profiles: next, appended: true, companionId: id }
|
|
113
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1 契约层(S1,docs/stage1-top-down-design.md §2)——顶层数据与工具面契约。
|
|
3
|
+
*
|
|
4
|
+
* 自顶向下纪律:本文件是唯一权威契约。实现(mock 循环/求解器挂载/真 LLM)都向这里对齐;
|
|
5
|
+
* 契约变更需走设计文档升版,不随实现漂移。
|
|
6
|
+
* 责任铁律:LLM 只做问句组织/翻译/解释;判定与算术永远是确定性组件。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JourneySpecTS } from './unified.ts'
|
|
10
|
+
|
|
11
|
+
// ---- TripState:会话状态契约(一切组件围绕它读写) --------------------------------
|
|
12
|
+
|
|
13
|
+
export interface CalendarState {
|
|
14
|
+
year: number
|
|
15
|
+
/** 一次断言终身使用:{"2026-07-17": "fri"}——Kimi 三轮日历混乱的解药 */
|
|
16
|
+
assertedWeekdays: Record<string, 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkWindowProfile {
|
|
20
|
+
homeTzOffsetMin: number
|
|
21
|
+
startMin: number
|
|
22
|
+
endMin: number
|
|
23
|
+
workdays: number[]
|
|
24
|
+
/** 证据:用户原话(P0 反幻觉,与动机画像同规) */
|
|
25
|
+
evidence: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TravelerProfile {
|
|
29
|
+
workWindow?: WorkWindowProfile
|
|
30
|
+
companions?: string[]
|
|
31
|
+
budgetTier?: 'economy' | 'comfort' | 'convenience'
|
|
32
|
+
/** 已订资源(航班/酒店)——Kimi 复盘:第 6 轮才被问出的关键事实 */
|
|
33
|
+
bookedResources?: Array<{ kind: 'flight' | 'hotel'; ref: string; window?: string }>
|
|
34
|
+
motivation?: { weights: Record<string, number>; evidence: string[] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GateOption {
|
|
38
|
+
label: string
|
|
39
|
+
tradeOff?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Gate {
|
|
43
|
+
id: string
|
|
44
|
+
question: string
|
|
45
|
+
options: GateOption[]
|
|
46
|
+
/** 回答后回填 */
|
|
47
|
+
answer?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WishEntry {
|
|
51
|
+
name: string
|
|
52
|
+
reason: string
|
|
53
|
+
conditions: Record<string, unknown>
|
|
54
|
+
addedAt?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TripState {
|
|
58
|
+
calendar: CalendarState
|
|
59
|
+
profile: TravelerProfile
|
|
60
|
+
spec?: JourneySpecTS
|
|
61
|
+
solve?: SolveResult
|
|
62
|
+
gates: Gate[]
|
|
63
|
+
wishes: WishEntry[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- 求解结果(unified 求解器输出的契约化引用) ----------------------------------
|
|
67
|
+
|
|
68
|
+
export interface SolveResult {
|
|
69
|
+
feasible: boolean
|
|
70
|
+
money_cny?: number
|
|
71
|
+
legs?: Array<Record<string, unknown> & { leg: string }>
|
|
72
|
+
red_flags?: string[]
|
|
73
|
+
unsat_core?: string[]
|
|
74
|
+
suggestions?: Array<{ relax: string; money_cny?: number }>
|
|
75
|
+
work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
|
|
76
|
+
skeleton_notes?: string[]
|
|
77
|
+
verdicts?: Array<Record<string, unknown>>
|
|
78
|
+
recommended?: string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- 工具面契约(五个;dsh 插件按此注册) -----------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface Turn {
|
|
84
|
+
role: 'user' | 'assistant'
|
|
85
|
+
text: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** ADR-9:访谈由缺失字段驱动(确定性),LLM 只润色问句 */
|
|
89
|
+
export interface InterviewQuestion {
|
|
90
|
+
key: string // 对应 profile 的缺失字段,如 "workWindow" / "bookedResources"
|
|
91
|
+
text: string
|
|
92
|
+
why: string // 为什么问(Kimi 复盘:不解释的追问=审讯)
|
|
93
|
+
options?: GateOption[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface GotryInterviewNextIO {
|
|
97
|
+
input: { state: TripState; brief?: string }
|
|
98
|
+
output: { questions: InterviewQuestion[]; missing: string[] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SpecAssumption {
|
|
102
|
+
field: string
|
|
103
|
+
value: unknown
|
|
104
|
+
source: 'user-verbatim' | 'inferred' | 'default' // inferred 必须在渲染时声明
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface GotrySpecExtractIO {
|
|
108
|
+
input: { history: Turn[]; state: TripState }
|
|
109
|
+
output: { spec: JourneySpecTS; assumptions: SpecAssumption[]; calendarConflicts?: string[] }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface GotrySolveIO {
|
|
113
|
+
input: { spec: JourneySpecTS }
|
|
114
|
+
output: { result: SolveResult }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface GotryRenderIO {
|
|
118
|
+
input: { state: TripState }
|
|
119
|
+
output: { replyMd: string; cardsMd: string[]; gates: Gate[] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface GotryWishPoolIO {
|
|
123
|
+
input: { entry: WishEntry }
|
|
124
|
+
output: { added: boolean; total: number }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 工具注册表:名字 → IO 类型(dsh 插件按此生成 parameters/output schema) */
|
|
128
|
+
export const TOOL_CONTRACTS = {
|
|
129
|
+
'gotry_interview_next': {} as GotryInterviewNextIO,
|
|
130
|
+
'gotry_spec_extract': {} as GotrySpecExtractIO,
|
|
131
|
+
'gotry_solve': {} as GotrySolveIO,
|
|
132
|
+
'gotry_render': {} as GotryRenderIO,
|
|
133
|
+
'gotry_wish_pool_add': {} as GotryWishPoolIO,
|
|
134
|
+
} as const
|
|
135
|
+
|
|
136
|
+
export type ToolName = keyof typeof TOOL_CONTRACTS
|
|
137
|
+
|
|
138
|
+
// ---- wire schema(dsh 注册用的 JSON Schema 面;type:'json' 根 + 关键字段文档) ----
|
|
139
|
+
|
|
140
|
+
export const TRIP_STATE_JSON_SCHEMA = {
|
|
141
|
+
type: 'object',
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
properties: {
|
|
144
|
+
calendar: {
|
|
145
|
+
type: 'object', additionalProperties: true,
|
|
146
|
+
description: '年份与已断言的星期映射;一次断言终身使用,冲突必须显式指出',
|
|
147
|
+
},
|
|
148
|
+
profile: {
|
|
149
|
+
type: 'object', additionalProperties: true,
|
|
150
|
+
description: 'workWindow(带 evidence)/companions/budgetTier/bookedResources/motivation',
|
|
151
|
+
},
|
|
152
|
+
spec: { type: 'json', description: '统一行程模型 JourneySpec(见 unified.ts)' },
|
|
153
|
+
solve: { type: 'json', description: '求解结果(锚点/排除/红旗/建议)' },
|
|
154
|
+
gates: {
|
|
155
|
+
type: 'array',
|
|
156
|
+
description: '待决问题,只能是选择题',
|
|
157
|
+
items: {
|
|
158
|
+
type: 'object', additionalProperties: true,
|
|
159
|
+
properties: { id: { type: 'string' }, question: { type: 'string' } },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
wishes: { type: 'array', description: '「下一次出发」清单', items: { type: 'object', additionalProperties: true } },
|
|
163
|
+
},
|
|
164
|
+
required: ['calendar', 'profile', 'gates', 'wishes'],
|
|
165
|
+
} as const
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
import { buildTimeAnchor } from './time-anchor.ts'
|
|
14
|
+
import { buildSlotSystem, flagExpiredSlots, normalizeExtraction, type TravelSlotExtraction } from './travel-slots.ts'
|
|
15
|
+
|
|
16
|
+
// 惰性读取(env 在调用时取值):模块顶常量会在 .env 加载前冻结(ESM import 提升),
|
|
17
|
+
// 脚本先 loadEnv 再 import 也救不了——401 错配(key 发往默认端点)的存量隐患由此根除。
|
|
18
|
+
const model = () => process.env['LLM_MODEL'] ?? process.env['DEEPSEEK_MODEL'] ?? 'MiniMax-M2'
|
|
19
|
+
const base = () => (process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_URL'] ?? 'https://api.minimax.io/v1').replace(/\/$/, '')
|
|
20
|
+
|
|
21
|
+
async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean): Promise<string> {
|
|
22
|
+
const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY']
|
|
23
|
+
if (!key) throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)')
|
|
24
|
+
const res = await fetch(`${base()}/chat/completions`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
model: model(),
|
|
29
|
+
messages,
|
|
30
|
+
...(json ? { response_format: { type: 'json_object' } } : {}),
|
|
31
|
+
temperature: json ? 0 : 0.7,
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
34
|
+
if (!res.ok) throw new Error(`llm ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
35
|
+
const data = await res.json() as { choices: Array<{ message: { content: string } }> }
|
|
36
|
+
const raw = data.choices[0]?.message?.content ?? ''
|
|
37
|
+
// 推理模型(MiniMax-M2 等):剥 <think> 块,只留正文;未闭合时留全文由上层容错
|
|
38
|
+
return raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || raw
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 从模型输出稳健地抠出 JSON 对象(容忍围栏/前后文) */
|
|
42
|
+
function parseJsonBlock(text: string): Record<string, unknown> | null {
|
|
43
|
+
const m = text.match(/\{[\s\S]*\}/)
|
|
44
|
+
if (!m) return null
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(m[0]) as Record<string, unknown>
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FACTS_SYSTEM = `你是旅行规划的事实抽取器。从对话中抽取两类事实并以 JSON 返回:
|
|
53
|
+
{"calendar": {"year": 数字, "assertedWeekdays": {"YYYY-MM-DD": "mon|tue|wed|thu|fri|sat|sun"}},
|
|
54
|
+
"profile": {"workWindow": {"homeTzOffsetMin": 数字, "startMin": 数字, "endMin": 数字, "workdays": [0,1,2,3,4], "evidence": "用户原话"},
|
|
55
|
+
"companions": ["..."], "budgetTier": "economy|comfort|convenience",
|
|
56
|
+
"bookedResources": [{"kind": "flight|hotel", "ref": "...", "window": "..."}]}}
|
|
57
|
+
只放用户明确说过的事实;没有的字段省略。分钟数从 HH:MM 换算;UTC+4 → homeTzOffsetMin=240。
|
|
58
|
+
**休假语义(关键)**:用户说「请假/年假/不用办公/休假」→ workWindow 输出 {"vacation": true}(不是省略!省略会触发重复追问);只有用户明确给了工作时间才输出完整 workWindow 对象。只输出 JSON。`
|
|
59
|
+
|
|
60
|
+
const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程的**骨架**——段(移动)与锚点,不包含任何班次数据(班次来自数据层,你不要编造时刻/价格/航班号)。
|
|
61
|
+
输出 JSON:{"scenario":"erhai|workation|yunnan|generic","segments":[{"id":"f1","role":"choice|fixed","route":"HKG->HKT","dateHint":"2026-07-18","anchors":{"arriveByMin":885}}]}
|
|
62
|
+
规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
|
|
63
|
+
scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`
|
|
64
|
+
|
|
65
|
+
export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date()): LlmPort {
|
|
66
|
+
const pack = flightPackPath
|
|
67
|
+
const historyText = (h: Turn[]) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n')
|
|
68
|
+
// 时间锚点卡:每条抽取链路都带上「今天」——legacy 路径此前无时间注入,
|
|
69
|
+
// 过期/相对日期语义全靠它(算术在 time-anchor 层,LLM 只查卡)。
|
|
70
|
+
const anchorContext = () => `时间锚点卡:\n${buildTimeAnchor(clock()).card}`
|
|
71
|
+
return {
|
|
72
|
+
async extractFacts(history) {
|
|
73
|
+
const out = await chat(
|
|
74
|
+
[{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }],
|
|
75
|
+
true,
|
|
76
|
+
)
|
|
77
|
+
const obj = parseJsonBlock(out)
|
|
78
|
+
if (!obj) return { assumptions: [] }
|
|
79
|
+
const calendar = obj['calendar'] as Partial<CalendarState> | undefined
|
|
80
|
+
const profile = obj['profile'] as Partial<TravelerProfile> | undefined
|
|
81
|
+
const assumptions = Object.keys(profile ?? {}).map(f => ({ field: f, source: 'user-verbatim' as const }))
|
|
82
|
+
return { calendar, profile, assumptions }
|
|
83
|
+
},
|
|
84
|
+
async extractSpec(history, state) {
|
|
85
|
+
// 架构(ADR-10):LLM 只产骨架与锚点;班次数据永远来自能力层(数据包/未来实时API)
|
|
86
|
+
const context = `${anchorContext()}\n已断言日历:${JSON.stringify(state.calendar.assertedWeekdays)}\nprofile:${JSON.stringify(state.profile)}`
|
|
87
|
+
const out = await chat(
|
|
88
|
+
[{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }],
|
|
89
|
+
true,
|
|
90
|
+
)
|
|
91
|
+
const skeleton = parseJsonBlock(out)
|
|
92
|
+
if (!skeleton || !Array.isArray(skeleton['segments']) || (skeleton['segments'] as unknown[]).length === 0) return null
|
|
93
|
+
// 能力层装数据:航班包提供 services;骨架按段 id 合并锚点
|
|
94
|
+
if (!pack) return null
|
|
95
|
+
const { readFile } = await import('node:fs/promises')
|
|
96
|
+
const scenario = String(skeleton['scenario'] ?? 'generic')
|
|
97
|
+
// 场景→数据包路由(薄壳段3:意图决定装哪个包,而非永远装通用包)
|
|
98
|
+
// generic 不装包——意图不明确时不进求解,让循环继续访谈(ADR-10:翻译不造数)
|
|
99
|
+
if (scenario === 'generic') return null
|
|
100
|
+
const packByScenario: Record<string, string> = {
|
|
101
|
+
erhai: pack.replace('flights_2026.json', 'golden_erhai.json'),
|
|
102
|
+
workation: pack, // 五段链
|
|
103
|
+
yunnan: pack.replace('flights_2026.json', 'yunnan-pack.json'),
|
|
104
|
+
}
|
|
105
|
+
const packPath = packByScenario[scenario]
|
|
106
|
+
if (!packPath) return null
|
|
107
|
+
let packSpec: JourneySpecTS
|
|
108
|
+
try {
|
|
109
|
+
if (scenario === 'erhai') {
|
|
110
|
+
// 洱海 = 候选集场景:不装五段链,返回洱海候选 spec 的轻量标记(由引擎的候选求解处理;
|
|
111
|
+
// 循环层看到 scenario=erhai 时走 solveChoiceSegment 而非 solveUnified)
|
|
112
|
+
return { segments: [], note: 'erhai-candidates', budgetCny: 3000 } as unknown as JourneySpecTS
|
|
113
|
+
}
|
|
114
|
+
packSpec = parseFlightPackToSpec(JSON.parse(await readFile(packPath, 'utf-8')))
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
const anchorsById = new Map<string, Record<string, unknown>>(
|
|
119
|
+
(skeleton['segments'] as Array<Record<string, unknown>>).map(s => [String(s['id'] ?? ''), (s['anchors'] ?? {}) as Record<string, unknown>]))
|
|
120
|
+
for (const seg of packSpec.segments) {
|
|
121
|
+
const a = anchorsById.get(seg.id) as { arriveByMin?: number } | undefined
|
|
122
|
+
if (a?.arriveByMin !== undefined) seg.anchors = { arriveByMin: a.arriveByMin }
|
|
123
|
+
}
|
|
124
|
+
packSpec.workWindow = state.profile.workWindow ? {
|
|
125
|
+
homeTzOffsetMin: state.profile.workWindow.homeTzOffsetMin,
|
|
126
|
+
startMin: state.profile.workWindow.startMin,
|
|
127
|
+
endMin: state.profile.workWindow.endMin,
|
|
128
|
+
workdays: state.profile.workWindow.workdays,
|
|
129
|
+
} : undefined
|
|
130
|
+
packSpec.budgetCny = 9000
|
|
131
|
+
return packSpec
|
|
132
|
+
},
|
|
133
|
+
async extractSlots(history, now?: Date): Promise<TravelSlotExtraction | null> {
|
|
134
|
+
// now 可注入(评测固定锚点);过期判定在 flagExpiredSlots(代码层),模型只管逐字抽取
|
|
135
|
+
const anchor = buildTimeAnchor(now ?? clock())
|
|
136
|
+
const out = await chat(
|
|
137
|
+
[{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }],
|
|
138
|
+
true,
|
|
139
|
+
)
|
|
140
|
+
const obj = parseJsonBlock(out)
|
|
141
|
+
if (!obj) return null
|
|
142
|
+
// language 判定归代码层(detectLanguage),模型输出仅供参考;过期判定同在代码层
|
|
143
|
+
const ext = normalizeExtraction(obj, history.map(t => t.text).join('\n'))
|
|
144
|
+
return ext ? flagExpiredSlots(ext, anchor) : null
|
|
145
|
+
},
|
|
146
|
+
async polishQuestion(q: InterviewQuestion) {
|
|
147
|
+
const out = await chat(
|
|
148
|
+
[{ role: 'system', content: '把旅行规划的追问润色得更自然,保留全部信息(含为什么问),一两句话,不要加表情。' },
|
|
149
|
+
{ role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }],
|
|
150
|
+
false,
|
|
151
|
+
)
|
|
152
|
+
return out.trim() || `【${q.key}】${q.text}`
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|