claude-brain 0.5.0 → 0.8.0
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/VERSION +1 -1
- package/assets/CLAUDE-unified.md +11 -0
- package/package.json +2 -1
- package/packs/backend/node.json +173 -0
- package/packs/core/javascript.json +176 -0
- package/packs/core/typescript.json +222 -0
- package/packs/frontend/react.json +254 -0
- package/packs/meta/testing.json +172 -0
- package/src/cli/bin.ts +14 -0
- package/src/cli/commands/chroma.ts +53 -17
- package/src/cli/commands/hooks.ts +214 -0
- package/src/cli/commands/pack.ts +197 -0
- package/src/cli/commands/serve.ts +34 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +85 -2
- package/src/hooks/brain-hook.ts +110 -0
- package/src/hooks/capture.ts +161 -0
- package/src/hooks/deduplicator.ts +72 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/installer.ts +181 -0
- package/src/hooks/passive-classifier.ts +366 -0
- package/src/hooks/queue.ts +122 -0
- package/src/hooks/session-tracker.ts +199 -0
- package/src/hooks/types.ts +47 -0
- package/src/memory/chroma/client.ts +1 -1
- package/src/memory/chroma/index.ts +1 -1
- package/src/memory/chroma/store.ts +29 -9
- package/src/memory/index.ts +1 -0
- package/src/memory/store.ts +1 -0
- package/src/packs/index.ts +9 -0
- package/src/packs/loader.ts +134 -0
- package/src/packs/manager.ts +204 -0
- package/src/packs/ranker.ts +78 -0
- package/src/packs/types.ts +81 -0
- package/src/routing/entity-extractor.ts +410 -0
- package/src/routing/intent-classifier.ts +229 -0
- package/src/routing/response-filter.ts +221 -0
- package/src/routing/router.ts +671 -0
- package/src/server/handlers/call-tool.ts +7 -0
- package/src/server/handlers/list-tools.ts +22 -5
- package/src/server/handlers/tools/brain.ts +85 -0
- package/src/server/handlers/tools/init-project.ts +47 -0
- package/src/server/handlers/tools/schemas.ts +12 -0
- package/src/server/http-api.ts +188 -0
- package/src/tools/registry.ts +9 -0
- package/src/tools/schemas.ts +33 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain Intent Classifier
|
|
3
|
+
* Phase 16: Rule-based intent classification for the unified brain() tool
|
|
4
|
+
*
|
|
5
|
+
* Priority-ordered — first confident match wins.
|
|
6
|
+
* No LLM calls, pure pattern matching.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Intent =
|
|
10
|
+
| 'session_start'
|
|
11
|
+
| 'context_needed'
|
|
12
|
+
| 'decision_made'
|
|
13
|
+
| 'pattern_found'
|
|
14
|
+
| 'mistake_learned'
|
|
15
|
+
| 'progress_update'
|
|
16
|
+
| 'question'
|
|
17
|
+
| 'comparison'
|
|
18
|
+
| 'exploration'
|
|
19
|
+
| 'no_action'
|
|
20
|
+
|
|
21
|
+
export interface ClassificationResult {
|
|
22
|
+
primary: Intent
|
|
23
|
+
confidence: number
|
|
24
|
+
secondary: Intent[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Decision-indicating phrases (from automation/decision-detector.ts)
|
|
28
|
+
const DECISION_PHRASES = [
|
|
29
|
+
'i recommend', 'you should use', 'the best approach',
|
|
30
|
+
'i suggest', 'better to use', 'prefer using',
|
|
31
|
+
'go with', 'decided to', "let's use", 'we will use',
|
|
32
|
+
'the solution is', 'implement using', 'going with',
|
|
33
|
+
'switching to', 'adopting', 'we chose', 'the plan is to'
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const REASONING_PHRASES = [
|
|
37
|
+
'because', 'since', 'due to', 'as it', 'which provides',
|
|
38
|
+
'this allows', 'this ensures', 'given that', 'considering',
|
|
39
|
+
'the reason is', 'this way'
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
// Mistake/correction indicators
|
|
43
|
+
const MISTAKE_PHRASES = [
|
|
44
|
+
'bug was', 'the issue was', 'the problem was', 'mistake was',
|
|
45
|
+
'should have', "shouldn't have", 'should not have',
|
|
46
|
+
'lesson learned', "don't use", 'avoid using', 'never use',
|
|
47
|
+
'the fix is', 'the fix was', 'fixed by', 'solved by',
|
|
48
|
+
'was wrong', 'was broken', 'was incorrect'
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
// Progress indicators
|
|
52
|
+
const PROGRESS_PHRASES = [
|
|
53
|
+
'finished', 'completed', 'done with', 'implemented',
|
|
54
|
+
'built', 'created', 'added', 'fixed', 'resolved',
|
|
55
|
+
'shipped', 'deployed', 'merged', 'released'
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
// Comparison indicators
|
|
59
|
+
const COMPARISON_PHRASES = [
|
|
60
|
+
' vs ', ' versus ', 'or should', 'compared to', 'comparing ',
|
|
61
|
+
'which is better', 'should i use', 'should we use',
|
|
62
|
+
'what if we switch', 'what if we change', 'what if we replace',
|
|
63
|
+
'pros and cons', 'tradeoffs', 'trade-offs'
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
// Pattern indicators
|
|
67
|
+
const PATTERN_PHRASES = [
|
|
68
|
+
'pattern:', 'best practice:', 'anti-pattern:', 'reusable solution',
|
|
69
|
+
'reusable approach', 'common issue:', 'this pattern',
|
|
70
|
+
'recognized pattern', 'document this pattern'
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
// Exploration indicators
|
|
74
|
+
const EXPLORATION_PHRASES = [
|
|
75
|
+
'show me', 'timeline', 'trends', 'evolution of',
|
|
76
|
+
'history of', 'graph', 'how has', 'what happened',
|
|
77
|
+
'decisions about', 'when did we', 'list episodes'
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
// Session start indicators
|
|
81
|
+
const SESSION_START_PHRASES = [
|
|
82
|
+
'starting work', 'beginning work', 'resuming',
|
|
83
|
+
'picking up', 'getting started', 'opening',
|
|
84
|
+
'starting on', 'working on', 'beginning on'
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
// Question indicators
|
|
88
|
+
const QUESTION_WORDS = [
|
|
89
|
+
'what', 'how', 'when', 'why', 'where', 'which',
|
|
90
|
+
'who', 'can', 'does', 'is', 'are', 'should', 'could', 'would'
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
// No-action indicators
|
|
94
|
+
const NO_ACTION_PHRASES = [
|
|
95
|
+
'ok', 'okay', 'thanks', 'thank you', 'got it',
|
|
96
|
+
'sure', 'yes', 'no', 'cool', 'nice', 'great',
|
|
97
|
+
'understood', 'noted', 'alright', 'right'
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
export class IntentClassifier {
|
|
101
|
+
/**
|
|
102
|
+
* Classify the intent of a message
|
|
103
|
+
*/
|
|
104
|
+
classify(message: string): ClassificationResult {
|
|
105
|
+
const lower = message.toLowerCase().trim()
|
|
106
|
+
const secondary: Intent[] = []
|
|
107
|
+
|
|
108
|
+
// Check in priority order — first confident match wins
|
|
109
|
+
|
|
110
|
+
// 1. no_action: very short messages, greetings, acknowledgments
|
|
111
|
+
if (this.isNoAction(lower)) {
|
|
112
|
+
return { primary: 'no_action', confidence: 0.95, secondary: [] }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. decision_made: decision phrases + reasoning
|
|
116
|
+
if (this.isDecisionMade(lower)) {
|
|
117
|
+
if (this.hasComparisonSignal(lower)) secondary.push('comparison')
|
|
118
|
+
return { primary: 'decision_made', confidence: 0.85, secondary }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3. mistake_learned: correction/bug/lesson indicators
|
|
122
|
+
if (this.isMistakeLearned(lower)) {
|
|
123
|
+
return { primary: 'mistake_learned', confidence: 0.85, secondary }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. progress_update: completed task indicators (NOT questions)
|
|
127
|
+
if (this.isProgressUpdate(lower, message)) {
|
|
128
|
+
if (this.hasSessionSignal(lower)) secondary.push('session_start')
|
|
129
|
+
return { primary: 'progress_update', confidence: 0.85, secondary }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 5. comparison: vs, which is better, etc.
|
|
133
|
+
if (this.isComparison(lower)) {
|
|
134
|
+
if (this.isQuestion(lower, message)) secondary.push('question')
|
|
135
|
+
return { primary: 'comparison', confidence: 0.85, secondary }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 6. pattern_found: explicit pattern documentation
|
|
139
|
+
if (this.isPatternFound(lower)) {
|
|
140
|
+
return { primary: 'pattern_found', confidence: 0.80, secondary }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 7. session_start: starting/resuming work
|
|
144
|
+
if (this.isSessionStart(lower)) {
|
|
145
|
+
secondary.push('context_needed')
|
|
146
|
+
return { primary: 'session_start', confidence: 0.90, secondary }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 8. exploration: timeline, trends, graph, history
|
|
150
|
+
if (this.isExploration(lower)) {
|
|
151
|
+
if (this.isQuestion(lower, message)) secondary.push('question')
|
|
152
|
+
return { primary: 'exploration', confidence: 0.75, secondary }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 9. question: starts with question word or ends with ?
|
|
156
|
+
if (this.isQuestion(lower, message)) {
|
|
157
|
+
if (this.hasComparisonSignal(lower)) secondary.push('comparison')
|
|
158
|
+
if (this.hasExplorationSignal(lower)) secondary.push('exploration')
|
|
159
|
+
return { primary: 'question', confidence: 0.80, secondary }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 10. Default: context_needed
|
|
163
|
+
return { primary: 'context_needed', confidence: 0.60, secondary }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private isNoAction(lower: string): boolean {
|
|
167
|
+
if (lower.length > 30) return false
|
|
168
|
+
const words = lower.split(/\s+/)
|
|
169
|
+
if (words.length > 5) return false
|
|
170
|
+
return NO_ACTION_PHRASES.some(p => lower === p || lower === p + '.' || lower === p + '!')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private isDecisionMade(lower: string): boolean {
|
|
174
|
+
const hasDecision = DECISION_PHRASES.some(p => lower.includes(p))
|
|
175
|
+
if (!hasDecision) return false
|
|
176
|
+
// Higher confidence if reasoning is also present
|
|
177
|
+
const hasReasoning = REASONING_PHRASES.some(p => lower.includes(p))
|
|
178
|
+
return hasReasoning || lower.length > 30
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private isMistakeLearned(lower: string): boolean {
|
|
182
|
+
return MISTAKE_PHRASES.some(p => lower.includes(p))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private isProgressUpdate(lower: string, original: string): boolean {
|
|
186
|
+
// Must not be a question
|
|
187
|
+
if (original.trim().endsWith('?')) return false
|
|
188
|
+
const firstWord = lower.split(/\s+/)[0] || ''
|
|
189
|
+
if (QUESTION_WORDS.includes(firstWord)) return false
|
|
190
|
+
|
|
191
|
+
return PROGRESS_PHRASES.some(p => lower.includes(p))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private isComparison(lower: string): boolean {
|
|
195
|
+
return COMPARISON_PHRASES.some(p => lower.includes(p))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private isPatternFound(lower: string): boolean {
|
|
199
|
+
const hasPattern = PATTERN_PHRASES.some(p => lower.includes(p))
|
|
200
|
+
return hasPattern && lower.length > 50
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private isSessionStart(lower: string): boolean {
|
|
204
|
+
return SESSION_START_PHRASES.some(p => lower.includes(p))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private isExploration(lower: string): boolean {
|
|
208
|
+
return EXPLORATION_PHRASES.some(p => lower.includes(p))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private isQuestion(lower: string, original: string): boolean {
|
|
212
|
+
if (original.trim().endsWith('?')) return true
|
|
213
|
+
const firstWord = lower.split(/\s+/)[0] || ''
|
|
214
|
+
return QUESTION_WORDS.includes(firstWord)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Secondary signal checks
|
|
218
|
+
private hasComparisonSignal(lower: string): boolean {
|
|
219
|
+
return COMPARISON_PHRASES.some(p => lower.includes(p))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private hasExplorationSignal(lower: string): boolean {
|
|
223
|
+
return EXPLORATION_PHRASES.some(p => lower.includes(p))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private hasSessionSignal(lower: string): boolean {
|
|
227
|
+
return SESSION_START_PHRASES.some(p => lower.includes(p))
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain Response Filter
|
|
3
|
+
* Phase 16: Filters noise, deduplicates, ranks, and synthesizes results
|
|
4
|
+
* from the unified brain() tool
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FilterableResult {
|
|
8
|
+
content: string
|
|
9
|
+
score: number
|
|
10
|
+
source: string
|
|
11
|
+
metadata?: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FilteredResult {
|
|
15
|
+
content: string
|
|
16
|
+
score: number
|
|
17
|
+
source: string
|
|
18
|
+
relevanceNote: string
|
|
19
|
+
metadata?: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BrainResponse {
|
|
23
|
+
action: 'retrieved' | 'stored' | 'analyzed' | 'none'
|
|
24
|
+
summary: string
|
|
25
|
+
content: string
|
|
26
|
+
relevantItems: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TierResults {
|
|
30
|
+
label: string
|
|
31
|
+
results: FilterableResult[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ResponseFilter {
|
|
35
|
+
// Infrastructure noise terms — strip from non-claude-brain projects
|
|
36
|
+
private readonly INFRA_NOISE = [
|
|
37
|
+
'chromadb', 'chroma', 'minisearch', 'compromise', 'better-sqlite3',
|
|
38
|
+
'pino', 'hono', 'bun:test', 'zod', 'mcp-server', 'claude-brain',
|
|
39
|
+
'model-context-protocol', 'embedding-service', 'vector-database',
|
|
40
|
+
'mcp tool', 'tool handler', 'phase 12', 'phase 13', 'phase 14', 'phase 15',
|
|
41
|
+
'semantic cache', 'precompute engine', 'knowledge graph builder'
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Filter a set of results to remove noise and improve relevance
|
|
46
|
+
*/
|
|
47
|
+
filter(results: FilterableResult[], query: string, project?: string): FilteredResult[] {
|
|
48
|
+
let filtered = results
|
|
49
|
+
|
|
50
|
+
// 1. Remove infrastructure noise (if project !== 'claude-brain')
|
|
51
|
+
if (project !== 'claude-brain') {
|
|
52
|
+
filtered = filtered.filter(r => !this.isInfrastructureNoise(r.content))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Remove near-duplicates (>85% word overlap, keep higher score)
|
|
56
|
+
filtered = this.deduplicateResults(filtered)
|
|
57
|
+
|
|
58
|
+
// 3. Apply dynamic threshold (at least 70% of median similarity)
|
|
59
|
+
filtered = this.applyDynamicThreshold(filtered)
|
|
60
|
+
|
|
61
|
+
// 4. Sort by score descending
|
|
62
|
+
filtered.sort((a, b) => b.score - a.score)
|
|
63
|
+
|
|
64
|
+
// 5. Limit to 5 results
|
|
65
|
+
filtered = filtered.slice(0, 5)
|
|
66
|
+
|
|
67
|
+
// 6. Add one-line relevance explanation per result
|
|
68
|
+
return filtered.map(r => ({
|
|
69
|
+
content: r.content,
|
|
70
|
+
score: r.score,
|
|
71
|
+
source: r.source,
|
|
72
|
+
relevanceNote: this.generateRelevanceNote(r, query),
|
|
73
|
+
metadata: r.metadata
|
|
74
|
+
}))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Synthesize results from multiple tiers into a unified BrainResponse
|
|
79
|
+
*/
|
|
80
|
+
synthesize(
|
|
81
|
+
tiers: TierResults[],
|
|
82
|
+
message: string,
|
|
83
|
+
project?: string,
|
|
84
|
+
action: BrainResponse['action'] = 'retrieved'
|
|
85
|
+
): BrainResponse {
|
|
86
|
+
// Combine all results from all tiers
|
|
87
|
+
const allResults: FilterableResult[] = []
|
|
88
|
+
for (const tier of tiers) {
|
|
89
|
+
allResults.push(...tier.results)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (allResults.length === 0) {
|
|
93
|
+
return {
|
|
94
|
+
action: 'none',
|
|
95
|
+
summary: 'No relevant information found',
|
|
96
|
+
content: `No results found for: "${message.slice(0, 100)}"`,
|
|
97
|
+
relevantItems: 0
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Filter the combined results
|
|
102
|
+
const filtered = this.filter(allResults, message, project)
|
|
103
|
+
|
|
104
|
+
if (filtered.length === 0) {
|
|
105
|
+
return {
|
|
106
|
+
action: 'none',
|
|
107
|
+
summary: 'Results filtered out as noise or irrelevant',
|
|
108
|
+
content: `No relevant results after filtering for: "${message.slice(0, 100)}"`,
|
|
109
|
+
relevantItems: 0
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Format filtered results
|
|
114
|
+
const contentParts: string[] = []
|
|
115
|
+
for (const result of filtered) {
|
|
116
|
+
const scoreStr = result.score > 0 ? ` [${Math.round(result.score * 100)}%]` : ''
|
|
117
|
+
contentParts.push(`**${result.source}**${scoreStr}\n${result.content}`)
|
|
118
|
+
if (result.relevanceNote) {
|
|
119
|
+
contentParts.push(`_${result.relevanceNote}_`)
|
|
120
|
+
}
|
|
121
|
+
contentParts.push('')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const summary = filtered.length === 1
|
|
125
|
+
? `Found 1 relevant result`
|
|
126
|
+
: `Found ${filtered.length} relevant results`
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
action,
|
|
130
|
+
summary,
|
|
131
|
+
content: contentParts.join('\n'),
|
|
132
|
+
relevantItems: filtered.length
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if content is infrastructure/internal noise
|
|
138
|
+
*/
|
|
139
|
+
private isInfrastructureNoise(content: string): boolean {
|
|
140
|
+
const lower = content.toLowerCase()
|
|
141
|
+
let noiseHits = 0
|
|
142
|
+
|
|
143
|
+
for (const term of this.INFRA_NOISE) {
|
|
144
|
+
if (lower.includes(term)) {
|
|
145
|
+
noiseHits++
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// If more than 2 infrastructure terms appear, it's likely noise
|
|
150
|
+
return noiseHits >= 2
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove near-duplicate results (>85% word overlap)
|
|
155
|
+
*/
|
|
156
|
+
private deduplicateResults(results: FilterableResult[]): FilterableResult[] {
|
|
157
|
+
const kept: FilterableResult[] = []
|
|
158
|
+
|
|
159
|
+
for (const result of results) {
|
|
160
|
+
const isDuplicate = kept.some(existing => {
|
|
161
|
+
const overlap = this.calculateWordOverlap(existing.content, result.content)
|
|
162
|
+
return overlap > 0.85
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
if (!isDuplicate) {
|
|
166
|
+
kept.push(result)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return kept
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Apply dynamic threshold — results must be at least 70% of median score
|
|
175
|
+
*/
|
|
176
|
+
private applyDynamicThreshold(results: FilterableResult[]): FilterableResult[] {
|
|
177
|
+
if (results.length <= 1) return results
|
|
178
|
+
|
|
179
|
+
// Calculate median score
|
|
180
|
+
const scores = results.map(r => r.score).sort((a, b) => a - b)
|
|
181
|
+
const mid = Math.floor(scores.length / 2)
|
|
182
|
+
const left = scores[mid - 1] ?? 0
|
|
183
|
+
const right = scores[mid] ?? 0
|
|
184
|
+
const median = scores.length % 2 === 0
|
|
185
|
+
? (left + right) / 2
|
|
186
|
+
: right
|
|
187
|
+
|
|
188
|
+
const threshold = median * 0.7
|
|
189
|
+
|
|
190
|
+
return results.filter(r => r.score >= threshold)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Calculate word overlap between two strings (0-1)
|
|
195
|
+
*/
|
|
196
|
+
private calculateWordOverlap(a: string, b: string): number {
|
|
197
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2))
|
|
198
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2))
|
|
199
|
+
|
|
200
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0
|
|
201
|
+
|
|
202
|
+
let intersection = 0
|
|
203
|
+
for (const word of wordsA) {
|
|
204
|
+
if (wordsB.has(word)) intersection++
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const smaller = Math.min(wordsA.size, wordsB.size)
|
|
208
|
+
return intersection / smaller
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Generate a one-line relevance explanation
|
|
213
|
+
*/
|
|
214
|
+
private generateRelevanceNote(result: FilterableResult, _query: string): string {
|
|
215
|
+
const score = Math.round(result.score * 100)
|
|
216
|
+
if (score >= 90) return `Highly relevant match (${score}%)`
|
|
217
|
+
if (score >= 70) return `Good match (${score}%)`
|
|
218
|
+
if (score >= 50) return `Partial match (${score}%)`
|
|
219
|
+
return `Low relevance match (${score}%)`
|
|
220
|
+
}
|
|
221
|
+
}
|