@aiwayds/dsh-topics-memory 0.22.0 → 0.23.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 +38 -1
- package/lib/config.js +29 -0
- package/lib/digest.d.ts +73 -0
- package/lib/digest.js +384 -0
- 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 +16 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +23 -6
- package/lib/pipeline-events.d.ts +36 -0
- package/lib/pipeline-events.js +121 -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 +17 -1
- package/lib/service.js +202 -11
- package/lib/store.d.ts +9 -0
- package/lib/store.js +32 -1
- package/package.json +2 -2
- package/src/config.ts +34 -0
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +17 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +335 -0
- package/src/service.ts +199 -12
- package/src/store.ts +32 -2
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)
|
package/src/retrieval.ts
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* @module retrieval
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
15
17
|
import type { TopicMeta } from './store.ts'
|
|
16
18
|
import { dependsSlugs } from './okf.ts'
|
|
19
|
+
import type { EmbedResult } from './embedding.ts'
|
|
17
20
|
|
|
18
21
|
export interface RetrievableTopic extends TopicMeta {
|
|
19
22
|
/** `# Conclusion` section text (may be empty). */
|
|
@@ -340,3 +343,335 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
|
|
|
340
343
|
}
|
|
341
344
|
return { hits, nearMisses, rosterSize: roster.length }
|
|
342
345
|
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Injection-quality line (wave/injection-quality, spec §2.1/§3.1/§3.3) —
|
|
349
|
+
// CAPABILITY ONLY. None of this is wired into searchTopics or any service
|
|
350
|
+
// path; the caller gates it on packageMode ≠ 'off' (Phase 2 wiring). With
|
|
351
|
+
// the config keys at their defaults every code path below is unreachable
|
|
352
|
+
// from the hot lane, so default-off behavior stays byte-for-byte.
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
// --- ① Query sanitization (A4 决议 4, spec §3.3) ---------------------------
|
|
356
|
+
// Pure, LLM-free, millisecond-scale. Treats the queryTokenCount p90=462
|
|
357
|
+
// paste-pollution tail: fenced code blocks, URLs, line-leading shell
|
|
358
|
+
// prompts, over-long lines, and a hard token cap.
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Token cap in tokenize() units — the SAME unit injections.jsonl logs as
|
|
362
|
+
* queryTokenCount (p90=462 baseline; real-query p50=153 stays untouched).
|
|
363
|
+
* Raised or lowered via SanitizeOptions.maxTokens.
|
|
364
|
+
*/
|
|
365
|
+
export const QUERY_TOKEN_CAP = 96
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Lines longer than this are folded to their head (log-paste guard). Sized
|
|
369
|
+
* so the 96-token cap is the binding constraint, not the fold: 96 latin
|
|
370
|
+
* tokens ≈ 650+ chars, so a legitimate long single-line query passes the
|
|
371
|
+
* fold intact and only the token cap trims it. (实现期定 constant.)
|
|
372
|
+
*/
|
|
373
|
+
export const MAX_LINE_CHARS = 720
|
|
374
|
+
|
|
375
|
+
export interface SanitizeOptions {
|
|
376
|
+
maxTokens?: number
|
|
377
|
+
maxLineChars?: number
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const FENCE_OPEN = /^[ \t]*(```|~~~)/
|
|
381
|
+
// Line-leading shell prompt: `user@host:~/repo$ `, `C:\> `, or a bare
|
|
382
|
+
// `$ `/`# `/`% `/`> `/`❯ ` marker. Requires trailing whitespace so words
|
|
383
|
+
// like `$PATH` or `#tag` survive untouched.
|
|
384
|
+
const LINE_PROMPT = /^[ \t]*(?:[\w.@:/\\~-]*[$#%>❯➜])[ \t]+/
|
|
385
|
+
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi
|
|
386
|
+
// Chunks the cap walks: one latin/digit word (or hyphenated compound) per
|
|
387
|
+
// match, or one CJK run (sliced when a run alone busts the cap).
|
|
388
|
+
const CHUNK_RUN = new RegExp(`${WORD_RUN.source}|${CJK_RUN.source}`, 'gu')
|
|
389
|
+
|
|
390
|
+
/** Remove fenced code blocks wholesale (markers AND body): fenced code is
|
|
391
|
+
* the paste-pollution payload; the prose around it is the actual query. */
|
|
392
|
+
function stripFencedBlocks(text: string): string {
|
|
393
|
+
const out: string[] = []
|
|
394
|
+
let open: string | null = null
|
|
395
|
+
for (const line of text.split('\n')) {
|
|
396
|
+
const m = line.match(FENCE_OPEN)
|
|
397
|
+
if (open === null) {
|
|
398
|
+
if (m !== null) open = m[1]
|
|
399
|
+
else out.push(line)
|
|
400
|
+
} else if (m !== null && m[1] === open) {
|
|
401
|
+
open = null // closing fence — block fully dropped
|
|
402
|
+
}
|
|
403
|
+
// inside an open fence: drop the line
|
|
404
|
+
}
|
|
405
|
+
return out.join('\n')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Cap the sanitized text to ≤ cap distinct retrieval tokens. Fast path:
|
|
409
|
+
* at or under the cap the text passes through VERBATIM (no re-chunking —
|
|
410
|
+
* hyphens, punctuation and spacing survive byte-exact; retrieval scores
|
|
411
|
+
* tokens, not bytes, but the string may be echoed into logs/injection).
|
|
412
|
+
* Only an over-cap text rebuilds from chunks: head-first (a query's intent
|
|
413
|
+
* leads; pollution trails), with CJK runs sliced at the character level so
|
|
414
|
+
* a single giant run truncates instead of being dropped whole. */
|
|
415
|
+
function capTokens(text: string, cap: number): string {
|
|
416
|
+
const total = tokenize(text).length
|
|
417
|
+
if (total === 0) return '' // nothing lexical survives — caller sees a zero-token query
|
|
418
|
+
if (total <= cap) return text
|
|
419
|
+
const seen = new Set<string>()
|
|
420
|
+
const kept: string[] = []
|
|
421
|
+
for (const chunk of text.match(CHUNK_RUN) ?? []) {
|
|
422
|
+
const fresh = tokenize(chunk).filter((t) => !seen.has(t))
|
|
423
|
+
if (fresh.length === 0) continue // already-covered chunk adds nothing
|
|
424
|
+
if (seen.size + fresh.length <= cap) {
|
|
425
|
+
for (const t of fresh) seen.add(t)
|
|
426
|
+
kept.push(chunk)
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
const room = cap - seen.size
|
|
430
|
+
if (room <= 0) break
|
|
431
|
+
// Over budget: CJK runs truncate exactly (slice room+1 chars → ≤ room
|
|
432
|
+
// fresh bigrams, so the cap invariant holds). Latin chunks are single
|
|
433
|
+
// tokens by construction — they never reach here with room > 0, but if
|
|
434
|
+
// one ever does, dropping it beats busting the cap.
|
|
435
|
+
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(chunk)) {
|
|
436
|
+
kept.push(chunk.slice(0, room + 1))
|
|
437
|
+
}
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
return kept.join(' ')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Sanitize a raw user query for retrieval (spec §3.3): strip fenced code
|
|
445
|
+
* blocks and URLs, fold line-leading shell prompts, fold over-long lines to
|
|
446
|
+
* their head, cap the distinct-token count. Returns '' when nothing lexical
|
|
447
|
+
* survives — the caller treats that as a zero-token query (natural skip).
|
|
448
|
+
* Pure function; the hot lane only reaches it behind packageMode.
|
|
449
|
+
*/
|
|
450
|
+
export function sanitizeQuery(raw: string, opts: SanitizeOptions = {}): string {
|
|
451
|
+
const cap = opts.maxTokens ?? QUERY_TOKEN_CAP
|
|
452
|
+
const maxLineChars = opts.maxLineChars ?? MAX_LINE_CHARS
|
|
453
|
+
if (raw === '') return ''
|
|
454
|
+
let text = stripFencedBlocks(raw)
|
|
455
|
+
text = text.replace(URL_RE, ' ')
|
|
456
|
+
text = text
|
|
457
|
+
.split('\n')
|
|
458
|
+
.map((line) => {
|
|
459
|
+
let l = line.replace(LINE_PROMPT, '')
|
|
460
|
+
if (l.length > maxLineChars) l = l.slice(0, maxLineChars)
|
|
461
|
+
return l
|
|
462
|
+
})
|
|
463
|
+
.join('\n')
|
|
464
|
+
text = text.replace(/\s+/g, ' ').trim()
|
|
465
|
+
if (text === '') return ''
|
|
466
|
+
return capTokens(text, cap)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// --- ② Embedding index (A4 决议 2, spec §3.1) -------------------------------
|
|
470
|
+
// meta/embeddings.json: { slug: { model, dim, vec } }. Loading and auditing
|
|
471
|
+
// never throws; a missing/corrupt index reads as EMPTY, which routes every
|
|
472
|
+
// semantic path into the explicit passthrough below.
|
|
473
|
+
|
|
474
|
+
export interface EmbeddingIndexEntry {
|
|
475
|
+
model: string
|
|
476
|
+
dim: number
|
|
477
|
+
vec: number[]
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** slug -> entry, exactly as persisted in meta/embeddings.json. */
|
|
481
|
+
export type EmbeddingIndex = Record<string, EmbeddingIndexEntry>
|
|
482
|
+
|
|
483
|
+
export function embeddingIndexPath(bundleDir: string): string {
|
|
484
|
+
return join(bundleDir, 'meta', 'embeddings.json')
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseEmbeddingIndexEntry(value: unknown): EmbeddingIndexEntry | null {
|
|
488
|
+
if (typeof value !== 'object' || value === null) return null
|
|
489
|
+
const e = value as Record<string, unknown>
|
|
490
|
+
if (typeof e.model !== 'string' || e.model === '') return null
|
|
491
|
+
if (typeof e.dim !== 'number' || !Number.isInteger(e.dim) || e.dim <= 0) return null
|
|
492
|
+
if (!Array.isArray(e.vec) || e.vec.length !== e.dim) return null
|
|
493
|
+
if (!e.vec.every((n) => typeof n === 'number' && Number.isFinite(n))) return null
|
|
494
|
+
return { model: e.model, dim: e.dim, vec: e.vec as number[] }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Load meta/embeddings.json from a bundle dir. Missing file, corrupt JSON,
|
|
498
|
+
* or malformed entries are skipped/empty — never throws (a broken index
|
|
499
|
+
* must degrade to the lexical path, not break retrieval). */
|
|
500
|
+
export function loadEmbeddingIndex(bundleDir: string): EmbeddingIndex {
|
|
501
|
+
let raw: string
|
|
502
|
+
try {
|
|
503
|
+
raw = readFileSync(embeddingIndexPath(bundleDir), 'utf8')
|
|
504
|
+
} catch {
|
|
505
|
+
return {}
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const json: unknown = JSON.parse(raw)
|
|
509
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) return {}
|
|
510
|
+
const out: EmbeddingIndex = {}
|
|
511
|
+
for (const [slug, entry] of Object.entries(json as Record<string, unknown>)) {
|
|
512
|
+
if (slug === '') continue
|
|
513
|
+
const parsed = parseEmbeddingIndexEntry(entry)
|
|
514
|
+
if (parsed !== null) out[slug] = parsed
|
|
515
|
+
}
|
|
516
|
+
return out
|
|
517
|
+
} catch {
|
|
518
|
+
return {}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Persist the index in the canonical shape (the rebuild's write side —
|
|
523
|
+
* embedding/rebuilding itself stays with the caller). Offline rebuild
|
|
524
|
+
* path, not the hot lane. */
|
|
525
|
+
export function saveEmbeddingIndex(bundleDir: string, index: EmbeddingIndex, mkdir = true): void {
|
|
526
|
+
if (mkdir) mkdirSync(join(bundleDir, 'meta'), { recursive: true })
|
|
527
|
+
writeFileSync(embeddingIndexPath(bundleDir), JSON.stringify(index))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* An embed function bound to a config, carrying the RESOLVED model identity
|
|
532
|
+
* the index must match (build once via embedding.ts's `embedderWith`).
|
|
533
|
+
* ensureIndexFresh reads `.model` only — it never calls the function; the
|
|
534
|
+
* rebuild is the caller's job.
|
|
535
|
+
*/
|
|
536
|
+
export type EmbedderFn = ((texts: readonly string[]) => Promise<EmbedResult>) & { model: string }
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Freshness audit: which slugs need (re)embedding — missing from the index,
|
|
540
|
+
* built by a different model, or shape-broken (dim/vec disagree). Returns
|
|
541
|
+
* them in roster order; an EMPTY list means the index covers the roster.
|
|
542
|
+
* A '' embedder model means the semantic layer is off → nothing to maintain.
|
|
543
|
+
*
|
|
544
|
+
* NOTE the freshness boundary: content-change invalidation needs a stored
|
|
545
|
+
* content hash, which the pinned {model,dim,vec} entry shape does not carry
|
|
546
|
+
* — the rebuild trigger policy is spec-实现期定 and stays with the caller.
|
|
547
|
+
*/
|
|
548
|
+
export function ensureIndexFresh(index: EmbeddingIndex, topics: readonly { slug: string }[], embedFn: EmbedderFn): string[] {
|
|
549
|
+
if ((embedFn.model ?? '') === '') return []
|
|
550
|
+
const stale: string[] = []
|
|
551
|
+
for (const t of topics) {
|
|
552
|
+
const e = index[t.slug]
|
|
553
|
+
if (e === undefined || e.model !== embedFn.model || e.dim !== e.vec.length || !(e.dim > 0)) stale.push(t.slug)
|
|
554
|
+
}
|
|
555
|
+
return stale
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- ③ nearFloor semantic rerank (A4 决议 2, spec §3.1) ---------------------
|
|
559
|
+
// Cosine reordering of the nearFloor band ONLY (0.20–0.30 — the 93%
|
|
560
|
+
// near-miss mass). Thresholds, gateScore and the structural gate are
|
|
561
|
+
// untouched: the reranker reorders and tags, never re-scores.
|
|
562
|
+
|
|
563
|
+
export const RERANK_FLOOR = 0.2
|
|
564
|
+
export const RERANK_CEILING = 0.3
|
|
565
|
+
export const SEMANTIC_RERANK_REASON = 'semantic-rerank'
|
|
566
|
+
|
|
567
|
+
export interface RerankedHit extends SearchHit {
|
|
568
|
+
/** Cosine(query, slug-vector), rounded 4dp. Present only on reranked
|
|
569
|
+
* hits; absent = the candidate rode through unchanged. */
|
|
570
|
+
similarity?: number
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export type RerankPassthroughReason =
|
|
574
|
+
| 'no-candidates' // nothing to rank
|
|
575
|
+
| 'no-query-vector' // embed failed upstream — the explicit degradation
|
|
576
|
+
| 'empty-index' // meta/embeddings.json absent/corrupt
|
|
577
|
+
| 'no-rerankable-candidates' // band candidates lack usable index vectors
|
|
578
|
+
|
|
579
|
+
export interface RerankOptions {
|
|
580
|
+
/** Expected index model; entries built by another model count as
|
|
581
|
+
* vector-less (they ride through at the band tail). */
|
|
582
|
+
expectedModel?: string
|
|
583
|
+
floor?: number
|
|
584
|
+
ceiling?: number
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export interface RerankResult {
|
|
588
|
+
/** Candidates in final order. On passthrough: the input array, same
|
|
589
|
+
* order, no tags — byte-identical ordering, explicit why. */
|
|
590
|
+
hits: RerankedHit[]
|
|
591
|
+
/** true = the semantic layer engaged (order may or may not have moved). */
|
|
592
|
+
reranked: boolean
|
|
593
|
+
passthroughReason?: RerankPassthroughReason
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Cosine similarity; 0 for dim mismatch or zero vectors (never throws). */
|
|
597
|
+
export function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
|
|
598
|
+
if (a.length === 0 || a.length !== b.length) return 0
|
|
599
|
+
let dot = 0
|
|
600
|
+
let na = 0
|
|
601
|
+
let nb = 0
|
|
602
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
603
|
+
dot += a[i]! * b[i]!
|
|
604
|
+
na += a[i]! * a[i]!
|
|
605
|
+
nb += b[i]! * b[i]!
|
|
606
|
+
}
|
|
607
|
+
if (na === 0 || nb === 0) return 0
|
|
608
|
+
return dot / Math.sqrt(na * nb)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Reorder the nearFloor band (0.20–0.30 by gateScore) by cosine similarity
|
|
613
|
+
* against the query vector. Band members with a usable index vector sort by
|
|
614
|
+
* similarity (stable — ties keep input order) and gain the
|
|
615
|
+
* `semantic-rerank` reason plus their similarity; everyone else — out of
|
|
616
|
+
* band, unindexed, model-mismatched — passes through UNCHANGED at the tail
|
|
617
|
+
* in input order. Scores are never touched.
|
|
618
|
+
*
|
|
619
|
+
* Degradation is explicit and total: provider/model unconfigured, probe
|
|
620
|
+
* failure, missing query vector or missing index all return the input as-is
|
|
621
|
+
* with `reranked:false` and a passthroughReason (原序透传,不许静默 — the
|
|
622
|
+
* caller logs it into the degraded status). `queryText` rides the signature
|
|
623
|
+
* for provenance/debug parity with the caller's logging; the v1 math is
|
|
624
|
+
* vector-only.
|
|
625
|
+
*/
|
|
626
|
+
export function rerankNearFloor(
|
|
627
|
+
queryText: string,
|
|
628
|
+
queryVec: readonly number[] | null | undefined,
|
|
629
|
+
nearFloorCandidates: readonly SearchHit[],
|
|
630
|
+
index: EmbeddingIndex,
|
|
631
|
+
opts: RerankOptions = {},
|
|
632
|
+
): RerankResult {
|
|
633
|
+
void queryText // v1 math is vector-only — see doc above
|
|
634
|
+
if (nearFloorCandidates.length === 0) {
|
|
635
|
+
return { hits: [], reranked: false, passthroughReason: 'no-candidates' }
|
|
636
|
+
}
|
|
637
|
+
if (!Array.isArray(queryVec) || queryVec.length === 0) {
|
|
638
|
+
return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-query-vector' }
|
|
639
|
+
}
|
|
640
|
+
if (Object.keys(index).length === 0) {
|
|
641
|
+
return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'empty-index' }
|
|
642
|
+
}
|
|
643
|
+
const floor = opts.floor ?? RERANK_FLOOR
|
|
644
|
+
const ceiling = opts.ceiling ?? RERANK_CEILING
|
|
645
|
+
const simOf = new Map<SearchHit, number | null>()
|
|
646
|
+
for (const hit of nearFloorCandidates) {
|
|
647
|
+
const entry = index[hit.slug]
|
|
648
|
+
const usable =
|
|
649
|
+
entry !== undefined &&
|
|
650
|
+
entry.dim === entry.vec.length &&
|
|
651
|
+
entry.dim > 0 &&
|
|
652
|
+
entry.dim === queryVec.length &&
|
|
653
|
+
(opts.expectedModel === undefined || entry.model === opts.expectedModel)
|
|
654
|
+
simOf.set(hit, usable ? cosineSimilarity(queryVec, entry.vec) : null)
|
|
655
|
+
}
|
|
656
|
+
const band = nearFloorCandidates.filter((h) => {
|
|
657
|
+
const sim = simOf.get(h)
|
|
658
|
+
return sim !== null && sim !== undefined && h.gateScore !== undefined && h.gateScore >= floor && h.gateScore < ceiling
|
|
659
|
+
})
|
|
660
|
+
if (band.length === 0) {
|
|
661
|
+
return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-rerankable-candidates' }
|
|
662
|
+
}
|
|
663
|
+
const rerankedSet = new Set(band)
|
|
664
|
+
const tail = nearFloorCandidates.filter((h) => !rerankedSet.has(h))
|
|
665
|
+
// Stable sort: V8's Array#sort keeps input order for equal similarities.
|
|
666
|
+
const ordered = [...band].sort((x, y) => (simOf.get(y) as number) - (simOf.get(x) as number))
|
|
667
|
+
return {
|
|
668
|
+
hits: [
|
|
669
|
+
...ordered.map((h) => {
|
|
670
|
+
const sim = simOf.get(h) as number
|
|
671
|
+
return { ...h, reasons: [...h.reasons, SEMANTIC_RERANK_REASON], similarity: Math.round(sim * 10_000) / 10_000 }
|
|
672
|
+
}),
|
|
673
|
+
...tail.map((h) => ({ ...h })),
|
|
674
|
+
],
|
|
675
|
+
reranked: true,
|
|
676
|
+
}
|
|
677
|
+
}
|