feat: semantic search with Qdrant + Ollama embeddings

- Add SemanticSearchService with embed() + searchQdrant() methods
- Add GET /api/search/semantic endpoint (?q=query&k=5)
- Wire SemanticSearchService into router and cmd/server/main.go
- Add SemanticSearch React page with results + similarity scores
- Add 'Semantic Search' nav link in App.tsx
- Add unit tests with mocked Ollama + Qdrant HTTP servers (4 tests, all passing)
- Add GitHub issue templates (bug report, feature request)
- Add pull request template
This commit is contained in:
Christopher Mayor
2026-04-24 11:13:50 -07:00
parent 97c502a5f9
commit 468519fde1
9 changed files with 646 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import { useState } from 'react'
import { fetchAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ErrorBanner from '../components/ErrorBanner'
interface SemanticResult {
id: number
title: string
media_type: string
year: number | null
score: number
overview: string
}
function scorePercent(score: number): string {
return `${Math.round(score * 100)}%`
}
function mediaTypeBadge(type: string): string {
const colors: Record<string, string> = {
movie: 'bg-blue-600',
series: 'bg-green-600',
music: 'bg-purple-600',
book: 'bg-orange-600',
audiobook: 'bg-yellow-600',
podcast: 'bg-pink-600',
}
return colors[type] ?? 'bg-gray-600'
}
export default function SemanticSearch() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SemanticResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasSearched, setHasSearched] = useState(false)
const [addingToQueue, setAddingToQueue] = useState<Set<number>>(new Set())
const { showToast } = useToast()
async function doSearch() {
const trimmed = query.trim()
if (!trimmed) return
setLoading(true)
setHasSearched(true)
setError(null)
try {
const res = await fetchAPI<{ results: SemanticResult[] }>(
'/api/search/semantic?q=' + encodeURIComponent(trimmed) + '&k=5'
)
setResults(res.results ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : 'Semantic search failed'
setError(message)
} finally {
setLoading(false)
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
doSearch()
}
async function handleAddToQueue(item: SemanticResult) {
setAddingToQueue(prev => new Set(prev).add(item.id))
try {
await postAPI<{ id: number }>('/api/queue', { media_id: item.id })
showToast(`Added "${item.title}" to queue`)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add to queue'
showToast(`Error: ${message}`)
} finally {
setAddingToQueue(prev => {
const next = new Set(prev)
next.delete(item.id)
return next
})
}
}
return (
<div>
<h2 className="text-2xl font-semibold text-gray-100 mb-6">Semantic Search</h2>
<form onSubmit={handleSearchSubmit} className="mb-6">
<div className="flex gap-3 max-w-2xl">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Describe what you're looking for..."
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-3 w-full outline-none transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-semibold transition-colors"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
<p className="text-gray-500 text-xs mt-2">AI-powered search using natural language descriptions</p>
</form>
{!hasSearched && (
<div className="text-center py-20 text-gray-500">
<p className="text-lg">Enter a natural language query to find media</p>
</div>
)}
{hasSearched && loading && (
<div className="animate-pulse space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 bg-gray-800 rounded-lg" />
))}
</div>
)}
{hasSearched && error && !loading && <ErrorBanner error={error} onRetry={doSearch} />}
{hasSearched && !loading && !error && results.length === 0 && (
<div className="text-gray-500 text-center py-12">No results found</div>
)}
{hasSearched && !loading && !error && results.length > 0 && (
<div className="space-y-3">
<p className="text-gray-400 text-sm mb-3">{results.length} result{results.length !== 1 ? 's' : ''}</p>
{results.map(item => {
const isAdding = addingToQueue.has(item.id)
return (
<div key={item.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-700 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`${mediaTypeBadge(item.media_type)} text-white text-xs font-semibold px-2 py-0.5 rounded`}>
{item.media_type}
</span>
<h3 className="text-gray-100 font-medium truncate">{item.title}</h3>
{item.year && <span className="text-gray-500 text-sm">{item.year}</span>}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="text-indigo-400 text-sm font-medium">{scorePercent(item.score)} match</span>
</div>
{item.overview && (
<p className="text-gray-400 text-sm line-clamp-2">{item.overview}</p>
)}
</div>
<button
disabled={isAdding}
onClick={() => handleAddToQueue(item)}
className="px-3 py-1.5 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap transition-colors"
>
{isAdding ? 'Adding...' : 'Add to Queue'}
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)
}