@rubytech/create-maxy-code 0.1.131 → 0.1.138
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/dist/__tests__/installer-specialist-registration.test.js +5 -40
- package/dist/index.js +24 -28
- package/dist/specialist-registration.js +0 -15
- package/package.json +1 -1
- package/payload/platform/lib/graph-search/dist/boosts.d.ts +39 -0
- package/payload/platform/lib/graph-search/dist/boosts.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/boosts.js +57 -0
- package/payload/platform/lib/graph-search/dist/boosts.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/dedup.d.ts +29 -0
- package/payload/platform/lib/graph-search/dist/dedup.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/dedup.js +97 -0
- package/payload/platform/lib/graph-search/dist/dedup.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/index.d.ts +70 -8
- package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-search/dist/index.js +323 -62
- package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-search/dist/query-expansion.d.ts +37 -0
- package/payload/platform/lib/graph-search/dist/query-expansion.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/query-expansion.js +101 -0
- package/payload/platform/lib/graph-search/dist/query-expansion.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/route.d.ts +29 -0
- package/payload/platform/lib/graph-search/dist/route.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/route.js +53 -0
- package/payload/platform/lib/graph-search/dist/route.js.map +1 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts +31 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts.map +1 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.js +57 -0
- package/payload/platform/lib/graph-search/dist/rrf-fusion.js.map +1 -0
- package/payload/platform/lib/graph-search/src/__tests__/boosts.test.ts +59 -0
- package/payload/platform/lib/graph-search/src/__tests__/dedup.test.ts +83 -0
- package/payload/platform/lib/graph-search/src/__tests__/query-expansion.test.ts +96 -0
- package/payload/platform/lib/graph-search/src/__tests__/route.test.ts +62 -0
- package/payload/platform/lib/graph-search/src/__tests__/rrf-fusion.test.ts +39 -0
- package/payload/platform/lib/graph-search/src/boosts.ts +61 -0
- package/payload/platform/lib/graph-search/src/dedup.ts +108 -0
- package/payload/platform/lib/graph-search/src/index.ts +407 -65
- package/payload/platform/lib/graph-search/src/query-expansion.ts +129 -0
- package/payload/platform/lib/graph-search/src/route.ts +70 -0
- package/payload/platform/lib/graph-search/src/rrf-fusion.ts +62 -0
- package/payload/platform/neo4j/schema.cypher +80 -1
- package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +4 -2
- package/payload/platform/plugins/admin/mcp/dist/index.js +6 -4
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/docs/references/internals.md +36 -10
- package/payload/platform/plugins/docs/references/neo4j.md +63 -0
- package/payload/platform/plugins/docs/references/session-retrospective.md +11 -4
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +2 -2
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/index.js +114 -5
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js +25 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js +70 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js +50 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts +33 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js +99 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts +21 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js +24 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts +13 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js +151 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts +55 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js +290 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts +19 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js +102 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js +18 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +14 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts +29 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js +22 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +31 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +182 -50
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +76 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts +30 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js +129 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/scripts/backfill-typed-edges.ts +72 -0
- package/payload/platform/plugins/memory/mcp/scripts/generate-edge-docs.ts +75 -0
- package/payload/platform/plugins/memory/mcp/vitest.config.ts +5 -0
- package/payload/platform/plugins/memory/references/schema-base.md +44 -0
- package/payload/platform/scripts/seed-neo4j.sh +49 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +4 -3
- package/payload/platform/templates/specialists/agents/database-operator.md +9 -1
- package/payload/server/{adminuser-self-heal-QAWOZ3JV.js → adminuser-self-heal-YC47O34W.js} +2 -1
- package/payload/server/{chunk-JSBRDJBE.js → chunk-HYQNUVGO.js} +9 -1
- package/payload/server/{chunk-HCYM5FLU.js → chunk-T3DJD5QR.js} +3 -1
- package/payload/server/maxy-edge.js +3 -2
- package/payload/server/public/assets/{admin-FcRHAL-3.js → admin-BM9aXUUh.js} +1 -1
- package/payload/server/public/assets/{data-Ds37mflX.js → data-Ds2ECtfP.js} +1 -1
- package/payload/server/public/assets/{graph-CmWRhaiS.js → graph-BzxObKMM.js} +1 -1
- package/payload/server/public/assets/{graph-labels-Ch2r00Gt.js → graph-labels-Dwgep_nr.js} +1 -1
- package/payload/server/public/assets/{page-BcHhJXUt.js → page-BlkBsjP2.js} +1 -1
- package/payload/server/public/assets/page-h_SA5SVQ.js +51 -0
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +803 -76
- package/payload/server/public/assets/page-BOtNny_4.js +0 -51
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Multi-query expansion via Haiku (Task 308).
|
|
4
|
+
*
|
|
5
|
+
* Generates 3-5 paraphrases of the user query so the hybrid pipeline runs
|
|
6
|
+
* each paraphrase against vector + BM25 in parallel and unions the
|
|
7
|
+
* candidate sets before fusion. Recovers recall on terse queries where the
|
|
8
|
+
* embedding's nearest-neighbour is unstable.
|
|
9
|
+
*
|
|
10
|
+
* The expansion runs through `@rubytech/oauth-llm` against Claude Haiku —
|
|
11
|
+
* the same OAuth-bearer path the admin classifiers use; no new SDK
|
|
12
|
+
* dependency and no Anthropic-API-key path. Cache is in-process, keyed by
|
|
13
|
+
* (accountId, normalisedQuery, retrievalClass), with a 1-hour TTL.
|
|
14
|
+
*
|
|
15
|
+
* Graceful degrade: if the LLM call fails, returns just the original
|
|
16
|
+
* query. The caller's hybrid pipeline still runs — just on one query
|
|
17
|
+
* instead of N — so missing paraphrases are a recall regression, not a
|
|
18
|
+
* fault.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.clearExpansionCache = clearExpansionCache;
|
|
22
|
+
exports.expandQuery = expandQuery;
|
|
23
|
+
const index_js_1 = require("../../oauth-llm/dist/index.js");
|
|
24
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
25
|
+
const HAIKU_MODEL = "claude-haiku-4-5";
|
|
26
|
+
const MAX_PARAPHRASES = 5;
|
|
27
|
+
const cache = new Map();
|
|
28
|
+
function clearExpansionCache() {
|
|
29
|
+
cache.clear();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Return `[query, ...paraphrases]` (original always first). On LLM
|
|
33
|
+
* failure returns `[query]` only and writes a single stderr line so the
|
|
34
|
+
* operator can see expansion is degrading without breaking search.
|
|
35
|
+
*/
|
|
36
|
+
async function expandQuery(params) {
|
|
37
|
+
const { query, retrievalClass = "general", accountId = "", useCache = true } = params;
|
|
38
|
+
const normalised = query.trim().toLowerCase();
|
|
39
|
+
const cacheKey = `${accountId}::${retrievalClass}::${normalised}`;
|
|
40
|
+
if (useCache) {
|
|
41
|
+
const hit = cache.get(cacheKey);
|
|
42
|
+
if (hit && hit.expiresAt > Date.now()) {
|
|
43
|
+
return [query, ...hit.expansions];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const systemPrompt = [
|
|
47
|
+
"You generate paraphrases of a user query for hybrid graph search.",
|
|
48
|
+
"Return 3-5 alternative phrasings that preserve the user's intent but vary the surface form (synonyms, word order, entity-vs-attribute framing).",
|
|
49
|
+
"Do not change the entity names. Do not invent constraints the user did not state.",
|
|
50
|
+
"Output ONLY a JSON object via the provided tool; never free text.",
|
|
51
|
+
].join("\n");
|
|
52
|
+
const classHint = retrievalClass === "general"
|
|
53
|
+
? ""
|
|
54
|
+
: `The retrieval class is '${retrievalClass}' — tailor paraphrases accordingly.`;
|
|
55
|
+
const userMessage = [
|
|
56
|
+
`Original query: ${query}`,
|
|
57
|
+
classHint,
|
|
58
|
+
].filter((s) => s.length > 0).join("\n");
|
|
59
|
+
const result = await (0, index_js_1.callOauthLlm)({
|
|
60
|
+
model: HAIKU_MODEL,
|
|
61
|
+
system: systemPrompt,
|
|
62
|
+
userMessage,
|
|
63
|
+
maxTokens: 512,
|
|
64
|
+
timeoutMs: 15_000,
|
|
65
|
+
tools: [
|
|
66
|
+
{
|
|
67
|
+
name: "emit_paraphrases",
|
|
68
|
+
description: "Emit 3-5 paraphrases of the user query.",
|
|
69
|
+
input_schema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
paraphrases: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: { type: "string" },
|
|
75
|
+
minItems: 3,
|
|
76
|
+
maxItems: MAX_PARAPHRASES,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ["paraphrases"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
toolChoiceName: "emit_paraphrases",
|
|
84
|
+
});
|
|
85
|
+
if (result.kind !== "ok-tool") {
|
|
86
|
+
process.stderr.write(`[graph-search:expansion-failed] cause=${result.kind === "fallback" ? result.cause : "unexpected"} query=${query.slice(0, 60).replace(/\s+/g, " ")}\n`);
|
|
87
|
+
return [query];
|
|
88
|
+
}
|
|
89
|
+
const raw = result.input.paraphrases;
|
|
90
|
+
if (!Array.isArray(raw))
|
|
91
|
+
return [query];
|
|
92
|
+
const expansions = raw
|
|
93
|
+
.filter((p) => typeof p === "string" && p.trim().length > 0)
|
|
94
|
+
.map((p) => p.trim())
|
|
95
|
+
.slice(0, MAX_PARAPHRASES);
|
|
96
|
+
if (useCache) {
|
|
97
|
+
cache.set(cacheKey, { expansions, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
98
|
+
}
|
|
99
|
+
return [query, ...expansions];
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=query-expansion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-expansion.js","sourceRoot":"","sources":["../src/query-expansion.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval-class routing (Task 308).
|
|
3
|
+
*
|
|
4
|
+
* Consumes the `retrievalClass` field produced by Task 304's
|
|
5
|
+
* gateway-classifier (landed) and returns a per-class index plan: which
|
|
6
|
+
* labels to filter to, which fusion weights to favour, and whether to
|
|
7
|
+
* skip the lookup entirely.
|
|
8
|
+
*
|
|
9
|
+
* No new Haiku call here — this module is a pure deterministic mapping
|
|
10
|
+
* over the gateway's existing classification.
|
|
11
|
+
*/
|
|
12
|
+
export type RetrievalClass = "entity" | "temporal" | "event" | "general" | "none";
|
|
13
|
+
export interface IndexPlan {
|
|
14
|
+
/** When true, the caller short-circuits and returns no results. */
|
|
15
|
+
skip: boolean;
|
|
16
|
+
/** Restrict vector + BM25 to these labels. Undefined means no filter. */
|
|
17
|
+
labelFilter?: string[];
|
|
18
|
+
/** Weight on the vector half of the (weighted-sum) fusion. */
|
|
19
|
+
vectorWeight: number;
|
|
20
|
+
/** Weight on the BM25 half of the (weighted-sum) fusion. */
|
|
21
|
+
bm25Weight: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Return the index plan for a retrieval class. Unknown values fall back
|
|
25
|
+
* to `general` rather than throwing — the routing is a soft hint and the
|
|
26
|
+
* caller's old behaviour is the safe default.
|
|
27
|
+
*/
|
|
28
|
+
export declare function pickIndexMix(retrievalClass: string | undefined): IndexPlan;
|
|
29
|
+
//# sourceMappingURL=route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route.d.ts","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,cAAc,GACtB,QAAQ,GACR,UAAU,GACV,OAAO,GACP,SAAS,GACT,MAAM,CAAC;AAEX,MAAM,WAAW,SAAS;IACxB,mEAAmE;IACnE,IAAI,EAAE,OAAO,CAAC;IACd,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;IACrB,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAkC1E"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Retrieval-class routing (Task 308).
|
|
4
|
+
*
|
|
5
|
+
* Consumes the `retrievalClass` field produced by Task 304's
|
|
6
|
+
* gateway-classifier (landed) and returns a per-class index plan: which
|
|
7
|
+
* labels to filter to, which fusion weights to favour, and whether to
|
|
8
|
+
* skip the lookup entirely.
|
|
9
|
+
*
|
|
10
|
+
* No new Haiku call here — this module is a pure deterministic mapping
|
|
11
|
+
* over the gateway's existing classification.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.pickIndexMix = pickIndexMix;
|
|
15
|
+
/**
|
|
16
|
+
* Return the index plan for a retrieval class. Unknown values fall back
|
|
17
|
+
* to `general` rather than throwing — the routing is a soft hint and the
|
|
18
|
+
* caller's old behaviour is the safe default.
|
|
19
|
+
*/
|
|
20
|
+
function pickIndexMix(retrievalClass) {
|
|
21
|
+
switch (retrievalClass) {
|
|
22
|
+
case "entity":
|
|
23
|
+
return {
|
|
24
|
+
skip: false,
|
|
25
|
+
labelFilter: ["Person", "Company", "Concept"],
|
|
26
|
+
vectorWeight: 0.85,
|
|
27
|
+
bm25Weight: 0.15,
|
|
28
|
+
};
|
|
29
|
+
case "temporal":
|
|
30
|
+
return {
|
|
31
|
+
skip: false,
|
|
32
|
+
labelFilter: ["Event"],
|
|
33
|
+
vectorWeight: 0.2,
|
|
34
|
+
bm25Weight: 0.8,
|
|
35
|
+
};
|
|
36
|
+
case "event":
|
|
37
|
+
return {
|
|
38
|
+
skip: false,
|
|
39
|
+
labelFilter: ["Event"],
|
|
40
|
+
vectorWeight: 0,
|
|
41
|
+
bm25Weight: 1,
|
|
42
|
+
};
|
|
43
|
+
case "none":
|
|
44
|
+
return { skip: true, vectorWeight: 0, bm25Weight: 0 };
|
|
45
|
+
case "general":
|
|
46
|
+
default:
|
|
47
|
+
if (retrievalClass && retrievalClass !== "general") {
|
|
48
|
+
process.stderr.write(`[graph-search:unknown-class] rc=${retrievalClass} fallback=general\n`);
|
|
49
|
+
}
|
|
50
|
+
return { skip: false, vectorWeight: 0.7, bm25Weight: 0.3 };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route.js","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;;AAyBH,oCAkCC;AAvCD;;;;GAIG;AACH,SAAgB,YAAY,CAAC,cAAkC;IAC7D,QAAQ,cAAc,EAAE,CAAC;QACvB,KAAK,QAAQ;YACX,OAAO;gBACL,IAAI,EAAE,KAAK;gBACX,WAAW,EAAE,CAAC,QAAQ,EAAE,SAAS,EAAE,SAAS,CAAC;gBAC7C,YAAY,EAAE,IAAI;gBAClB,UAAU,EAAE,IAAI;aACjB,CAAC;QACJ,KAAK,UAAU;YACb,OAAO;gBACL,IAAI,EAAE,KAAK;gBACX,WAAW,EAAE,CAAC,OAAO,CAAC;gBACtB,YAAY,EAAE,GAAG;gBACjB,UAAU,EAAE,GAAG;aAChB,CAAC;QACJ,KAAK,OAAO;YACV,OAAO;gBACL,IAAI,EAAE,KAAK;gBACX,WAAW,EAAE,CAAC,OAAO,CAAC;gBACtB,YAAY,EAAE,CAAC;gBACf,UAAU,EAAE,CAAC;aACd,CAAC;QACJ,KAAK,MAAM;YACT,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC;QACxD,KAAK,SAAS,CAAC;QACf;YACE,IAAI,cAAc,IAAI,cAAc,KAAK,SAAS,EAAE,CAAC;gBACnD,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,mCAAmC,cAAc,qBAAqB,CACvE,CAAC;YACJ,CAAC;YACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;IAC/D,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reciprocal Rank Fusion (Task 308).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the weighted-sum fusion (`0.7 * vector + 0.3 * bm25_norm`) when
|
|
5
|
+
* the `MAXY_GS_RRF` flag is on. RRF is robust to score-distribution drift
|
|
6
|
+
* between the vector and BM25 indexes — it sums `1 / (k + rank_i)` for each
|
|
7
|
+
* node across the ranked lists it appears in. Higher rank (lower index) →
|
|
8
|
+
* larger contribution; the constant `k` (default 60, per the gbrain
|
|
9
|
+
* default) smooths the long tail.
|
|
10
|
+
*
|
|
11
|
+
* The weighted-sum baseline stays as the fallback so flag flips are
|
|
12
|
+
* reversible without code edits.
|
|
13
|
+
*/
|
|
14
|
+
export interface FusionCandidate {
|
|
15
|
+
nodeId: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fuse multiple ranked lists by Reciprocal Rank Fusion.
|
|
19
|
+
*
|
|
20
|
+
* Each input list is treated as already sorted (best first). For each
|
|
21
|
+
* candidate, score = sum over lists of `1 / (k + rank)`. Returns the union
|
|
22
|
+
* sorted by descending RRF score.
|
|
23
|
+
*
|
|
24
|
+
* Ties on RRF score are broken by the candidate's earliest appearance
|
|
25
|
+
* across the input lists (first-seen wins), which is deterministic given
|
|
26
|
+
* a deterministic input order.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fuseRrf<T extends FusionCandidate>(rankedLists: T[][], k?: number): Array<T & {
|
|
29
|
+
rrfScore: number;
|
|
30
|
+
}>;
|
|
31
|
+
//# sourceMappingURL=rrf-fusion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rrf-fusion.d.ts","sourceRoot":"","sources":["../src/rrf-fusion.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,OAAO,CAAC,CAAC,SAAS,eAAe,EAC/C,WAAW,EAAE,CAAC,EAAE,EAAE,EAClB,CAAC,SAAK,GACL,KAAK,CAAC,CAAC,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CA6BjC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reciprocal Rank Fusion (Task 308).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the weighted-sum fusion (`0.7 * vector + 0.3 * bm25_norm`) when
|
|
6
|
+
* the `MAXY_GS_RRF` flag is on. RRF is robust to score-distribution drift
|
|
7
|
+
* between the vector and BM25 indexes — it sums `1 / (k + rank_i)` for each
|
|
8
|
+
* node across the ranked lists it appears in. Higher rank (lower index) →
|
|
9
|
+
* larger contribution; the constant `k` (default 60, per the gbrain
|
|
10
|
+
* default) smooths the long tail.
|
|
11
|
+
*
|
|
12
|
+
* The weighted-sum baseline stays as the fallback so flag flips are
|
|
13
|
+
* reversible without code edits.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.fuseRrf = fuseRrf;
|
|
17
|
+
/**
|
|
18
|
+
* Fuse multiple ranked lists by Reciprocal Rank Fusion.
|
|
19
|
+
*
|
|
20
|
+
* Each input list is treated as already sorted (best first). For each
|
|
21
|
+
* candidate, score = sum over lists of `1 / (k + rank)`. Returns the union
|
|
22
|
+
* sorted by descending RRF score.
|
|
23
|
+
*
|
|
24
|
+
* Ties on RRF score are broken by the candidate's earliest appearance
|
|
25
|
+
* across the input lists (first-seen wins), which is deterministic given
|
|
26
|
+
* a deterministic input order.
|
|
27
|
+
*/
|
|
28
|
+
function fuseRrf(rankedLists, k = 60) {
|
|
29
|
+
const scores = new Map();
|
|
30
|
+
const representatives = new Map();
|
|
31
|
+
const firstSeen = new Map();
|
|
32
|
+
let order = 0;
|
|
33
|
+
for (const list of rankedLists) {
|
|
34
|
+
for (let rank = 0; rank < list.length; rank++) {
|
|
35
|
+
const candidate = list[rank];
|
|
36
|
+
const contribution = 1 / (k + rank);
|
|
37
|
+
const prior = scores.get(candidate.nodeId) ?? 0;
|
|
38
|
+
scores.set(candidate.nodeId, prior + contribution);
|
|
39
|
+
if (!representatives.has(candidate.nodeId)) {
|
|
40
|
+
representatives.set(candidate.nodeId, candidate);
|
|
41
|
+
firstSeen.set(candidate.nodeId, order++);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const [nodeId, rrfScore] of scores) {
|
|
47
|
+
const rep = representatives.get(nodeId);
|
|
48
|
+
out.push({ ...rep, rrfScore });
|
|
49
|
+
}
|
|
50
|
+
out.sort((a, b) => {
|
|
51
|
+
if (b.rrfScore !== a.rrfScore)
|
|
52
|
+
return b.rrfScore - a.rrfScore;
|
|
53
|
+
return (firstSeen.get(a.nodeId) - firstSeen.get(b.nodeId));
|
|
54
|
+
});
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=rrf-fusion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rrf-fusion.js","sourceRoot":"","sources":["../src/rrf-fusion.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;GAYG;;AAiBH,0BAgCC;AA3CD;;;;;;;;;;GAUG;AACH,SAAgB,OAAO,CACrB,WAAkB,EAClB,CAAC,GAAG,EAAE;IAEN,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,MAAM,eAAe,GAAG,IAAI,GAAG,EAAa,CAAC;IAC7C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;YAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC;YAC7B,MAAM,YAAY,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;YACpC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAChD,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,GAAG,YAAY,CAAC,CAAC;YACnD,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC3C,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;gBACjD,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;YAC3C,CAAC;QACH,CAAC;IACH,CAAC;IAED,MAAM,GAAG,GAAoC,EAAE,CAAC;IAChD,KAAK,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACxC,MAAM,GAAG,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC;QACzC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;IACjC,CAAC;IACD,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QAChB,IAAI,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,QAAQ;YAAE,OAAO,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC;QAC9D,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAE,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAE,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IACH,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applyBacklinkBoost, applyCompiledTruthBoost } from "../boosts.js";
|
|
3
|
+
|
|
4
|
+
describe("applyCompiledTruthBoost", () => {
|
|
5
|
+
it("is identity on hits without compiledTruth", () => {
|
|
6
|
+
const hits = [
|
|
7
|
+
{ nodeId: "a", score: 1.0, properties: {} },
|
|
8
|
+
{ nodeId: "b", score: 0.5, properties: { compiledTruth: null } },
|
|
9
|
+
{ nodeId: "c", score: 0.3, properties: { compiledTruth: "" } },
|
|
10
|
+
];
|
|
11
|
+
const out = applyCompiledTruthBoost(hits);
|
|
12
|
+
expect(out.map((h) => h.score)).toEqual([1.0, 0.5, 0.3]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("adds 15% to hits with a non-empty compiledTruth", () => {
|
|
16
|
+
const hits = [
|
|
17
|
+
{ nodeId: "a", score: 1.0, properties: { compiledTruth: "Adam founded X." } },
|
|
18
|
+
];
|
|
19
|
+
const out = applyCompiledTruthBoost(hits);
|
|
20
|
+
expect(out[0].score).toBeCloseTo(1.15, 6);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("respects the weight override", () => {
|
|
24
|
+
const hits = [{ nodeId: "a", score: 1.0, properties: { compiledTruth: "x" } }];
|
|
25
|
+
const out = applyCompiledTruthBoost(hits, 0.25);
|
|
26
|
+
expect(out[0].score).toBeCloseTo(1.25, 6);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("throws on out-of-range weight", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
applyCompiledTruthBoost([{ nodeId: "a", score: 1, properties: {} }], 1.5),
|
|
32
|
+
).toThrow();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("applyBacklinkBoost", () => {
|
|
37
|
+
it("is identity on missing or zero count", () => {
|
|
38
|
+
const hits = [
|
|
39
|
+
{ nodeId: "a", score: 1.0, properties: {} },
|
|
40
|
+
{ nodeId: "b", score: 1.0, properties: { backlinkCount: 0 } },
|
|
41
|
+
];
|
|
42
|
+
const out = applyBacklinkBoost(hits);
|
|
43
|
+
expect(out.map((h) => h.score)).toEqual([1.0, 1.0]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("scales log-weighted with the documented floor/ceiling", () => {
|
|
47
|
+
const hits = [
|
|
48
|
+
{ nodeId: "a", score: 1.0, properties: { backlinkCount: 1 } },
|
|
49
|
+
{ nodeId: "b", score: 1.0, properties: { backlinkCount: 10 } },
|
|
50
|
+
{ nodeId: "c", score: 1.0, properties: { backlinkCount: 100 } },
|
|
51
|
+
{ nodeId: "d", score: 1.0, properties: { backlinkCount: 10_000 } },
|
|
52
|
+
];
|
|
53
|
+
const out = applyBacklinkBoost(hits);
|
|
54
|
+
expect(out[0].score).toBeCloseTo(1.05, 6); // 1 → +5%
|
|
55
|
+
expect(out[1].score).toBeCloseTo(1.10, 6); // 10 → +10%
|
|
56
|
+
expect(out[2].score).toBeCloseTo(1.15, 6); // 100 → +15%
|
|
57
|
+
expect(out[3].score).toBeCloseTo(1.25, 6); // 10k → capped at +25%
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { DEFAULT_LAYERS, dedup } from "../dedup.js";
|
|
4
|
+
|
|
5
|
+
describe("dedup", () => {
|
|
6
|
+
it("keeps the highest-score representative on nodeId collision", () => {
|
|
7
|
+
const hits = [
|
|
8
|
+
{ nodeId: "a", score: 0.5, properties: {} },
|
|
9
|
+
{ nodeId: "a", score: 0.9, properties: {} },
|
|
10
|
+
];
|
|
11
|
+
const out = dedup(hits);
|
|
12
|
+
expect(out).toHaveLength(1);
|
|
13
|
+
expect(out[0].score).toBe(0.9);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("collapses slug collisions across distinct nodeIds", () => {
|
|
17
|
+
const hits = [
|
|
18
|
+
{ nodeId: "a", score: 0.9, properties: { slug: "adam-langley" } },
|
|
19
|
+
{ nodeId: "b", score: 0.6, properties: { slug: "adam-langley" } },
|
|
20
|
+
];
|
|
21
|
+
const out = dedup(hits);
|
|
22
|
+
expect(out.map((h) => h.nodeId)).toEqual(["a"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("collapses canonicalName collisions case-insensitively, falls back to name", () => {
|
|
26
|
+
const hits = [
|
|
27
|
+
{ nodeId: "a", score: 0.9, properties: { canonicalName: "Acme Co" } },
|
|
28
|
+
{ nodeId: "b", score: 0.6, properties: { name: "acme co" } },
|
|
29
|
+
];
|
|
30
|
+
const out = dedup(hits);
|
|
31
|
+
expect(out.map((h) => h.nodeId)).toEqual(["a"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("collapses contentHash collisions (compiledTruth wins over content)", () => {
|
|
35
|
+
const hits = [
|
|
36
|
+
{ nodeId: "a", score: 0.9, properties: { compiledTruth: "X" } },
|
|
37
|
+
{ nodeId: "b", score: 0.6, properties: { content: "X" } },
|
|
38
|
+
];
|
|
39
|
+
const out = dedup(hits);
|
|
40
|
+
// hash("X") matches across both → "a" wins by score.
|
|
41
|
+
expect(out.map((h) => h.nodeId)).toEqual(["a"]);
|
|
42
|
+
const h = createHash("sha256").update("X").digest("hex").slice(0, 16);
|
|
43
|
+
expect(h).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("skips a layer for hits missing its key — no false collision", () => {
|
|
47
|
+
const hits = [
|
|
48
|
+
{ nodeId: "a", score: 0.9, properties: {} },
|
|
49
|
+
{ nodeId: "b", score: 0.6, properties: {} },
|
|
50
|
+
];
|
|
51
|
+
const out = dedup(hits);
|
|
52
|
+
expect(out.map((h) => h.nodeId)).toEqual(["a", "b"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("honours layer subset", () => {
|
|
56
|
+
const hits = [
|
|
57
|
+
{ nodeId: "a", score: 0.9, properties: { slug: "same" } },
|
|
58
|
+
{ nodeId: "b", score: 0.6, properties: { slug: "same" } },
|
|
59
|
+
];
|
|
60
|
+
// nodeId-only dedup keeps both because nodeIds differ.
|
|
61
|
+
const out = dedup(hits, ["nodeId"]);
|
|
62
|
+
expect(out).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("preserves descending-score order in the output", () => {
|
|
66
|
+
const hits = [
|
|
67
|
+
{ nodeId: "a", score: 0.3, properties: {} },
|
|
68
|
+
{ nodeId: "b", score: 0.9, properties: {} },
|
|
69
|
+
{ nodeId: "c", score: 0.6, properties: {} },
|
|
70
|
+
];
|
|
71
|
+
const out = dedup(hits);
|
|
72
|
+
expect(out.map((h) => h.score)).toEqual([0.9, 0.6, 0.3]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("exports the default-layers constant for reference", () => {
|
|
76
|
+
expect(DEFAULT_LAYERS).toEqual([
|
|
77
|
+
"nodeId",
|
|
78
|
+
"slug",
|
|
79
|
+
"canonicalName",
|
|
80
|
+
"contentHash",
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const callMock = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("../../../oauth-llm/dist/index.js", () => ({
|
|
6
|
+
callOauthLlm: (...args: unknown[]) => callMock(...args),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { clearExpansionCache, expandQuery } from "../query-expansion.js";
|
|
10
|
+
|
|
11
|
+
describe("expandQuery", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
callMock.mockReset();
|
|
14
|
+
clearExpansionCache();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
callMock.mockReset();
|
|
19
|
+
clearExpansionCache();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns [original, ...paraphrases] on Haiku success", async () => {
|
|
23
|
+
callMock.mockResolvedValueOnce({
|
|
24
|
+
kind: "ok-tool",
|
|
25
|
+
toolName: "emit_paraphrases",
|
|
26
|
+
input: {
|
|
27
|
+
paraphrases: ["who is adam langley", "adam langley bio", "adam langley founder"],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const out = await expandQuery({ query: "Adam Langley", accountId: "acc-1" });
|
|
31
|
+
expect(out[0]).toBe("Adam Langley");
|
|
32
|
+
expect(out.slice(1)).toEqual([
|
|
33
|
+
"who is adam langley",
|
|
34
|
+
"adam langley bio",
|
|
35
|
+
"adam langley founder",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("serves the second identical call from cache (no second LLM call)", async () => {
|
|
40
|
+
callMock.mockResolvedValueOnce({
|
|
41
|
+
kind: "ok-tool",
|
|
42
|
+
toolName: "emit_paraphrases",
|
|
43
|
+
input: { paraphrases: ["p1", "p2", "p3"] },
|
|
44
|
+
});
|
|
45
|
+
const first = await expandQuery({ query: "X", accountId: "a" });
|
|
46
|
+
const second = await expandQuery({ query: "X", accountId: "a" });
|
|
47
|
+
expect(first).toEqual(second);
|
|
48
|
+
expect(callMock).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("caches per retrievalClass — different class re-invokes Haiku", async () => {
|
|
52
|
+
callMock.mockResolvedValueOnce({
|
|
53
|
+
kind: "ok-tool",
|
|
54
|
+
toolName: "emit_paraphrases",
|
|
55
|
+
input: { paraphrases: ["p1", "p2", "p3"] },
|
|
56
|
+
});
|
|
57
|
+
callMock.mockResolvedValueOnce({
|
|
58
|
+
kind: "ok-tool",
|
|
59
|
+
toolName: "emit_paraphrases",
|
|
60
|
+
input: { paraphrases: ["q1", "q2", "q3"] },
|
|
61
|
+
});
|
|
62
|
+
const entityCall = await expandQuery({
|
|
63
|
+
query: "X",
|
|
64
|
+
accountId: "a",
|
|
65
|
+
retrievalClass: "entity",
|
|
66
|
+
});
|
|
67
|
+
const temporalCall = await expandQuery({
|
|
68
|
+
query: "X",
|
|
69
|
+
accountId: "a",
|
|
70
|
+
retrievalClass: "temporal",
|
|
71
|
+
});
|
|
72
|
+
expect(entityCall.slice(1)).toEqual(["p1", "p2", "p3"]);
|
|
73
|
+
expect(temporalCall.slice(1)).toEqual(["q1", "q2", "q3"]);
|
|
74
|
+
expect(callMock).toHaveBeenCalledTimes(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("falls back to [query] on LLM fallback result", async () => {
|
|
78
|
+
callMock.mockResolvedValueOnce({
|
|
79
|
+
kind: "fallback",
|
|
80
|
+
cause: "missing-creds",
|
|
81
|
+
reason: "no oauth",
|
|
82
|
+
});
|
|
83
|
+
const out = await expandQuery({ query: "X", accountId: "a", useCache: false });
|
|
84
|
+
expect(out).toEqual(["X"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("falls back to [query] when the tool input is malformed", async () => {
|
|
88
|
+
callMock.mockResolvedValueOnce({
|
|
89
|
+
kind: "ok-tool",
|
|
90
|
+
toolName: "emit_paraphrases",
|
|
91
|
+
input: { paraphrases: "not an array" },
|
|
92
|
+
});
|
|
93
|
+
const out = await expandQuery({ query: "X", accountId: "a", useCache: false });
|
|
94
|
+
expect(out).toEqual(["X"]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pickIndexMix } from "../route.js";
|
|
3
|
+
|
|
4
|
+
describe("pickIndexMix", () => {
|
|
5
|
+
it("entity → vector-heavy + Person|Company|Concept", () => {
|
|
6
|
+
const plan = pickIndexMix("entity");
|
|
7
|
+
expect(plan.skip).toBe(false);
|
|
8
|
+
expect(plan.labelFilter).toEqual(["Person", "Company", "Concept"]);
|
|
9
|
+
expect(plan.vectorWeight).toBe(0.85);
|
|
10
|
+
expect(plan.bm25Weight).toBe(0.15);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("temporal → bm25-heavy + Event", () => {
|
|
14
|
+
const plan = pickIndexMix("temporal");
|
|
15
|
+
expect(plan.labelFilter).toEqual(["Event"]);
|
|
16
|
+
expect(plan.vectorWeight).toBe(0.2);
|
|
17
|
+
expect(plan.bm25Weight).toBe(0.8);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("event → bm25-only + Event", () => {
|
|
21
|
+
const plan = pickIndexMix("event");
|
|
22
|
+
expect(plan.labelFilter).toEqual(["Event"]);
|
|
23
|
+
expect(plan.vectorWeight).toBe(0);
|
|
24
|
+
expect(plan.bm25Weight).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("general → balanced (matches the baseline weighted-sum defaults)", () => {
|
|
28
|
+
const plan = pickIndexMix("general");
|
|
29
|
+
expect(plan.skip).toBe(false);
|
|
30
|
+
expect(plan.labelFilter).toBeUndefined();
|
|
31
|
+
expect(plan.vectorWeight).toBe(0.7);
|
|
32
|
+
expect(plan.bm25Weight).toBe(0.3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("none → skip", () => {
|
|
36
|
+
const plan = pickIndexMix("none");
|
|
37
|
+
expect(plan.skip).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("unknown value → general (with stderr warning)", () => {
|
|
41
|
+
const errs: string[] = [];
|
|
42
|
+
const orig = process.stderr.write.bind(process.stderr);
|
|
43
|
+
(process.stderr.write as unknown as (s: string) => boolean) = (s: string) => {
|
|
44
|
+
errs.push(s);
|
|
45
|
+
return true;
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
const plan = pickIndexMix("nonsense");
|
|
49
|
+
expect(plan.skip).toBe(false);
|
|
50
|
+
expect(plan.vectorWeight).toBe(0.7);
|
|
51
|
+
} finally {
|
|
52
|
+
(process.stderr.write as unknown as typeof orig) = orig;
|
|
53
|
+
}
|
|
54
|
+
expect(errs.some((e) => e.includes("[graph-search:unknown-class]"))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("undefined → general silently", () => {
|
|
58
|
+
const plan = pickIndexMix(undefined);
|
|
59
|
+
expect(plan.skip).toBe(false);
|
|
60
|
+
expect(plan.vectorWeight).toBe(0.7);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fuseRrf } from "../rrf-fusion.js";
|
|
3
|
+
|
|
4
|
+
describe("fuseRrf", () => {
|
|
5
|
+
it("sums 1/(k+rank) per node across lists", () => {
|
|
6
|
+
const listA = [{ nodeId: "a" }, { nodeId: "b" }, { nodeId: "c" }];
|
|
7
|
+
const listB = [{ nodeId: "b" }, { nodeId: "d" }, { nodeId: "a" }];
|
|
8
|
+
const fused = fuseRrf([listA, listB], 60);
|
|
9
|
+
|
|
10
|
+
// a: list A rank 0 + list B rank 2 = 1/60 + 1/62
|
|
11
|
+
// b: list A rank 1 + list B rank 0 = 1/61 + 1/60
|
|
12
|
+
// c: list A rank 2 only = 1/62
|
|
13
|
+
// d: list B rank 1 only = 1/61
|
|
14
|
+
const byId = new Map(fused.map((f) => [f.nodeId, f.rrfScore]));
|
|
15
|
+
expect(byId.get("a")).toBeCloseTo(1 / 60 + 1 / 62, 6);
|
|
16
|
+
expect(byId.get("b")).toBeCloseTo(1 / 61 + 1 / 60, 6);
|
|
17
|
+
expect(byId.get("c")).toBeCloseTo(1 / 62, 6);
|
|
18
|
+
expect(byId.get("d")).toBeCloseTo(1 / 61, 6);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("sorts by descending RRF score", () => {
|
|
22
|
+
const listA = [{ nodeId: "a" }, { nodeId: "b" }];
|
|
23
|
+
const listB = [{ nodeId: "b" }, { nodeId: "a" }];
|
|
24
|
+
const fused = fuseRrf([listA, listB], 60);
|
|
25
|
+
// Both appear in both lists at ranks (0,1) and (1,0) → same score; tie broken by first-seen → "a" first.
|
|
26
|
+
expect(fused.map((f) => f.nodeId)).toEqual(["a", "b"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("honours configurable k", () => {
|
|
30
|
+
const listA = [{ nodeId: "a" }];
|
|
31
|
+
const fused = fuseRrf([listA], 10);
|
|
32
|
+
expect(fused[0].rrfScore).toBeCloseTo(1 / 10, 6);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns [] for empty input", () => {
|
|
36
|
+
expect(fuseRrf([])).toEqual([]);
|
|
37
|
+
expect(fuseRrf([[], []])).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
});
|