codetrap 0.1.2 → 0.1.3
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/.agents/plugins/marketplace.json +20 -0
- package/README.md +107 -32
- package/docs/installation.md +18 -10
- package/package.json +4 -1
- package/plugins/codetrap-agent/.codex-plugin/plugin.json +34 -0
- package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +25 -0
- package/plugins/codetrap-agent/hooks/pre-edit.example.sh +10 -0
- package/plugins/codetrap-agent/hooks.json +11 -0
- package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +19 -0
- package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +14 -0
- package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +25 -0
- package/scripts/release-preflight.ts +55 -0
- package/skills/codetrap-add/SKILL.md +4 -1
- package/skills/codetrap-check/SKILL.md +24 -4
- package/skills/codetrap-search/SKILL.md +32 -12
- package/src/commands/command-result.ts +29 -0
- package/src/commands/router.ts +6 -400
- package/src/commands/workflow.ts +466 -0
- package/src/db/embedding-queries.ts +33 -0
- package/src/db/queries.ts +119 -20
- package/src/db/repository.ts +39 -2
- package/src/db/schema.ts +35 -0
- package/src/domain/trap.ts +31 -2
- package/src/index.ts +13 -1
- package/src/lib/config.ts +102 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor.ts +76 -0
- package/src/lib/embedding-health.ts +49 -0
- package/src/lib/format.ts +5 -1
- package/src/lib/output-json.ts +116 -0
- package/src/lib/scope-context.ts +116 -0
- package/src/lib/scope-migration.ts +360 -0
- package/src/lib/search-normalizer.ts +6 -0
- package/src/lib/search-policy.ts +276 -0
- package/src/lib/search-result-card.ts +1 -0
- package/src/lib/search-service.ts +36 -98
- package/src/lib/store.ts +96 -107
- package/src/lib/trap-archive.ts +9 -42
- package/src/lib/trap-codec.ts +113 -0
- package/src/lib/trap-json-fields.ts +12 -0
- package/src/lib/trap-mutation-result.ts +36 -0
- package/src/lib/trap-operations.ts +27 -6
- package/src/lib/trap-scope-match.ts +112 -0
- package/src/lib/trap-search-document.ts +8 -1
- package/src/lib/trap-transfer.ts +88 -0
- package/src/mcp/server.ts +75 -57
- package/src/mcp/tools.ts +32 -5
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { RankingSignal, Trap, TrapSearchResult } from "../domain/trap";
|
|
2
|
+
import { EmbeddingProviderUnavailableError } from "./embedder";
|
|
3
|
+
import { parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
|
|
4
|
+
import {
|
|
5
|
+
hasSpecificPathMatch,
|
|
6
|
+
trapMatchesApplicability,
|
|
7
|
+
type ApplicabilityFilter,
|
|
8
|
+
} from "./trap-scope-match";
|
|
9
|
+
|
|
10
|
+
export interface SearchPolicyOptions extends ApplicabilityFilter {
|
|
11
|
+
limit?: number;
|
|
12
|
+
rerank?: boolean;
|
|
13
|
+
includeRankingSignals?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RankingConfig {
|
|
17
|
+
rrfK: number;
|
|
18
|
+
semanticMinScore: number;
|
|
19
|
+
lengthNormAnchor: number;
|
|
20
|
+
maxBoost: number;
|
|
21
|
+
titleTokenBoost: number;
|
|
22
|
+
tagTokenBoost: number;
|
|
23
|
+
identifierBoost: number;
|
|
24
|
+
severityBoost: Record<string, number>;
|
|
25
|
+
pathMatchBoost: number;
|
|
26
|
+
moduleMatchBoost: number;
|
|
27
|
+
ownerMatchBoost: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_RANKING_CONFIG: RankingConfig = {
|
|
31
|
+
rrfK: 60,
|
|
32
|
+
semanticMinScore: 0.3,
|
|
33
|
+
lengthNormAnchor: 500,
|
|
34
|
+
maxBoost: 0.45,
|
|
35
|
+
titleTokenBoost: 0.16,
|
|
36
|
+
tagTokenBoost: 0.2,
|
|
37
|
+
identifierBoost: 0.18,
|
|
38
|
+
severityBoost: {
|
|
39
|
+
warning: 0,
|
|
40
|
+
error: 0.04,
|
|
41
|
+
critical: 0.07,
|
|
42
|
+
},
|
|
43
|
+
pathMatchBoost: 0.12,
|
|
44
|
+
moduleMatchBoost: 0.08,
|
|
45
|
+
ownerMatchBoost: 0.04,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class TrapSearchPolicy {
|
|
49
|
+
constructor(private readonly ranking: RankingConfig = DEFAULT_RANKING_CONFIG) {}
|
|
50
|
+
|
|
51
|
+
candidateLimit(opts: SearchPolicyOptions, resultLimit: number): number {
|
|
52
|
+
return shouldOverfetch(opts) ? Math.max(resultLimit * 5, 50) : resultLimit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
semanticMinScore(): number {
|
|
56
|
+
return this.ranking.semanticMinScore;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
filterResults(results: TrapSearchResult[], filter: ApplicabilityFilter): TrapSearchResult[] {
|
|
60
|
+
return results.filter((result) => trapMatchesApplicability(result.trap, filter));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
matchesTrap(trap: Trap, filter: ApplicabilityFilter): boolean {
|
|
64
|
+
return trapMatchesApplicability(trap, filter);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
filterTraps(traps: Trap[], filter: ApplicabilityFilter): Trap[] {
|
|
68
|
+
return traps.filter((trap) => this.matchesTrap(trap, filter));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
rankResults(
|
|
72
|
+
results: TrapSearchResult[],
|
|
73
|
+
query: string,
|
|
74
|
+
opts: SearchPolicyOptions,
|
|
75
|
+
limit: number
|
|
76
|
+
): TrapSearchResult[] {
|
|
77
|
+
return applyReranking(results, query, opts, this.ranking).slice(0, limit);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fuse(
|
|
81
|
+
ftsResults: TrapSearchResult[],
|
|
82
|
+
semanticResults: TrapSearchResult[],
|
|
83
|
+
query: string,
|
|
84
|
+
opts: SearchPolicyOptions,
|
|
85
|
+
limit: number
|
|
86
|
+
): TrapSearchResult[] {
|
|
87
|
+
const byId = new Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>();
|
|
88
|
+
|
|
89
|
+
addRankedResults(byId, ftsResults, "fts", this.ranking);
|
|
90
|
+
addRankedResults(byId, semanticResults, "semantic", this.ranking);
|
|
91
|
+
|
|
92
|
+
const fused = [...byId.values()]
|
|
93
|
+
.map((result) => ({
|
|
94
|
+
...result,
|
|
95
|
+
score: applyLengthNormalization(result.score, result.trap, this.ranking),
|
|
96
|
+
rank: applyLengthNormalization(result.score, result.trap, this.ranking),
|
|
97
|
+
}))
|
|
98
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
99
|
+
.slice(0, limit);
|
|
100
|
+
|
|
101
|
+
return this.rankResults(fused, query, opts, limit);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
withDiagnostics(
|
|
105
|
+
results: TrapSearchResult[],
|
|
106
|
+
diagnostic: { code: string; message: string }
|
|
107
|
+
): TrapSearchResult[] {
|
|
108
|
+
return results.map((result) => ({
|
|
109
|
+
...result,
|
|
110
|
+
diagnostics: [...(result.diagnostics ?? []), diagnostic],
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
semanticDiagnostic(error: unknown): { code: string; message: string } {
|
|
115
|
+
if (error instanceof EmbeddingProviderUnavailableError) {
|
|
116
|
+
return {
|
|
117
|
+
code: "semantic_unavailable",
|
|
118
|
+
message: error.message,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
code: "semantic_failed",
|
|
123
|
+
message: error instanceof Error ? error.message : "Semantic search failed; hybrid search returned FTS results.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function addRankedResults(
|
|
129
|
+
byId: Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>,
|
|
130
|
+
results: TrapSearchResult[],
|
|
131
|
+
source: "fts" | "semantic",
|
|
132
|
+
ranking: RankingConfig
|
|
133
|
+
): void {
|
|
134
|
+
results.forEach((result, index) => {
|
|
135
|
+
const score = 1 / (ranking.rrfK + index + 1);
|
|
136
|
+
const existing = byId.get(result.trap.id);
|
|
137
|
+
if (existing) {
|
|
138
|
+
existing.score += score;
|
|
139
|
+
if (!existing.sources.includes(source)) existing.sources.push(source);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
byId.set(result.trap.id, {
|
|
143
|
+
...result,
|
|
144
|
+
score,
|
|
145
|
+
sources: [source],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyLengthNormalization(score: number, trap: Trap, ranking: RankingConfig): number {
|
|
151
|
+
const length = `${trap.context}\n${trap.mistake}\n${trap.fix}`.length;
|
|
152
|
+
if (length <= ranking.lengthNormAnchor) return score;
|
|
153
|
+
return score * Math.sqrt(ranking.lengthNormAnchor / length);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyReranking(
|
|
157
|
+
results: TrapSearchResult[],
|
|
158
|
+
query: string,
|
|
159
|
+
opts: SearchPolicyOptions,
|
|
160
|
+
ranking: RankingConfig
|
|
161
|
+
): TrapSearchResult[] {
|
|
162
|
+
if (opts.rerank === false) return stripRankingSignals(results, opts);
|
|
163
|
+
|
|
164
|
+
const queryInfo = analyzeQuery(query);
|
|
165
|
+
return results
|
|
166
|
+
.map((result) => {
|
|
167
|
+
const signals = rankingSignals(result.trap, queryInfo, opts, ranking);
|
|
168
|
+
const boost = Math.min(
|
|
169
|
+
ranking.maxBoost,
|
|
170
|
+
signals.reduce((sum, signal) => sum + signal.weight, 0)
|
|
171
|
+
);
|
|
172
|
+
const score = (result.score ?? result.rank ?? 0) * (1 + boost);
|
|
173
|
+
return {
|
|
174
|
+
...result,
|
|
175
|
+
score,
|
|
176
|
+
rank: score,
|
|
177
|
+
...(opts.includeRankingSignals && signals.length > 0 ? { ranking_signals: signals } : {}),
|
|
178
|
+
};
|
|
179
|
+
})
|
|
180
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stripRankingSignals(results: TrapSearchResult[], opts: SearchPolicyOptions): TrapSearchResult[] {
|
|
184
|
+
if (opts.includeRankingSignals) return results.map((result) => ({ ...result, ranking_signals: [] }));
|
|
185
|
+
return results.map(({ ranking_signals: _rankingSignals, ...result }) => result);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rankingSignals(
|
|
189
|
+
trap: Trap,
|
|
190
|
+
query: QueryInfo,
|
|
191
|
+
filter: ApplicabilityFilter,
|
|
192
|
+
ranking: RankingConfig
|
|
193
|
+
): RankingSignal[] {
|
|
194
|
+
const signals: RankingSignal[] = [];
|
|
195
|
+
const titleTokens = tokenize(trap.title);
|
|
196
|
+
const tags = parseTrapTags(trap.tags).map((tag) => tag.toLowerCase());
|
|
197
|
+
const allFieldTokens = tokenize([
|
|
198
|
+
trap.title,
|
|
199
|
+
trap.context,
|
|
200
|
+
trap.mistake,
|
|
201
|
+
trap.fix,
|
|
202
|
+
trap.before_code ?? "",
|
|
203
|
+
trap.after_code ?? "",
|
|
204
|
+
parseTrapPathGlobs(trap.path_globs).join(" "),
|
|
205
|
+
trap.module ?? "",
|
|
206
|
+
trap.owner ?? "",
|
|
207
|
+
].join(" "));
|
|
208
|
+
|
|
209
|
+
for (const token of query.tokens) {
|
|
210
|
+
if (titleTokens.has(token)) {
|
|
211
|
+
signals.push({ code: "title_token_exact", weight: ranking.titleTokenBoost, detail: token });
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const token of query.tokens) {
|
|
217
|
+
if (tags.includes(token)) {
|
|
218
|
+
signals.push({ code: "tag_exact", weight: ranking.tagTokenBoost, detail: token });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const token of query.identifierTokens) {
|
|
224
|
+
if (allFieldTokens.has(token)) {
|
|
225
|
+
signals.push({ code: "code_identifier_exact", weight: ranking.identifierBoost, detail: token });
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const severityBoost = ranking.severityBoost[trap.severity] ?? 0;
|
|
231
|
+
if (severityBoost > 0) {
|
|
232
|
+
signals.push({ code: "severity", weight: severityBoost, detail: trap.severity });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (hasSpecificPathMatch(trap, filter.path)) {
|
|
236
|
+
signals.push({ code: "path_scope_match", weight: ranking.pathMatchBoost, detail: filter.path });
|
|
237
|
+
}
|
|
238
|
+
if (filter.module && trap.module === filter.module) {
|
|
239
|
+
signals.push({ code: "module_scope_match", weight: ranking.moduleMatchBoost, detail: filter.module });
|
|
240
|
+
}
|
|
241
|
+
if (filter.owner && trap.owner === filter.owner) {
|
|
242
|
+
signals.push({ code: "owner_scope_match", weight: ranking.ownerMatchBoost, detail: filter.owner });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return signals;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
type QueryInfo = {
|
|
249
|
+
tokens: Set<string>;
|
|
250
|
+
identifierTokens: Set<string>;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function analyzeQuery(query: string): QueryInfo {
|
|
254
|
+
const tokens = tokenize(query);
|
|
255
|
+
return {
|
|
256
|
+
tokens,
|
|
257
|
+
identifierTokens: new Set([...tokens].filter(isIdentifierLike)),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function tokenize(value: string): Set<string> {
|
|
262
|
+
return new Set((value.match(/[A-Za-z0-9_.$/@:-]+/g) ?? []).map((token) => token.toLowerCase()));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isIdentifierLike(token: string): boolean {
|
|
266
|
+
return (
|
|
267
|
+
/[_.$/@:-]/.test(token) ||
|
|
268
|
+
/\d/.test(token) ||
|
|
269
|
+
/[a-z][A-Z]/.test(token) ||
|
|
270
|
+
token.length >= 8
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function shouldOverfetch(opts: SearchPolicyOptions): boolean {
|
|
275
|
+
return Boolean(opts.path || opts.module || opts.owner || opts.rerank !== false);
|
|
276
|
+
}
|
|
@@ -15,6 +15,7 @@ export function toTrapActionCard(result: TrapSearchResult, scope: Scope): TrapAc
|
|
|
15
15
|
severity: trap.severity,
|
|
16
16
|
score: result.score ?? null,
|
|
17
17
|
sources: result.sources ?? [],
|
|
18
|
+
...(result.ranking_signals ? { ranking_signals: result.ranking_signals } : {}),
|
|
18
19
|
next_action: {
|
|
19
20
|
details_tool: "get_trap",
|
|
20
21
|
details_args: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
2
|
import * as embeddingQueries from "../db/embedding-queries";
|
|
3
3
|
import * as queries from "../db/queries";
|
|
4
|
-
import type {
|
|
4
|
+
import type { TrapSearchResult } from "../domain/trap";
|
|
5
5
|
import type { SearchMode, TrapStatus } from "./constants";
|
|
6
6
|
import {
|
|
7
7
|
cosineSimilarity,
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
embeddingConfig,
|
|
10
10
|
type EmbeddingProvider,
|
|
11
11
|
} from "./embedder";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_RANKING_CONFIG,
|
|
14
|
+
TrapSearchPolicy,
|
|
15
|
+
type RankingConfig,
|
|
16
|
+
} from "./search-policy";
|
|
12
17
|
|
|
13
18
|
export interface SearchOptions {
|
|
14
19
|
category?: string;
|
|
@@ -16,28 +21,25 @@ export interface SearchOptions {
|
|
|
16
21
|
limit?: number;
|
|
17
22
|
mode?: SearchMode;
|
|
18
23
|
status?: TrapStatus | "all";
|
|
24
|
+
path?: string;
|
|
25
|
+
module?: string;
|
|
26
|
+
owner?: string;
|
|
27
|
+
rerank?: boolean;
|
|
28
|
+
includeRankingSignals?: boolean;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
export interface RankingConfig {
|
|
22
|
-
rrfK: number;
|
|
23
|
-
semanticMinScore: number;
|
|
24
|
-
lengthNormAnchor: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const DEFAULT_RANKING_CONFIG: RankingConfig = {
|
|
28
|
-
rrfK: 60,
|
|
29
|
-
semanticMinScore: 0.3,
|
|
30
|
-
lengthNormAnchor: 500,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
31
|
const DEFAULT_LIMIT = 20;
|
|
34
32
|
|
|
35
33
|
export class SearchService {
|
|
34
|
+
private readonly policy: TrapSearchPolicy;
|
|
35
|
+
|
|
36
36
|
constructor(
|
|
37
37
|
private readonly db: Database,
|
|
38
38
|
private readonly embedder?: EmbeddingProvider,
|
|
39
|
-
|
|
40
|
-
) {
|
|
39
|
+
ranking: RankingConfig = DEFAULT_RANKING_CONFIG
|
|
40
|
+
) {
|
|
41
|
+
this.policy = new TrapSearchPolicy(ranking);
|
|
42
|
+
}
|
|
41
43
|
|
|
42
44
|
async search(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
43
45
|
if (!query.trim()) return [];
|
|
@@ -56,11 +58,17 @@ export class SearchService {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
ftsSearch(query: string, opts: SearchOptions = {}): TrapSearchResult[] {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
62
|
+
const searchLimit = this.policy.candidateLimit(opts, limit);
|
|
63
|
+
const candidates = queries
|
|
64
|
+
.searchTraps(this.db, query, { ...opts, limit: searchLimit })
|
|
65
|
+
.filter((result) => this.policy.matchesTrap(result.trap, opts))
|
|
66
|
+
.map((result) => ({
|
|
67
|
+
...result,
|
|
68
|
+
sources: ["fts"] as ("fts")[],
|
|
69
|
+
score: ftsScore(result.rank),
|
|
70
|
+
}));
|
|
71
|
+
return this.policy.rankResults(candidates, query, opts, limit);
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
async semanticSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
@@ -78,7 +86,7 @@ export class SearchService {
|
|
|
78
86
|
status: opts.status,
|
|
79
87
|
});
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
const results = candidates
|
|
82
90
|
.map(({ trap, embedding }) => {
|
|
83
91
|
const score = cosineSimilarity(queryEmbedding, embedding);
|
|
84
92
|
return {
|
|
@@ -88,9 +96,11 @@ export class SearchService {
|
|
|
88
96
|
score,
|
|
89
97
|
};
|
|
90
98
|
})
|
|
91
|
-
.filter((result) => (result.
|
|
99
|
+
.filter((result) => this.policy.matchesTrap(result.trap, opts))
|
|
100
|
+
.filter((result) => (result.score ?? 0) >= this.policy.semanticMinScore())
|
|
92
101
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
93
|
-
.
|
|
102
|
+
.map((result) => result as TrapSearchResult);
|
|
103
|
+
return this.policy.rankResults(results, query, opts, opts.limit ?? DEFAULT_LIMIT);
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
async hybridSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
@@ -100,90 +110,18 @@ export class SearchService {
|
|
|
100
110
|
try {
|
|
101
111
|
const semanticResults = await this.semanticSearch(query, { ...opts, limit });
|
|
102
112
|
if (semanticResults.length === 0) {
|
|
103
|
-
return withDiagnostics(ftsResults, {
|
|
113
|
+
return this.policy.withDiagnostics(ftsResults, {
|
|
104
114
|
code: "semantic_no_candidates",
|
|
105
115
|
message: "Hybrid search used FTS results because no fresh semantic candidates passed the score threshold.",
|
|
106
116
|
});
|
|
107
117
|
}
|
|
108
|
-
return
|
|
118
|
+
return this.policy.fuse(ftsResults, semanticResults, query, opts, limit);
|
|
109
119
|
} catch (error) {
|
|
110
|
-
return withDiagnostics(ftsResults, semanticDiagnostic(error));
|
|
120
|
+
return this.policy.withDiagnostics(ftsResults, this.policy.semanticDiagnostic(error));
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
export function rrfFuse(
|
|
116
|
-
ftsResults: TrapSearchResult[],
|
|
117
|
-
semanticResults: TrapSearchResult[],
|
|
118
|
-
limit = DEFAULT_LIMIT,
|
|
119
|
-
ranking: RankingConfig = DEFAULT_RANKING_CONFIG
|
|
120
|
-
): TrapSearchResult[] {
|
|
121
|
-
const byId = new Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>();
|
|
122
|
-
|
|
123
|
-
addRankedResults(byId, ftsResults, "fts", ranking);
|
|
124
|
-
addRankedResults(byId, semanticResults, "semantic", ranking);
|
|
125
|
-
|
|
126
|
-
return [...byId.values()]
|
|
127
|
-
.map((result) => ({
|
|
128
|
-
...result,
|
|
129
|
-
score: applyLengthNormalization(result.score, result.trap, ranking),
|
|
130
|
-
rank: applyLengthNormalization(result.score, result.trap, ranking),
|
|
131
|
-
}))
|
|
132
|
-
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
133
|
-
.slice(0, limit);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function addRankedResults(
|
|
137
|
-
byId: Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>,
|
|
138
|
-
results: TrapSearchResult[],
|
|
139
|
-
source: "fts" | "semantic",
|
|
140
|
-
ranking: RankingConfig
|
|
141
|
-
): void {
|
|
142
|
-
results.forEach((result, index) => {
|
|
143
|
-
const score = 1 / (ranking.rrfK + index + 1);
|
|
144
|
-
const existing = byId.get(result.trap.id);
|
|
145
|
-
if (existing) {
|
|
146
|
-
existing.score += score;
|
|
147
|
-
if (!existing.sources.includes(source)) existing.sources.push(source);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
byId.set(result.trap.id, {
|
|
151
|
-
...result,
|
|
152
|
-
score,
|
|
153
|
-
sources: [source],
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function applyLengthNormalization(score: number, trap: Trap, ranking: RankingConfig): number {
|
|
159
|
-
const length = `${trap.context}\n${trap.mistake}\n${trap.fix}`.length;
|
|
160
|
-
if (length <= ranking.lengthNormAnchor) return score;
|
|
161
|
-
return score * Math.sqrt(ranking.lengthNormAnchor / length);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
125
|
function ftsScore(rank: number): number {
|
|
165
126
|
return Number.isFinite(rank) ? -rank : 0;
|
|
166
127
|
}
|
|
167
|
-
|
|
168
|
-
function withDiagnostics(
|
|
169
|
-
results: TrapSearchResult[],
|
|
170
|
-
diagnostic: { code: string; message: string }
|
|
171
|
-
): TrapSearchResult[] {
|
|
172
|
-
return results.map((result) => ({
|
|
173
|
-
...result,
|
|
174
|
-
diagnostics: [...(result.diagnostics ?? []), diagnostic],
|
|
175
|
-
}));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function semanticDiagnostic(error: unknown): { code: string; message: string } {
|
|
179
|
-
if (error instanceof EmbeddingProviderUnavailableError) {
|
|
180
|
-
return {
|
|
181
|
-
code: "semantic_unavailable",
|
|
182
|
-
message: error.message,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
return {
|
|
186
|
-
code: "semantic_failed",
|
|
187
|
-
message: error instanceof Error ? error.message : "Semantic search failed; hybrid search returned FTS results.",
|
|
188
|
-
};
|
|
189
|
-
}
|