search improvements (facets, limit, score threshold)

This commit is contained in:
phact 2025-08-12 12:37:55 -04:00
parent 7ab8433aa8
commit bf23ad8729
8 changed files with 839 additions and 110 deletions

View file

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
@ -1086,6 +1088,66 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz",
"integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz",
"integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View file

@ -10,6 +10,8 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",

View file

@ -1,11 +1,13 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Search, Loader2, FileText, Zap } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Search, Loader2, FileText, Zap, ChevronDown, ChevronUp, Filter, X } from "lucide-react"
import { ProtectedRoute } from "@/components/protected-route"
interface SearchResult {
@ -14,16 +16,56 @@ interface SearchResult {
page: number
text: string
score: number
source_url?: string
owner?: string
}
interface FacetBucket {
key: string
count: number
}
interface Facets {
data_sources?: FacetBucket[]
document_types?: FacetBucket[]
owners?: FacetBucket[]
}
interface SearchResponse {
results: SearchResult[]
aggregations: any
error?: string
}
interface SelectedFilters {
data_sources: string[]
document_types: string[]
owners: string[]
}
function SearchPage() {
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [facets, setFacets] = useState<Facets>({})
const [searchPerformed, setSearchPerformed] = useState(false)
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({
data_sources: [],
document_types: [],
owners: []
})
const [openSections, setOpenSections] = useState({
data_sources: true,
document_types: true,
owners: true
})
const [sidebarOpen, setSidebarOpen] = useState(true)
const [resultLimit, setResultLimit] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(0)
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
const handleSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (!query.trim()) return
setLoading(true)
@ -35,28 +77,185 @@ function SearchPage() {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold,
...(searchPerformed && { filters: selectedFilters })
}),
})
const result = await response.json()
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
// Process aggregations into facets
const aggs = result.aggregations
const processedFacets: Facets = {}
const newSelectedFilters: SelectedFilters = {
data_sources: [],
document_types: [],
owners: []
}
if (aggs && Object.keys(aggs).length > 0) {
processedFacets.data_sources = aggs.data_sources?.buckets?.map((b: any) => ({ key: b.key, count: b.doc_count })).filter((b: any) => b.count > 0) || []
processedFacets.document_types = aggs.document_types?.buckets?.map((b: any) => ({ key: b.key, count: b.doc_count })).filter((b: any) => b.count > 0) || []
processedFacets.owners = aggs.owners?.buckets?.map((b: any) => ({ key: b.key, count: b.doc_count })).filter((b: any) => b.count > 0) || []
// Set all filters as checked by default
newSelectedFilters.data_sources = processedFacets.data_sources?.map(f => f.key) || []
newSelectedFilters.document_types = processedFacets.document_types?.map(f => f.key) || []
newSelectedFilters.owners = processedFacets.owners?.map(f => f.key) || []
}
setFacets(processedFacets)
setSelectedFilters(newSelectedFilters)
setSearchPerformed(true)
} else {
console.error("Search failed:", result.error)
setResults([])
setFacets({})
setSearchPerformed(true)
}
} catch (error) {
console.error("Search error:", error)
setResults([])
setFacets({})
setSearchPerformed(true)
} finally {
setLoading(false)
}
}
const handleFilterChange = async (facetType: keyof SelectedFilters, value: string, checked: boolean) => {
const newFilters = {
...selectedFilters,
[facetType]: checked
? [...selectedFilters[facetType], value]
: selectedFilters[facetType].filter(item => item !== value)
}
setSelectedFilters(newFilters)
// Re-search immediately if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold,
filters: newFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}
const clearAllFilters = () => {
setSelectedFilters({
data_sources: [],
document_types: [],
owners: []
})
}
const selectAllFilters = () => {
setSelectedFilters({
data_sources: facets.data_sources?.map(f => f.key) || [],
document_types: facets.document_types?.map(f => f.key) || [],
owners: facets.owners?.map(f => f.key) || []
})
}
const toggleSection = (section: keyof typeof openSections) => {
setOpenSections(prev => ({
...prev,
[section]: !prev[section]
}))
}
const getSelectedFilterCount = () => {
return selectedFilters.data_sources.length +
selectedFilters.document_types.length +
selectedFilters.owners.length
}
const FacetSection = ({
title,
buckets,
facetType,
isOpen,
onToggle
}: {
title: string
buckets: FacetBucket[]
facetType: keyof SelectedFilters
isOpen: boolean
onToggle: () => void
}) => {
if (!buckets || buckets.length === 0) return null
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-0 h-auto font-medium text-left">
<span className="text-sm font-medium">{title}</span>
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mt-3">
{buckets.map((bucket, index) => {
const isSelected = selectedFilters[facetType].includes(bucket.key)
return (
<div key={index} className="flex items-center space-x-2">
<Checkbox
id={`${facetType}-${index}`}
checked={isSelected}
onCheckedChange={(checked) =>
handleFilterChange(facetType, bucket.key, checked as boolean)
}
/>
<Label
htmlFor={`${facetType}-${index}`}
className="text-sm font-normal flex-1 cursor-pointer flex items-center justify-between"
>
<span className="truncate" title={bucket.key}>
{bucket.key}
</span>
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded ml-2 flex-shrink-0">
{bucket.count}
</span>
</Label>
</div>
)
})}
</CollapsibleContent>
</Collapsible>
)
}
return (
<div className="space-y-8">
{/* Hero Section */}
@ -67,10 +266,10 @@ function SearchPage() {
</h1>
</div>
<p className="text-xl text-muted-foreground">
Find documents using semantic search
Find documents using hybrid search
</p>
<p className="text-sm text-muted-foreground max-w-2xl">
Enter your search query to find relevant documents using AI-powered semantic search across your document collection.
Enter your search query to find relevant documents using AI-powered semantic search combined with keyword matching across your document collection.
</p>
</div>
@ -82,7 +281,7 @@ function SearchPage() {
Search Documents
</CardTitle>
<CardDescription>
Enter your search query to find relevant documents using semantic search
Enter your search query to find relevant documents using hybrid search (semantic + keyword)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -91,115 +290,426 @@ function SearchPage() {
<Label htmlFor="search-query" className="font-medium">
Search Query
</Label>
<Input
id="search-query"
type="text"
placeholder="e.g., 'financial reports from Q4' or 'user authentication setup'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20"
/>
<div className="flex gap-2">
<Input
id="search-query"
type="text"
placeholder="e.g., 'financial reports from Q4' or 'user authentication setup'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20 flex-1"
/>
<Button
type="submit"
disabled={!query.trim() || loading}
className="h-12 px-6 transition-all duration-200"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Searching...
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
Search
</>
)}
</Button>
</div>
</div>
<Button
type="submit"
disabled={!query.trim() || loading}
className="w-full h-12 transition-all duration-200"
>
{loading ? (
<>
<Loader2 className="mr-3 h-5 w-5 animate-spin" />
Searching...
</>
) : (
<>
<Search className="mr-3 h-5 w-5" />
Search Documents
</>
)}
</Button>
</form>
{/* Results Section */}
<div className="mt-8">
{searchPerformed ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
{/* Search Results with Filters */}
{searchPerformed && (
<div className="space-y-4">
{/* Filter Toggle - Always visible when filters are available */}
{(facets.data_sources?.length || facets.document_types?.length || facets.owners?.length) && (
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold flex items-center gap-2">
<Zap className="h-6 w-6 text-yellow-400" />
Search Results
</h2>
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''} found
</span>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''} returned
</span>
</div>
<Button
variant="outline"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
{getSelectedFilterCount() > 0 && (
<span className="bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded">
{getSelectedFilterCount()}
</span>
)}
</Button>
</div>
</div>
{results.length === 0 ? (
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
<Search className="h-8 w-8 text-muted-foreground/50" />
)}
<div className="flex gap-6">
{/* Main Content */}
<div className="flex-1 space-y-6">
{/* Active Filters Display */}
{getSelectedFilterCount() > 0 && getSelectedFilterCount() < (facets.data_sources?.length || 0) + (facets.document_types?.length || 0) + (facets.owners?.length || 0) && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Active Filters</h3>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={selectAllFilters} className="h-auto px-2 py-1 text-xs">
Select all
</Button>
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="h-auto px-2 py-1 text-xs">
Clear all
</Button>
</div>
<p className="text-lg font-medium text-muted-foreground">
No documents found
</p>
<p className="text-sm text-muted-foreground/70 max-w-md mx-auto">
Try adjusting your search terms or check if documents have been indexed.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{results.map((result, index) => (
<Card key={index} className="bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/70 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/10">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
<FileText className="h-4 w-4 text-blue-400" />
</div>
<span className="truncate">{result.filename}</span>
</CardTitle>
<div className="flex items-center gap-2">
<div className="px-2 py-1 rounded-md bg-green-500/20 border border-green-500/30">
<span className="text-xs font-medium text-green-400">
{result.score.toFixed(2)}
</span>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(selectedFilters).map(([facetType, values]) =>
values.map((value: string) => (
<div key={`${facetType}-${value}`} className="flex items-center gap-1 bg-primary/10 text-primary px-2 py-1 rounded-md text-xs">
<span>{value}</span>
<Button
variant="ghost"
size="sm"
className="h-auto w-auto p-0.5 hover:bg-primary/20"
onClick={() => handleFilterChange(facetType as keyof SelectedFilters, value, false)}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<CardDescription className="flex items-center gap-4 text-sm">
<span className="px-2 py-1 rounded bg-muted/50 text-muted-foreground">
{result.mimetype}
</span>
<span className="text-muted-foreground">
Page {result.page}
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-l-2 border-blue-400/50 pl-4 py-2 bg-muted/20 rounded-r-lg">
<p className="text-sm leading-relaxed text-foreground/90">
{result.text}
))
)}
</div>
</div>
)}
{/* Results Section */}
<div>
{results.length === 0 ? (
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
<CardContent className="pt-8 pb-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
<Search className="h-8 w-8 text-muted-foreground/50" />
</div>
<p className="text-lg font-medium text-muted-foreground">
No documents found
</p>
<p className="text-sm text-muted-foreground/70 max-w-md mx-auto">
Try adjusting your search terms or check if documents have been indexed.
</p>
</div>
</CardContent>
</Card>
))}
) : (
<div className="space-y-4">
{results.map((result, index) => (
<Card key={index} className="bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/70 transition-all duration-200 hover:shadow-lg hover:shadow-blue-500/10">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
<FileText className="h-4 w-4 text-blue-400" />
</div>
<span className="truncate">{result.filename}</span>
</CardTitle>
<div className="flex items-center gap-2">
<div className="px-2 py-1 rounded-md bg-green-500/20 border border-green-500/30">
<span className="text-xs font-medium text-green-400">
{result.score.toFixed(2)}
</span>
</div>
</div>
</div>
<CardDescription className="flex items-center gap-4 text-sm">
<span className="px-2 py-1 rounded bg-muted/50 text-muted-foreground">
{result.mimetype}
</span>
<span className="text-muted-foreground">
Page {result.page}
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-l-2 border-blue-400/50 pl-4 py-2 bg-muted/20 rounded-r-lg">
<p className="text-sm leading-relaxed text-foreground/90">
{result.text}
</p>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
{/* Right Sidebar - Filters */}
{(facets.data_sources?.length || facets.document_types?.length || facets.owners?.length) && sidebarOpen && (
<div className="w-64 space-y-6 flex-shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Filter className="h-5 w-5" />
Filters
</h2>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={selectAllFilters}
className="text-xs h-auto px-2 py-1"
>
All
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-xs h-auto px-2 py-1"
>
None
</Button>
</div>
</div>
<div className="space-y-6">
<FacetSection
title="Data Sources"
buckets={facets.data_sources || []}
facetType="data_sources"
isOpen={openSections.data_sources}
onToggle={() => toggleSection('data_sources')}
/>
<FacetSection
title="Document Types"
buckets={facets.document_types || []}
facetType="document_types"
isOpen={openSections.document_types}
onToggle={() => toggleSection('document_types')}
/>
<FacetSection
title="Owners"
buckets={facets.owners || []}
facetType="owners"
isOpen={openSections.owners}
onToggle={() => toggleSection('owners')}
/>
{/* Result Limit Control */}
<div className="space-y-4 pt-4 border-t border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Limit</Label>
<Input
type="number"
min="1"
max="1000"
value={resultLimit}
onChange={async (e) => {
const newLimit = Math.max(1, Math.min(1000, parseInt(e.target.value) || 1))
setResultLimit(newLimit)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: newLimit,
scoreThreshold,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-16 h-6 text-xs text-center"
/>
</div>
<input
type="range"
min={1}
max={1000}
value={resultLimit}
onChange={async (e) => {
const value = parseInt(e.target.value)
setResultLimit(value)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: value,
scoreThreshold,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
{/* Score Threshold Control */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Score Threshold</Label>
<Input
type="number"
min="0"
max="10"
step="0.1"
value={scoreThreshold}
onChange={async (e) => {
const newThreshold = Math.max(0, Math.min(10, parseFloat(e.target.value) || 0))
setScoreThreshold(newThreshold)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold: newThreshold,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-16 h-6 text-xs text-center"
/>
</div>
<input
type="range"
min={0}
max={10}
step={0.1}
value={scoreThreshold}
onChange={async (e) => {
const value = parseFloat(e.target.value)
setScoreThreshold(value)
// Re-search if search has been performed
if (searchPerformed && query.trim()) {
setLoading(true)
try {
const response = await fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit: resultLimit,
scoreThreshold: value,
filters: selectedFilters
}),
})
const result: SearchResponse = await response.json()
if (response.ok) {
setResults(result.results || [])
} else {
console.error("Search failed:", result.error)
setResults([])
}
} catch (error) {
console.error("Search error:", error)
setResults([])
} finally {
setLoading(false)
}
}
}}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
</div>
</div>
)}
</div>
) : (
<div className="h-32 flex items-center justify-center">
<p className="text-muted-foreground/50 text-sm">
Enter a search query above to get started
</p>
</div>
)}
</div>
</div>
)}
{/* Empty State */}
{!searchPerformed && (
<div className="h-32 flex items-center justify-center">
<p className="text-muted-foreground/50 text-sm">
Enter a search query above to get started
</p>
</div>
)}
</CardContent>
</Card>
</div>

View file

@ -8,9 +8,13 @@ async def search(request: Request, search_service, session_manager):
if not query:
return JSONResponse({"error": "Query is required"}, status_code=400)
filters = payload.get("filters", {}) # Optional filters, defaults to empty dict
limit = payload.get("limit", 10) # Optional limit, defaults to 10
score_threshold = payload.get("scoreThreshold", 0) # Optional score threshold, defaults to 0
user = request.state.user
# Extract JWT token from cookie for OpenSearch OIDC auth
jwt_token = request.cookies.get("auth_token")
result = await search_service.search(query, user_id=user.user_id, jwt_token=jwt_token)
result = await search_service.search(query, user_id=user.user_id, jwt_token=jwt_token, filters=filters, limit=limit, score_threshold=score_threshold)
return JSONResponse(result)

View file

@ -3,11 +3,14 @@ Authentication context for tool functions.
Uses contextvars to safely pass user auth info through async calls.
"""
from contextvars import ContextVar
from typing import Optional
from typing import Optional, Dict, Any
# Context variables for current request authentication
_current_user_id: ContextVar[Optional[str]] = ContextVar('current_user_id', default=None)
_current_jwt_token: ContextVar[Optional[str]] = ContextVar('current_jwt_token', default=None)
_current_search_filters: ContextVar[Optional[Dict[str, Any]]] = ContextVar('current_search_filters', default=None)
_current_search_limit: ContextVar[Optional[int]] = ContextVar('current_search_limit', default=10)
_current_score_threshold: ContextVar[Optional[float]] = ContextVar('current_score_threshold', default=0)
def set_auth_context(user_id: str, jwt_token: str):
"""Set authentication context for the current async context"""
@ -24,4 +27,28 @@ def get_current_jwt_token() -> Optional[str]:
def get_auth_context() -> tuple[Optional[str], Optional[str]]:
"""Get current authentication context (user_id, jwt_token)"""
return _current_user_id.get(), _current_jwt_token.get()
return _current_user_id.get(), _current_jwt_token.get()
def set_search_filters(filters: Dict[str, Any]):
"""Set search filters for the current async context"""
_current_search_filters.set(filters)
def get_search_filters() -> Optional[Dict[str, Any]]:
"""Get current search filters from context"""
return _current_search_filters.get()
def set_search_limit(limit: int):
"""Set search limit for the current async context"""
_current_search_limit.set(limit)
def get_search_limit() -> int:
"""Get current search limit from context"""
return _current_search_limit.get()
def set_score_threshold(threshold: float):
"""Set score threshold for the current async context"""
_current_score_threshold.set(threshold)
def get_score_threshold() -> float:
"""Get current score threshold from context"""
return _current_score_threshold.get()

View file

@ -20,30 +20,99 @@ class SearchService:
"""
# Get authentication context from the current async context
user_id, jwt_token = get_auth_context()
# Get search filters, limit, and score threshold from context
from auth_context import get_search_filters, get_search_limit, get_score_threshold
filters = get_search_filters() or {}
limit = get_search_limit()
score_threshold = get_score_threshold()
# Embed the query
resp = await clients.patched_async_client.embeddings.create(model=EMBED_MODEL, input=[query])
query_embedding = resp.data[0].embedding
# Base query structure
# Build filter clauses
filter_clauses = []
if filters:
# Map frontend filter names to backend field names
field_mapping = {
"data_sources": "filename",
"document_types": "mimetype",
"owners": "owner"
}
for filter_key, values in filters.items():
if values is not None and isinstance(values, list):
# Map frontend key to backend field name
field_name = field_mapping.get(filter_key, filter_key)
if len(values) == 0:
# Empty array means "match nothing" - use impossible filter
filter_clauses.append({"term": {field_name: "__IMPOSSIBLE_VALUE__"}})
elif len(values) == 1:
# Single value filter
filter_clauses.append({"term": {field_name: values[0]}})
else:
# Multiple values filter
filter_clauses.append({"terms": {field_name: values}})
# Hybrid search query structure (semantic + keyword)
search_body = {
"query": {
"bool": {
"must": [
"should": [
{
"knn": {
"chunk_embedding": {
"vector": query_embedding,
"k": 10
"k": 10,
"boost": 0.7
}
}
},
{
"multi_match": {
"query": query,
"fields": ["text^2", "filename^1.5"],
"type": "best_fields",
"fuzziness": "AUTO",
"boost": 0.3
}
}
]
],
"minimum_should_match": 1
}
},
"aggs": {
"data_sources": {
"terms": {
"field": "filename",
"size": 20
}
},
"document_types": {
"terms": {
"field": "mimetype",
"size": 10
}
},
"owners": {
"terms": {
"field": "owner",
"size": 10
}
}
},
"_source": ["filename", "mimetype", "page", "text", "source_url", "owner", "allowed_users", "allowed_groups"],
"size": 10
"size": limit
}
# Add score threshold if specified
if score_threshold > 0:
search_body["min_score"] = score_threshold
# Add filter clauses if any exist
if filter_clauses:
search_body["query"]["bool"]["filter"] = filter_clauses
# Authentication required - DLS will handle document filtering automatically
if not user_id:
return {"results": [], "error": "Authentication required"}
@ -52,7 +121,7 @@ class SearchService:
opensearch_client = clients.create_user_opensearch_client(jwt_token)
results = await opensearch_client.search(index=INDEX_NAME, body=search_body)
# Transform results
# Transform results (keep for backward compatibility)
chunks = []
for hit in results["hits"]["hits"]:
chunks.append({
@ -64,13 +133,27 @@ class SearchService:
"source_url": hit["_source"].get("source_url"),
"owner": hit["_source"].get("owner")
})
return {"results": chunks}
# Return both transformed results and aggregations
return {
"results": chunks,
"aggregations": results.get("aggregations", {})
}
async def search(self, query: str, user_id: str = None, jwt_token: str = None) -> Dict[str, Any]:
async def search(self, query: str, user_id: str = None, jwt_token: str = None, filters: Dict[str, Any] = None, limit: int = 10, score_threshold: float = 0) -> Dict[str, Any]:
"""Public search method for API endpoints"""
# Set auth context if provided (for direct API calls)
if user_id and jwt_token:
from auth_context import set_auth_context
set_auth_context(user_id, jwt_token)
# Set filters and limit in context if provided
if filters:
from auth_context import set_search_filters
set_search_filters(filters)
from auth_context import set_search_limit, set_score_threshold
set_search_limit(limit)
set_score_threshold(score_threshold)
return await self.search_tool(query)