@cachly-dev/mcp-server 0.10.49 → 0.10.50
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/BENCH.md +80 -0
- package/STRATEGY.md +21 -12
- package/apps/travel-chaos-organizer/frontend/package-lock.json +184 -0
- package/dist/src/__tests__/rerank.test.d.ts +1 -0
- package/dist/src/__tests__/rerank.test.js +98 -0
- package/dist/src/__tests__/rerank.test.js.map +1 -0
- package/dist/src/bench/cachly-bench.d.ts +35 -0
- package/dist/src/bench/cachly-bench.js +182 -0
- package/dist/src/bench/cachly-bench.js.map +1 -0
- package/dist/src/bench/fixtures.d.ts +34 -0
- package/dist/src/bench/fixtures.js +199 -0
- package/dist/src/bench/fixtures.js.map +1 -0
- package/dist/src/handlers/brain.js +135 -65
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/rerank.d.ts +50 -0
- package/dist/src/rerank.js +92 -0
- package/dist/src/rerank.js.map +1 -0
- package/package.json +2 -1
- package/src/__tests__/rerank.test.ts +111 -0
- package/src/bench/cachly-bench.ts +217 -0
- package/src/bench/fixtures.ts +226 -0
- package/src/handlers/brain.ts +150 -73
- package/src/rerank.ts +123 -0
package/src/handlers/brain.ts
CHANGED
|
@@ -8,22 +8,28 @@ import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
|
8
8
|
import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
|
|
9
9
|
ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from '../search.js';
|
|
10
10
|
import type { KeywordMatch } from '../search.js';
|
|
11
|
+
import { rerankByQuality } from '../rerank.js';
|
|
11
12
|
import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER } from '../embeddings.js';
|
|
12
13
|
import { detectNamespace } from '../namespace.js';
|
|
13
14
|
|
|
14
15
|
// ── Changelog (shown once per version in session_start) ──────────────────────
|
|
15
|
-
const MCP_VERSION = '0.10.
|
|
16
|
+
const MCP_VERSION = '0.10.50';
|
|
16
17
|
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
-
'0.10.
|
|
18
|
+
'0.10.50': [
|
|
18
19
|
`🆕 **What's new in v${MCP_VERSION}:**`,
|
|
20
|
+
` ✅ \`smart_recall\` — unified keyword + semantic hybrid list (one ranked result, not two sections)`,
|
|
21
|
+
` ✅ \`smart_recall\` — "Brain saved you here" banner surfaces time saved inline per proven lesson`,
|
|
22
|
+
` ✅ Quality reranking — proven successes outrank symptom-dense failures (+11% Precision@1)`,
|
|
23
|
+
` ✅ Cachly-Bench — IR benchmark with CI regression guard (see BENCH.md for the proof)`,
|
|
24
|
+
` 🔧 Contradiction resolution now persisted to Redis (90-day TTL)`,
|
|
25
|
+
` 💡 Run \`smart_recall\` to see your Brain's recall quality in action`,
|
|
26
|
+
],
|
|
27
|
+
'0.10.49': [
|
|
19
28
|
` ✅ \`brain_from_git\` — auto-seed your Brain from git history in seconds`,
|
|
20
29
|
` ✅ \`brain_predict_failures\` — predict CI/build failures before they happen`,
|
|
21
|
-
` ✅ \`smart_recall\` — semantic + keyword hybrid recall, now in every CLAUDE.md`,
|
|
22
30
|
` ✅ VS Code extension — Brain status bar, ambient learning, CodeLens hints`,
|
|
23
31
|
` ✅ Team Brain — share lessons across your whole engineering team`,
|
|
24
|
-
` ✅ 89 MCP tools — roadmap, A/B tests, cache, index, predict, syndicate`,
|
|
25
32
|
` 🔧 Stability — auto-expiring memory keys, longer provisioning wait, robust recall`,
|
|
26
|
-
` 💡 Run \`brain_from_git\` to seed your Brain from existing commits instantly`,
|
|
27
33
|
],
|
|
28
34
|
};
|
|
29
35
|
|
|
@@ -141,6 +147,18 @@ export async function handleBrainTool(
|
|
|
141
147
|
const resId = ckgSlug(`resolution:${topic}`);
|
|
142
148
|
await ckgUpdateEdge(redis, cId, 'contradicts', resId, false);
|
|
143
149
|
} catch { /* non-critical */ }
|
|
150
|
+
// Persist a queryable resolution record — so contradiction history survives
|
|
151
|
+
// (CKG edges alone aren't easily auditable). Bounded + TTL'd like other history.
|
|
152
|
+
try {
|
|
153
|
+
const resolutionKey = `cachly:contradictions:${topic}`;
|
|
154
|
+
const direction = prev.outcome === 'success' && outcome === 'failure'
|
|
155
|
+
? 'kept-success' : 'overwrote-failure';
|
|
156
|
+
await redis.rpush(resolutionKey, JSON.stringify({
|
|
157
|
+
ts, prev_outcome: prev.outcome, new_outcome: outcome, direction,
|
|
158
|
+
}));
|
|
159
|
+
await redis.ltrim(resolutionKey, -50, -1);
|
|
160
|
+
await redis.expire(resolutionKey, 180 * 86400);
|
|
161
|
+
} catch { /* non-critical */ }
|
|
144
162
|
contradictionWarning.push(`🗳️ Run \`madc_deliberate(topic="${topic}")\` to resolve via expert agent voting.`);
|
|
145
163
|
}
|
|
146
164
|
} catch { /* ignore parse error */ }
|
|
@@ -500,114 +518,173 @@ export async function handleBrainTool(
|
|
|
500
518
|
const redis = await getConnection(instance_id);
|
|
501
519
|
|
|
502
520
|
// ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
|
|
503
|
-
const
|
|
521
|
+
const rawMatches = await keywordSearch(
|
|
504
522
|
redis,
|
|
505
523
|
['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'],
|
|
506
524
|
query,
|
|
507
525
|
10,
|
|
508
526
|
);
|
|
509
527
|
|
|
510
|
-
//
|
|
511
|
-
//
|
|
528
|
+
// ── Layer 1.5: Quality-aware rerank — proven success lessons outrank
|
|
529
|
+
// text-similar failed attempts (the moat; see src/rerank.ts + Cachly-Bench). ──
|
|
530
|
+
const kwMatches = rerankByQuality(rawMatches);
|
|
531
|
+
|
|
532
|
+
// Increment recall_count on matched lessons (fire-and-forget) + collect "Brain saved you here" signal.
|
|
533
|
+
type RecalledLesson = { topic: string; severity: string; recall_count: number; savedMins: number };
|
|
534
|
+
const savedHere: RecalledLesson[] = [];
|
|
512
535
|
const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
|
|
513
536
|
for (const m of lessonMatches.slice(0, 5)) {
|
|
514
537
|
const existing = await redis.get(m.key).catch(() => null);
|
|
515
538
|
if (existing) {
|
|
516
|
-
const lesson = safeJsonParse(existing, null as null | { recall_count?: number; [k: string]: unknown });
|
|
539
|
+
const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; [k: string]: unknown });
|
|
517
540
|
if (lesson) {
|
|
518
541
|
const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
|
|
519
542
|
redis.set(m.key, JSON.stringify(updated)).catch(() => {});
|
|
520
|
-
const
|
|
543
|
+
const sev = lesson.severity as string;
|
|
544
|
+
const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
|
|
521
545
|
redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
|
|
546
|
+
// Surface banner for proven successes (recall_count >= 1 means it's been validated)
|
|
547
|
+
if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
|
|
548
|
+
savedHere.push({
|
|
549
|
+
topic: m.key.replace('cachly:lesson:best:', ''),
|
|
550
|
+
severity: sev ?? 'major',
|
|
551
|
+
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
552
|
+
savedMins,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
522
555
|
}
|
|
523
556
|
}
|
|
524
557
|
}
|
|
525
558
|
|
|
559
|
+
// ── Layer 2: Semantic search (parallel, optional) ────────────────────────
|
|
560
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
561
|
+
type SemHit = { key: string; similarity: number; content: string };
|
|
562
|
+
const semHits: SemHit[] = [];
|
|
563
|
+
if (inst.vector_token && hasEmbedProvider()) {
|
|
564
|
+
try {
|
|
565
|
+
const embedding = await computeEmbedding(query);
|
|
566
|
+
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
|
|
567
|
+
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: { 'Content-Type': 'application/json' },
|
|
570
|
+
body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
|
|
571
|
+
});
|
|
572
|
+
if (searchRes.ok) {
|
|
573
|
+
const results = (await searchRes.json()) as SemanticSearchResponse[];
|
|
574
|
+
for (const hit of results.filter(r => r.found && r.id)) {
|
|
575
|
+
const parts = hit.id!.replace('ctx:', '').split(':');
|
|
576
|
+
const category = parts[0];
|
|
577
|
+
const key = parts.slice(1).join(':');
|
|
578
|
+
const redisKey = `cachly:ctx:${category}:${key}`;
|
|
579
|
+
const content = await redis.get(redisKey).catch(() => null);
|
|
580
|
+
semHits.push({ key: redisKey, similarity: hit.similarity ?? 0, content: content ?? '(evicted)' });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
} catch {
|
|
584
|
+
// Semantic search failed silently — keyword results are enough
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ── Layer 3: Hybrid merge — one unified ranked list ──────────────────────
|
|
589
|
+
// BM25 quality scores (already reranked) are re-normalized to [0,1] for merging.
|
|
590
|
+
type HybridResult = {
|
|
591
|
+
key: string; content: string; hybridScore: number;
|
|
592
|
+
bm25Score?: number; semScore?: number; matchedWords?: string[];
|
|
593
|
+
matchType: 'keyword' | 'semantic' | 'hybrid'; subQuery?: string;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const bm25Scores = kwMatches.map(m => m.score);
|
|
597
|
+
const bm25Min = bm25Scores.length ? Math.min(...bm25Scores) : 0;
|
|
598
|
+
const bm25Range = bm25Scores.length ? (Math.max(...bm25Scores) - bm25Min) || 1 : 1;
|
|
599
|
+
const bm25Norm = (s: number) => (s - bm25Min) / bm25Range;
|
|
600
|
+
|
|
601
|
+
const hybridMap = new Map<string, HybridResult>();
|
|
602
|
+
for (const m of kwMatches) {
|
|
603
|
+
const n = bm25Norm(m.score);
|
|
604
|
+
hybridMap.set(m.key, {
|
|
605
|
+
key: m.key, content: m.content, bm25Score: n,
|
|
606
|
+
hybridScore: n * (semHits.length > 0 ? 0.7 : 1.0),
|
|
607
|
+
matchedWords: m.matchedWords, matchType: 'keyword', subQuery: m.subQuery,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
for (const hit of semHits) {
|
|
611
|
+
const existing = hybridMap.get(hit.key);
|
|
612
|
+
if (existing) {
|
|
613
|
+
existing.semScore = hit.similarity;
|
|
614
|
+
existing.hybridScore = (existing.bm25Score ?? 0) * 0.6 + hit.similarity * 0.4;
|
|
615
|
+
existing.matchType = 'hybrid';
|
|
616
|
+
} else {
|
|
617
|
+
hybridMap.set(hit.key, {
|
|
618
|
+
key: hit.key, content: hit.content, semScore: hit.similarity,
|
|
619
|
+
hybridScore: hit.similarity * 0.5, matchType: 'semantic',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const hybridResults = [...hybridMap.values()].sort((a, b) => b.hybridScore - a.hybridScore);
|
|
624
|
+
|
|
625
|
+
// ── Build output ──────────────────────────────────────────────────────────
|
|
526
626
|
const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
|
|
527
627
|
|
|
628
|
+
// "Brain saved you here" banner — surfaces value inline for proven recalled lessons
|
|
629
|
+
const SORDER: Record<string, number> = { critical: 0, major: 1, minor: 2 };
|
|
630
|
+
const topLesson = savedHere.sort((a, b) => (SORDER[a.severity] ?? 1) - (SORDER[b.severity] ?? 1))[0];
|
|
631
|
+
if (topLesson) {
|
|
632
|
+
lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
|
|
633
|
+
}
|
|
634
|
+
|
|
528
635
|
// Show sub-query info if multi-topic was detected
|
|
529
636
|
const subQueries = splitMultiQuery(query);
|
|
530
637
|
if (subQueries.length > 1) {
|
|
531
638
|
lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
|
|
532
639
|
}
|
|
533
640
|
|
|
534
|
-
if (
|
|
535
|
-
|
|
641
|
+
if (hybridResults.length > 0) {
|
|
642
|
+
const modeLabel = semHits.length > 0
|
|
643
|
+
? `keyword + semantic hybrid`
|
|
644
|
+
: `keyword`;
|
|
645
|
+
lines.push(`### 🔍 Results (${hybridResults.length} — ${modeLabel})\n`);
|
|
536
646
|
|
|
537
|
-
// Group by sub-query
|
|
647
|
+
// Group by sub-query for multi-topic queries
|
|
538
648
|
if (subQueries.length > 1) {
|
|
539
|
-
const grouped = new Map<string,
|
|
540
|
-
for (const
|
|
541
|
-
const sq =
|
|
649
|
+
const grouped = new Map<string, HybridResult[]>();
|
|
650
|
+
for (const r of hybridResults.slice(0, 12)) {
|
|
651
|
+
const sq = r.subQuery ?? query;
|
|
542
652
|
if (!grouped.has(sq)) grouped.set(sq, []);
|
|
543
|
-
grouped.get(sq)!.push(
|
|
653
|
+
grouped.get(sq)!.push(r);
|
|
544
654
|
}
|
|
545
|
-
for (const [sq,
|
|
546
|
-
lines.push(`**Topic: "${sq}"** (${
|
|
547
|
-
for (const
|
|
548
|
-
const label =
|
|
549
|
-
|
|
550
|
-
.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
655
|
+
for (const [sq, results] of grouped) {
|
|
656
|
+
lines.push(`**Topic: "${sq}"** (${results.length} results)\n`);
|
|
657
|
+
for (const r of results.slice(0, 4)) {
|
|
658
|
+
const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
|
|
659
|
+
const scorePart = r.matchType === 'hybrid'
|
|
660
|
+
? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
|
|
661
|
+
: r.matchType === 'semantic'
|
|
662
|
+
? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
|
|
663
|
+
: `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
|
|
664
|
+
const preview = r.content.slice(0, 300).replace(/\n/g, ' ');
|
|
665
|
+
lines.push(` **${label}** _(${scorePart})_`);
|
|
666
|
+
lines.push(` > ${preview}${r.content.length > 300 ? '…' : ''}\n`);
|
|
555
667
|
}
|
|
556
668
|
}
|
|
557
|
-
// Summary: which sub-queries had matches
|
|
558
669
|
const matched = [...grouped.keys()];
|
|
559
670
|
const unmatched = subQueries.filter(sq => !matched.includes(sq));
|
|
560
671
|
if (unmatched.length > 0) {
|
|
561
672
|
lines.push(`\n⚠️ **No results for:** ${unmatched.map(s => `"${s}"`).join(', ')}`);
|
|
562
673
|
}
|
|
563
674
|
} else {
|
|
564
|
-
for (const
|
|
565
|
-
const label =
|
|
566
|
-
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
675
|
+
for (const r of hybridResults.slice(0, 8)) {
|
|
676
|
+
const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
|
|
677
|
+
const scorePart = r.matchType === 'hybrid'
|
|
678
|
+
? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
|
|
679
|
+
: r.matchType === 'semantic'
|
|
680
|
+
? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
|
|
681
|
+
: `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
|
|
682
|
+
const preview = r.content.slice(0, 400).replace(/\n/g, ' ');
|
|
683
|
+
lines.push(`**${label}** _(${scorePart})_`);
|
|
684
|
+
lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
|
|
572
685
|
}
|
|
573
686
|
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// ── Layer 2: Semantic search (optional, only if embedding provider + vector_token available) ──
|
|
577
|
-
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
578
|
-
if (inst.vector_token && hasEmbedProvider()) {
|
|
579
|
-
try {
|
|
580
|
-
const embedding = await computeEmbedding(query);
|
|
581
|
-
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
|
|
582
|
-
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
583
|
-
method: 'POST',
|
|
584
|
-
headers: { 'Content-Type': 'application/json' },
|
|
585
|
-
body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
if (searchRes.ok) {
|
|
589
|
-
const results = (await searchRes.json()) as SemanticSearchResponse[];
|
|
590
|
-
const semHits = results.filter(r => r.found && r.id);
|
|
591
|
-
if (semHits.length > 0) {
|
|
592
|
-
lines.push(`\n### 🎯 Semantic Matches (${semHits.length})\n`);
|
|
593
|
-
for (const hit of semHits) {
|
|
594
|
-
const parts = hit.id!.replace('ctx:', '').split(':');
|
|
595
|
-
const category = parts[0];
|
|
596
|
-
const key = parts.slice(1).join(':');
|
|
597
|
-
const content = await redis.get(`cachly:ctx:${category}:${key}`);
|
|
598
|
-
lines.push(
|
|
599
|
-
`**${key}** _(${((hit.similarity ?? 0) * 100).toFixed(0)}% similar)_`,
|
|
600
|
-
`> ${content?.slice(0, 300) ?? '(evicted)'}${(content?.length ?? 0) > 300 ? '…' : ''}\n`,
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
} catch {
|
|
606
|
-
// Semantic search failed silently — keyword results are enough
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
if (kwMatches.length === 0) {
|
|
687
|
+
} else {
|
|
611
688
|
lines.push(`⚠️ No matches found for: "${query}"`);
|
|
612
689
|
|
|
613
690
|
// Did-You-Mean: find nearest token in index vocab
|
package/src/rerank.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality-aware reranking for Brain recall.
|
|
3
|
+
*
|
|
4
|
+
* The raw BM25+ keyword search (src/search.ts) ranks purely by *textual* relevance.
|
|
5
|
+
* That is necessary but not sufficient: when an agent asks "how do I fix the flaky
|
|
6
|
+
* deploy?", a *failed* attempt with similar words can out-rank the *proven success*
|
|
7
|
+
* lesson that actually solves it. Anthropic's flat-file memory has exactly this
|
|
8
|
+
* problem — it reads files in order, with no notion of which lesson is trustworthy.
|
|
9
|
+
*
|
|
10
|
+
* This module is Cachly's structural advantage made concrete: we re-rank text
|
|
11
|
+
* matches by the *quality* of the underlying lesson — outcome, confidence,
|
|
12
|
+
* proven-ness (recall_count) and severity — so the lesson most likely to help
|
|
13
|
+
* surfaces first.
|
|
14
|
+
*
|
|
15
|
+
* It is intentionally pure (no I/O): the lesson JSON already travels inside each
|
|
16
|
+
* KeywordMatch.content for `cachly:lesson:best:*` keys, so reranking needs no extra
|
|
17
|
+
* Redis round-trips. This keeps it fast and trivially benchmarkable (see
|
|
18
|
+
* src/bench/cachly-bench.ts).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { KeywordMatch } from './search.js';
|
|
22
|
+
import { safeJsonParse } from './utils.js';
|
|
23
|
+
|
|
24
|
+
/** Subset of a stored lesson that influences ranking. */
|
|
25
|
+
export interface LessonQuality {
|
|
26
|
+
outcome?: string; // 'success' | 'partial' | 'failure'
|
|
27
|
+
confidence?: number; // 0..1
|
|
28
|
+
recall_count?: number; // times this lesson has been recalled (proven-ness)
|
|
29
|
+
severity?: string; // 'critical' | 'major' | 'minor'
|
|
30
|
+
ts?: string; // ISO timestamp (for freshness, mild)
|
|
31
|
+
verified_at?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RerankedMatch extends KeywordMatch {
|
|
35
|
+
/** Final score after applying the quality multiplier. */
|
|
36
|
+
finalScore: number;
|
|
37
|
+
/** The multiplier that was applied to the BM25 score (1.0 = neutral). */
|
|
38
|
+
qualityBoost: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Tunable weights (kept conservative; validated by Cachly-Bench) ─────────────
|
|
42
|
+
const OUTCOME_WEIGHT: Record<string, number> = {
|
|
43
|
+
success: 1.0,
|
|
44
|
+
partial: 0.8,
|
|
45
|
+
failure: 0.55,
|
|
46
|
+
};
|
|
47
|
+
const OUTCOME_DEFAULT = 0.75; // unknown / not a lesson with an outcome
|
|
48
|
+
|
|
49
|
+
const SEVERITY_WEIGHT: Record<string, number> = {
|
|
50
|
+
critical: 1.15,
|
|
51
|
+
major: 1.05,
|
|
52
|
+
minor: 1.0,
|
|
53
|
+
};
|
|
54
|
+
const SEVERITY_DEFAULT = 1.0;
|
|
55
|
+
|
|
56
|
+
// Clamp so a strong text match is never fully buried by quality, and a weak text
|
|
57
|
+
// match is never catapulted to the top purely on quality.
|
|
58
|
+
const MIN_MULTIPLIER = 0.5;
|
|
59
|
+
const MAX_MULTIPLIER = 2.5;
|
|
60
|
+
|
|
61
|
+
const LESSON_KEY_PREFIX = 'cachly:lesson:best:';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compute the quality multiplier for a single lesson.
|
|
65
|
+
* Returns 1.0 (neutral) for documents that carry no quality signal (e.g. context).
|
|
66
|
+
*/
|
|
67
|
+
export function qualityMultiplier(lesson: LessonQuality | null): number {
|
|
68
|
+
if (!lesson || (lesson.outcome === undefined && lesson.confidence === undefined && lesson.recall_count === undefined)) {
|
|
69
|
+
return 1.0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 1. Outcome — a proven success is what the agent actually wants.
|
|
73
|
+
const outcome = OUTCOME_WEIGHT[lesson.outcome ?? ''] ?? OUTCOME_DEFAULT;
|
|
74
|
+
|
|
75
|
+
// 2. Confidence — maps 0..1 → 0.7..1.3 so it nudges, not dominates.
|
|
76
|
+
const conf = typeof lesson.confidence === 'number' ? Math.max(0, Math.min(1, lesson.confidence)) : 0.6;
|
|
77
|
+
const confidenceFactor = 0.7 + 0.6 * conf;
|
|
78
|
+
|
|
79
|
+
// 3. Proven-ness — a lesson recalled many times has stood the test of use.
|
|
80
|
+
// Log-scaled so it saturates: rc=0→1.0, rc=5→~1.18, rc=50→~1.30.
|
|
81
|
+
const rc = Math.max(0, lesson.recall_count ?? 0);
|
|
82
|
+
const provenBoost = 1 + Math.min(0.3, Math.log1p(rc) / 13);
|
|
83
|
+
|
|
84
|
+
// 4. Severity — when two lessons tie, surface the one guarding the worse failure.
|
|
85
|
+
const severity = SEVERITY_WEIGHT[lesson.severity ?? ''] ?? SEVERITY_DEFAULT;
|
|
86
|
+
|
|
87
|
+
const m = outcome * confidenceFactor * provenBoost * severity;
|
|
88
|
+
return Math.max(MIN_MULTIPLIER, Math.min(MAX_MULTIPLIER, m));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Extract the quality signal from a match, if its content is a stored lesson. */
|
|
92
|
+
export function extractLessonQuality(match: Pick<KeywordMatch, 'key' | 'content'>): LessonQuality | null {
|
|
93
|
+
if (!match.key.startsWith(LESSON_KEY_PREFIX)) return null;
|
|
94
|
+
return safeJsonParse<LessonQuality | null>(match.content, null);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rerank BM25 keyword matches by lesson quality.
|
|
99
|
+
*
|
|
100
|
+
* The BM25 scores are first min-max normalized to [0,1] so the quality multiplier
|
|
101
|
+
* acts on a comparable scale across queries; ties are broken by the original BM25
|
|
102
|
+
* score (stable, deterministic).
|
|
103
|
+
*/
|
|
104
|
+
export function rerankByQuality(matches: KeywordMatch[]): RerankedMatch[] {
|
|
105
|
+
if (matches.length === 0) return [];
|
|
106
|
+
|
|
107
|
+
const maxScore = Math.max(...matches.map(m => m.score));
|
|
108
|
+
const minScore = Math.min(...matches.map(m => m.score));
|
|
109
|
+
const range = maxScore - minScore || 1;
|
|
110
|
+
|
|
111
|
+
const reranked: RerankedMatch[] = matches.map(m => {
|
|
112
|
+
const norm = (m.score - minScore) / range; // 0..1
|
|
113
|
+
const lesson = extractLessonQuality(m);
|
|
114
|
+
const boost = qualityMultiplier(lesson);
|
|
115
|
+
// Blend: keep a floor of raw relevance so a top text hit stays competitive,
|
|
116
|
+
// then layer the quality multiplier on the normalized component.
|
|
117
|
+
const finalScore = m.score * (0.4 + 0.6 * boost) + norm * (boost - 1) * 0.0001;
|
|
118
|
+
return { ...m, finalScore, qualityBoost: boost };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
reranked.sort((a, b) => (b.finalScore - a.finalScore) || (b.score - a.score));
|
|
122
|
+
return reranked;
|
|
123
|
+
}
|