codetrap 0.1.5 → 0.1.7

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.
@@ -0,0 +1,505 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { openDatabase } from "../db/connection";
3
+ import { TrapRepository } from "../db/repository";
4
+ import type { TrapInput, TrapSearchResult } from "../domain/trap";
5
+ import { SEARCH_MODES, type SearchMode } from "./constants";
6
+ import {
7
+ createDefaultEmbeddingProvider,
8
+ embeddingConfig,
9
+ type EmbeddingConfig,
10
+ type EmbeddingProvider,
11
+ type EmbeddingTask,
12
+ } from "./embedder";
13
+
14
+ export type PhaseGate = "phase0" | "phase1" | "phase4" | "dogfood";
15
+ export const DOGFOOD_JUDGMENTS = ["useful_hit", "miss", "noisy_hit", "no_relevant_trap"] as const;
16
+ export type DogfoodJudgment = (typeof DOGFOOD_JUDGMENTS)[number];
17
+
18
+ export type EvalQuery = {
19
+ query: string;
20
+ mode: SearchMode;
21
+ goldTrapIds: number[];
22
+ phaseGate: PhaseGate;
23
+ minRecallAt3?: number;
24
+ minRecallAt5: number;
25
+ source?: string;
26
+ judgment?: DogfoodJudgment;
27
+ observedTopTitles?: string[];
28
+ note?: string;
29
+ };
30
+
31
+ export type EvalFixture = {
32
+ traps: TrapInput[];
33
+ queries: EvalQuery[];
34
+ };
35
+
36
+ export type EvalCaseReport = {
37
+ query: string;
38
+ mode: SearchMode;
39
+ phaseGate: PhaseGate;
40
+ judgment?: DogfoodJudgment;
41
+ goldTrapIds: number[];
42
+ expectedTitles: string[];
43
+ topResults: { id: number; title: string; sources: string[]; diagnostics: string[] }[];
44
+ recallAt3: number;
45
+ recallAt5: number;
46
+ reciprocalRank: number;
47
+ passed: boolean;
48
+ error?: string;
49
+ };
50
+
51
+ export type SearchEvalMetrics = {
52
+ recall_at_3: number;
53
+ recall_at_5: number;
54
+ mrr: number;
55
+ hybrid_fallback_count: number;
56
+ semantic_error_count: number;
57
+ };
58
+
59
+ export type SearchEvalNextAction = {
60
+ command: string;
61
+ reason: string;
62
+ };
63
+
64
+ export type SearchEvalReport = {
65
+ mode: "deterministic" | "live";
66
+ fixture: string;
67
+ provider: EmbeddingConfig | null;
68
+ semantic_available: boolean;
69
+ provider_error: string | null;
70
+ total_cases: number;
71
+ metrics: SearchEvalMetrics;
72
+ dogfood: {
73
+ total: number;
74
+ judgment_counts: Record<DogfoodJudgment, number>;
75
+ };
76
+ failures: EvalCaseReport[];
77
+ misses: EvalCaseReport[];
78
+ noisy_hits: EvalCaseReport[];
79
+ next_actions: SearchEvalNextAction[];
80
+ };
81
+
82
+ export type RecordDogfoodResult = {
83
+ success: true;
84
+ fixture: string;
85
+ query: EvalQuery;
86
+ query_count: number;
87
+ };
88
+
89
+ export class EvalEmbedder implements EmbeddingProvider {
90
+ readonly provider = "eval";
91
+ readonly model = "eval-embedding";
92
+ readonly dimensions = 14;
93
+
94
+ async embed(texts: string[], _task: EmbeddingTask): Promise<Float32Array[]> {
95
+ return texts.map(vectorFor);
96
+ }
97
+ }
98
+
99
+ export const DEFAULT_SEARCH_EVAL_FIXTURE = "src/tests/fixtures/search-eval.json";
100
+
101
+ export function recordDogfoodCase(fixturePath: string, jsonInput: string | undefined): RecordDogfoodResult {
102
+ if (!jsonInput) throw new Error("record requires --json '<record>'.");
103
+
104
+ const fixture = readEvalFixture(fixturePath);
105
+ const query = normalizeRecord(JSON.parse(jsonInput) as unknown, fixture);
106
+ fixture.queries.push(query);
107
+ writeEvalFixture(fixturePath, fixture);
108
+ return {
109
+ success: true,
110
+ fixture: fixturePath,
111
+ query,
112
+ query_count: fixture.queries.length,
113
+ };
114
+ }
115
+
116
+ export async function reportDogfood(fixturePath: string, live: boolean): Promise<SearchEvalReport> {
117
+ const fixture = readEvalFixture(fixturePath);
118
+ const provider = live ? createDefaultEmbeddingProvider() : new EvalEmbedder();
119
+ const evaluated = await evaluateSearchFixture(fixture, provider);
120
+ const mode: SearchEvalReport["mode"] = live ? "live" : "deterministic";
121
+ const report: Omit<SearchEvalReport, "next_actions"> = {
122
+ mode,
123
+ fixture: fixturePath,
124
+ ...evaluated,
125
+ };
126
+ return {
127
+ ...report,
128
+ next_actions: buildSearchEvalNextActions(report),
129
+ };
130
+ }
131
+
132
+ export async function evaluateSearchFixture(
133
+ fixture: EvalFixture,
134
+ provider: EmbeddingProvider | undefined
135
+ ): Promise<Omit<SearchEvalReport, "mode" | "fixture" | "next_actions">> {
136
+ const repo = fixtureRepository(fixture, provider);
137
+
138
+ let providerError: string | null = null;
139
+ if (provider) {
140
+ try {
141
+ await repo.ensureEmbeddings();
142
+ } catch (error) {
143
+ providerError = errorMessage(error);
144
+ }
145
+ }
146
+ const searchRepo = providerError ? fixtureRepository(fixture, undefined) : repo;
147
+
148
+ const cases: EvalCaseReport[] = [];
149
+ let hybridFallbackCount = 0;
150
+ let semanticErrorCount = 0;
151
+
152
+ for (const item of fixture.queries) {
153
+ try {
154
+ const results = await searchRepo.search(item.query, { mode: item.mode, limit: 5 });
155
+ const report = caseReport(item, fixture, results);
156
+ cases.push(report);
157
+ if (item.mode === "hybrid" && (!provider || hasSemanticFallback(results))) {
158
+ hybridFallbackCount++;
159
+ }
160
+ } catch (error) {
161
+ semanticErrorCount++;
162
+ cases.push(caseReport(item, fixture, [], errorMessage(error)));
163
+ }
164
+ }
165
+
166
+ const dogfoodCases = cases.filter((item) => item.phaseGate === "dogfood" || item.judgment !== undefined);
167
+ const failures = cases.filter((item) => !item.passed);
168
+ const misses = cases.filter((item) => item.judgment === "miss" || item.recallAt5 < (fixtureQuery(item, fixture)?.minRecallAt5 ?? 1));
169
+ const noisyHits = cases.filter((item) => item.judgment === "noisy_hit");
170
+ const metrics = aggregateMetrics(cases);
171
+ return {
172
+ provider: provider ? embeddingConfig(provider) : null,
173
+ semantic_available: Boolean(provider && providerError === null),
174
+ provider_error: providerError,
175
+ total_cases: cases.length,
176
+ metrics: {
177
+ ...metrics,
178
+ hybrid_fallback_count: hybridFallbackCount,
179
+ semantic_error_count: semanticErrorCount,
180
+ },
181
+ dogfood: {
182
+ total: dogfoodCases.length,
183
+ judgment_counts: {
184
+ useful_hit: dogfoodCases.filter((item) => item.judgment === "useful_hit").length,
185
+ miss: dogfoodCases.filter((item) => item.judgment === "miss").length,
186
+ noisy_hit: dogfoodCases.filter((item) => item.judgment === "noisy_hit").length,
187
+ no_relevant_trap: dogfoodCases.filter((item) => item.judgment === "no_relevant_trap").length,
188
+ },
189
+ },
190
+ failures,
191
+ misses,
192
+ noisy_hits: noisyHits,
193
+ };
194
+ }
195
+
196
+ export function readEvalFixture(path: string): EvalFixture {
197
+ if (!existsSync(path)) throw new Error(`Fixture not found: ${path}`);
198
+ return parseEvalFixture(readFileSync(path, "utf-8"), path);
199
+ }
200
+
201
+ export function parseEvalFixture(text: string, path: string): EvalFixture {
202
+ const parsed = JSON.parse(text) as unknown;
203
+ if (!isRecord(parsed) || !Array.isArray(parsed.traps) || !Array.isArray(parsed.queries)) {
204
+ throw new Error(`Invalid eval fixture: ${path}`);
205
+ }
206
+ return parsed as EvalFixture;
207
+ }
208
+
209
+ export function writeEvalFixture(path: string, fixture: EvalFixture): void {
210
+ writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`);
211
+ }
212
+
213
+ export function formatSearchEvalReport(report: SearchEvalReport): string {
214
+ const lines = [
215
+ `Dogfood eval (${report.mode})`,
216
+ `Fixture: ${report.fixture}`,
217
+ `Provider: ${providerLabel(report.provider)}`,
218
+ `Semantic available: ${String(report.semantic_available)}`,
219
+ ];
220
+ if (report.provider_error) lines.push(`Provider error: ${report.provider_error}`);
221
+ lines.push(
222
+ `Cases: ${report.total_cases}`,
223
+ `Recall@3: ${report.metrics.recall_at_3}`,
224
+ `Recall@5: ${report.metrics.recall_at_5}`,
225
+ `MRR: ${report.metrics.mrr}`,
226
+ `Hybrid fallback count: ${report.metrics.hybrid_fallback_count}`,
227
+ `Semantic error count: ${report.metrics.semantic_error_count}`,
228
+ `Dogfood cases: ${report.dogfood.total}`,
229
+ `Judgments: ${formatJudgmentCounts(report.dogfood.judgment_counts)}`
230
+ );
231
+ appendCaseSection(lines, "Failures", report.failures);
232
+ appendCaseSection(lines, "Misses to inspect", report.misses);
233
+ appendCaseSection(lines, "Noisy hits to inspect", report.noisy_hits);
234
+ lines.push("Next actions:", ...formatNextActions(report.next_actions));
235
+ return lines.join("\n");
236
+ }
237
+
238
+ function buildSearchEvalNextActions(
239
+ report: Omit<SearchEvalReport, "next_actions">
240
+ ): SearchEvalNextAction[] {
241
+ const actions: SearchEvalNextAction[] = [];
242
+ if (report.mode === "live" && !report.semantic_available) {
243
+ actions.push({
244
+ command: "export JINA_API_KEY=<your-jina-api-key>",
245
+ reason: "Enable live semantic checks, then rerun bun run eval:dogfood -- report --live.",
246
+ });
247
+ }
248
+ if (report.failures.length > 0) {
249
+ actions.push({
250
+ command: "bun run eval:dogfood -- report --json",
251
+ reason: "Inspect expected ids, top results, and errors before changing search behavior or fixture expectations.",
252
+ });
253
+ }
254
+ if (report.misses.length > 0) {
255
+ actions.push({
256
+ command: 'codetrap search "<miss query>" --mode hybrid --ranking-signals --json',
257
+ reason: "Replay a miss with ranking signals before deciding whether to tune search or promote a fixture case.",
258
+ });
259
+ }
260
+ if (report.noisy_hits.length > 0) {
261
+ actions.push({
262
+ command: 'codetrap search "<noisy query>" --mode hybrid --ranking-signals --json',
263
+ reason: "Inspect why noisy results ranked before deciding whether the case belongs in dogfood eval.",
264
+ });
265
+ }
266
+ if (actions.length === 0) {
267
+ actions.push({
268
+ command: 'codetrap search "<task keywords>" --mode hybrid --json',
269
+ reason: "Keep logging real pre-edit searches in dogfood-log.md before automating promotion.",
270
+ });
271
+ }
272
+ return actions;
273
+ }
274
+
275
+ function formatJudgmentCounts(counts: Record<DogfoodJudgment, number>): string {
276
+ return DOGFOOD_JUDGMENTS.map((judgment) => `${judgment}=${counts[judgment]}`).join(", ");
277
+ }
278
+
279
+ function appendCaseSection(lines: string[], title: string, cases: EvalCaseReport[]): void {
280
+ if (cases.length === 0) return;
281
+ lines.push(`${title}:`);
282
+ for (const item of cases.slice(0, 5)) {
283
+ lines.push(` - [${item.mode}] ${item.query}`);
284
+ if (item.goldTrapIds.length > 0) {
285
+ lines.push(` expected: ${formatExpected(item)}`);
286
+ }
287
+ lines.push(` top: ${formatTopResults(item)}`);
288
+ if (item.error) lines.push(` error: ${item.error}`);
289
+ }
290
+ if (cases.length > 5) lines.push(` ... ${cases.length - 5} more`);
291
+ }
292
+
293
+ function formatExpected(item: EvalCaseReport): string {
294
+ return item.goldTrapIds
295
+ .map((id, index) => `#${id} ${item.expectedTitles[index] ?? ""}`.trim())
296
+ .join(", ");
297
+ }
298
+
299
+ function formatTopResults(item: EvalCaseReport): string {
300
+ if (item.topResults.length === 0) return "(none)";
301
+ return item.topResults
302
+ .slice(0, 3)
303
+ .map((result) => `#${result.id} ${result.title}`)
304
+ .join("; ");
305
+ }
306
+
307
+ function formatNextActions(actions: SearchEvalNextAction[]): string[] {
308
+ if (actions.length === 0) return [" (none)"];
309
+ return actions.map((action) => ` - ${action.command} # ${action.reason}`);
310
+ }
311
+
312
+ function fixtureRepository(fixture: EvalFixture, provider: EmbeddingProvider | undefined): TrapRepository {
313
+ const repo = new TrapRepository(openDatabase(":memory:"), provider);
314
+ for (const trap of fixture.traps) repo.add(trap);
315
+ return repo;
316
+ }
317
+
318
+ function caseReport(item: EvalQuery, fixture: EvalFixture, results: TrapSearchResult[], error?: string): EvalCaseReport {
319
+ const caseError =
320
+ error ??
321
+ (item.goldTrapIds.length === 0 && item.judgment !== "no_relevant_trap"
322
+ ? "goldTrapIds is required unless judgment is no_relevant_trap."
323
+ : undefined);
324
+ const resultIdsAt3 = new Set(results.slice(0, 3).map((result) => result.trap.id));
325
+ const resultIdsAt5 = new Set(results.map((result) => result.trap.id));
326
+ const hasGoldTraps = item.goldTrapIds.length > 0;
327
+ const recallAt3 = hasGoldTraps
328
+ ? item.goldTrapIds.filter((id) => resultIdsAt3.has(id)).length / item.goldTrapIds.length
329
+ : 1;
330
+ const recallAt5 = hasGoldTraps
331
+ ? item.goldTrapIds.filter((id) => resultIdsAt5.has(id)).length / item.goldTrapIds.length
332
+ : 1;
333
+ const rank = hasGoldTraps ? results.findIndex((result) => item.goldTrapIds.includes(result.trap.id)) : -1;
334
+ const reciprocalRank = rank >= 0 ? 1 / (rank + 1) : 0;
335
+ const minRecallAt3 = item.minRecallAt3 ?? 0;
336
+ const minRecallAt5 = item.minRecallAt5 ?? 0;
337
+ const passed = !caseError && recallAt3 >= minRecallAt3 && recallAt5 >= minRecallAt5;
338
+
339
+ return {
340
+ query: item.query,
341
+ mode: item.mode,
342
+ phaseGate: item.phaseGate,
343
+ judgment: item.judgment,
344
+ goldTrapIds: item.goldTrapIds,
345
+ expectedTitles: item.goldTrapIds.map((id) => fixture.traps[id - 1]?.title ?? `#${id}`),
346
+ topResults: results.map((result) => ({
347
+ id: result.trap.id,
348
+ title: result.trap.title,
349
+ sources: result.sources ?? [],
350
+ diagnostics: (result.diagnostics ?? []).map((diagnostic) => diagnostic.code),
351
+ })),
352
+ recallAt3,
353
+ recallAt5,
354
+ reciprocalRank,
355
+ passed,
356
+ ...(caseError ? { error: caseError } : {}),
357
+ };
358
+ }
359
+
360
+ function aggregateMetrics(cases: EvalCaseReport[]): Pick<SearchEvalMetrics, "recall_at_3" | "recall_at_5" | "mrr"> {
361
+ const recallCases = cases.filter((item) => item.goldTrapIds.length > 0);
362
+ const total = recallCases.length || 1;
363
+ return {
364
+ recall_at_3: round(recallCases.reduce((sum, item) => sum + item.recallAt3, 0) / total),
365
+ recall_at_5: round(recallCases.reduce((sum, item) => sum + item.recallAt5, 0) / total),
366
+ mrr: round(recallCases.reduce((sum, item) => sum + item.reciprocalRank, 0) / total),
367
+ };
368
+ }
369
+
370
+ function normalizeRecord(value: unknown, fixture: EvalFixture): EvalQuery {
371
+ if (!isRecord(value)) throw new Error("record JSON must be an object.");
372
+ const query = stringField(value, "query");
373
+ const mode = searchModeField(value, "mode");
374
+ const judgment = judgmentField(value, "judgment");
375
+ const goldTrapIds = goldTrapIdsField(value, "goldTrapIds", judgment);
376
+ for (const id of goldTrapIds) {
377
+ if (!fixture.traps[id - 1]) {
378
+ throw new Error(
379
+ `goldTrapIds contains unknown trap id: ${id}. Add a compact copy of the expected trap to fixture.traps before recording this dogfood case.`
380
+ );
381
+ }
382
+ }
383
+
384
+ return {
385
+ query,
386
+ mode,
387
+ goldTrapIds,
388
+ phaseGate: "dogfood",
389
+ minRecallAt3: numberField(value, "minRecallAt3") ?? (judgment === "no_relevant_trap" ? 0 : 1),
390
+ minRecallAt5: numberField(value, "minRecallAt5") ?? (judgment === "no_relevant_trap" ? 0 : 1),
391
+ source: typeof value.source === "string" && value.source.trim() ? value.source.trim() : "dogfood",
392
+ judgment,
393
+ observedTopTitles: optionalStringArray(value, "observedTopTitles"),
394
+ note: typeof value.note === "string" && value.note.trim() ? value.note.trim() : undefined,
395
+ };
396
+ }
397
+
398
+ function fixtureQuery(report: EvalCaseReport, fixture: EvalFixture): EvalQuery | undefined {
399
+ return fixture.queries.find((item) => item.query === report.query && item.mode === report.mode);
400
+ }
401
+
402
+ function hasSemanticFallback(results: TrapSearchResult[]): boolean {
403
+ return results.some((result) =>
404
+ (result.diagnostics ?? []).some((diagnostic) =>
405
+ ["semantic_unavailable", "semantic_no_candidates", "semantic_failed"].includes(diagnostic.code)
406
+ )
407
+ );
408
+ }
409
+
410
+ function stringField(value: Record<string, unknown>, key: string): string {
411
+ const field = value[key];
412
+ if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} is required.`);
413
+ return field.trim();
414
+ }
415
+
416
+ function searchModeField(value: Record<string, unknown>, key: string): SearchMode {
417
+ const mode = stringField(value, key);
418
+ if (!(SEARCH_MODES as readonly string[]).includes(mode)) {
419
+ throw new Error(`${key} must be one of: ${SEARCH_MODES.join(", ")}`);
420
+ }
421
+ return mode as SearchMode;
422
+ }
423
+
424
+ function judgmentField(value: Record<string, unknown>, key: string): DogfoodJudgment {
425
+ const judgment = stringField(value, key);
426
+ if (!(DOGFOOD_JUDGMENTS as readonly string[]).includes(judgment)) {
427
+ throw new Error(`${key} must be one of: ${DOGFOOD_JUDGMENTS.join(", ")}`);
428
+ }
429
+ return judgment as DogfoodJudgment;
430
+ }
431
+
432
+ function goldTrapIdsField(value: Record<string, unknown>, key: string, judgment: DogfoodJudgment): number[] {
433
+ const field = value[key];
434
+ if (judgment === "no_relevant_trap") {
435
+ if (field === undefined) return [];
436
+ if (!Array.isArray(field)) throw new Error(`${key} must be an array of positive integer trap ids.`);
437
+ if (field.length > 0) throw new Error(`${key} must be empty when judgment is no_relevant_trap.`);
438
+ return [];
439
+ }
440
+ return intArrayField(value, key);
441
+ }
442
+
443
+ function intArrayField(value: Record<string, unknown>, key: string): number[] {
444
+ const field = value[key];
445
+ if (!Array.isArray(field) || field.length === 0) throw new Error(`${key} must be a non-empty array.`);
446
+ const ids = field.map((item) => Number(item));
447
+ if (!ids.every((id) => Number.isInteger(id) && id > 0)) {
448
+ throw new Error(`${key} must contain positive integer trap ids.`);
449
+ }
450
+ return ids;
451
+ }
452
+
453
+ function optionalStringArray(value: Record<string, unknown>, key: string): string[] | undefined {
454
+ const field = value[key];
455
+ if (field === undefined) return undefined;
456
+ if (!Array.isArray(field)) throw new Error(`${key} must be an array of strings.`);
457
+ const values = field.map(String).map((item) => item.trim()).filter(Boolean);
458
+ return values.length > 0 ? values : undefined;
459
+ }
460
+
461
+ function numberField(value: Record<string, unknown>, key: string): number | undefined {
462
+ const field = value[key];
463
+ if (field === undefined) return undefined;
464
+ const number = Number(field);
465
+ if (!Number.isFinite(number) || number < 0 || number > 1) throw new Error(`${key} must be a number between 0 and 1.`);
466
+ return number;
467
+ }
468
+
469
+ function providerLabel(provider: EmbeddingConfig | null): string {
470
+ if (!provider) return "(none)";
471
+ return `${provider.provider}/${provider.model}`;
472
+ }
473
+
474
+ function isRecord(value: unknown): value is Record<string, unknown> {
475
+ return typeof value === "object" && value !== null && !Array.isArray(value);
476
+ }
477
+
478
+ function round(value: number): number {
479
+ return Math.round(value * 10000) / 10000;
480
+ }
481
+
482
+ function errorMessage(error: unknown): string {
483
+ return error instanceof Error ? error.message : String(error);
484
+ }
485
+
486
+ function vectorFor(text: string): Float32Array {
487
+ const lower = text.toLowerCase();
488
+ const vector = new Float32Array(14);
489
+ if (/(http|https|fetch|axios|request|api|remote|network|网络|请求)/.test(lower)) vector[0] = 1;
490
+ if (/(auth|authentication|login|session|认证)/.test(lower)) vector[1] = 1;
491
+ if (/(db|database|sqlite|sql|migration|schema|table|数据库)/.test(lower)) vector[2] = 1;
492
+ if (/(config|env|environment|process\.env|配置)/.test(lower)) vector[3] = 1;
493
+ if (/(cache|redis|stale|缓存)/.test(lower)) vector[4] = 1;
494
+ if (/(cli|flag|parseargs|query text|positionals)/.test(lower)) vector[5] = 1;
495
+ if (/(sticks3|esp-idf|esp32p4|hello_world|flash|target|8mb|idf)/.test(lower)) vector[6] = 1;
496
+ if (/(m5unified|button|buttons|按键|按钮|screen|display|lcd|屏幕|兼容)/.test(lower)) vector[7] = 1;
497
+ if (/(pmic|i2c|st7789|power|电源|屏幕不亮|backlight)/.test(lower)) vector[8] = 1;
498
+ if (/(asr|豆包|voice|语音|firmware|固件|mac代理|agent-side)/.test(lower)) vector[9] = 1;
499
+ if (/(voice_server_uri|localhost|127\.0\.0\.1|websocket|局域网|lan)/.test(lower)) vector[10] = 1;
500
+ if (/(es8311|0x18|7-bit|8-bit|codec|probe|地址)/.test(lower)) vector[11] = 1;
501
+ if (/(gpio14|gpio16|i2s|din|wav|全 0|zeros|speaker dout)/.test(lower)) vector[12] = 1;
502
+ if (/(peak|32768|gain|quality|asr_timing|增益|重刷)/.test(lower)) vector[13] = 1;
503
+ if (vector.every((value) => value === 0)) vector[5] = 1;
504
+ return vector;
505
+ }
@@ -0,0 +1,96 @@
1
+ import type { CandidateTrap, SessionMetadata, SessionNote } from "../domain/session";
2
+ import { CATEGORIES, SCOPES, SEVERITIES, type Category, type Scope, type Severity } from "./constants";
3
+ import { scoreCandidateTrap } from "./trap-quality";
4
+
5
+ type CandidateDraft = Pick<CandidateTrap, "trap" | "evidence">;
6
+
7
+ export function proposeCandidateTraps(session: SessionMetadata, notes: SessionNote[]): CandidateTrap[] {
8
+ const candidates: CandidateTrap[] = [];
9
+ for (const note of notes) {
10
+ const draft = explicitTrapDraft(session, note);
11
+ if (!draft) continue;
12
+
13
+ const scored = scoreCandidateTrap(draft);
14
+ candidates.push({
15
+ id: `cand-${String(candidates.length + 1).padStart(3, "0")}`,
16
+ status: "proposed",
17
+ quality_score: scored.score,
18
+ quality: scored.quality,
19
+ ...draft,
20
+ });
21
+ }
22
+ return candidates;
23
+ }
24
+
25
+ function explicitTrapDraft(session: SessionMetadata, note: SessionNote): CandidateDraft | null {
26
+ const fields = parseLabeledFields(note.text);
27
+ const title = fields.title ?? fields.trap_title;
28
+ const context = fields.context ?? fields.trigger;
29
+ const mistake = fields.mistake ?? fields.avoid;
30
+ const fix = fields.fix ?? fields.do_instead;
31
+ if (!title || !context || !mistake || !fix) return null;
32
+
33
+ return {
34
+ trap: {
35
+ title,
36
+ category: parseCategory(fields.category),
37
+ scope: parseScope(fields.scope),
38
+ context,
39
+ mistake,
40
+ fix,
41
+ severity: parseSeverity(fields.severity),
42
+ tags: splitList(fields.tags),
43
+ path_globs: splitList(fields.path_globs ?? fields.paths ?? fields.related_files) || note.related_files,
44
+ module: fields.module ?? session.module,
45
+ owner: fields.owner ?? session.owner,
46
+ },
47
+ evidence: [evidenceFromNote(session, note, "Captured from explicit session candidate fields.")],
48
+ };
49
+ }
50
+
51
+ function evidenceFromNote(session: SessionMetadata, note: SessionNote, noteText: string) {
52
+ return {
53
+ source_type: note.kind === "test_failure" ? "test_failure" : "conversation",
54
+ source_ref: note.source_ref ?? `session:${session.id}`,
55
+ related_files: note.related_files,
56
+ note: noteText,
57
+ };
58
+ }
59
+
60
+ function parseLabeledFields(text: string): Record<string, string> {
61
+ const fields: Record<string, string> = {};
62
+ for (const line of text.split(/\r?\n/)) {
63
+ const match = line.match(/^([A-Za-z][A-Za-z0-9 _-]{1,40}):\s*(.+)$/);
64
+ if (!match) continue;
65
+ fields[normalizeFieldName(match[1])] = match[2].trim();
66
+ }
67
+ return fields;
68
+ }
69
+
70
+ function normalizeFieldName(value: string): string {
71
+ return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
72
+ }
73
+
74
+ function splitList(value: string | undefined): string[] | undefined {
75
+ if (!value) return undefined;
76
+ const values = value
77
+ .split(",")
78
+ .map((item) => item.trim())
79
+ .filter(Boolean);
80
+ return values.length > 0 ? values : undefined;
81
+ }
82
+
83
+ function parseCategory(value: string | undefined): Category {
84
+ if (value && (CATEGORIES as readonly string[]).includes(value)) return value as Category;
85
+ return "other";
86
+ }
87
+
88
+ function parseSeverity(value: string | undefined): Severity {
89
+ if (value && (SEVERITIES as readonly string[]).includes(value)) return value as Severity;
90
+ return "warning";
91
+ }
92
+
93
+ function parseScope(value: string | undefined): Scope {
94
+ if (value && (SCOPES as readonly string[]).includes(value)) return value as Scope;
95
+ return "project";
96
+ }