@lotargo/memory_plugin 1.6.6 → 1.6.8
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 +148 -101
- package/README.md +436 -304
- package/mcp-server/cli/direct_commands.js +39 -0
- package/mcp-server/cli.js +16 -5
- package/mcp-server/cli_boot.js +4 -1
- package/mcp-server/client_cli.js +73 -0
- package/mcp-server/client_paths.js +44 -0
- package/mcp-server/client_registration.js +38 -0
- package/mcp-server/codex_config.js +86 -8
- package/mcp-server/db/database.js +14 -21
- package/mcp-server/db/migrations.js +66 -77
- package/mcp-server/db/rag_blob_transport.js +143 -0
- package/mcp-server/db/rag_sync.js +284 -0
- package/mcp-server/db/sync_queue.js +219 -307
- package/mcp-server/dev_link.js +142 -0
- package/mcp-server/fact_format.js +44 -12
- package/mcp-server/index.js +17 -7
- package/mcp-server/ingest/exporter.js +44 -38
- package/mcp-server/ingest/normalizer.js +1 -1
- package/mcp-server/ingest/pipeline.js +260 -248
- package/mcp-server/persona_migration.js +39 -0
- package/mcp-server/prompt_manager.js +162 -55
- package/mcp-server/retrieval/retriever.js +99 -64
- package/mcp-server/setup.js +150 -100
- package/mcp-server/storage/blob_store.js +53 -1
- package/mcp-server/tools/core/knowledge_read_core.js +163 -0
- package/mcp-server/tools/core/memory_core.js +24 -4
- package/mcp-server/tools/core/memory_routing.js +10 -0
- package/mcp-server/tools/core/note_core.js +53 -0
- package/mcp-server/tools/core/rag_query_core.js +169 -0
- package/mcp-server/tools/index.js +11 -9
- package/mcp-server/tools/memory_tools.js +4 -1
- package/mcp-server/tools/note_tools.js +35 -0
- package/mcp-server/tools/rag_tools.js +211 -364
- package/mcp-server/uninstall.js +627 -0
- package/opencode-plugin/index.js +80 -12
- package/opencode-plugin/main.js +136 -0
- package/package.json +25 -5
- package/skills/using-memory/SKILL.md +28 -19
- package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
- package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
- package/mcp-server/benchmarks/policy_dominance_test.js +0 -221
- package/mcp-server/benchmarks/quality_evaluator.js +0 -598
- package/mcp-server/benchmarks/raw_corpus_data.js +0 -613
- package/mcp-server/benchmarks/run_benchmarks.js +0 -366
- package/mcp-server/benchmarks/stress_ingestion.js +0 -195
- package/mcp-server/benchmarks/table_code_retrieval.js +0 -453
- package/mcp-server/benchmarks/test_dual_layer.js +0 -141
|
@@ -1,598 +0,0 @@
|
|
|
1
|
-
import { bm25Search, vectorSearch, rrfFusion, rsfFusion } from "../retrieval/retriever.js";
|
|
2
|
-
import { embedText } from "../ml/model_manager.js";
|
|
3
|
-
import { basename } from "node:path";
|
|
4
|
-
|
|
5
|
-
const PANEL_WIDTH = 58;
|
|
6
|
-
|
|
7
|
-
function printRichPanel(title, subtitle = "") {
|
|
8
|
-
const line = "─".repeat(PANEL_WIDTH - 2);
|
|
9
|
-
console.log(`\x1b[36m╭${line}╮\x1b[0m`);
|
|
10
|
-
console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37m${title.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
|
|
11
|
-
if (subtitle) {
|
|
12
|
-
console.log(`\x1b[36m│\x1b[0m \x1b[90m${subtitle.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
|
|
13
|
-
}
|
|
14
|
-
console.log(`\x1b[36m╰${line}╯\x1b[0m`);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const CHALLENGING_EVALUATION_QUERIES = [
|
|
18
|
-
// Category A: Semantic & Paraphrased Queries (No exact keyword overlap)
|
|
19
|
-
{ query: "Библиотека для выполнения HTTP запросов и отмены отправки данных", expectedDocIds: ["axios_readme"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
20
|
-
{ query: "Инструмент для мгновенной горячей перезагрузки кода при разработке frontend", expectedDocIds: ["vite_readme"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
21
|
-
{ query: "Централизованное управление состоянием приложения в одном сторе", expectedDocIds: ["redux_readme"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
22
|
-
{ query: "Управление изолированными контейнерами приложений через командную строку", expectedDocIds: ["docker_cli_readme"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
23
|
-
{ query: "Компактная база данных прямо внутри процесса без отдельного сервера", expectedDocIds: ["sqlite_readme", "sqlite_fts5_spec"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
24
|
-
{ query: "JavaScript рантайм с нативной поддержкой TypeScript и JSX из коробки", expectedDocIds: ["bun_readme"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
25
|
-
{ query: "Библиотека для декларативной валидации схем с автоматическим выводом типов TypeScript", expectedDocIds: ["zod_readme"], category: "Semantic RU->EN", instruction: "Retrieve relevant software documentation and technical guides" },
|
|
26
|
-
|
|
27
|
-
// Category B: Cross-Lingual Technical Concepts (Russian prompt -> English documentation)
|
|
28
|
-
{ query: "Асинхронный веб фреймворк на Python с автоматической OpenAPI документацией", expectedDocIds: ["fastapi_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
29
|
-
{ query: "Язык программирования с гарантией безопасности памяти без Garbage Collector", expectedDocIds: ["rust_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
30
|
-
{ query: "Верстка элементов интерфейса через атомарные CSS утилиты", expectedDocIds: ["tailwindcss_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
31
|
-
{ query: "Безопасная среда выполнения TypeScript с возможностью бана сетевых прав", expectedDocIds: ["deno_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
32
|
-
{ query: "Автоматизация сценариев пользователя в браузере и проверка работы веб-страниц", expectedDocIds: ["playwright_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
33
|
-
{ query: "Прогрессивный JavaScript фреймворк для создания пользовательских интерфейсов с реактивной моделью данных", expectedDocIds: ["vue_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
34
|
-
{ query: "Фреймворк для серверного рендеринга React приложений с файловой маршрутизацией", expectedDocIds: ["nextjs_readme"], category: "Cross-Lingual", instruction: "Retrieve relevant technical documentation and framework specs" },
|
|
35
|
-
|
|
36
|
-
// Category C: Direct Code & Keyword Searches
|
|
37
|
-
{ query: "isCancel AxiosError require default export", expectedDocIds: ["axios_readme"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
38
|
-
{ query: "PRAGMA user_version FTS5 unicode61 tokenizer", expectedDocIds: ["sqlite_readme", "sqlite_fts5_spec"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
39
|
-
{ query: "pipeline feature-extraction quantized ONNX", expectedDocIds: ["transformers_js_readme", "onnx_runtime_spec"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
40
|
-
{ query: "useContext useReducer JSX render DOM", expectedDocIds: ["react_readme"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
41
|
-
{ query: "browser page goto expect locator test", expectedDocIds: ["playwright_readme"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
42
|
-
{ query: "z.object z.string z.number z.enum z.array infer output", expectedDocIds: ["zod_readme"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
43
|
-
{ query: "next dev build start create-next-app React framework", expectedDocIds: ["nextjs_readme"], category: "Code/Keyword", instruction: "Retrieve relevant code snippets and API function signatures" },
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
// Smoke mode: representative subset for fast iteration loops (e.g. during dev).
|
|
47
|
-
// Nine queries over five target docs span all three categories. Targets are picked
|
|
48
|
-
// so each doc is exercised by ≥2 queries across categories (axios/sqlite/zod in A+C,
|
|
49
|
-
// playwright/nextjs in B+C), giving quick signal on whether hybrid fusion still
|
|
50
|
-
// works without paying the ~30s full-corpus ingestion + 1000-iter bootstrap cost.
|
|
51
|
-
export const SMOKE_QUERY_INDICES = [0, 4, 6, 11, 13, 14, 15, 18, 20];
|
|
52
|
-
export const SMOKE_DOC_IDS = [
|
|
53
|
-
"axios_readme",
|
|
54
|
-
"sqlite_readme",
|
|
55
|
-
"sqlite_fts5_spec",
|
|
56
|
-
"zod_readme",
|
|
57
|
-
"playwright_readme",
|
|
58
|
-
"nextjs_readme",
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
// Derives the corpus source-id from a stored doc path. Benchmark corpus files are
|
|
62
|
-
// named exactly as in RAW_DOC_SOURCES (e.g. "axios_readme.md"), so basename-no-ext
|
|
63
|
-
// gives a stable, strict key to compare against qObj.expectedDocIds.
|
|
64
|
-
function deriveSourceId(docMeta) {
|
|
65
|
-
if (!docMeta) return null;
|
|
66
|
-
const p = (docMeta.path || docMeta.doc_path || "") || "";
|
|
67
|
-
if (p.startsWith("virtual://")) return null;
|
|
68
|
-
return basename(p).replace(/\.[^.]+$/, "");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function getTopHitSourceId(hits, docMetaStmt) {
|
|
72
|
-
if (!Array.isArray(hits) || hits.length === 0) return "NONE";
|
|
73
|
-
const hit = hits[0];
|
|
74
|
-
let meta = null;
|
|
75
|
-
if (hit.id && docMetaStmt) meta = await docMetaStmt.get(hit.id);
|
|
76
|
-
else if (hit.chunk_id && docMetaStmt) meta = await docMetaStmt.get(hit.chunk_id);
|
|
77
|
-
return deriveSourceId(meta || hit) || "UNKNOWN";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function rankHitsById(hits, docMetaStmt, expectedDocIds, K = 5) {
|
|
81
|
-
const top = Array.isArray(hits) ? hits.slice(0, K) : [];
|
|
82
|
-
for (let r = 0; r < top.length; r++) {
|
|
83
|
-
const hit = top[r];
|
|
84
|
-
let meta = null;
|
|
85
|
-
if (hit.id && docMetaStmt) meta = await docMetaStmt.get(hit.id);
|
|
86
|
-
else if (hit.chunk_id && docMetaStmt) meta = await docMetaStmt.get(hit.chunk_id);
|
|
87
|
-
const sourceId = deriveSourceId(meta || hit);
|
|
88
|
-
if (sourceId && expectedDocIds.includes(sourceId)) return r + 1;
|
|
89
|
-
}
|
|
90
|
-
return 0;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Computes rank given pre-fetched candidate lists. Avoids redundant embedText
|
|
94
|
-
// (ONNX inference) calls per query, which previously inflated latency ~4x.
|
|
95
|
-
async function rankFromPrepared(prepared, docMetaStmt, qObj, mode, K = 5, { alpha = 0.5, rrfK = 60 } = {}) {
|
|
96
|
-
let hits = [];
|
|
97
|
-
if (mode === "bm25_only") {
|
|
98
|
-
hits = prepared.bm25Hits.slice(0, K);
|
|
99
|
-
} else if (mode === "vector_only") {
|
|
100
|
-
hits = prepared.vectorHits.slice(0, K);
|
|
101
|
-
} else if (mode === "hybrid_rrf") {
|
|
102
|
-
hits = rrfFusion(prepared.bm25Hits, prepared.vectorHits, rrfK, 0.01);
|
|
103
|
-
} else if (mode === "hybrid_rsf") {
|
|
104
|
-
hits = rsfFusion(prepared.bm25Hits, prepared.vectorHits, alpha, 0.01);
|
|
105
|
-
} else {
|
|
106
|
-
return 0;
|
|
107
|
-
}
|
|
108
|
-
return await rankHitsById(hits, docMetaStmt, qObj.expectedDocIds, K);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Compute per-query metric contribution
|
|
112
|
-
function metricFromRank(rank) {
|
|
113
|
-
if (rank <= 0) return { mrr: 0, recall: 0, ndcg: 0 };
|
|
114
|
-
return { mrr: 1 / rank, recall: 1, ndcg: 1 / Math.log2(rank + 1) };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function aggregate(perQuery) {
|
|
118
|
-
// perQuery = array of {rank}
|
|
119
|
-
let mrr = 0, recall = 0, ndcg = 0;
|
|
120
|
-
for (const q of perQuery) {
|
|
121
|
-
const m = metricFromRank(q.rank);
|
|
122
|
-
mrr += m.mrr;
|
|
123
|
-
recall += m.recall;
|
|
124
|
-
ndcg += m.ndcg;
|
|
125
|
-
}
|
|
126
|
-
const n = perQuery.length || 1;
|
|
127
|
-
return {
|
|
128
|
-
mrr: Number((mrr / n).toFixed(4)),
|
|
129
|
-
recall: Number((recall / n).toFixed(4)),
|
|
130
|
-
ndcg: Number((ndcg / n).toFixed(4)),
|
|
131
|
-
n,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Bootstrap 95% confidence intervals for aggregate metrics over query resampling.
|
|
136
|
-
function bootstrapCI(perQuery, { iterations = 1000, seed = 42 } = {}) {
|
|
137
|
-
const n = perQuery.length;
|
|
138
|
-
if (n === 0) return { mrrCI: [0, 0], recallCI: [0, 0], ndcgCI: [0, 0] };
|
|
139
|
-
let s = seed;
|
|
140
|
-
function randInt(max) {
|
|
141
|
-
s = (s * 1103515245 + 12345) & 0x7fffffff;
|
|
142
|
-
return s % max;
|
|
143
|
-
}
|
|
144
|
-
const mrrSamples = new Array(iterations);
|
|
145
|
-
const recallSamples = new Array(iterations);
|
|
146
|
-
const ndcgSamples = new Array(iterations);
|
|
147
|
-
for (let i = 0; i < iterations; i++) {
|
|
148
|
-
let mrr = 0, recall = 0, ndcg = 0;
|
|
149
|
-
for (let j = 0; j < n; j++) {
|
|
150
|
-
const q = perQuery[randInt(n)];
|
|
151
|
-
const m = metricFromRank(q.rank);
|
|
152
|
-
mrr += m.mrr;
|
|
153
|
-
recall += m.recall;
|
|
154
|
-
ndcg += m.ndcg;
|
|
155
|
-
}
|
|
156
|
-
mrrSamples[i] = mrr / n;
|
|
157
|
-
recallSamples[i] = recall / n;
|
|
158
|
-
ndcgSamples[i] = ndcg / n;
|
|
159
|
-
}
|
|
160
|
-
const quantile = (arr, p) => {
|
|
161
|
-
arr.sort((a, b) => a - b);
|
|
162
|
-
const idx = Math.min(arr.length - 1, Math.max(0, Math.floor(p * (arr.length - 1))));
|
|
163
|
-
return Number(arr[idx].toFixed(4));
|
|
164
|
-
};
|
|
165
|
-
return {
|
|
166
|
-
mrrCI: [quantile([...mrrSamples], 0.025), quantile([...mrrSamples], 0.975)],
|
|
167
|
-
recallCI: [quantile([...recallSamples], 0.025), quantile([...recallSamples], 0.975)],
|
|
168
|
-
ndcgCI: [quantile([...ndcgSamples], 0.025), quantile([...ndcgSamples], 0.975)],
|
|
169
|
-
iterations,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Pairwise paired t-test comparing per-query reciprocal rank (MRR contributions).
|
|
174
|
-
function pairedTTestLR(a, b) {
|
|
175
|
-
let d = 0, d2 = 0;
|
|
176
|
-
const n = a.length;
|
|
177
|
-
for (let i = 0; i < n; i++) {
|
|
178
|
-
const av = a[i].rank > 0 ? 1 / a[i].rank : 0;
|
|
179
|
-
const bv = b[i].rank > 0 ? 1 / b[i].rank : 0;
|
|
180
|
-
const diff = av - bv;
|
|
181
|
-
d += diff;
|
|
182
|
-
d2 += diff * diff;
|
|
183
|
-
}
|
|
184
|
-
const mean = d / n;
|
|
185
|
-
let t = 0, p = 1, sem = 0;
|
|
186
|
-
if (n > 1) {
|
|
187
|
-
const variance = (d2 - (d * d) / n) / (n - 1);
|
|
188
|
-
sem = Math.sqrt(variance / n);
|
|
189
|
-
t = sem > 0 ? mean / sem : 0;
|
|
190
|
-
// Approximate two-sided p from t using a rough normal-df-based CDF (n-1 df).
|
|
191
|
-
// This is an approximation sufficient for binary "significantly different?" calls.
|
|
192
|
-
const z = Math.abs(t);
|
|
193
|
-
// Survival function approx (Abramowitz-Stegun 26.2.17).
|
|
194
|
-
const zz = z / Math.sqrt(2);
|
|
195
|
-
const sf = Math.exp(-zz * zz) * (0.254829592 + -0.284496736 * zz + 1.421413741 * zz * zz + -1.453152027 * zz * zz * zz + 1.061405429 * zz * zz * zz * zz) / (1 + 0.3275911 * Math.abs(zz));
|
|
196
|
-
p = Math.min(1, 2 * sf);
|
|
197
|
-
}
|
|
198
|
-
return { meanDiff: Number(mean.toFixed(4)), t, p: Number(p.toFixed(4)), sem: Number(sem.toFixed(4)), n };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function renderQueryBreakdownTable(breakdown) {
|
|
202
|
-
console.log("\n \x1b[1m\x1b[36m=== [PER-QUERY BENCHMARK BREAKDOWN & ANSWER EVALUATION] ===\x1b[0m\n");
|
|
203
|
-
|
|
204
|
-
const termCols = Math.min(process.stdout.columns || 130, 150);
|
|
205
|
-
|
|
206
|
-
function visibleLength(str) {
|
|
207
|
-
return String(str).replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function padVisible(str, width, alignRight = false) {
|
|
211
|
-
const len = visibleLength(str);
|
|
212
|
-
if (len >= width) return str;
|
|
213
|
-
const pad = " ".repeat(width - len);
|
|
214
|
-
return alignRight ? pad + str : str + pad;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function wrapText(text, width) {
|
|
218
|
-
if (!text) return [""];
|
|
219
|
-
const words = String(text).split(/\s+/);
|
|
220
|
-
const lines = [];
|
|
221
|
-
let currentLine = "";
|
|
222
|
-
|
|
223
|
-
for (const word of words) {
|
|
224
|
-
if (!currentLine) {
|
|
225
|
-
currentLine = word;
|
|
226
|
-
} else if (currentLine.length + 1 + word.length <= width) {
|
|
227
|
-
currentLine += " " + word;
|
|
228
|
-
} else {
|
|
229
|
-
lines.push(currentLine);
|
|
230
|
-
currentLine = word;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (currentLine) lines.push(currentLine);
|
|
234
|
-
return lines.length > 0 ? lines : [""];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const wId = 4;
|
|
238
|
-
const wBM = 6;
|
|
239
|
-
const wVec = 6;
|
|
240
|
-
const wRRF = 6;
|
|
241
|
-
const wRSF = 6;
|
|
242
|
-
const wAnsTok = 9;
|
|
243
|
-
|
|
244
|
-
const remain = Math.max(70, termCols - (wId + wBM + wVec + wRRF + wRSF + wAnsTok + 20));
|
|
245
|
-
const wQ = Math.floor(remain * 0.38);
|
|
246
|
-
const wTarget = Math.floor(remain * 0.24);
|
|
247
|
-
const wSnippet = remain - (wQ + wTarget);
|
|
248
|
-
|
|
249
|
-
const header = `│ ${padVisible("#", wId)} │ ${padVisible("Question / Query", wQ)} │ ${padVisible("Target Doc (Tokens)", wTarget)} │ ${padVisible("BM25", wBM)} │ ${padVisible("Vector", wVec)} │ ${padVisible("RRF", wRRF)} │ ${padVisible("RSF", wRSF)} │ ${padVisible("Top Hit & Answer Snippet", wSnippet)} │ ${padVisible("Ans Tok", wAnsTok, true)} │`;
|
|
250
|
-
const sep = `├─${"─".repeat(wId)}─┼─${"─".repeat(wQ)}─┼─${"─".repeat(wTarget)}─┼─${"─".repeat(wBM)}─┼─${"─".repeat(wVec)}─┼─${"─".repeat(wRRF)}─┼─${"─".repeat(wRSF)}─┼─${"─".repeat(wSnippet)}─┼─${"─".repeat(wAnsTok)}─┤`;
|
|
251
|
-
const topBorder = `┌─${"─".repeat(wId)}─┬─${"─".repeat(wQ)}─┬─${"─".repeat(wTarget)}─┬─${"─".repeat(wBM)}─┬─${"─".repeat(wVec)}─┬─${"─".repeat(wRRF)}─┬─${"─".repeat(wRSF)}─┬─${"─".repeat(wSnippet)}─┬─${"─".repeat(wAnsTok)}─┐`;
|
|
252
|
-
const botBorder = `└─${"─".repeat(wId)}─┴─${"─".repeat(wQ)}─┴─${"─".repeat(wTarget)}─┴─${"─".repeat(wBM)}─┴─${"─".repeat(wVec)}─┴─${"─".repeat(wRRF)}─┴─${"─".repeat(wRSF)}─┴─${"─".repeat(wSnippet)}─┴─${"─".repeat(wAnsTok)}─┘`;
|
|
253
|
-
|
|
254
|
-
console.log(`\x1b[36m${topBorder}\x1b[0m`);
|
|
255
|
-
console.log(`\x1b[36m${header}\x1b[0m`);
|
|
256
|
-
console.log(`\x1b[36m${sep}\x1b[0m`);
|
|
257
|
-
|
|
258
|
-
breakdown.forEach((row, rIdx) => {
|
|
259
|
-
const qLines = wrapText(row.query, wQ);
|
|
260
|
-
|
|
261
|
-
const docTokFormatted = row.docTokens > 1000 ? `${(row.docTokens / 1000).toFixed(1)}k tok` : `${row.docTokens} tok`;
|
|
262
|
-
const targetStr = `${row.target} (${docTokFormatted})`;
|
|
263
|
-
const targetLines = wrapText(targetStr, wTarget);
|
|
264
|
-
|
|
265
|
-
const topHitStr = row.topHitSnippet ? `${row.topHit}: "${row.topHitSnippet.substring(0, 120).replace(/\n/g, " ")}..."` : (row.topHit || "None");
|
|
266
|
-
const snippetLines = wrapText(topHitStr, wSnippet);
|
|
267
|
-
|
|
268
|
-
const maxSubLines = Math.max(qLines.length, targetLines.length, snippetLines.length);
|
|
269
|
-
|
|
270
|
-
for (let l = 0; l < maxSubLines; l++) {
|
|
271
|
-
const cellId = l === 0 ? `Q${row.id}` : "";
|
|
272
|
-
const cellQ = qLines[l] || "";
|
|
273
|
-
const cellTarget = targetLines[l] || "";
|
|
274
|
-
const cellBM = l === 0 ? row.bm25Rank : "";
|
|
275
|
-
const cellVec = l === 0 ? row.vectorRank : "";
|
|
276
|
-
const cellRRF = l === 0 ? row.rrfRank : "";
|
|
277
|
-
const cellRSF = l === 0 ? (row.rsfRank === "#1" ? `\x1b[32m${row.rsfRank}\x1b[0m` : row.rsfRank) : "";
|
|
278
|
-
const cellSnippet = snippetLines[l] || "";
|
|
279
|
-
const cellAnsTok = l === 0 ? `${row.outputTokens} tok` : "";
|
|
280
|
-
|
|
281
|
-
console.log(
|
|
282
|
-
`│ ${padVisible(cellId, wId)} │ ${padVisible(cellQ, wQ)} │ ${padVisible(cellTarget, wTarget)} │ ${padVisible(cellBM, wBM)} │ ${padVisible(cellVec, wVec)} │ ${padVisible(cellRRF, wRRF)} │ ${padVisible(cellRSF, wRSF)} │ ${padVisible(cellSnippet, wSnippet)} │ ${padVisible(cellAnsTok, wAnsTok, true)} │`
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (rIdx < breakdown.length - 1) {
|
|
287
|
-
console.log(`\x1b[90m${sep}\x1b[0m`);
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
console.log(`\x1b[36m${botBorder}\x1b[0m\n`);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export async function evaluateSearchQualityComparison(db, {
|
|
295
|
-
silent = false,
|
|
296
|
-
onProgress = null,
|
|
297
|
-
alphaGrid = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
|
|
298
|
-
kGrid = [10, 30, 60, 100],
|
|
299
|
-
defaultAlpha = 0.5,
|
|
300
|
-
defaultRrfK = 60,
|
|
301
|
-
mode = "full",
|
|
302
|
-
} = {}) {
|
|
303
|
-
if (!db) {
|
|
304
|
-
const { getDatabase } = await import("../db/database.js");
|
|
305
|
-
db = await getDatabase();
|
|
306
|
-
}
|
|
307
|
-
const isSmoke = mode === "smoke";
|
|
308
|
-
// In smoke mode we drop grid search: nothing produced, no cost.
|
|
309
|
-
if (isSmoke) {
|
|
310
|
-
alphaGrid = [];
|
|
311
|
-
kGrid = [];
|
|
312
|
-
}
|
|
313
|
-
// Select query subset for smoke; full otherwise.
|
|
314
|
-
const queryList = isSmoke
|
|
315
|
-
? SMOKE_QUERY_INDICES.map((i) => CHALLENGING_EVALUATION_QUERIES[i]).filter(Boolean)
|
|
316
|
-
: CHALLENGING_EVALUATION_QUERIES;
|
|
317
|
-
|
|
318
|
-
if (!silent) {
|
|
319
|
-
printRichPanel(
|
|
320
|
-
isSmoke ? "SMOKE SEARCH QUALITY EVALUATION" : "SEARCH QUALITY EVALUATION",
|
|
321
|
-
isSmoke
|
|
322
|
-
? `${queryList.length} queries on ${SMOKE_DOC_IDS.length} docs | stats skipped`
|
|
323
|
-
: `${queryList.length} queries | strict doc-id match | bootstrap CI + grid search`,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const docMetaStmt = db.prepare(`
|
|
328
|
-
SELECT d.id, d.path, d.title,
|
|
329
|
-
(SELECT COALESCE(SUM(token_count), 0) FROM sections WHERE doc_id = d.id) as doc_tokens
|
|
330
|
-
FROM micro_chunks m
|
|
331
|
-
JOIN documents d ON m.doc_id = d.id
|
|
332
|
-
WHERE m.id = ?;
|
|
333
|
-
`);
|
|
334
|
-
|
|
335
|
-
const targetTokensStmt = db.prepare(`
|
|
336
|
-
SELECT d.id, d.title, d.path,
|
|
337
|
-
(SELECT COALESCE(SUM(token_count), 0) FROM sections WHERE doc_id = d.id) as doc_tokens
|
|
338
|
-
FROM documents d
|
|
339
|
-
WHERE d.path LIKE ? OR d.id = ?;
|
|
340
|
-
`);
|
|
341
|
-
|
|
342
|
-
const K = 5;
|
|
343
|
-
const total = queryList.length;
|
|
344
|
-
const queryBreakdown = [];
|
|
345
|
-
|
|
346
|
-
// per-query rank arrays (parallel) for paired comparison & bootstrap
|
|
347
|
-
const perQueryRanks = {
|
|
348
|
-
bm25: [],
|
|
349
|
-
vector: [],
|
|
350
|
-
rrf: [],
|
|
351
|
-
rsf: [],
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
// grid search aggregation: [{alpha, mrr, recall, ndcg}, ...] for RSF, [{k, ...}] for RRF
|
|
355
|
-
const rsfGridAcc = new Map();
|
|
356
|
-
const rrfGridAcc = new Map();
|
|
357
|
-
for (const a of alphaGrid) rsfGridAcc.set(a, { wins: 0, mrr: 0, recall: 0, ndcg: 0 });
|
|
358
|
-
for (const k of kGrid) rrfGridAcc.set(k, { wins: 0, mrr: 0, recall: 0, ndcg: 0 });
|
|
359
|
-
|
|
360
|
-
// per-category per-mode ranks (map<category, {bm25:[], vector:[], rrf[], rsf[]}>)
|
|
361
|
-
const catMap = new Map();
|
|
362
|
-
|
|
363
|
-
for (let i = 0; i < queryList.length; i++) {
|
|
364
|
-
const qObj = queryList[i];
|
|
365
|
-
|
|
366
|
-
// Pre-fetch bm25 hits, query embedding, and vector hits ONCE — reused across
|
|
367
|
-
// all 4 modes and the grid search. Eliminates ~3 redundant ONNX inferences per query.
|
|
368
|
-
const startQ = performance.now();
|
|
369
|
-
const bm25Hits = await bm25Search(db, qObj.query, 30);
|
|
370
|
-
const qVec = await embedText(qObj.query, true, null, null, qObj.instruction || null);
|
|
371
|
-
const vectorHits = await vectorSearch(db, qVec, 30, 0.10);
|
|
372
|
-
const prepared = { bm25Hits, qVec, vectorHits };
|
|
373
|
-
const qLatencyMs = performance.now() - startQ;
|
|
374
|
-
|
|
375
|
-
const bm25Rank = await rankFromPrepared(prepared, docMetaStmt, qObj, "bm25_only", K);
|
|
376
|
-
const vecRank = await rankFromPrepared(prepared, docMetaStmt, qObj, "vector_only", K);
|
|
377
|
-
const rrfRank = await rankFromPrepared(prepared, docMetaStmt, qObj, "hybrid_rrf", K, { rrfK: defaultRrfK });
|
|
378
|
-
const rsfRank = await rankFromPrepared(prepared, docMetaStmt, qObj, "hybrid_rsf", K, { alpha: defaultAlpha });
|
|
379
|
-
|
|
380
|
-
perQueryRanks.bm25.push({ rank: bm25Rank, idx: i, latencyMs: qLatencyMs });
|
|
381
|
-
perQueryRanks.vector.push({ rank: vecRank, idx: i, latencyMs: qLatencyMs });
|
|
382
|
-
perQueryRanks.rrf.push({ rank: rrfRank, idx: i, latencyMs: qLatencyMs });
|
|
383
|
-
perQueryRanks.rsf.push({ rank: rsfRank, idx: i, latencyMs: qLatencyMs });
|
|
384
|
-
|
|
385
|
-
// Track per category
|
|
386
|
-
const cat = qObj.category || "Uncategorized";
|
|
387
|
-
if (!catMap.has(cat)) catMap.set(cat, { bm25: [], vector: [], rrf: [], rsf: [] });
|
|
388
|
-
catMap.get(cat).bm25.push({ rank: bm25Rank, idx: i });
|
|
389
|
-
catMap.get(cat).vector.push({ rank: vecRank, idx: i });
|
|
390
|
-
catMap.get(cat).rrf.push({ rank: rrfRank, idx: i });
|
|
391
|
-
catMap.get(cat).rsf.push({ rank: rsfRank, idx: i });
|
|
392
|
-
|
|
393
|
-
// Grid search: reuse pre-fetched hits; fusion is in-memory, cheap.
|
|
394
|
-
for (const a of alphaGrid) {
|
|
395
|
-
const fused = rsfFusion(bm25Hits, vectorHits, a, 0.01);
|
|
396
|
-
const r = await rankHitsById(fused, docMetaStmt, qObj.expectedDocIds, K);
|
|
397
|
-
const m = metricFromRank(r);
|
|
398
|
-
const acc = rsfGridAcc.get(a);
|
|
399
|
-
acc.mrr += m.mrr;
|
|
400
|
-
acc.recall += m.recall;
|
|
401
|
-
acc.ndcg += m.ndcg;
|
|
402
|
-
if (r > 0 && r === 1) acc.wins += 1;
|
|
403
|
-
}
|
|
404
|
-
for (const k of kGrid) {
|
|
405
|
-
const fused = rrfFusion(bm25Hits, vectorHits, k, 0.01);
|
|
406
|
-
const r = await rankHitsById(fused, docMetaStmt, qObj.expectedDocIds, K);
|
|
407
|
-
const m = metricFromRank(r);
|
|
408
|
-
const acc = rrfGridAcc.get(k);
|
|
409
|
-
acc.mrr += m.mrr;
|
|
410
|
-
acc.recall += m.recall;
|
|
411
|
-
acc.ndcg += m.ndcg;
|
|
412
|
-
if (r > 0 && r === 1) acc.wins += 1;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (onProgress) onProgress({ phase: "evaluate", current: i + 1, total });
|
|
416
|
-
|
|
417
|
-
const rsfHits = rsfFusion(bm25Hits, vectorHits, defaultAlpha, 0.01);
|
|
418
|
-
const topHitDoc = await getTopHitSourceId(rsfHits, docMetaStmt);
|
|
419
|
-
const topHitObj = Array.isArray(rsfHits) && rsfHits.length > 0 ? rsfHits[0] : null;
|
|
420
|
-
|
|
421
|
-
let targetDocTokens = 0;
|
|
422
|
-
if (qObj.expectedDocIds && qObj.expectedDocIds.length > 0) {
|
|
423
|
-
const primaryTarget = qObj.expectedDocIds[0];
|
|
424
|
-
const match = await targetTokensStmt.get(`%${primaryTarget}%`, primaryTarget);
|
|
425
|
-
if (match) targetDocTokens = match.doc_tokens || 0;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
let outputTokens = 0;
|
|
429
|
-
let snippetPreview = "";
|
|
430
|
-
if (topHitObj) {
|
|
431
|
-
snippetPreview = topHitObj.snippet || topHitObj.content || "";
|
|
432
|
-
outputTokens = topHitObj.token_count || (snippetPreview ? Math.round(snippetPreview.length / 4) : 0);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
queryBreakdown.push({
|
|
436
|
-
id: i + 1,
|
|
437
|
-
target: qObj.expectedDocIds.join("/"),
|
|
438
|
-
category: qObj.category,
|
|
439
|
-
bm25Rank: bm25Rank > 0 ? `#${bm25Rank}` : "MISSED",
|
|
440
|
-
vectorRank: vecRank > 0 ? `#${vecRank}` : "MISSED",
|
|
441
|
-
rrfRank: rrfRank > 0 ? `#${rrfRank}` : "MISSED",
|
|
442
|
-
rsfRank: rsfRank > 0 ? `#${rsfRank}` : "MISSED",
|
|
443
|
-
topHit: topHitDoc,
|
|
444
|
-
topHitSnippet: snippetPreview,
|
|
445
|
-
docTokens: targetDocTokens,
|
|
446
|
-
outputTokens,
|
|
447
|
-
query: qObj.query,
|
|
448
|
-
expectedDocIds: qObj.expectedDocIds,
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Determine actual winner (not hardcoded); pick by MRR, then recall.
|
|
453
|
-
const agg = (perQuery) => aggregate(perQuery);
|
|
454
|
-
const bm25Res = agg(perQueryRanks.bm25);
|
|
455
|
-
const vectorRes = agg(perQueryRanks.vector);
|
|
456
|
-
const rrfRes = agg(perQueryRanks.rrf);
|
|
457
|
-
const rsfRes = agg(perQueryRanks.rsf);
|
|
458
|
-
const candidates = [
|
|
459
|
-
{ name: "bm25", ...bm25Res },
|
|
460
|
-
{ name: "vector", ...vectorRes },
|
|
461
|
-
{ name: "hybrid_rrf", ...rrfRes },
|
|
462
|
-
{ name: "hybrid_rsf", ...rsfRes },
|
|
463
|
-
];
|
|
464
|
-
const winner = candidates.slice().sort((a, b) => b.mrr - a.mrr || b.recall - a.recall)[0].name;
|
|
465
|
-
|
|
466
|
-
// Bootstrap CIs (using per-query reciprocal ranks) — skipped in smoke mode.
|
|
467
|
-
const bm25CI = isSmoke ? null : bootstrapCI(perQueryRanks.bm25);
|
|
468
|
-
const vectorCI = isSmoke ? null : bootstrapCI(perQueryRanks.vector);
|
|
469
|
-
const rrfCI = isSmoke ? null : bootstrapCI(perQueryRanks.rrf);
|
|
470
|
-
const rsfCI = isSmoke ? null : bootstrapCI(perQueryRanks.rsf);
|
|
471
|
-
|
|
472
|
-
// Paired comparisons: hybrid vs best single retriever (vector), rrf vs rsf.
|
|
473
|
-
// Skipped in smoke mode (no need for significance testing on a 9-query subset).
|
|
474
|
-
const tRrfSel = isSmoke ? null : pairedTTestLR(perQueryRanks.rrf, perQueryRanks.vector);
|
|
475
|
-
const tRsfSel = isSmoke ? null : pairedTTestLR(perQueryRanks.rsf, perQueryRanks.vector);
|
|
476
|
-
const tRrfRsf = isSmoke ? null : pairedTTestLR(perQueryRanks.rrf, perQueryRanks.rsf);
|
|
477
|
-
|
|
478
|
-
// Per-category aggregate metrics
|
|
479
|
-
const categoryBreakdown = [];
|
|
480
|
-
for (const [cat, ranks] of catMap.entries()) {
|
|
481
|
-
categoryBreakdown.push({
|
|
482
|
-
category: cat,
|
|
483
|
-
n: ranks.bm25.length,
|
|
484
|
-
bm25: agg(ranks.bm25),
|
|
485
|
-
vector: agg(ranks.vector),
|
|
486
|
-
rrf: agg(ranks.rrf),
|
|
487
|
-
rsf: agg(ranks.rsf),
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Grid summaries normalized by N
|
|
492
|
-
const rsfGrid = [...rsfGridAcc.entries()].map(([a, acc]) => ({
|
|
493
|
-
alpha: a,
|
|
494
|
-
mrr: Number((acc.mrr / total).toFixed(4)),
|
|
495
|
-
recall: Number((acc.recall / total).toFixed(4)),
|
|
496
|
-
ndcg: Number((acc.ndcg / total).toFixed(4)),
|
|
497
|
-
top1Wins: acc.wins,
|
|
498
|
-
}));
|
|
499
|
-
const rrfGrid = [...rrfGridAcc.entries()].map(([k, acc]) => ({
|
|
500
|
-
k,
|
|
501
|
-
mrr: Number((acc.mrr / total).toFixed(4)),
|
|
502
|
-
recall: Number((acc.recall / total).toFixed(4)),
|
|
503
|
-
ndcg: Number((acc.ndcg / total).toFixed(4)),
|
|
504
|
-
top1Wins: acc.wins,
|
|
505
|
-
}));
|
|
506
|
-
|
|
507
|
-
const bestRsfAlpha = rsfGrid.slice().sort((a, b) => b.mrr - a.mrr)[0];
|
|
508
|
-
const bestRrfK = rrfGrid.slice().sort((a, b) => b.mrr - a.mrr)[0];
|
|
509
|
-
|
|
510
|
-
// Latency stats from per-query pre-fetch latencies (shared across modes).
|
|
511
|
-
const latencies = perQueryRanks.vector.map((q) => q.latencyMs || 0).sort((a, b) => a - b);
|
|
512
|
-
const quant = (arr, p) => (arr.length ? arr[Math.min(arr.length - 1, Math.max(0, Math.floor(p * (arr.length - 1))))] : 0);
|
|
513
|
-
const latencyStats = {
|
|
514
|
-
p50: Number((quant(latencies, 0.5) || 0).toFixed(2)),
|
|
515
|
-
p95: Number((quant(latencies, 0.95) || 0).toFixed(2)),
|
|
516
|
-
p99: Number((quant(latencies, 0.99) || 0).toFixed(2)),
|
|
517
|
-
mean: Number((latencies.reduce((a, b) => a + b, 0) / (latencies.length || 1)).toFixed(2)),
|
|
518
|
-
max: Number((latencies[latencies.length - 1] || 0).toFixed(2)),
|
|
519
|
-
n: latencies.length,
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
if (!silent) {
|
|
523
|
-
renderQueryBreakdownTable(queryBreakdown);
|
|
524
|
-
|
|
525
|
-
console.log("\n [Aggregate Metric Comparison]");
|
|
526
|
-
console.table([bm25Res, vectorRes, rrfRes, rsfRes]);
|
|
527
|
-
|
|
528
|
-
if (!isSmoke) {
|
|
529
|
-
console.log("\n [Bootstrap 95% CIs]");
|
|
530
|
-
console.table([
|
|
531
|
-
{ mode: "bm25", mrrLow: bm25CI.mrrCI[0], mrrHigh: bm25CI.mrrCI[1], recallLow: bm25CI.recallCI[0], recallHigh: bm25CI.recallCI[1] },
|
|
532
|
-
{ mode: "vector", mrrLow: vectorCI.mrrCI[0], mrrHigh: vectorCI.mrrCI[1], recallLow: vectorCI.recallCI[0], recallHigh: vectorCI.recallCI[1] },
|
|
533
|
-
{ mode: "rrf", mrrLow: rrfCI.mrrCI[0], mrrHigh: rrfCI.mrrCI[1], recallLow: rrfCI.recallCI[0], recallHigh: rrfCI.recallCI[1] },
|
|
534
|
-
{ mode: "rsf", mrrLow: rsfCI.mrrCI[0], mrrHigh: rsfCI.mrrCI[1], recallLow: rsfCI.recallCI[0], recallHigh: rsfCI.recallCI[1] },
|
|
535
|
-
]);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
console.log(`\n [Winner by MRR]: ${winner}`);
|
|
539
|
-
|
|
540
|
-
if (!isSmoke) {
|
|
541
|
-
console.log(`\n [Paired t-tests]`);
|
|
542
|
-
console.table([
|
|
543
|
-
{ test: "rrf vs vector", meanDiff: tRrfSel.meanDiff, t: tRrfSel.t.toFixed(2), p: tRrfSel.p, n: tRrfSel.n },
|
|
544
|
-
{ test: "rsf vs vector", meanDiff: tRsfSel.meanDiff, t: tRsfSel.t.toFixed(2), p: tRsfSel.p, n: tRsfSel.n },
|
|
545
|
-
{ test: "rrf vs rsf", meanDiff: tRrfRsf.meanDiff, t: tRrfRsf.t.toFixed(2), p: tRrfRsf.p, n: tRrfRsf.n },
|
|
546
|
-
]);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
console.log("\n [Per-Category Aggregate]");
|
|
550
|
-
console.table(
|
|
551
|
-
categoryBreakdown.map((c) => ({
|
|
552
|
-
category: c.category,
|
|
553
|
-
n: c.n,
|
|
554
|
-
bm25Mrr: c.bm25.mrr,
|
|
555
|
-
vecMrr: c.vector.mrr,
|
|
556
|
-
rrfMrr: c.rrf.mrr,
|
|
557
|
-
rsfMrr: c.rsf.mrr,
|
|
558
|
-
rrfRecall: c.rrf.recall,
|
|
559
|
-
rsfRecall: c.rsf.recall,
|
|
560
|
-
})),
|
|
561
|
-
);
|
|
562
|
-
|
|
563
|
-
if (!isSmoke) {
|
|
564
|
-
console.log("\n [RSF Alpha Grid]");
|
|
565
|
-
console.table(rsfGrid);
|
|
566
|
-
console.log("\n [RRF k Grid]");
|
|
567
|
-
console.table(rrfGrid);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
return {
|
|
572
|
-
mode: isSmoke ? "smoke" : "full",
|
|
573
|
-
// Parameters the headline hybridRrf/hybridRsf rows were actually computed
|
|
574
|
-
// with. Reports must label the rows with these, NOT with bestRrfK/
|
|
575
|
-
// bestRsfAlpha (which come from the separate grid sweep).
|
|
576
|
-
defaultAlpha,
|
|
577
|
-
defaultRrfK,
|
|
578
|
-
bm25: bm25Res,
|
|
579
|
-
vector: vectorRes,
|
|
580
|
-
hybridRrf: rrfRes,
|
|
581
|
-
hybridRsf: rsfRes,
|
|
582
|
-
winner,
|
|
583
|
-
bootstrap: isSmoke ? null : { bm25: bm25CI, vector: vectorCI, rrf: rrfCI, rsf: rsfCI },
|
|
584
|
-
pairedTests: isSmoke ? null : { rrfVsVector: tRrfSel, rsfVsVector: tRsfSel, rrfVsRsf: tRrfRsf },
|
|
585
|
-
categoryBreakdown,
|
|
586
|
-
rsfGrid,
|
|
587
|
-
rrfGrid,
|
|
588
|
-
bestRsfAlpha,
|
|
589
|
-
bestRrfK,
|
|
590
|
-
latency: latencyStats,
|
|
591
|
-
breakdown: queryBreakdown,
|
|
592
|
-
perQueryRanks,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (process.argv[1] && process.argv[1].includes("quality_evaluator.js")) {
|
|
597
|
-
console.log("Run main benchmark runner run_benchmarks.js.");
|
|
598
|
-
}
|