dsh-plugin-discover 0.0.0-stage → 1.0.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/CHANGELOG.md +31 -0
- package/LICENSE +26 -0
- package/README.md +209 -3
- package/README.zh.md +208 -0
- package/client.js +477 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +14 -0
- package/index.js +347 -0
- package/lib/bm25.js +190 -0
- package/lib/build-worker.js +32 -0
- package/lib/build.js +145 -0
- package/lib/cli.js +495 -0
- package/lib/compat.js +157 -0
- package/lib/filters.js +105 -0
- package/lib/model-client.js +163 -0
- package/lib/normalize.js +369 -0
- package/lib/paths.js +49 -0
- package/lib/quality.js +61 -0
- package/lib/readme-clean.js +128 -0
- package/lib/readme-source.js +380 -0
- package/lib/report.js +244 -0
- package/lib/runtime.js +247 -0
- package/lib/search.js +447 -0
- package/lib/store.js +184 -0
- package/lib/tokenize.js +141 -0
- package/lib/vector-store.js +142 -0
- package/lib/vector.js +592 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -6
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side runtime: load the published index lazily, keep one searcher in
|
|
3
|
+
* memory, and start background builds without blocking a session.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { appendFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { createSearcher } from './search.js'
|
|
9
|
+
import { detectRuntimeVersion } from './compat.js'
|
|
10
|
+
import { buildIndex, isStale } from './build.js'
|
|
11
|
+
import { defaultIndexDir, buildLogPath } from './paths.js'
|
|
12
|
+
import {
|
|
13
|
+
isLockStale, readBuildLog, readLock, readManifest, readRecords,
|
|
14
|
+
} from './store.js'
|
|
15
|
+
|
|
16
|
+
const WORKER = fileURLToPath(new URL('./build-worker.js', import.meta.url))
|
|
17
|
+
|
|
18
|
+
function readConfig(value) {
|
|
19
|
+
return value && typeof value.get === 'function' ? value.get() : value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveIndexDir(configured) {
|
|
23
|
+
const value = readConfig(configured)
|
|
24
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
25
|
+
return text === '' ? defaultIndexDir() : text
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nodeCommand() {
|
|
29
|
+
if (process.env.DSH_PLUGIN_DISCOVER_NODE) return process.env.DSH_PLUGIN_DISCOVER_NODE
|
|
30
|
+
return process.execPath
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One runtime per plugin activation. */
|
|
34
|
+
export function createRuntime(config = {}) {
|
|
35
|
+
let indexDir = resolveIndexDir(config.indexDir)
|
|
36
|
+
let loaded = null
|
|
37
|
+
let building = false
|
|
38
|
+
let lastError = null
|
|
39
|
+
let child = null
|
|
40
|
+
let inProcess = false
|
|
41
|
+
|
|
42
|
+
/** Apply a live indexDir override between calls, without restarting the Host. */
|
|
43
|
+
function reconfigure(next = {}) {
|
|
44
|
+
const nextDir = resolveIndexDir(next.indexDir)
|
|
45
|
+
if (nextDir === indexDir || building) return
|
|
46
|
+
indexDir = nextDir
|
|
47
|
+
loaded = null
|
|
48
|
+
lastError = null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function load() {
|
|
52
|
+
const current = readManifest(indexDir)
|
|
53
|
+
if (!current) return null
|
|
54
|
+
if (loaded && loaded.name === current.name && loaded.mtimeMs === current.mtimeMs) return loaded
|
|
55
|
+
const records = readRecords(indexDir)
|
|
56
|
+
if (!records) return null
|
|
57
|
+
loaded = {
|
|
58
|
+
name: current.name,
|
|
59
|
+
mtimeMs: current.mtimeMs,
|
|
60
|
+
manifest: current.manifest,
|
|
61
|
+
searcher: createSearcher(records.records, { runtimeVersion: detectRuntimeVersion() }),
|
|
62
|
+
}
|
|
63
|
+
return loaded
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function invalidate() {
|
|
67
|
+
loaded = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function activeLock() {
|
|
71
|
+
const lock = readLock(indexDir)
|
|
72
|
+
if (!lock) return null
|
|
73
|
+
return isLockStale(lock) ? null : lock
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function status() {
|
|
77
|
+
const manifest = readManifest(indexDir)
|
|
78
|
+
const lock = activeLock()
|
|
79
|
+
return {
|
|
80
|
+
indexDir,
|
|
81
|
+
hasIndex: manifest !== null,
|
|
82
|
+
manifest: manifest?.manifest ?? null,
|
|
83
|
+
name: manifest?.name ?? null,
|
|
84
|
+
building: building || lock !== null,
|
|
85
|
+
lock,
|
|
86
|
+
lastError,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function finishBuild(error) {
|
|
91
|
+
building = false
|
|
92
|
+
child = null
|
|
93
|
+
invalidate()
|
|
94
|
+
if (error) lastError = error
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function startInProcess() {
|
|
98
|
+
if (inProcess) return
|
|
99
|
+
inProcess = true
|
|
100
|
+
building = true
|
|
101
|
+
child = null
|
|
102
|
+
const log = message => {
|
|
103
|
+
try { appendFileSync(buildLogPath(indexDir), `[${new Date().toISOString()}] ${message}\n`) } catch {}
|
|
104
|
+
}
|
|
105
|
+
void buildIndex({ indexDir, onProgress: log })
|
|
106
|
+
.then(() => finishBuild(null))
|
|
107
|
+
.catch(error => finishBuild(error instanceof Error ? error.message : String(error)))
|
|
108
|
+
.finally(() => { inProcess = false })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function kickBuild() {
|
|
112
|
+
if (building) return { started: false, reason: 'running' }
|
|
113
|
+
const lock = activeLock()
|
|
114
|
+
if (lock) return { started: false, reason: 'locked', lock }
|
|
115
|
+
building = true
|
|
116
|
+
lastError = null
|
|
117
|
+
try {
|
|
118
|
+
const env = { ...process.env }
|
|
119
|
+
if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = '1'
|
|
120
|
+
child = spawn(nodeCommand(), [WORKER, indexDir], {
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: 'ignore',
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
env,
|
|
125
|
+
})
|
|
126
|
+
child.once('error', error => {
|
|
127
|
+
lastError = `后台建库启动失败,改为主进程执行:${error.message}`
|
|
128
|
+
startInProcess()
|
|
129
|
+
})
|
|
130
|
+
child.once('exit', code => {
|
|
131
|
+
if (inProcess) return
|
|
132
|
+
if (code && code !== 0) lastError = readBuildLog(indexDir).slice(-1200) || `建库进程退出码 ${code}`
|
|
133
|
+
finishBuild(null)
|
|
134
|
+
})
|
|
135
|
+
child.unref()
|
|
136
|
+
return { started: true }
|
|
137
|
+
} catch (error) {
|
|
138
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
139
|
+
startInProcess()
|
|
140
|
+
return { started: true, fallback: 'in-process' }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ensureIndex(policy = 'daily') {
|
|
145
|
+
const current = load()
|
|
146
|
+
if (current) {
|
|
147
|
+
if (isStale(current.manifest, policy)) {
|
|
148
|
+
const kick = kickBuild()
|
|
149
|
+
return { current, building: kick.started || kick.reason === 'locked' || kick.reason === 'running', kick }
|
|
150
|
+
}
|
|
151
|
+
return { current, building: building || activeLock() !== null }
|
|
152
|
+
}
|
|
153
|
+
if (policy === 'off') return { current: null, building: false, kick: { started: false, reason: 'off' } }
|
|
154
|
+
const kick = kickBuild()
|
|
155
|
+
return { current: null, building: kick.started || kick.reason === 'locked' || kick.reason === 'running', kick }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function indexAgeMs() {
|
|
159
|
+
const current = readManifest(indexDir)
|
|
160
|
+
const builtAt = Date.parse(current?.manifest?.builtAt ?? '')
|
|
161
|
+
return Number.isFinite(builtAt) ? Date.now() - builtAt : Number.POSITIVE_INFINITY
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isOlderThan(days) {
|
|
165
|
+
return indexAgeMs() >= days * 86_400_000
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function waitForBuild() {
|
|
169
|
+
if (!building && activeLock() === null) return
|
|
170
|
+
await new Promise(resolve => {
|
|
171
|
+
const timer = setInterval(() => {
|
|
172
|
+
if (!building && activeLock() === null) {
|
|
173
|
+
clearInterval(timer)
|
|
174
|
+
resolve()
|
|
175
|
+
}
|
|
176
|
+
}, 200)
|
|
177
|
+
timer.unref?.()
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Blocking freshness gate for a user search. A stale/missing index must be
|
|
183
|
+
* rebuilt before searching; the previous index is only a failure fallback.
|
|
184
|
+
*/
|
|
185
|
+
async function ensureFresh(days = 7) {
|
|
186
|
+
const current = load()
|
|
187
|
+
if (current && !isOlderThan(days)) {
|
|
188
|
+
return { current, stale: false, refreshed: false, fallback: false, error: null }
|
|
189
|
+
}
|
|
190
|
+
const previous = current
|
|
191
|
+
kickBuild()
|
|
192
|
+
await waitForBuild()
|
|
193
|
+
const next = load()
|
|
194
|
+
if (next && !isOlderThan(days)) {
|
|
195
|
+
return { current: next, stale: true, refreshed: true, fallback: false, error: null }
|
|
196
|
+
}
|
|
197
|
+
if (previous) {
|
|
198
|
+
return {
|
|
199
|
+
current: previous,
|
|
200
|
+
stale: true,
|
|
201
|
+
refreshed: false,
|
|
202
|
+
fallback: true,
|
|
203
|
+
error: lastError || 'refresh did not produce a fresh index',
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
current: null,
|
|
208
|
+
stale: true,
|
|
209
|
+
refreshed: false,
|
|
210
|
+
fallback: false,
|
|
211
|
+
error: lastError || 'refresh failed and no previous index exists',
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function dispose() {
|
|
216
|
+
loaded = null
|
|
217
|
+
if (child) {
|
|
218
|
+
try { child.removeAllListeners() } catch {}
|
|
219
|
+
child = null
|
|
220
|
+
}
|
|
221
|
+
building = false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
get indexDir() { return indexDir },
|
|
226
|
+
load,
|
|
227
|
+
invalidate,
|
|
228
|
+
status,
|
|
229
|
+
kickBuild,
|
|
230
|
+
ensureIndex,
|
|
231
|
+
ensureFresh,
|
|
232
|
+
waitForBuild,
|
|
233
|
+
reconfigure,
|
|
234
|
+
dispose,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Coverage block for the tool value. */
|
|
239
|
+
export function coverageOf(manifest) {
|
|
240
|
+
if (!manifest) return null
|
|
241
|
+
const sources = manifest.sources ?? {}
|
|
242
|
+
return {
|
|
243
|
+
sources: sources.catalog?.ok ? ['dsh.so', 'dsh-plugin-catalog'] : ['dsh.so'],
|
|
244
|
+
indexed: manifest.stats?.cards ?? 0,
|
|
245
|
+
generatedAt: sources.dshSo?.generatedAt ?? manifest.generatedAt ?? null,
|
|
246
|
+
}
|
|
247
|
+
}
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local retrieval pipeline: normalize, hard-filter, four-way BM25, RRF,
|
|
3
|
+
* repo merge, (optional) vector rerank, quality prior, and a bounded result.
|
|
4
|
+
*/
|
|
5
|
+
import { buildBm25 } from './bm25.js'
|
|
6
|
+
import { qualityFactor } from './quality.js'
|
|
7
|
+
import { selectFilter } from './filters.js'
|
|
8
|
+
import { prepareQueries, tokenize } from './tokenize.js'
|
|
9
|
+
import { vectorRerank } from './vector.js'
|
|
10
|
+
|
|
11
|
+
const RRF_K = 60
|
|
12
|
+
const ROUTE_WEIGHT = 1
|
|
13
|
+
const ALT_WEIGHT = 0.7
|
|
14
|
+
const CANDIDATE_POOL = 50
|
|
15
|
+
|
|
16
|
+
/** v2.4 gate1: exact-evidence check used only to decide whether a query has a match. */
|
|
17
|
+
export const GATE1_MIN_STRONG_COVERAGE = 0.55
|
|
18
|
+
export const GATE1_MIN_HITS_COVERAGE = 0.65
|
|
19
|
+
export const GATE1_MAX_HITS = 8
|
|
20
|
+
export const GATE1_MAX_DENOMINATOR = 8
|
|
21
|
+
|
|
22
|
+
/** User-facing irrelevant-result filter strength. */
|
|
23
|
+
export const NOISE_FILTER_LEVELS = Object.freeze({
|
|
24
|
+
none: Object.freeze({ gate1: null, gate2: null }),
|
|
25
|
+
low: Object.freeze({
|
|
26
|
+
gate1: Object.freeze({ strong: 0.45, hits: 0.53 }),
|
|
27
|
+
gate2: 0.815,
|
|
28
|
+
}),
|
|
29
|
+
medium: Object.freeze({
|
|
30
|
+
gate1: Object.freeze({ strong: GATE1_MIN_STRONG_COVERAGE, hits: GATE1_MIN_HITS_COVERAGE }),
|
|
31
|
+
gate2: 0.85,
|
|
32
|
+
}),
|
|
33
|
+
high: Object.freeze({
|
|
34
|
+
gate1: Object.freeze({ strong: 0.60, hits: 0.70 }),
|
|
35
|
+
gate2: 0.87,
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
export const DEFAULT_NOISE_FILTER = 'medium'
|
|
39
|
+
export const NOISE_FILTERS = Object.freeze(Object.keys(NOISE_FILTER_LEVELS))
|
|
40
|
+
|
|
41
|
+
export function noiseFilterConfig(level) {
|
|
42
|
+
return NOISE_FILTER_LEVELS[level] ?? NOISE_FILTER_LEVELS[DEFAULT_NOISE_FILTER]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build the per-index search state once and reuse it for every query. */
|
|
46
|
+
export function createSearcher(records, { runtimeVersion = null } = {}) {
|
|
47
|
+
const byKey = new Map()
|
|
48
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
49
|
+
const record = records[index]
|
|
50
|
+
byKey.set(record.id, index)
|
|
51
|
+
byKey.set(record.name, index)
|
|
52
|
+
if (record.alias) byKey.set(record.alias, index)
|
|
53
|
+
if (record.install?.target) byKey.set(record.install.target, index)
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
records,
|
|
57
|
+
bm25: buildBm25(records),
|
|
58
|
+
byKey,
|
|
59
|
+
runtimeVersion,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function groupKey(record) {
|
|
64
|
+
return record.repoKey || `name:${record.name.toLowerCase()}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordContains(record, term) {
|
|
68
|
+
const fields = [
|
|
69
|
+
record.name,
|
|
70
|
+
record.title,
|
|
71
|
+
(record.topics ?? []).join(' '),
|
|
72
|
+
(record.capability ?? []).join(' '),
|
|
73
|
+
record.description,
|
|
74
|
+
]
|
|
75
|
+
return fields.some(value => typeof value === 'string' && value.toLowerCase().includes(term))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Zero out allowed documents that contain a negated term. */
|
|
79
|
+
function applyNegation(records, allowed, terms) {
|
|
80
|
+
if (terms.length === 0) return { allowed, dropped: 0 }
|
|
81
|
+
const masked = Uint8Array.from(allowed)
|
|
82
|
+
let dropped = 0
|
|
83
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
84
|
+
if (masked[index] === 0) continue
|
|
85
|
+
if (terms.some(term => recordContains(records[index], term))) {
|
|
86
|
+
masked[index] = 0
|
|
87
|
+
dropped += 1
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { allowed: masked, dropped }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function routeTermsOf(prepared, from) {
|
|
94
|
+
if (from === 'query') return prepared.original.terms
|
|
95
|
+
const index = Number(String(from).replace('alt', '')) - 1
|
|
96
|
+
return prepared.rewrites[index]?.terms ?? prepared.original.terms
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function evidenceFor(record, terms, maxHits = 6) {
|
|
100
|
+
const fields = []
|
|
101
|
+
const text = {
|
|
102
|
+
name: record.name,
|
|
103
|
+
title: record.title ?? '',
|
|
104
|
+
topics: (record.topics ?? []).join(' '),
|
|
105
|
+
capability: (record.capability ?? []).join(' '),
|
|
106
|
+
description: record.description ?? '',
|
|
107
|
+
}
|
|
108
|
+
const hits = []
|
|
109
|
+
for (const term of terms) {
|
|
110
|
+
let matched = false
|
|
111
|
+
for (const [field, value] of Object.entries(text)) {
|
|
112
|
+
if (typeof value !== 'string' || value === '') continue
|
|
113
|
+
if (value.toLowerCase().includes(term)) {
|
|
114
|
+
if (!fields.includes(field)) fields.push(field)
|
|
115
|
+
matched = true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (matched) hits.push(term)
|
|
119
|
+
if (hits.length >= maxHits) break
|
|
120
|
+
}
|
|
121
|
+
return { fields, terms: hits }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* gate1 evidence check. It is a judgment, not a filter: the caller uses
|
|
126
|
+
* `pass` only to decide whether the query has any matching candidate. It does
|
|
127
|
+
* not reorder or delete individual candidates.
|
|
128
|
+
*/
|
|
129
|
+
export function evaluateGate1(record, terms, thresholds = NOISE_FILTER_LEVELS.medium.gate1) {
|
|
130
|
+
const evidence = evidenceFor(record, terms, GATE1_MAX_HITS)
|
|
131
|
+
const denominator = Math.max(1, Math.min(terms.length, GATE1_MAX_DENOMINATOR))
|
|
132
|
+
const coverage = evidence.terms.length / denominator
|
|
133
|
+
const strongField = evidence.fields.some(field => field === 'name' || field === 'topics' || field === 'capability')
|
|
134
|
+
const pass = (strongField && coverage >= thresholds.strong)
|
|
135
|
+
|| (evidence.terms.length >= 2 && coverage >= thresholds.hits)
|
|
136
|
+
return {
|
|
137
|
+
pass,
|
|
138
|
+
hits: evidence.terms.length,
|
|
139
|
+
terms: terms.length,
|
|
140
|
+
denominator,
|
|
141
|
+
coverage,
|
|
142
|
+
strongField,
|
|
143
|
+
fields: evidence.fields,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function badgesFor(record) {
|
|
148
|
+
const badges = {}
|
|
149
|
+
if (record.verification?.level) badges.verification = `L${record.verification.level}`
|
|
150
|
+
if (record.security?.status === 'audited' && record.security.riskLevel) {
|
|
151
|
+
badges.security = record.security.riskLevel
|
|
152
|
+
} else if (record.security?.status) {
|
|
153
|
+
badges.security = record.security.status
|
|
154
|
+
}
|
|
155
|
+
if (record.updatedAt) badges.updatedAt = record.updatedAt.slice(0, 10)
|
|
156
|
+
return badges
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resultFor(record, { from, terms, extraAlternatives = [], evidence = null }) {
|
|
160
|
+
const result = {
|
|
161
|
+
id: record.id,
|
|
162
|
+
name: record.name,
|
|
163
|
+
description: record.description,
|
|
164
|
+
evidence: { ...(evidence ?? evidenceFor(record, terms)), from },
|
|
165
|
+
alternatives: [
|
|
166
|
+
...new Set([
|
|
167
|
+
...(record.alternatives ?? []).map(item => item.name),
|
|
168
|
+
...extraAlternatives,
|
|
169
|
+
]),
|
|
170
|
+
].filter(name => name && name !== record.name).slice(0, 6),
|
|
171
|
+
}
|
|
172
|
+
if (typeof record.stars === 'number') result.stars = record.stars
|
|
173
|
+
if (record.title) result.title = record.title
|
|
174
|
+
if (record.install?.target) result.install = { target: record.install.target }
|
|
175
|
+
const badges = badgesFor(record)
|
|
176
|
+
if (Object.keys(badges).length > 0) result.badges = badges
|
|
177
|
+
return result
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run one search.
|
|
182
|
+
* @param {ReturnType<typeof createSearcher>} searcher
|
|
183
|
+
* @param {object} params
|
|
184
|
+
*/
|
|
185
|
+
export async function search(searcher, {
|
|
186
|
+
query,
|
|
187
|
+
queryAlts = [],
|
|
188
|
+
limit = 10,
|
|
189
|
+
preset = 'balanced',
|
|
190
|
+
minVerificationLevel = 4,
|
|
191
|
+
vectorMode = 'off',
|
|
192
|
+
vectorTopN = CANDIDATE_POOL,
|
|
193
|
+
noiseFilter = DEFAULT_NOISE_FILTER,
|
|
194
|
+
readmeOptions = null,
|
|
195
|
+
indexDir = null,
|
|
196
|
+
disposeClient = true,
|
|
197
|
+
coverage = null,
|
|
198
|
+
} = {}) {
|
|
199
|
+
const prepared = prepareQueries(query, queryAlts)
|
|
200
|
+
const routes = [prepared.original, ...prepared.rewrites].filter(route => route.terms.length > 0)
|
|
201
|
+
const selectedNoise = NOISE_FILTERS.includes(noiseFilter) ? noiseFilter : DEFAULT_NOISE_FILTER
|
|
202
|
+
const noise = noiseFilterConfig(selectedNoise)
|
|
203
|
+
const interpreted = {
|
|
204
|
+
terms: prepared.original.terms,
|
|
205
|
+
not: prepared.not,
|
|
206
|
+
filters: { preset },
|
|
207
|
+
}
|
|
208
|
+
const empty = {
|
|
209
|
+
query,
|
|
210
|
+
interpreted,
|
|
211
|
+
filterStats: { droppedBy: { security: 0, compat: 0, verification: 0, negation: 0 } },
|
|
212
|
+
results: [],
|
|
213
|
+
noMatch: true,
|
|
214
|
+
...(coverage ? { coverage } : {}),
|
|
215
|
+
}
|
|
216
|
+
if (routes.length === 0) return empty
|
|
217
|
+
|
|
218
|
+
const filter = selectFilter(searcher.records, {
|
|
219
|
+
preset,
|
|
220
|
+
minVerificationLevel,
|
|
221
|
+
runtimeVersion: searcher.runtimeVersion,
|
|
222
|
+
})
|
|
223
|
+
interpreted.filters.preset = filter.preset
|
|
224
|
+
if (filter.relaxedFrom) interpreted.filters.relaxedFrom = filter.relaxedFrom
|
|
225
|
+
|
|
226
|
+
const negation = applyNegation(searcher.records, filter.allowed, prepared.not)
|
|
227
|
+
|
|
228
|
+
const fused = new Map()
|
|
229
|
+
routes.forEach((route, routeIndex) => {
|
|
230
|
+
const weight = routeIndex === 0 ? ROUTE_WEIGHT : ALT_WEIGHT
|
|
231
|
+
const hits = searcher.bm25.search(route.terms, { allowed: negation.allowed, limit: 400 })
|
|
232
|
+
hits.forEach((hit, rank) => {
|
|
233
|
+
const contribution = weight / (RRF_K + rank + 1)
|
|
234
|
+
const current = fused.get(hit.doc)
|
|
235
|
+
if (!current) {
|
|
236
|
+
fused.set(hit.doc, {
|
|
237
|
+
doc: hit.doc,
|
|
238
|
+
score: contribution,
|
|
239
|
+
best: contribution,
|
|
240
|
+
from: routeIndex === 0 ? 'query' : `alt${routeIndex}`,
|
|
241
|
+
matched: hit.matched,
|
|
242
|
+
})
|
|
243
|
+
} else {
|
|
244
|
+
current.score += contribution
|
|
245
|
+
if (contribution > current.best) {
|
|
246
|
+
current.best = contribution
|
|
247
|
+
current.from = routeIndex === 0 ? 'query' : `alt${routeIndex}`
|
|
248
|
+
}
|
|
249
|
+
current.matched = Math.max(current.matched, hit.matched)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const ranked = [...fused.values()].sort((a, b) => b.score - a.score || a.doc - b.doc)
|
|
255
|
+
const candidatePool = vectorMode === 'off'
|
|
256
|
+
? CANDIDATE_POOL
|
|
257
|
+
: Math.min(200, Math.max(2, Math.round(Number(vectorTopN) || CANDIDATE_POOL)))
|
|
258
|
+
const groups = new Map()
|
|
259
|
+
for (const entry of ranked) {
|
|
260
|
+
const record = searcher.records[entry.doc]
|
|
261
|
+
const key = groupKey(record)
|
|
262
|
+
const group = groups.get(key)
|
|
263
|
+
if (group) {
|
|
264
|
+
group.extras.push(record.name)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
groups.set(key, { ...entry, extras: [] })
|
|
268
|
+
if (groups.size >= candidatePool) break
|
|
269
|
+
}
|
|
270
|
+
let candidates = [...groups.values()].slice(0, candidatePool)
|
|
271
|
+
const gate1Total = candidates.length
|
|
272
|
+
const gate1Enabled = Boolean(noise.gate1)
|
|
273
|
+
if (gate1Enabled) {
|
|
274
|
+
for (const entry of candidates) {
|
|
275
|
+
const record = searcher.records[entry.doc]
|
|
276
|
+
entry.gate1 = evaluateGate1(record, routeTermsOf(prepared, entry.from), noise.gate1)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const gate1PassCount = gate1Enabled
|
|
280
|
+
? candidates.filter(entry => entry.gate1.pass).length
|
|
281
|
+
: gate1Total
|
|
282
|
+
const gate1Any = gate1Enabled ? gate1PassCount > 0 : true
|
|
283
|
+
const filterStats = {
|
|
284
|
+
droppedBy: {
|
|
285
|
+
security: filter.droppedBy.security,
|
|
286
|
+
compat: filter.droppedBy.compatibility,
|
|
287
|
+
verification: filter.droppedBy.verification,
|
|
288
|
+
negation: negation.dropped,
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
const gatesFor = gate2 => ({
|
|
292
|
+
level: selectedNoise,
|
|
293
|
+
gate1: {
|
|
294
|
+
enabled: gate1Enabled,
|
|
295
|
+
passed: gate1Any,
|
|
296
|
+
passCount: gate1PassCount,
|
|
297
|
+
total: gate1Total,
|
|
298
|
+
},
|
|
299
|
+
...(gate2 ? { gate2 } : {}),
|
|
300
|
+
})
|
|
301
|
+
const noMatchValue = gates => ({
|
|
302
|
+
query,
|
|
303
|
+
interpreted,
|
|
304
|
+
filterStats,
|
|
305
|
+
results: [],
|
|
306
|
+
noMatch: true,
|
|
307
|
+
...(coverage ? { coverage } : {}),
|
|
308
|
+
gates,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// gate1 is a judgment, not a per-candidate filter or reorder. In lexical
|
|
312
|
+
// mode a query with no gate1-passing candidate is a genuine noMatch; a
|
|
313
|
+
// passing candidate keeps the normal BM25 order (including gate1 failures).
|
|
314
|
+
if (gate1Enabled && vectorMode === 'off' && !gate1Any) return noMatchValue(gatesFor())
|
|
315
|
+
|
|
316
|
+
const vector = await vectorRerank(candidates, routes, {
|
|
317
|
+
mode: vectorMode,
|
|
318
|
+
gate2Threshold: noise.gate2,
|
|
319
|
+
indexDir,
|
|
320
|
+
records: searcher.records,
|
|
321
|
+
disposeClient,
|
|
322
|
+
readmeOptions,
|
|
323
|
+
})
|
|
324
|
+
if (vector?.candidates) candidates = vector.candidates
|
|
325
|
+
|
|
326
|
+
// gate2 is also a judgment. Balance/high only become noMatch when neither
|
|
327
|
+
// gate1 nor gate2 has a passing candidate; otherwise the normal vector order
|
|
328
|
+
// is returned, including candidates that fail one or both gates.
|
|
329
|
+
let gate2 = null
|
|
330
|
+
if (vectorMode !== 'off') {
|
|
331
|
+
if (!noise.gate2) {
|
|
332
|
+
gate2 = { enabled: false, passed: true, available: Boolean(vector) }
|
|
333
|
+
} else if (vector?.gate2) {
|
|
334
|
+
gate2 = { ...vector.gate2, enabled: true, available: true }
|
|
335
|
+
} else {
|
|
336
|
+
gate2 = { enabled: true, available: false, passed: false, threshold: noise.gate2 }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (gate1Enabled && vectorMode !== 'off' && !gate1Any && gate2 && !gate2.passed) {
|
|
340
|
+
return noMatchValue(gatesFor(gate2))
|
|
341
|
+
}
|
|
342
|
+
const gates = gatesFor(gate2)
|
|
343
|
+
|
|
344
|
+
// Evidence is kept for diagnostics/reporting; it never drops a candidate.
|
|
345
|
+
const rankedWithEvidence = candidates.map(entry => {
|
|
346
|
+
const record = searcher.records[entry.doc]
|
|
347
|
+
const terms = routeTermsOf(prepared, entry.from)
|
|
348
|
+
const evidenceTerms = [...new Set([...terms, ...prepared.original.terms])]
|
|
349
|
+
const evidence = evidenceTerms.length === terms.length
|
|
350
|
+
? evidenceFor(record, terms)
|
|
351
|
+
: evidenceFor(record, evidenceTerms)
|
|
352
|
+
return { ...entry, evidence, terms: evidenceTerms }
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const results = rankedWithEvidence
|
|
356
|
+
.map(entry => {
|
|
357
|
+
const record = searcher.records[entry.doc]
|
|
358
|
+
return {
|
|
359
|
+
entry,
|
|
360
|
+
record,
|
|
361
|
+
finalScore: entry.score * qualityFactor(record),
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
.sort((a, b) => b.finalScore - a.finalScore || a.entry.doc - b.entry.doc)
|
|
365
|
+
.slice(0, Math.min(20, Math.max(1, limit)))
|
|
366
|
+
.map(({ entry, record }) => resultFor(record, {
|
|
367
|
+
from: entry.from,
|
|
368
|
+
terms: entry.terms,
|
|
369
|
+
extraAlternatives: entry.extras,
|
|
370
|
+
evidence: entry.evidence,
|
|
371
|
+
}))
|
|
372
|
+
|
|
373
|
+
const value = {
|
|
374
|
+
query,
|
|
375
|
+
interpreted,
|
|
376
|
+
filterStats,
|
|
377
|
+
results,
|
|
378
|
+
noMatch: results.length === 0,
|
|
379
|
+
gates,
|
|
380
|
+
...(coverage ? { coverage } : {}),
|
|
381
|
+
}
|
|
382
|
+
if (vector) {
|
|
383
|
+
value.vector = {
|
|
384
|
+
mode: vector.mode,
|
|
385
|
+
...(vector.algorithm ? { algorithm: vector.algorithm } : {}),
|
|
386
|
+
covered: vector.covered,
|
|
387
|
+
...(vector.fieldCovered !== undefined ? { fieldCovered: vector.fieldCovered } : {}),
|
|
388
|
+
...(vector.degraded ? { degraded: vector.degraded } : {}),
|
|
389
|
+
...(vector.error ? { error: vector.error } : {}),
|
|
390
|
+
...(vector.readmeCovered !== undefined ? { readmeCovered: vector.readmeCovered } : {}),
|
|
391
|
+
...(vector.readmeFallback !== undefined ? { readmeFallback: vector.readmeFallback } : {}),
|
|
392
|
+
...(vector.readmeSources ? { readmeSources: vector.readmeSources } : {}),
|
|
393
|
+
...(vector.circuit ? { circuit: vector.circuit } : {}),
|
|
394
|
+
...(vector.notice ? { notice: vector.notice } : {}),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return value
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Look up one record without searching. */
|
|
401
|
+
export function explain(searcher, id, { query = '', coverage = null } = {}) {
|
|
402
|
+
const index = searcher.byKey.get(id)
|
|
403
|
+
const value = {
|
|
404
|
+
query,
|
|
405
|
+
noMatch: index === undefined,
|
|
406
|
+
results: [],
|
|
407
|
+
...(coverage ? { coverage } : {}),
|
|
408
|
+
}
|
|
409
|
+
if (index === undefined) {
|
|
410
|
+
value.explain = { found: false, id }
|
|
411
|
+
return value
|
|
412
|
+
}
|
|
413
|
+
const record = searcher.records[index]
|
|
414
|
+
value.explain = {
|
|
415
|
+
found: true,
|
|
416
|
+
id: record.id,
|
|
417
|
+
name: record.name,
|
|
418
|
+
alias: record.alias,
|
|
419
|
+
title: record.title,
|
|
420
|
+
description: record.description,
|
|
421
|
+
topics: record.topics,
|
|
422
|
+
capability: record.capability,
|
|
423
|
+
stars: record.stars,
|
|
424
|
+
verification: record.verification,
|
|
425
|
+
security: record.security,
|
|
426
|
+
repo: record.repo,
|
|
427
|
+
url: record.url,
|
|
428
|
+
install: record.install,
|
|
429
|
+
dshRange: record.dshRange,
|
|
430
|
+
updatedAt: record.updatedAt,
|
|
431
|
+
kind: record.kind,
|
|
432
|
+
sources: record.sources,
|
|
433
|
+
alternatives: record.alternatives ?? [],
|
|
434
|
+
}
|
|
435
|
+
return value
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Diagnostics helper: the token list a document contributes, for tests. */
|
|
439
|
+
export function documentTokens(record) {
|
|
440
|
+
return [
|
|
441
|
+
...tokenize(record.name),
|
|
442
|
+
...tokenize(record.title),
|
|
443
|
+
...tokenize((record.topics ?? []).join(' ')),
|
|
444
|
+
...tokenize((record.capability ?? []).join(' ')),
|
|
445
|
+
...tokenize(record.description),
|
|
446
|
+
]
|
|
447
|
+
}
|