search improvements (facets, limit, score threshold)
This commit is contained in:
parent
7ab8433aa8
commit
bf23ad8729
8 changed files with 839 additions and 110 deletions
30
frontend/components/ui/checkbox.tsx
Normal file
30
frontend/components/ui/checkbox.tsx
Normal 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 }
|
||||
11
frontend/components/ui/collapsible.tsx
Normal file
11
frontend/components/ui/collapsible.tsx
Normal 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 }
|
||||
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue