@aiwayds/dsh-topics-memory 0.22.0 → 0.24.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/config.d.ts +55 -1
- package/lib/config.js +42 -0
- package/lib/digest.d.ts +111 -5
- package/lib/digest.js +437 -11
- package/lib/distill.d.ts +39 -0
- package/lib/distill.js +250 -15
- package/lib/embedding.d.ts +113 -0
- package/lib/embedding.js +208 -0
- package/lib/ilog.d.ts +30 -0
- package/lib/index.d.ts +15 -1
- package/lib/index.js +24 -6
- package/lib/okf.d.ts +34 -0
- package/lib/okf.js +41 -0
- package/lib/paths.d.ts +9 -0
- package/lib/paths.js +15 -0
- package/lib/pipeline-events.d.ts +38 -0
- package/lib/pipeline-events.js +123 -0
- package/lib/quality.d.ts +47 -7
- package/lib/quality.js +124 -20
- package/lib/retrieval.d.ts +106 -0
- package/lib/retrieval.js +280 -0
- package/lib/service.d.ts +103 -2
- package/lib/service.js +623 -16
- package/lib/store.d.ts +39 -3
- package/lib/store.js +60 -2
- package/package.json +2 -2
- package/src/config.ts +49 -0
- package/src/digest.ts +497 -8
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +26 -0
- package/src/index.ts +32 -5
- package/src/okf.ts +68 -0
- package/src/paths.ts +15 -0
- package/src/pipeline-events.ts +136 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +335 -0
- package/src/service.ts +661 -19
- package/src/store.ts +77 -5
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta/pipeline-events.jsonl — the pipeline-level anomaly ledger (spec §8.1,
|
|
3
|
+
* A2/A6 BL-1 revised: the "记 wtf 账" mechanism never existed, this IS the
|
|
4
|
+
* explicit ledger). One line JSON per event; always on, no config key
|
|
5
|
+
* (恒开无键 — observability is not a gray-release feature).
|
|
6
|
+
*
|
|
7
|
+
* Discipline = decisions.jsonl (src/jev/log.ts) verbatim: field-whitelist
|
|
8
|
+
* copy on write (extra caller fields cannot leak in, detail values must be
|
|
9
|
+
* scalar), 512KB auto-compaction keeping the most recent quarter, and write
|
|
10
|
+
* failures swallowed silently — a broken sink must never break the pipeline.
|
|
11
|
+
* Unlike jev/log this writer takes the bundle root explicitly (callers like
|
|
12
|
+
* the slow lane already hold it; tests point it at a tmp dir).
|
|
13
|
+
*
|
|
14
|
+
* @module pipeline-events
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { metaDir } from './paths.ts'
|
|
21
|
+
|
|
22
|
+
/** Closed vocabulary, spec §8.1 + W-C wave start set. `slow-lane-outcome` is
|
|
23
|
+
* the slow lane's per-run disposition row (quality.ts); the distill/link
|
|
24
|
+
* kinds are the A2/A6 producers' contract — they import this module. The
|
|
25
|
+
* two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
|
|
26
|
+
* caller-identity audit — opts.actor — rides every seam call). */
|
|
27
|
+
export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status'
|
|
28
|
+
|
|
29
|
+
/** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
|
|
30
|
+
export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl'
|
|
31
|
+
|
|
32
|
+
export interface PipelineEvent {
|
|
33
|
+
at: string
|
|
34
|
+
kind: PipelineEventKind
|
|
35
|
+
/** Bounded scalar metadata only — never conversation or conclusion text. */
|
|
36
|
+
detail?: Record<string, string | number | boolean>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const COMPACT_LIMIT = 512 * 1024 // ~512KB cap, same as the decisions/ilog sidecars
|
|
40
|
+
/** Whitelist copy bounds: detail entries and their values stay metadata-sized. */
|
|
41
|
+
const DETAIL_MAX_ENTRIES = 16
|
|
42
|
+
const DETAIL_KEY_MAX_CHARS = 64
|
|
43
|
+
const DETAIL_VALUE_MAX_CHARS = 256
|
|
44
|
+
|
|
45
|
+
/** Runtime mirror of PipelineEventKind — the closed vocabulary is enforced
|
|
46
|
+
* at the write gate too (a JS caller cannot smuggle an unknown kind in). */
|
|
47
|
+
const KNOWN_KINDS: ReadonlySet<string> = new Set<string>([
|
|
48
|
+
'slow-lane-outcome',
|
|
49
|
+
'distill-op-invalid',
|
|
50
|
+
'distill-op-retry-exhausted',
|
|
51
|
+
'link-ref-rejected',
|
|
52
|
+
'seam-observations-fed',
|
|
53
|
+
'seam-lesson-status',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
export function pipelineEventsFile(bundleDir: string): string {
|
|
57
|
+
return join(metaDir(bundleDir), PIPELINE_EVENTS_FILE)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Serialized like jev/log: single-record appends are line-atomic anyway, but
|
|
61
|
+
// the compaction rewrite must not interleave with an append.
|
|
62
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
63
|
+
|
|
64
|
+
function enqueue<T>(op: () => Promise<T>): Promise<T> {
|
|
65
|
+
const run = queue.then(op, op)
|
|
66
|
+
queue = run.catch(() => undefined)
|
|
67
|
+
return run
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** All write failures die HERE — never upstream. */
|
|
71
|
+
async function appendJsonl(file: string, lines: readonly string[]): Promise<void> {
|
|
72
|
+
await enqueue(async () => {
|
|
73
|
+
try {
|
|
74
|
+
await mkdir(dirname(file), { recursive: true })
|
|
75
|
+
await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8')
|
|
76
|
+
await compactIfNeeded(file)
|
|
77
|
+
} catch {
|
|
78
|
+
// fail-open: a broken ledger must never break the pipeline (§8.1)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// jev/log compactIfNeeded pattern: ~512KB cap; keep the most recent quarter
|
|
84
|
+
// when exceeded (headroom for the next window).
|
|
85
|
+
async function compactIfNeeded(file: string): Promise<void> {
|
|
86
|
+
let raw: string
|
|
87
|
+
try {
|
|
88
|
+
raw = await readFile(file, 'utf8')
|
|
89
|
+
} catch {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (raw.length <= COMPACT_LIMIT) return
|
|
93
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '')
|
|
94
|
+
const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)))
|
|
95
|
+
const tmp = `${file}.tmp-${randomUUID()}`
|
|
96
|
+
try {
|
|
97
|
+
await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8')
|
|
98
|
+
await rename(tmp, file)
|
|
99
|
+
} catch {
|
|
100
|
+
// swallow — the next append retries the compaction
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Copy one event through the field whitelist onto the ledger. Only `at`,
|
|
106
|
+
* `kind` and a scalar-valued `detail` map can pass; non-scalar detail values
|
|
107
|
+
* (objects/arrays/null/NaN), surplus entries and over-long keys/values are
|
|
108
|
+
* dropped here so no unbounded content can ride in. Write failures are
|
|
109
|
+
* swallowed downstream — this never rejects and never throws.
|
|
110
|
+
*/
|
|
111
|
+
export async function recordPipelineEvent(bundleDir: string, event: PipelineEvent): Promise<void> {
|
|
112
|
+
if (bundleDir === '' || typeof bundleDir !== 'string') return
|
|
113
|
+
if (event === null || typeof event !== 'object') return
|
|
114
|
+
if (typeof event.kind !== 'string' || !KNOWN_KINDS.has(event.kind)) return
|
|
115
|
+
const detail: Record<string, string | number | boolean> = {}
|
|
116
|
+
if (event.detail !== undefined && typeof event.detail === 'object') {
|
|
117
|
+
for (const [key, value] of Object.entries(event.detail)) {
|
|
118
|
+
if (Object.keys(detail).length >= DETAIL_MAX_ENTRIES) break
|
|
119
|
+
const t = typeof value
|
|
120
|
+
if (t === 'string') {
|
|
121
|
+
if ((value as string) !== '') detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = (value as string).slice(0, DETAIL_VALUE_MAX_CHARS)
|
|
122
|
+
} else if (t === 'number') {
|
|
123
|
+
if (Number.isFinite(value)) detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value
|
|
124
|
+
} else if (t === 'boolean') {
|
|
125
|
+
detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value
|
|
126
|
+
}
|
|
127
|
+
// objects/arrays/null/undefined never pass the whitelist
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const at = typeof event.at === 'string' && event.at !== '' ? event.at : new Date().toISOString()
|
|
131
|
+
const row =
|
|
132
|
+
Object.keys(detail).length === 0
|
|
133
|
+
? { at, kind: event.kind }
|
|
134
|
+
: { at, kind: event.kind, detail }
|
|
135
|
+
await appendJsonl(pipelineEventsFile(bundleDir), [JSON.stringify(row)])
|
|
136
|
+
}
|
package/src/quality.ts
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* Picks rest in a per-session pending slot and inject at the NEXT spliced
|
|
16
16
|
* (steer message), then the slot is gone (消费即清). Hard bounds: per-call
|
|
17
17
|
* 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
|
|
18
|
-
* contained
|
|
19
|
-
*
|
|
18
|
+
* contained — async products may be missing, never blocking (I3 可丢弃性) —
|
|
19
|
+
* but no longer SILENT (A4 决议 3): each run lands a `slow-lane-outcome`
|
|
20
|
+
* disposition (SlowOutcome) on the session and in the pipeline-events
|
|
21
|
+
* ledger, so 「没跑」and 「跑了全败」are distinguishable in the log. The
|
|
22
|
+
* lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
|
|
20
23
|
* records verdicts into ilog and never blocks a pick.
|
|
21
24
|
*
|
|
22
25
|
* @module quality
|
|
@@ -26,7 +29,7 @@ import type { TopicsService } from './service.ts'
|
|
|
26
29
|
import type { ModelCaller } from './distill.ts'
|
|
27
30
|
import { searchTopics } from './retrieval.ts'
|
|
28
31
|
import type { RingEntry } from './observer.ts'
|
|
29
|
-
import type { QueryBuildShape } from './ilog.ts'
|
|
32
|
+
import type { QueryBuildShape, SlowOutcome } from './ilog.ts'
|
|
30
33
|
import type { TopicsConfigValue } from './config.ts'
|
|
31
34
|
import { jevAskDual } from './jev/dual.ts'
|
|
32
35
|
import type { JevQuestion } from './jev/client.ts'
|
|
@@ -35,6 +38,11 @@ import { band as jevBand } from './jev/thresholds.ts'
|
|
|
35
38
|
import type { JevBand } from './jev/thresholds.ts'
|
|
36
39
|
import { digestFor, logVerdicts } from './jev/log.ts'
|
|
37
40
|
import type { JevVerdictRecord } from './jev/log.ts'
|
|
41
|
+
import { recordPipelineEvent } from './pipeline-events.ts'
|
|
42
|
+
|
|
43
|
+
/** Re-exported for the ilog writers (spec §8.2): the service layer takes a
|
|
44
|
+
* pending's `outcome` (or lastOutcomeOf) as the record's `slowOutcome`. */
|
|
45
|
+
export type { SlowOutcome }
|
|
38
46
|
|
|
39
47
|
export const PENDING_TTL_MS = 10 * 60_000
|
|
40
48
|
/** Hard drift bound: a pending older than this many turn-ends expires. */
|
|
@@ -197,6 +205,13 @@ export interface PendingInjection {
|
|
|
197
205
|
queryBuild: QueryBuildShape
|
|
198
206
|
model: string
|
|
199
207
|
ms: number
|
|
208
|
+
/**
|
|
209
|
+
* The producing run's disposition (A4 决议 3, spec §8.2) — always
|
|
210
|
+
* 'delivered' here (a pending only exists after a settled run). The
|
|
211
|
+
* service layer copies it into the ilog record's `slowOutcome` at
|
|
212
|
+
* consumption; expiry surfaces as `expired` via ConsumeResult instead.
|
|
213
|
+
*/
|
|
214
|
+
outcome: SlowOutcome
|
|
200
215
|
}
|
|
201
216
|
|
|
202
217
|
export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | 'turn-lag' } | undefined
|
|
@@ -270,11 +285,32 @@ export function parseJsonObject(raw: string): Record<string, unknown> {
|
|
|
270
285
|
throw new Error('unbalanced JSON in output')
|
|
271
286
|
}
|
|
272
287
|
|
|
288
|
+
/** The closed outcome for an error that escaped the produce body: our own
|
|
289
|
+
* withTimeout rejections and the pipeline-deadline abort are `timeout`,
|
|
290
|
+
* everything else (model error, bad transport, unexpected throw) is
|
|
291
|
+
* `llm-error`. Raw error text never enters the ledger (jev/log redaction
|
|
292
|
+
* envelope — the outcome class is the observable, not the message). */
|
|
293
|
+
function classifySlowError(e: unknown, controller: AbortController): SlowOutcome {
|
|
294
|
+
if (e !== null && typeof e === 'object') {
|
|
295
|
+
const msg = (e as { message?: unknown }).message
|
|
296
|
+
if (typeof msg === 'string' && msg.startsWith('slow-lane call timeout')) return 'timeout'
|
|
297
|
+
}
|
|
298
|
+
if (controller.signal.aborted) return 'timeout'
|
|
299
|
+
return 'llm-error'
|
|
300
|
+
}
|
|
301
|
+
|
|
273
302
|
export class SlowLane {
|
|
274
303
|
private pending = new Map<string, PendingInjection>()
|
|
275
304
|
private inFlight = new Map<string, Promise<void>>()
|
|
276
305
|
/** Same-session shadow serialization — a second trigger never overlaps. */
|
|
277
306
|
private shadowInFlight = new Map<string, Promise<void>>()
|
|
307
|
+
/**
|
|
308
|
+
* The session's most recent slow-lane disposition (A4 决议 3): what a
|
|
309
|
+
* spliced with NO pending reports as its `slowOutcome` — without it a
|
|
310
|
+
* consumed-undefined round cannot say whether the lane never ran or ran
|
|
311
|
+
* and failed. One slot per session, overwritten by every trigger.
|
|
312
|
+
*/
|
|
313
|
+
private outcomeBySession = new Map<string, SlowOutcome>()
|
|
278
314
|
private readonly service: TopicsService
|
|
279
315
|
private readonly caller: ModelCaller | undefined
|
|
280
316
|
/**
|
|
@@ -302,10 +338,40 @@ export class SlowLane {
|
|
|
302
338
|
return this.inFlight.has(sessionId)
|
|
303
339
|
}
|
|
304
340
|
|
|
341
|
+
/**
|
|
342
|
+
* The session's most recent slow-lane disposition (A4 §3.2): a round whose
|
|
343
|
+
* consume() came up empty writes THIS as its ilog `slowOutcome` (spec §8.2
|
|
344
|
+
* — 「spliced 收到但未 produce 也留痕」). Undefined when the lane never
|
|
345
|
+
* triggered for the session.
|
|
346
|
+
*/
|
|
347
|
+
lastOutcomeOf(sessionId: string): SlowOutcome | undefined {
|
|
348
|
+
return this.outcomeBySession.get(sessionId)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* One disposition, two sinks (A4 决议 3): the per-session slot above and a
|
|
353
|
+
* `slow-lane-outcome` row in the pipeline-events ledger. Metadata only
|
|
354
|
+
* (outcome/reason/session/turn/timing — never ring or query text). Never
|
|
355
|
+
* throws, never rejects: traceability must not become a failure source.
|
|
356
|
+
*/
|
|
357
|
+
private traceOutcome(sessionId: string, turnId: number, outcome: SlowOutcome, reason: string, extra: Record<string, string | number | boolean> = {}): void {
|
|
358
|
+
try {
|
|
359
|
+
this.outcomeBySession.set(sessionId, outcome)
|
|
360
|
+
void recordPipelineEvent(this.service.store.root, {
|
|
361
|
+
at: new Date().toISOString(),
|
|
362
|
+
kind: 'slow-lane-outcome',
|
|
363
|
+
detail: { outcome, reason, sessionId, turnId, ...extra },
|
|
364
|
+
})
|
|
365
|
+
} catch {
|
|
366
|
+
// contained — the ledger is best-effort by design
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
305
370
|
/** Drop the pending slot (session teardown, restore boundary). */
|
|
306
371
|
clear(sessionId: string): void {
|
|
307
372
|
this.pending.delete(sessionId)
|
|
308
373
|
this.shadowInFlight.delete(sessionId)
|
|
374
|
+
this.outcomeBySession.delete(sessionId)
|
|
309
375
|
}
|
|
310
376
|
|
|
311
377
|
/**
|
|
@@ -400,17 +466,36 @@ export class SlowLane {
|
|
|
400
466
|
|
|
401
467
|
/**
|
|
402
468
|
* Produce a pending slot for the session's next steer message. Fire-and-
|
|
403
|
-
* forget; every guard failure is
|
|
404
|
-
*
|
|
405
|
-
* the
|
|
469
|
+
* forget; every guard failure is contained (never breaks the event
|
|
470
|
+
* handler) and QUALIFIED (A4 决议 3): a skipped trigger lands a `no-run`
|
|
471
|
+
* outcome naming the guard, so a later spliced can report why there was
|
|
472
|
+
* nothing to consume. Policy gates live here (config, sampling, in-flight);
|
|
473
|
+
* host gates (delegation, distill yield) live with the caller, which owns
|
|
474
|
+
* those services.
|
|
406
475
|
*/
|
|
407
476
|
dispatch(sessionId: string, input: DispatchInput): void {
|
|
408
477
|
try {
|
|
409
478
|
const cfg = this.service.cfg
|
|
410
|
-
if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
479
|
+
if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') {
|
|
480
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'lane-off')
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) {
|
|
484
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'cadence')
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
if (this.caller === undefined) {
|
|
488
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'no-caller')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
if (this.inFlight.has(sessionId)) {
|
|
492
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'in-flight')
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
if (input.ring.length === 0) {
|
|
496
|
+
this.traceOutcome(sessionId, input.turnId, 'no-run', 'empty-ring')
|
|
497
|
+
return
|
|
498
|
+
}
|
|
414
499
|
const run = this.produce(sessionId, input).finally(() => {
|
|
415
500
|
this.inFlight.delete(sessionId)
|
|
416
501
|
this.onSettle(sessionId)
|
|
@@ -424,11 +509,15 @@ export class SlowLane {
|
|
|
424
509
|
|
|
425
510
|
private async produce(sessionId: string, input: DispatchInput): Promise<void> {
|
|
426
511
|
const caller = this.caller
|
|
427
|
-
if (caller === undefined) return
|
|
512
|
+
if (caller === undefined) return // unreachable — dispatch gated it, already traced
|
|
428
513
|
const started = Date.now()
|
|
429
514
|
const controller = new AbortController()
|
|
430
515
|
const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS)
|
|
431
516
|
deadline.unref?.()
|
|
517
|
+
// A4 决议 3: every exit qualifies — outcome on the session + the ledger.
|
|
518
|
+
const trace = (outcome: SlowOutcome, reason: string): void => {
|
|
519
|
+
this.traceOutcome(sessionId, input.turnId, outcome, reason, { ms: Date.now() - started })
|
|
520
|
+
}
|
|
432
521
|
try {
|
|
433
522
|
const cfg = this.service.cfg
|
|
434
523
|
const ringText = input.ring
|
|
@@ -453,9 +542,13 @@ export class SlowLane {
|
|
|
453
542
|
try {
|
|
454
543
|
built = parseJsonObject(buildRaw)
|
|
455
544
|
} catch {
|
|
456
|
-
|
|
545
|
+
trace('llm-error', 'build-bad-json') // unparseable build → no pending, next turn retries
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') {
|
|
549
|
+
trace('no-claim', 'build-no-need') // the query builder claimed no background need
|
|
550
|
+
return
|
|
457
551
|
}
|
|
458
|
-
if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') return
|
|
459
552
|
const query = built.query.trim()
|
|
460
553
|
const stripped = Array.isArray(built.ignore)
|
|
461
554
|
? built.ignore.filter((s): s is string => typeof s === 'string' && s.trim() !== '').slice(0, 5)
|
|
@@ -463,7 +556,10 @@ export class SlowLane {
|
|
|
463
556
|
const queryBuild: QueryBuildShape = { rawChars: ringText.length, keptChars: query.length, stripped }
|
|
464
557
|
|
|
465
558
|
const roster = await this.service.roster().catch(() => [])
|
|
466
|
-
if (roster.length === 0)
|
|
559
|
+
if (roster.length === 0) {
|
|
560
|
+
trace('no-candidates', 'roster-empty')
|
|
561
|
+
return
|
|
562
|
+
}
|
|
467
563
|
// Recall-oriented band: gate OFF (the rerank IS this lane's gate);
|
|
468
564
|
// candidates = threshold passers + the near-miss band beneath them.
|
|
469
565
|
const band = searchTopics(query, roster, {
|
|
@@ -478,7 +574,10 @@ export class SlowLane {
|
|
|
478
574
|
const candidates = [...band.hits, ...band.nearMisses]
|
|
479
575
|
.filter((c) => !exclude?.has(c.slug))
|
|
480
576
|
.slice(0, CANDIDATE_LIMIT)
|
|
481
|
-
if (candidates.length === 0)
|
|
577
|
+
if (candidates.length === 0) {
|
|
578
|
+
trace('no-candidates', 'band-empty')
|
|
579
|
+
return
|
|
580
|
+
}
|
|
482
581
|
const bySlug = new Map(roster.map((r) => [r.slug, r]))
|
|
483
582
|
const payload = candidates.flatMap((c) => {
|
|
484
583
|
const meta = bySlug.get(c.slug)
|
|
@@ -494,7 +593,10 @@ export class SlowLane {
|
|
|
494
593
|
},
|
|
495
594
|
]
|
|
496
595
|
})
|
|
497
|
-
if (payload.length === 0)
|
|
596
|
+
if (payload.length === 0) {
|
|
597
|
+
trace('no-candidates', 'payload-empty')
|
|
598
|
+
return
|
|
599
|
+
}
|
|
498
600
|
// ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
|
|
499
601
|
// One batched noul request over the whole band; the calibrated bands
|
|
500
602
|
// decide the picks. A settled jev round is FINAL here (picks or none) —
|
|
@@ -503,9 +605,15 @@ export class SlowLane {
|
|
|
503
605
|
if (this.service.cfg.jevEnabled === true) {
|
|
504
606
|
const jevItems = await this.jevRerank(query, payload)
|
|
505
607
|
if (jevItems !== undefined) {
|
|
506
|
-
if (jevItems.length > 0)
|
|
608
|
+
if (jevItems.length > 0) {
|
|
609
|
+
this.settlePending(sessionId, input, jevItems, queryBuild, started)
|
|
610
|
+
trace('delivered', 'jev-picks')
|
|
611
|
+
} else {
|
|
612
|
+
trace('no-candidates', 'jev-zero-picks')
|
|
613
|
+
}
|
|
507
614
|
return
|
|
508
615
|
}
|
|
616
|
+
// jev failure → the legacy rerank below owns the final trace.
|
|
509
617
|
}
|
|
510
618
|
const rerankRaw = await withTimeout(
|
|
511
619
|
caller({
|
|
@@ -528,6 +636,7 @@ export class SlowLane {
|
|
|
528
636
|
try {
|
|
529
637
|
reranked = parseJsonObject(rerankRaw)
|
|
530
638
|
} catch {
|
|
639
|
+
trace('llm-error', 'rerank-bad-json')
|
|
531
640
|
return
|
|
532
641
|
}
|
|
533
642
|
const legal = new Set(payload.map((p) => p.slug))
|
|
@@ -545,16 +654,28 @@ export class SlowLane {
|
|
|
545
654
|
items.push({ slug, why: why.trim().slice(0, 200) })
|
|
546
655
|
if (items.length >= 2) break
|
|
547
656
|
}
|
|
548
|
-
if (items.length === 0)
|
|
657
|
+
if (items.length === 0) {
|
|
658
|
+
trace('no-candidates', 'rerank-zero-picks') // ran fine, gate picked nothing worth injecting
|
|
659
|
+
return
|
|
660
|
+
}
|
|
549
661
|
this.settlePending(sessionId, input, items, queryBuild, started)
|
|
550
|
-
|
|
551
|
-
|
|
662
|
+
trace('delivered', 'rerank-picks')
|
|
663
|
+
} catch (e) {
|
|
664
|
+
// RED LINE, A4 决议 3 (revising the old silent catch): the fast lane's
|
|
665
|
+
// fail-silently principle only covers the SYNCHRONOUS seam — this is an
|
|
666
|
+
// async lane, so the failure must be observable while still never
|
|
667
|
+
// rethrowing at the caller (I3 可丢弃性: the async product is absent,
|
|
668
|
+
// but its absence is now qualified, not mute).
|
|
669
|
+
trace(classifySlowError(e, controller), 'call-failed')
|
|
552
670
|
} finally {
|
|
553
671
|
clearTimeout(deadline)
|
|
554
672
|
}
|
|
555
673
|
}
|
|
556
674
|
|
|
557
|
-
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
|
|
675
|
+
/** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
|
|
676
|
+
* The pending carries its producing run's disposition (A4 §3.2) so the
|
|
677
|
+
* ilog writer can copy it verbatim — a pending only exists when the run
|
|
678
|
+
* delivered, hence the constant. */
|
|
558
679
|
private settlePending(sessionId: string, input: DispatchInput, items: { slug: string; why: string }[], queryBuild: QueryBuildShape, started: number): void {
|
|
559
680
|
const route = this.service.cfg
|
|
560
681
|
this.pending.set(sessionId, {
|
|
@@ -564,6 +685,7 @@ export class SlowLane {
|
|
|
564
685
|
queryBuild,
|
|
565
686
|
model: `${route.distillProvider}/${route.distillModel}`,
|
|
566
687
|
ms: Date.now() - started,
|
|
688
|
+
outcome: 'delivered',
|
|
567
689
|
})
|
|
568
690
|
}
|
|
569
691
|
|
|
@@ -657,10 +779,12 @@ export class SlowLane {
|
|
|
657
779
|
const produced = Date.parse(pending.computedAt)
|
|
658
780
|
if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
|
|
659
781
|
this.pending.delete(sessionId)
|
|
782
|
+
this.traceOutcome(sessionId, turnId, 'expired', 'ttl', { ageMs: Date.now() - produced })
|
|
660
783
|
return { expired: 'ttl' }
|
|
661
784
|
}
|
|
662
785
|
if (turnId - pending.turnId > TURN_LAG_LIMIT) {
|
|
663
786
|
this.pending.delete(sessionId)
|
|
787
|
+
this.traceOutcome(sessionId, turnId, 'expired', 'turn-lag', { producedTurnId: pending.turnId })
|
|
664
788
|
return { expired: 'turn-lag' }
|
|
665
789
|
}
|
|
666
790
|
this.pending.delete(sessionId)
|