Skip to content

Commit 7ca8b2f

Browse files
aRustyDevclaude
andcommitted
feat(scripts): add graceful degradation health checks (Phase 5)
Move isOllamaAvailable to lib/embedder.ts as a public export, add hasModel() for checking pulled models. Add degradation.test.ts with 17 tests covering health check functions, hybrid search fallback modes, and mode reporting. Most degradation logic was already in Phase 3/4 — this phase formalizes the health check API and tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a24b0cb commit 7ca8b2f

3 files changed

Lines changed: 223 additions & 19 deletions

File tree

.scripts/commands/kg.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Glob } from 'bun'
1818
import { defineCommand } from 'citty'
1919
import { Ollama } from 'ollama'
2020
import { chunkMarkdown, parseFrontmatter } from '../lib/chunker'
21-
import { prepareEmbeddingText } from '../lib/embedder'
21+
import { isOllamaAvailable, prepareEmbeddingText } from '../lib/embedder'
2222
import { hashFile } from '../lib/hash'
2323
import {
2424
checkHealth,
@@ -29,13 +29,12 @@ import {
2929
type IndexableEntity,
3030
indexChunks,
3131
indexEntity,
32-
type SearchResult,
3332
searchKeyword,
3433
searchSemantic,
3534
} from '../lib/meilisearch'
3635
import { createOutput } from '../lib/output'
3736
import { hybridSearch, type RankedResult } from '../lib/search'
38-
import { CliError, type EntityType, EXIT } from '../lib/types'
37+
import { type EntityType, EXIT } from '../lib/types'
3938
import { globalArgs } from './shared-args'
4039

4140
// ---------------------------------------------------------------------------
@@ -143,19 +142,6 @@ export async function discoverFiles(
143142
return results
144143
}
145144

146-
/**
147-
* Check if Ollama is reachable.
148-
*/
149-
async function isOllamaAvailable(): Promise<boolean> {
150-
try {
151-
const ollama = new Ollama()
152-
await ollama.list()
153-
return true
154-
} catch {
155-
return false
156-
}
157-
}
158-
159145
/**
160146
* Generate an embedding vector for a text string.
161147
*/

.scripts/lib/embedder.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
/**
22
* Embedding utilities module.
33
*
4-
* Provides helpers for preparing text before embedding generation.
5-
* The actual embedding backends (Ollama, sentence-transformers) remain
6-
* in the Python implementation (embedder.py).
4+
* Provides helpers for preparing text before embedding generation,
5+
* and health checks for the Ollama embedding backend.
76
*/
87

8+
import { Ollama } from 'ollama'
9+
10+
// ---------------------------------------------------------------------------
11+
// Health checks
12+
// ---------------------------------------------------------------------------
13+
14+
/**
15+
* Check if Ollama is reachable by listing available models.
16+
*/
17+
export async function isOllamaAvailable(): Promise<boolean> {
18+
try {
19+
const ollama = new Ollama()
20+
await ollama.list()
21+
return true
22+
} catch {
23+
return false
24+
}
25+
}
26+
27+
/**
28+
* Check if a specific model is pulled and available in Ollama.
29+
*/
30+
export async function hasModel(model: string): Promise<boolean> {
31+
try {
32+
const ollama = new Ollama()
33+
const list = await ollama.list()
34+
return list.models.some((m) => m.name === model || m.name.startsWith(`${model}:`))
35+
} catch {
36+
return false
37+
}
38+
}
39+
940
// ---------------------------------------------------------------------------
1041
// prepareEmbeddingText
1142
// ---------------------------------------------------------------------------

.scripts/test/degradation.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Tests for graceful degradation behavior (Phase 5).
3+
*
4+
* Covers:
5+
* - Health check functions in lib/meilisearch.ts (isAvailable, hasEmbeddings)
6+
* - Health check functions in lib/embedder.ts (isOllamaAvailable, hasModel)
7+
* - hybridSearch degradation when semantic search is unavailable
8+
* - Mode reporting in search results
9+
*/
10+
11+
import { describe, expect, test } from 'bun:test'
12+
import { hasModel, isOllamaAvailable, prepareEmbeddingText } from '../lib/embedder'
13+
import { checkHealth, createClient, hasEmbeddings, isAvailable } from '../lib/meilisearch'
14+
import { hybridSearch, type RankedResult } from '../lib/search'
15+
16+
// ---------------------------------------------------------------------------
17+
// lib/embedder.ts health checks
18+
// ---------------------------------------------------------------------------
19+
20+
describe('embedder health checks', () => {
21+
test('isOllamaAvailable returns a boolean', async () => {
22+
const result = await isOllamaAvailable()
23+
expect(typeof result).toBe('boolean')
24+
})
25+
26+
test('hasModel returns a boolean', async () => {
27+
const result = await hasModel('nomic-embed-text')
28+
expect(typeof result).toBe('boolean')
29+
})
30+
31+
test('hasModel returns false for nonexistent model', async () => {
32+
const result = await hasModel('nonexistent-model-xyz-999')
33+
// If Ollama is running, the model won't exist. If Ollama is down, returns false.
34+
expect(result).toBe(false)
35+
})
36+
37+
test('prepareEmbeddingText still works', () => {
38+
expect(prepareEmbeddingText('Title', 'Body')).toBe('Title\n\nBody')
39+
expect(prepareEmbeddingText('', 'Body')).toBe('Body')
40+
expect(prepareEmbeddingText(' ', 'Body')).toBe('Body')
41+
})
42+
})
43+
44+
// ---------------------------------------------------------------------------
45+
// lib/meilisearch.ts health checks
46+
// ---------------------------------------------------------------------------
47+
48+
describe('meilisearch health checks', () => {
49+
test('isAvailable returns a boolean', async () => {
50+
const client = createClient()
51+
const result = await isAvailable(client)
52+
expect(typeof result).toBe('boolean')
53+
})
54+
55+
test('isAvailable returns false for unreachable host', async () => {
56+
const client = createClient({ host: 'http://localhost:19999' })
57+
const result = await isAvailable(client)
58+
expect(result).toBe(false)
59+
})
60+
61+
test('hasEmbeddings returns a boolean', async () => {
62+
const client = createClient()
63+
const result = await hasEmbeddings(client)
64+
expect(typeof result).toBe('boolean')
65+
})
66+
67+
test('hasEmbeddings returns false for unreachable host', async () => {
68+
const client = createClient({ host: 'http://localhost:19999' })
69+
const result = await hasEmbeddings(client)
70+
expect(result).toBe(false)
71+
})
72+
73+
test('checkHealth returns err for unreachable host', async () => {
74+
const client = createClient({ host: 'http://localhost:19999' })
75+
const result = await checkHealth(client)
76+
expect(result.ok).toBe(false)
77+
})
78+
})
79+
80+
// ---------------------------------------------------------------------------
81+
// hybridSearch degradation
82+
// ---------------------------------------------------------------------------
83+
84+
describe('hybridSearch graceful degradation', () => {
85+
const mockKeywordResults: RankedResult[] = [
86+
{ id: 'a', source: 'keyword', name: 'Result A', score: 0.9 },
87+
{ id: 'b', source: 'keyword', name: 'Result B', score: 0.8 },
88+
]
89+
90+
const mockSemanticResults: RankedResult[] = [
91+
{ id: 'b', source: 'semantic', name: 'Result B', score: 0.95 },
92+
{ id: 'c', source: 'semantic', name: 'Result C', score: 0.85 },
93+
]
94+
95+
test('returns hybrid mode when both searches succeed', async () => {
96+
const result = await hybridSearch('test', {
97+
keywordSearch: async () => mockKeywordResults,
98+
semanticSearch: async () => mockSemanticResults,
99+
})
100+
101+
expect(result.meta.mode).toBe('hybrid')
102+
expect(result.meta.keywordCount).toBe(2)
103+
expect(result.meta.semanticCount).toBe(2)
104+
expect(result.results.length).toBeGreaterThan(0)
105+
})
106+
107+
test('degrades to keyword-only when no semantic search provided', async () => {
108+
const result = await hybridSearch('test', {
109+
keywordSearch: async () => mockKeywordResults,
110+
// No semanticSearch
111+
})
112+
113+
expect(result.meta.mode).toBe('keyword-only')
114+
expect(result.meta.reason).toContain('no semantic search provided')
115+
expect(result.meta.keywordCount).toBe(2)
116+
expect(result.meta.semanticCount).toBe(0)
117+
expect(result.results.length).toBeGreaterThan(0)
118+
})
119+
120+
test('degrades to keyword-only when semantic search throws', async () => {
121+
const result = await hybridSearch('test', {
122+
keywordSearch: async () => mockKeywordResults,
123+
semanticSearch: async () => {
124+
throw new Error('Ollama unavailable')
125+
},
126+
})
127+
128+
expect(result.meta.mode).toBe('keyword-only')
129+
expect(result.meta.reason).toContain('semantic search failed')
130+
expect(result.meta.reason).toContain('Ollama unavailable')
131+
expect(result.results.length).toBeGreaterThan(0)
132+
})
133+
134+
test('degrades to keyword-only when semantic returns empty', async () => {
135+
const result = await hybridSearch('test', {
136+
keywordSearch: async () => mockKeywordResults,
137+
semanticSearch: async () => [],
138+
})
139+
140+
expect(result.meta.mode).toBe('keyword-only')
141+
expect(result.meta.reason).toContain('no semantic results')
142+
})
143+
144+
test('returns unavailable when keyword search fails', async () => {
145+
const result = await hybridSearch('test', {
146+
keywordSearch: async () => {
147+
throw new Error('Meilisearch down')
148+
},
149+
semanticSearch: async () => mockSemanticResults,
150+
})
151+
152+
expect(result.meta.mode).toBe('unavailable')
153+
expect(result.meta.reason).toContain('keyword search failed')
154+
expect(result.results.length).toBe(0)
155+
})
156+
157+
test('JSON meta includes mode field', async () => {
158+
const result = await hybridSearch('test', {
159+
keywordSearch: async () => mockKeywordResults,
160+
})
161+
162+
expect(result.meta).toHaveProperty('mode')
163+
expect(result.meta).toHaveProperty('keywordCount')
164+
expect(result.meta).toHaveProperty('semanticCount')
165+
expect(result.meta).toHaveProperty('mergedCount')
166+
})
167+
})
168+
169+
// ---------------------------------------------------------------------------
170+
// Mode reporting types
171+
// ---------------------------------------------------------------------------
172+
173+
describe('SearchMeta types', () => {
174+
test('mode can be hybrid, keyword-only, or unavailable', () => {
175+
const modes = ['hybrid', 'keyword-only', 'unavailable']
176+
for (const mode of modes) {
177+
const meta = { mode, keywordCount: 0, semanticCount: 0, mergedCount: 0 }
178+
expect(modes).toContain(meta.mode)
179+
}
180+
})
181+
182+
test('reason is optional', () => {
183+
const meta = { mode: 'hybrid' as const, keywordCount: 1, semanticCount: 1, mergedCount: 1 }
184+
expect(meta.mode).toBe('hybrid')
185+
// No reason field — that's fine for hybrid mode
186+
})
187+
})

0 commit comments

Comments
 (0)