@aiwayds/dsh-topics-memory 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands.js +23 -2
- package/lib/config.d.ts +19 -5
- package/lib/config.js +21 -3
- package/lib/digest.d.ts +38 -1
- package/lib/digest.js +92 -11
- package/lib/distill.d.ts +1 -0
- package/lib/distill.js +3 -1
- package/lib/ilog.d.ts +36 -0
- package/lib/index.js +95 -23
- package/lib/observer.d.ts +17 -0
- package/lib/observer.js +31 -4
- package/lib/okf.d.ts +7 -0
- package/lib/okf.js +12 -1
- package/lib/quality.d.ts +90 -0
- package/lib/quality.js +315 -0
- package/lib/retrieval.d.ts +35 -3
- package/lib/retrieval.js +73 -19
- package/lib/service.d.ts +48 -7
- package/lib/service.js +198 -25
- package/lib/store.d.ts +13 -0
- package/lib/store.js +54 -0
- package/lib/tools.js +49 -1
- package/package.json +2 -2
- package/src/commands.ts +26 -2
- package/src/config.ts +21 -3
- package/src/digest.ts +118 -11
- package/src/distill.ts +4 -1
- package/src/ilog.ts +39 -2
- package/src/index.ts +87 -19
- package/src/observer.ts +42 -4
- package/src/okf.ts +17 -1
- package/src/quality.ts +335 -0
- package/src/retrieval.ts +93 -19
- package/src/service.ts +214 -25
- package/src/store.ts +53 -0
- package/src/tools.ts +51 -1
package/src/observer.ts
CHANGED
|
@@ -24,8 +24,25 @@ interface SessionState {
|
|
|
24
24
|
userText: string
|
|
25
25
|
assistantText: string
|
|
26
26
|
capturing: boolean
|
|
27
|
+
/** Last-K completed turns (v4 slow-lane data source, K≈3). */
|
|
28
|
+
ring: RingEntry[]
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/** One completed turn in the ring buffer — the slow lane's only context source. */
|
|
32
|
+
export interface RingEntry {
|
|
33
|
+
user: string
|
|
34
|
+
assistant: string
|
|
35
|
+
at: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ring capacity (v4 §4.2 B1): the slow lane sees at most the last 3 completed
|
|
40
|
+
* turns. Deliberately small — query building wants the current intent, not a
|
|
41
|
+
* transcript; larger windows re-introduce the verbatim-priming surface the
|
|
42
|
+
* lane exists to remove.
|
|
43
|
+
*/
|
|
44
|
+
export const RING_CAPACITY = 3
|
|
45
|
+
|
|
29
46
|
export interface UserMessageLike {
|
|
30
47
|
source?: { kind?: string }
|
|
31
48
|
content: readonly { type: string; text?: string }[]
|
|
@@ -58,12 +75,22 @@ export class Observer {
|
|
|
58
75
|
private stateFor(sessionId: string): SessionState {
|
|
59
76
|
let s = this.sessions.get(sessionId)
|
|
60
77
|
if (s === undefined) {
|
|
61
|
-
s = { turnCount: 0, userText: '', assistantText: '', capturing: false }
|
|
78
|
+
s = { turnCount: 0, userText: '', assistantText: '', capturing: false, ring: [] }
|
|
62
79
|
this.sessions.set(sessionId, s)
|
|
63
80
|
}
|
|
64
81
|
return s
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
/** Completed-turn snapshot for the slow lane (deep copy — callers may hold it). */
|
|
85
|
+
recentTurns(sessionId: string): RingEntry[] {
|
|
86
|
+
return (this.sessions.get(sessionId)?.ring ?? []).map((e) => ({ ...e }))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Current turn count for the session (0 before the first turn ends). */
|
|
90
|
+
turnCountOf(sessionId: string): number {
|
|
91
|
+
return this.sessions.get(sessionId)?.turnCount ?? 0
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
/** Entry point wired to ctx.on('session/event', …). */
|
|
68
95
|
onSessionEvent(sessionId: string, eventType: string, data: unknown): void {
|
|
69
96
|
try {
|
|
@@ -91,8 +118,22 @@ export class Observer {
|
|
|
91
118
|
}
|
|
92
119
|
case 'turn/end': {
|
|
93
120
|
state.turnCount += 1
|
|
121
|
+
// Cadence trigger FIRST and synchronously: index.ts dispatches the
|
|
122
|
+
// slow quality lane the moment onSessionEvent returns — which happens
|
|
123
|
+
// at the first await below (the observation write). The lane's
|
|
124
|
+
// distill-yield guard reads distiller.hasPending, so the every-N slot
|
|
125
|
+
// must be claimed before that first await or the %15 collision would
|
|
126
|
+
// never yield. The trigger's run fetches observations lazily; this
|
|
127
|
+
// turn's capture below still lands in the pool for a later run.
|
|
128
|
+
if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
|
|
129
|
+
this.onRequestDistill(sessionId, 'every-n')
|
|
130
|
+
}
|
|
94
131
|
const user = state.userText.trim()
|
|
95
132
|
const assistant = state.assistantText.trim()
|
|
133
|
+
if (user !== '' || assistant !== '') {
|
|
134
|
+
state.ring.push({ user, assistant, at: new Date().toISOString() })
|
|
135
|
+
if (state.ring.length > RING_CAPACITY) state.ring.splice(0, state.ring.length - RING_CAPACITY)
|
|
136
|
+
}
|
|
96
137
|
if (cfg.autoObserve && user !== '' && assistant !== '') {
|
|
97
138
|
await this.service.store.appendObservation({
|
|
98
139
|
kind: 'turn',
|
|
@@ -106,9 +147,6 @@ export class Observer {
|
|
|
106
147
|
}
|
|
107
148
|
state.userText = ''
|
|
108
149
|
state.assistantText = ''
|
|
109
|
-
if (cfg.distillEveryTurns > 0 && state.turnCount % cfg.distillEveryTurns === 0) {
|
|
110
|
-
this.onRequestDistill(sessionId, 'every-n')
|
|
111
|
-
}
|
|
112
150
|
return
|
|
113
151
|
}
|
|
114
152
|
// Real session teardown: both cordis events fire at teardown (agent
|
package/src/okf.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface TopicFrontmatter {
|
|
|
36
36
|
title: string
|
|
37
37
|
description?: string
|
|
38
38
|
tags: string[]
|
|
39
|
+
/**
|
|
40
|
+
* Self-declared recall triggers (v4 dual-channel design §4.1): short
|
|
41
|
+
* phrases that should recall this topic when they appear in a query.
|
|
42
|
+
* Optional; bundles written before the field simply have no trigger
|
|
43
|
+
* matching (degraded, never an error). Highest-weight strong field.
|
|
44
|
+
*/
|
|
45
|
+
triggers?: string[]
|
|
39
46
|
/** Bundle-relative paths: `topics/<slug>.md`. */
|
|
40
47
|
depends: string[]
|
|
41
48
|
open_questions: string[]
|
|
@@ -123,6 +130,14 @@ export function parseTopicDoc(raw: string): TopicDoc {
|
|
|
123
130
|
generated,
|
|
124
131
|
}
|
|
125
132
|
if (typeof fmRaw.description === 'string' && fmRaw.description !== '') fm.description = fmRaw.description
|
|
133
|
+
// Triggers are producer-optional and must never invalidate an otherwise
|
|
134
|
+
// sound doc: non-string entries are filtered, not thrown (degrade, per the
|
|
135
|
+
// v4 design's "旧 bundle 无此字段降级不命中").
|
|
136
|
+
if (fmRaw.triggers !== undefined && fmRaw.triggers !== null) {
|
|
137
|
+
const raw = Array.isArray(fmRaw.triggers) ? fmRaw.triggers : [fmRaw.triggers]
|
|
138
|
+
const triggers = raw.filter((t): t is string => typeof t === 'string' && t.trim() !== '').map((t) => t.trim())
|
|
139
|
+
if (triggers.length > 0) fm.triggers = triggers
|
|
140
|
+
}
|
|
126
141
|
if ('verified' in fmRaw && fmRaw.verified !== null && fmRaw.verified !== undefined) fm.verified = asTrust(fmRaw.verified, 'verified')
|
|
127
142
|
if (typeof fmRaw.stale_after === 'string' && fmRaw.stale_after !== '') fm.stale_after = fmRaw.stale_after
|
|
128
143
|
if (Array.isArray(fmRaw.sources)) {
|
|
@@ -134,7 +149,7 @@ export function parseTopicDoc(raw: string): TopicDoc {
|
|
|
134
149
|
})
|
|
135
150
|
}
|
|
136
151
|
// Preserve unknown producer-defined keys verbatim (OKF §4.1 extensions).
|
|
137
|
-
const known = new Set(['type', 'title', 'description', 'tags', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
|
|
152
|
+
const known = new Set(['type', 'title', 'description', 'tags', 'triggers', 'depends', 'open_questions', 'impact', 'status', 'generated', 'verified', 'stale_after', 'sources'])
|
|
138
153
|
for (const [k, v] of Object.entries(fmRaw)) {
|
|
139
154
|
if (!known.has(k)) fm[k] = v
|
|
140
155
|
}
|
|
@@ -150,6 +165,7 @@ export function serializeTopicDoc(doc: TopicDoc): string {
|
|
|
150
165
|
}
|
|
151
166
|
if (fm.description !== undefined) out.description = fm.description
|
|
152
167
|
out.tags = fm.tags
|
|
168
|
+
if (fm.triggers !== undefined && fm.triggers.length > 0) out.triggers = fm.triggers
|
|
153
169
|
out.depends = fm.depends
|
|
154
170
|
out.open_questions = fm.open_questions
|
|
155
171
|
out.impact = fm.impact
|
package/src/quality.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slow quality lane (v4 dual-channel design §4.2) — asynchronous, turn-end
|
|
3
|
+
* triggered, LLM-gated injection. Two serial aux calls per run:
|
|
4
|
+
*
|
|
5
|
+
* 1. intent-query build over the observer's last-K ring buffer — kills
|
|
6
|
+
* verbatim priming at the root (the query is what the model needs, not
|
|
7
|
+
* what the user typed);
|
|
8
|
+
* 2. lexical candidate band (hits + near-misses, recall-oriented) then an
|
|
9
|
+
* LLM rerank that releases 0-2 picks, each with a one-line why.
|
|
10
|
+
*
|
|
11
|
+
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
12
|
+
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
13
|
+
* 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
|
|
14
|
+
* contained and silent — I3 (可丢弃性): async products may be missing, never
|
|
15
|
+
* blocking. The lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
|
|
16
|
+
* records verdicts into ilog and never blocks a pick.
|
|
17
|
+
*
|
|
18
|
+
* @module quality
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { TopicsService } from './service.ts'
|
|
22
|
+
import type { ModelCaller } from './distill.ts'
|
|
23
|
+
import { searchTopics } from './retrieval.ts'
|
|
24
|
+
import type { RingEntry } from './observer.ts'
|
|
25
|
+
import type { QueryBuildShape } from './ilog.ts'
|
|
26
|
+
|
|
27
|
+
export const PENDING_TTL_MS = 10 * 60_000
|
|
28
|
+
/** Hard drift bound: a pending older than this many turn-ends expires. */
|
|
29
|
+
export const TURN_LAG_LIMIT = 2
|
|
30
|
+
export const CALL_TIMEOUT_MS = 20_000
|
|
31
|
+
export const PIPELINE_TIMEOUT_MS = 45_000
|
|
32
|
+
/** qualityLane 'sampled' runs on every Nth turn (design default 1/3). */
|
|
33
|
+
export const SAMPLED_EVERY = 3
|
|
34
|
+
|
|
35
|
+
/** Max ring-buffer chars fed to the query build (features budget, not a transcript). */
|
|
36
|
+
const RING_CHARS = 6000
|
|
37
|
+
/** Candidate band size fed to the rerank. */
|
|
38
|
+
const CANDIDATE_LIMIT = 6
|
|
39
|
+
|
|
40
|
+
export const QUERY_BUILD_PROMPT = [
|
|
41
|
+
'你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
|
|
42
|
+
'只输出一个 JSON 对象,不要任何其他文字:{"needs": true, "query": "3-8 个检索词加一句意图", "ignore": ["被你忽略的内容类型"]}',
|
|
43
|
+
'规则:',
|
|
44
|
+
'- query 给词法检索用:写具体的名词、术语、项目名,不要照抄对话原句。',
|
|
45
|
+
'- 对话里粘贴的日志、代码块、URL、命令输出等大段内容必须忽略,并把类型写进 ignore 数组(如 "粘贴的命令输出")。',
|
|
46
|
+
'- 没有需要补充背景知识的迹象时输出 {"needs": false}。',
|
|
47
|
+
].join('\n')
|
|
48
|
+
|
|
49
|
+
export const RERANK_PROMPT = [
|
|
50
|
+
'你是注入门禁。输入一个检索 query 和候选 topic 列表。逐个判断候选是否「真的与当前工作相关」。',
|
|
51
|
+
'只输出一个 JSON 对象,不要任何其他文字:{"picks": [{"slug": "候选里的 slug", "why": "一句话说明为什么现在需要它"}]}',
|
|
52
|
+
'规则:',
|
|
53
|
+
'- 最多 2 条,宁缺勿滥;没有真相关的就输出 {"picks": []}。',
|
|
54
|
+
'- why 面向模型自己读:说清这条记忆能帮上当前哪一步。',
|
|
55
|
+
'- slug 必须逐字取自候选列表,禁止编造。',
|
|
56
|
+
].join('\n')
|
|
57
|
+
|
|
58
|
+
export interface PendingInjection {
|
|
59
|
+
items: { slug: string; why: string }[]
|
|
60
|
+
computedAt: string
|
|
61
|
+
/** Observer turnCount when the pending was produced (turn-lag clock). */
|
|
62
|
+
turnId: number
|
|
63
|
+
queryBuild: QueryBuildShape
|
|
64
|
+
model: string
|
|
65
|
+
ms: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | 'turn-lag' } | undefined
|
|
69
|
+
|
|
70
|
+
export interface DispatchInput {
|
|
71
|
+
ring: readonly RingEntry[]
|
|
72
|
+
turnId: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Race `p` against a timeout; `onTimeout` fires so callers can abort upstream work. */
|
|
76
|
+
function withTimeout<T>(p: Promise<T>, ms: number, onTimeout: () => void): Promise<T> {
|
|
77
|
+
return new Promise<T>((resolve, reject) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
onTimeout()
|
|
80
|
+
reject(new Error(`slow-lane call timeout (${ms}ms)`))
|
|
81
|
+
}, ms)
|
|
82
|
+
timer.unref?.()
|
|
83
|
+
p.then(
|
|
84
|
+
(v) => {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
resolve(v)
|
|
87
|
+
},
|
|
88
|
+
(e) => {
|
|
89
|
+
clearTimeout(timer)
|
|
90
|
+
reject(e)
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Extract the first balanced JSON object from model output (tolerates fences). */
|
|
97
|
+
export function parseJsonObject(raw: string): Record<string, unknown> {
|
|
98
|
+
let text = raw.trim()
|
|
99
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
|
|
100
|
+
if (fence !== null) text = fence[1].trim()
|
|
101
|
+
const start = text.indexOf('{')
|
|
102
|
+
if (start < 0) throw new Error('no JSON object in output')
|
|
103
|
+
let depth = 0
|
|
104
|
+
let inString = false
|
|
105
|
+
let escaped = false
|
|
106
|
+
for (let i = start; i < text.length; i += 1) {
|
|
107
|
+
const ch = text[i]
|
|
108
|
+
if (escaped) {
|
|
109
|
+
escaped = false
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (ch === '\\') {
|
|
113
|
+
escaped = true
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (ch === '"') inString = !inString
|
|
117
|
+
if (inString) continue
|
|
118
|
+
if (ch === '{') depth += 1
|
|
119
|
+
if (ch === '}') {
|
|
120
|
+
depth -= 1
|
|
121
|
+
if (depth === 0) {
|
|
122
|
+
const parsed: unknown = JSON.parse(text.slice(start, i + 1))
|
|
123
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
|
|
124
|
+
return parsed as Record<string, unknown>
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error('unbalanced JSON in output')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class SlowLane {
|
|
132
|
+
private pending = new Map<string, PendingInjection>()
|
|
133
|
+
private inFlight = new Map<string, Promise<void>>()
|
|
134
|
+
private readonly service: TopicsService
|
|
135
|
+
private readonly caller: ModelCaller | undefined
|
|
136
|
+
/**
|
|
137
|
+
* Settle hook: fires when a session's pipeline leaves the in-flight map.
|
|
138
|
+
* The host uses it to re-check the sessionLlm release guards — a disposal
|
|
139
|
+
* that landed mid-pipeline held the entry open for THIS pipeline, and this
|
|
140
|
+
* callback is the only chance to release it afterwards (the distill lane
|
|
141
|
+
* has the same shape via its run.finally).
|
|
142
|
+
*/
|
|
143
|
+
private readonly onSettle: (sessionId: string) => void
|
|
144
|
+
|
|
145
|
+
constructor(service: TopicsService, caller: ModelCaller | undefined, onSettle: (sessionId: string) => void = () => undefined) {
|
|
146
|
+
this.service = service
|
|
147
|
+
this.caller = caller
|
|
148
|
+
this.onSettle = onSettle
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** True while the session has a pending slot awaiting the next spliced. */
|
|
152
|
+
hasPending(sessionId: string): boolean {
|
|
153
|
+
return this.pending.has(sessionId)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** True while the session's produce pipeline is still running (llm-capture guard). */
|
|
157
|
+
hasInFlight(sessionId: string): boolean {
|
|
158
|
+
return this.inFlight.has(sessionId)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Drop the pending slot (session teardown, restore boundary). */
|
|
162
|
+
clear(sessionId: string): void {
|
|
163
|
+
this.pending.delete(sessionId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
168
|
+
* forget; every guard failure is silent. Policy gates live here (config,
|
|
169
|
+
* sampling, in-flight); host gates (delegation, distill yield) live with
|
|
170
|
+
* the caller, which owns those services.
|
|
171
|
+
*/
|
|
172
|
+
dispatch(sessionId: string, input: DispatchInput): void {
|
|
173
|
+
try {
|
|
174
|
+
const cfg = this.service.cfg
|
|
175
|
+
if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') return
|
|
176
|
+
if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) return
|
|
177
|
+
if (this.caller === undefined || this.inFlight.has(sessionId)) return
|
|
178
|
+
if (input.ring.length === 0) return
|
|
179
|
+
const run = this.produce(sessionId, input).finally(() => {
|
|
180
|
+
this.inFlight.delete(sessionId)
|
|
181
|
+
this.onSettle(sessionId)
|
|
182
|
+
})
|
|
183
|
+
this.inFlight.set(sessionId, run)
|
|
184
|
+
void run.catch(() => undefined)
|
|
185
|
+
} catch {
|
|
186
|
+
// contained — the lane must never break the event handler
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async produce(sessionId: string, input: DispatchInput): Promise<void> {
|
|
191
|
+
const caller = this.caller
|
|
192
|
+
if (caller === undefined) return
|
|
193
|
+
const started = Date.now()
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS)
|
|
196
|
+
deadline.unref?.()
|
|
197
|
+
try {
|
|
198
|
+
const cfg = this.service.cfg
|
|
199
|
+
const ringText = input.ring
|
|
200
|
+
.map((e, i) => {
|
|
201
|
+
const turn = `【轮 ${i + 1}】\n用户: ${e.user}\n助手: ${e.assistant}`
|
|
202
|
+
return turn.length > RING_CHARS / input.ring.length ? turn.slice(0, Math.floor(RING_CHARS / input.ring.length)) : turn
|
|
203
|
+
})
|
|
204
|
+
.join('\n\n')
|
|
205
|
+
const buildRaw = await withTimeout(
|
|
206
|
+
caller({
|
|
207
|
+
system: QUERY_BUILD_PROMPT,
|
|
208
|
+
user: ringText,
|
|
209
|
+
purpose: 'topics-quality',
|
|
210
|
+
sessionId,
|
|
211
|
+
maxTokens: 300,
|
|
212
|
+
signal: controller.signal,
|
|
213
|
+
}),
|
|
214
|
+
CALL_TIMEOUT_MS,
|
|
215
|
+
() => controller.abort(),
|
|
216
|
+
)
|
|
217
|
+
let built: Record<string, unknown>
|
|
218
|
+
try {
|
|
219
|
+
built = parseJsonObject(buildRaw)
|
|
220
|
+
} catch {
|
|
221
|
+
return // unparseable build → no pending, nothing logged, next turn retries
|
|
222
|
+
}
|
|
223
|
+
if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') return
|
|
224
|
+
const query = built.query.trim()
|
|
225
|
+
const stripped = Array.isArray(built.ignore)
|
|
226
|
+
? built.ignore.filter((s): s is string => typeof s === 'string' && s.trim() !== '').slice(0, 5)
|
|
227
|
+
: []
|
|
228
|
+
const queryBuild: QueryBuildShape = { rawChars: ringText.length, keptChars: query.length, stripped }
|
|
229
|
+
|
|
230
|
+
const roster = await this.service.roster().catch(() => [])
|
|
231
|
+
if (roster.length === 0) return
|
|
232
|
+
// Recall-oriented band: gate OFF (the rerank IS this lane's gate);
|
|
233
|
+
// candidates = threshold passers + the near-miss band beneath them.
|
|
234
|
+
const band = searchTopics(query, roster, {
|
|
235
|
+
threshold: this.service.cfg.matchThreshold,
|
|
236
|
+
topK: CANDIDATE_LIMIT,
|
|
237
|
+
tagBoost: this.service.cfg.tagBoost,
|
|
238
|
+
graphDepth: 0,
|
|
239
|
+
recencyWindowDays: this.service.cfg.recencyWindowDays,
|
|
240
|
+
structuralGate: false,
|
|
241
|
+
})
|
|
242
|
+
const candidates = [...band.hits, ...band.nearMisses].slice(0, CANDIDATE_LIMIT)
|
|
243
|
+
if (candidates.length === 0) return
|
|
244
|
+
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
245
|
+
const payload = candidates.flatMap((c) => {
|
|
246
|
+
const meta = bySlug.get(c.slug)
|
|
247
|
+
if (meta === undefined) return []
|
|
248
|
+
const conclusionFirst = meta.conclusion.split('\n').find((l) => l.trim() !== '') ?? ''
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
slug: c.slug,
|
|
252
|
+
title: meta.title,
|
|
253
|
+
status: meta.status,
|
|
254
|
+
description: meta.description ?? '',
|
|
255
|
+
conclusion: conclusionFirst.slice(0, 160),
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
})
|
|
259
|
+
if (payload.length === 0) return
|
|
260
|
+
const rerankRaw = await withTimeout(
|
|
261
|
+
caller({
|
|
262
|
+
system: RERANK_PROMPT,
|
|
263
|
+
user: [
|
|
264
|
+
`检索 query:${query}`,
|
|
265
|
+
'',
|
|
266
|
+
`候选 topic(${payload.length} 个):`,
|
|
267
|
+
JSON.stringify(payload),
|
|
268
|
+
].join('\n'),
|
|
269
|
+
purpose: 'topics-quality',
|
|
270
|
+
sessionId,
|
|
271
|
+
maxTokens: 400,
|
|
272
|
+
signal: controller.signal,
|
|
273
|
+
}),
|
|
274
|
+
CALL_TIMEOUT_MS,
|
|
275
|
+
() => controller.abort(),
|
|
276
|
+
)
|
|
277
|
+
let reranked: Record<string, unknown>
|
|
278
|
+
try {
|
|
279
|
+
reranked = parseJsonObject(rerankRaw)
|
|
280
|
+
} catch {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
const legal = new Set(payload.map((p) => p.slug))
|
|
284
|
+
const rawPicks = Array.isArray(reranked.picks) ? reranked.picks : []
|
|
285
|
+
const items: { slug: string; why: string }[] = []
|
|
286
|
+
const picked = new Set<string>()
|
|
287
|
+
for (const pick of rawPicks) {
|
|
288
|
+
if (pick === null || typeof pick !== 'object') continue
|
|
289
|
+
const slug = (pick as { slug?: unknown }).slug
|
|
290
|
+
const why = (pick as { why?: unknown }).why
|
|
291
|
+
if (typeof slug !== 'string' || !legal.has(slug)) continue
|
|
292
|
+
if (typeof why !== 'string' || why.trim() === '') continue
|
|
293
|
+
if (picked.has(slug)) continue
|
|
294
|
+
picked.add(slug)
|
|
295
|
+
items.push({ slug, why: why.trim().slice(0, 200) })
|
|
296
|
+
if (items.length >= 2) break
|
|
297
|
+
}
|
|
298
|
+
if (items.length === 0) return
|
|
299
|
+
const route = this.service.cfg
|
|
300
|
+
this.pending.set(sessionId, {
|
|
301
|
+
items,
|
|
302
|
+
computedAt: new Date().toISOString(),
|
|
303
|
+
turnId: input.turnId,
|
|
304
|
+
queryBuild,
|
|
305
|
+
model: `${route.distillProvider}/${route.distillModel}`,
|
|
306
|
+
ms: Date.now() - started,
|
|
307
|
+
})
|
|
308
|
+
} catch {
|
|
309
|
+
// timeouts, aborts, model errors — I3: the async product is simply absent
|
|
310
|
+
} finally {
|
|
311
|
+
clearTimeout(deadline)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Consumption point (the next spliced). Returns the pending exactly once
|
|
317
|
+
* (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn
|
|
318
|
+
* count so the drift bound can judge. Undefined when nothing is pending.
|
|
319
|
+
*/
|
|
320
|
+
consume(sessionId: string, turnId: number): ConsumeResult {
|
|
321
|
+
const pending = this.pending.get(sessionId)
|
|
322
|
+
if (pending === undefined) return undefined
|
|
323
|
+
const produced = Date.parse(pending.computedAt)
|
|
324
|
+
if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
|
|
325
|
+
this.pending.delete(sessionId)
|
|
326
|
+
return { expired: 'ttl' }
|
|
327
|
+
}
|
|
328
|
+
if (turnId - pending.turnId > TURN_LAG_LIMIT) {
|
|
329
|
+
this.pending.delete(sessionId)
|
|
330
|
+
return { expired: 'turn-lag' }
|
|
331
|
+
}
|
|
332
|
+
this.pending.delete(sessionId)
|
|
333
|
+
return { pending }
|
|
334
|
+
}
|
|
335
|
+
}
|
package/src/retrieval.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface RetrievableTopic extends TopicMeta {
|
|
|
20
20
|
conclusion: string
|
|
21
21
|
/** Body-referenced topic slugs ([[wikilinks]] + markdown links) — graph edges. */
|
|
22
22
|
links?: string[]
|
|
23
|
+
/** Self-declared recall triggers (v4 structural gate strong field). */
|
|
24
|
+
triggers?: string[]
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface RetrievalConfig {
|
|
@@ -31,6 +33,13 @@ export interface RetrievalConfig {
|
|
|
31
33
|
now?: Date
|
|
32
34
|
/** Slugs currently conflicted — demoted, not dropped. */
|
|
33
35
|
conflicts?: ReadonlySet<string>
|
|
36
|
+
/**
|
|
37
|
+
* v4 structural gate v0 (design §4.1): a threshold-passing candidate only
|
|
38
|
+
* injects when it ALSO hits a strong field (triggers/title/slug) or ≥2
|
|
39
|
+
* distinct body terms — tags alone and single body terms can no longer
|
|
40
|
+
* self-cross. Off for the explicit topic_search tool (the model asked).
|
|
41
|
+
*/
|
|
42
|
+
structuralGate: boolean
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export const DEFAULT_RETRIEVAL: RetrievalConfig = {
|
|
@@ -39,13 +48,22 @@ export const DEFAULT_RETRIEVAL: RetrievalConfig = {
|
|
|
39
48
|
tagBoost: 0.15,
|
|
40
49
|
graphDepth: 2,
|
|
41
50
|
recencyWindowDays: 7,
|
|
51
|
+
structuralGate: true,
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/** Trigger hits outrank every other lexical field (pi-llm-wiki: highest weight). */
|
|
55
|
+
export const TRIGGER_WEIGHT = 5
|
|
56
|
+
|
|
44
57
|
export interface SearchHit {
|
|
45
58
|
slug: string
|
|
46
59
|
score: number
|
|
47
60
|
reasons: string[]
|
|
48
61
|
viaGraph: boolean
|
|
62
|
+
/** Structural gate v0 evidence (absent on graph-expanded entries). */
|
|
63
|
+
strong?: boolean
|
|
64
|
+
bodyHits?: number
|
|
65
|
+
/** Recency-free score; graph decay starts from it (settle ties with the gate). */
|
|
66
|
+
gateScore?: number
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
export interface SearchOutcome {
|
|
@@ -93,6 +111,12 @@ export interface ScoredTopic {
|
|
|
93
111
|
slug: string
|
|
94
112
|
score: number
|
|
95
113
|
reasons: string[]
|
|
114
|
+
/** Strong-field hit (triggers/title/slug) — gate v0 pass condition A. */
|
|
115
|
+
strong: boolean
|
|
116
|
+
/** Distinct query terms found in description ∪ conclusion — pass condition B is ≥2. */
|
|
117
|
+
bodyHits: number
|
|
118
|
+
/** Score with the recency tiebreaker removed — the only score the gate looks at. */
|
|
119
|
+
gateScore: number
|
|
96
120
|
}
|
|
97
121
|
|
|
98
122
|
/** Score one topic against the query tokens. */
|
|
@@ -103,21 +127,35 @@ export function scoreTopic(
|
|
|
103
127
|
): ScoredTopic {
|
|
104
128
|
const title = new Set(tokenize(topic.title))
|
|
105
129
|
const slug = new Set(tokenize(topic.slug))
|
|
130
|
+
const triggers = new Set((topic.triggers ?? []).flatMap((t) => tokenize(t)))
|
|
106
131
|
const tags = new Set(topic.tags.flatMap((t) => tokenize(t)))
|
|
107
132
|
const description = new Set(tokenize(topic.description ?? ''))
|
|
108
133
|
const conclusion = new Set(tokenize(topic.conclusion))
|
|
109
134
|
const reasons: string[] = []
|
|
110
135
|
let score = 0
|
|
111
136
|
|
|
137
|
+
const cTriggers = containment(queryTokens, triggers)
|
|
138
|
+
if (cTriggers > 0) {
|
|
139
|
+
score += TRIGGER_WEIGHT * cTriggers
|
|
140
|
+
reasons.push(`triggers:${cTriggers.toFixed(2)}`)
|
|
141
|
+
}
|
|
112
142
|
const cTitle = containment(queryTokens, title)
|
|
113
143
|
if (cTitle > 0) {
|
|
114
144
|
score += 3 * cTitle
|
|
115
145
|
reasons.push(`title:${cTitle.toFixed(2)}`)
|
|
116
146
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
// Slug and tags are scored separately since the structural gate (v4): the
|
|
148
|
+
// slug is a strong field, tags are not. Weights unchanged (2) so historical
|
|
149
|
+
// score distributions stay comparable.
|
|
150
|
+
const cSlug = containment(queryTokens, slug)
|
|
151
|
+
if (cSlug > 0) {
|
|
152
|
+
score += 2 * cSlug
|
|
153
|
+
reasons.push(`slug:${cSlug.toFixed(2)}`)
|
|
154
|
+
}
|
|
155
|
+
const cTags = containment(queryTokens, tags)
|
|
156
|
+
if (cTags > 0) {
|
|
157
|
+
score += 2 * cTags
|
|
158
|
+
reasons.push(`tags:${cTags.toFixed(2)}`)
|
|
121
159
|
}
|
|
122
160
|
const cDesc = containment(queryTokens, description)
|
|
123
161
|
if (cDesc > 0) {
|
|
@@ -129,25 +167,28 @@ export function scoreTopic(
|
|
|
129
167
|
score += 0.8 * cConc
|
|
130
168
|
reasons.push(`conclusion:${cConc.toFixed(2)}`)
|
|
131
169
|
}
|
|
132
|
-
// Exact tag hit boost, additive and capped (
|
|
133
|
-
//
|
|
170
|
+
// Exact tag hit boost, additive and capped at ONE boost's worth (v4 design:
|
|
171
|
+
// the old ×3 cap let shared tags mint a 0.45 floor — the priming main
|
|
172
|
+
// culprit). Never folded into the token set (dilution breaks the guard).
|
|
134
173
|
let tagHits = 0
|
|
135
174
|
for (const t of tags) {
|
|
136
175
|
if (overlap(queryTokens, new Set([...tokenize(t)]))) tagHits += 1
|
|
137
176
|
}
|
|
138
177
|
if (tagHits > 0) {
|
|
139
|
-
const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost
|
|
178
|
+
const boost = Math.min(cfg.tagBoost * tagHits, cfg.tagBoost)
|
|
140
179
|
score += boost
|
|
141
180
|
reasons.push(`tag-boost:+${boost.toFixed(2)}`)
|
|
142
181
|
}
|
|
143
|
-
// Recency is a tiebreaker
|
|
144
|
-
//
|
|
182
|
+
// Recency is a RANKING tiebreaker only (v4): it pads `score` but is kept
|
|
183
|
+
// out of `gateScore`, so it can never carry a candidate across the numeric
|
|
184
|
+
// threshold. Conflict demotion applies to the pre-tiebreaker score.
|
|
185
|
+
let recencyBonus = 0
|
|
145
186
|
if (score > 0 && cfg.recencyWindowDays > 0) {
|
|
146
187
|
const generated = Date.parse(topic.generatedAt)
|
|
147
188
|
if (!Number.isNaN(generated)) {
|
|
148
189
|
const ageDays = ((cfg.now?.getTime() ?? Date.now()) - generated) / 86_400_000
|
|
149
190
|
if (ageDays >= 0 && ageDays <= cfg.recencyWindowDays) {
|
|
150
|
-
|
|
191
|
+
recencyBonus = 0.2
|
|
151
192
|
reasons.push('recency')
|
|
152
193
|
}
|
|
153
194
|
}
|
|
@@ -156,13 +197,36 @@ export function scoreTopic(
|
|
|
156
197
|
score *= 0.3
|
|
157
198
|
reasons.push('conflicted-demoted')
|
|
158
199
|
}
|
|
159
|
-
|
|
200
|
+
const gateScore = score
|
|
201
|
+
score += recencyBonus
|
|
202
|
+
const bodySet = new Set([...description, ...conclusion])
|
|
203
|
+
let bodyHits = 0
|
|
204
|
+
for (const t of queryTokens) if (bodySet.has(t)) bodyHits += 1
|
|
205
|
+
const strong = cTriggers > 0 || cTitle > 0 || cSlug > 0
|
|
206
|
+
return {
|
|
207
|
+
slug: topic.slug,
|
|
208
|
+
score: Math.round(score * 1000) / 1000,
|
|
209
|
+
reasons,
|
|
210
|
+
strong,
|
|
211
|
+
bodyHits,
|
|
212
|
+
gateScore: Math.round(gateScore * 1000) / 1000,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Structural gate v0 verdict: strong-field hit, or ≥2 distinct body terms. */
|
|
217
|
+
export function passesGate(s: { strong: boolean; bodyHits: number }): boolean {
|
|
218
|
+
return s.strong || s.bodyHits >= 2
|
|
160
219
|
}
|
|
161
220
|
|
|
162
221
|
/**
|
|
163
|
-
* Search the roster: score every topic,
|
|
164
|
-
*
|
|
165
|
-
*
|
|
222
|
+
* Search the roster: score every topic, apply the numeric threshold to the
|
|
223
|
+
* gate score (recency excluded), split by the structural gate, then expand
|
|
224
|
+
* gate-passed seeds through the `depends` graph (both directions, geometric
|
|
225
|
+
* decay, depth ≤ cfg.graphDepth). Expanded entries enter at the back of the
|
|
226
|
+
* hits and are exempt from the structural gate — they carry no lexical
|
|
227
|
+
* evidence by design, only graph adjacency. Threshold-passing candidates the
|
|
228
|
+
* gate rejects lead the near-misses (reason `gate-blocked`) so the replay
|
|
229
|
+
* evidence shows what the gate is costing.
|
|
166
230
|
*/
|
|
167
231
|
export function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg: Partial<RetrievalConfig> = {}): SearchOutcome {
|
|
168
232
|
const c: RetrievalConfig = { ...DEFAULT_RETRIEVAL, ...cfg }
|
|
@@ -171,10 +235,18 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
|
|
|
171
235
|
return { hits: [], nearMisses: [], rosterSize: roster.length }
|
|
172
236
|
}
|
|
173
237
|
const scored = roster.map((t) => scoreTopic(queryTokens, t, c)).sort((a, b) => b.score - a.score)
|
|
174
|
-
const
|
|
175
|
-
const
|
|
238
|
+
const passing = scored.filter((s) => s.gateScore >= c.threshold)
|
|
239
|
+
const directHits = passing.filter((s) => !c.structuralGate || passesGate(s)).slice(0, c.topK)
|
|
240
|
+
const gateBlocked = c.structuralGate
|
|
241
|
+
? passing.filter((s) => !passesGate(s)).slice(0, c.topK)
|
|
242
|
+
: []
|
|
243
|
+
const nearFloor = scored.filter((s) => s.gateScore < c.threshold && s.gateScore >= c.threshold * 0.5).slice(0, 8)
|
|
176
244
|
|
|
177
|
-
const hits: SearchHit[] =
|
|
245
|
+
const hits: SearchHit[] = directHits.map((s) => ({ ...s, viaGraph: false }))
|
|
246
|
+
const nearMisses: SearchHit[] = [
|
|
247
|
+
...gateBlocked.map((s) => ({ ...s, reasons: [...s.reasons, 'gate-blocked'], viaGraph: false })),
|
|
248
|
+
...nearFloor.map((s) => ({ ...s, viaGraph: false })),
|
|
249
|
+
].slice(0, 8)
|
|
178
250
|
// Graph expansion (ADR 0005 graph-shaped bundle): walk depends + body
|
|
179
251
|
// links, both directions, from the seeds; include up to topK+2 slots at
|
|
180
252
|
// decaying score.
|
|
@@ -196,7 +268,9 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
|
|
|
196
268
|
}
|
|
197
269
|
}
|
|
198
270
|
const seen = new Set(hits.map((h) => h.slug))
|
|
199
|
-
|
|
271
|
+
// Decay starts from the gateScore: recency must not re-enter through the
|
|
272
|
+
// graph channel after being banned from the direct gate.
|
|
273
|
+
let frontier = hits.map((h) => ({ slug: h.slug, score: h.gateScore ?? h.score }))
|
|
200
274
|
for (let depth = 1; depth <= c.graphDepth && frontier.length > 0; depth += 1) {
|
|
201
275
|
const next: { slug: string; score: number }[] = []
|
|
202
276
|
for (const node of frontier) {
|
|
@@ -215,5 +289,5 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
|
|
|
215
289
|
frontier = next
|
|
216
290
|
}
|
|
217
291
|
}
|
|
218
|
-
return { hits, nearMisses
|
|
292
|
+
return { hits, nearMisses, rosterSize: roster.length }
|
|
219
293
|
}
|