search improvements (facets, limit, score threshold)
This commit is contained in:
parent
ca8431caae
commit
31e9c1b13f
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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@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-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@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-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
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"
|
import { ProtectedRoute } from "@/components/protected-route"
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
|
|
@ -14,16 +16,56 @@ interface SearchResult {
|
||||||
page: number
|
page: number
|
||||||
text: string
|
text: string
|
||||||
score: number
|
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() {
|
function SearchPage() {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [results, setResults] = useState<SearchResult[]>([])
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [facets, setFacets] = useState<Facets>({})
|
||||||
const [searchPerformed, setSearchPerformed] = useState(false)
|
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
|
if (!query.trim()) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -35,28 +77,185 @@ function SearchPage() {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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) {
|
if (response.ok) {
|
||||||
setResults(result.results || [])
|
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)
|
setSearchPerformed(true)
|
||||||
} else {
|
} else {
|
||||||
console.error("Search failed:", result.error)
|
console.error("Search failed:", result.error)
|
||||||
setResults([])
|
setResults([])
|
||||||
|
setFacets({})
|
||||||
setSearchPerformed(true)
|
setSearchPerformed(true)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Search error:", error)
|
console.error("Search error:", error)
|
||||||
setResults([])
|
setResults([])
|
||||||
|
setFacets({})
|
||||||
setSearchPerformed(true)
|
setSearchPerformed(true)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
|
|
@ -67,10 +266,10 @@ function SearchPage() {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl text-muted-foreground">
|
<p className="text-xl text-muted-foreground">
|
||||||
Find documents using semantic search
|
Find documents using hybrid search
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -82,7 +281,7 @@ function SearchPage() {
|
||||||
Search Documents
|
Search Documents
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
|
@ -91,115 +290,426 @@ function SearchPage() {
|
||||||
<Label htmlFor="search-query" className="font-medium">
|
<Label htmlFor="search-query" className="font-medium">
|
||||||
Search Query
|
Search Query
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="search-query"
|
<Input
|
||||||
type="text"
|
id="search-query"
|
||||||
placeholder="e.g., 'financial reports from Q4' or 'user authentication setup'"
|
type="text"
|
||||||
value={query}
|
placeholder="e.g., 'financial reports from Q4' or 'user authentication setup'"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
value={query}
|
||||||
className="h-12 bg-background/50 border-border/50 focus:border-blue-400/50 focus:ring-blue-400/20"
|
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>
|
</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>
|
</form>
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Search Results with Filters */}
|
||||||
<div className="mt-8">
|
{searchPerformed && (
|
||||||
{searchPerformed ? (
|
<div className="space-y-4">
|
||||||
<div className="space-y-6">
|
{/* Filter Toggle - Always visible when filters are available */}
|
||||||
<div className="flex items-center justify-between">
|
{(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">
|
<h2 className="text-2xl font-semibold flex items-center gap-2">
|
||||||
<Zap className="h-6 w-6 text-yellow-400" />
|
<Zap className="h-6 w-6 text-yellow-400" />
|
||||||
Search Results
|
Search Results
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse"></div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="h-2 w-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||||
{results.length} result{results.length !== 1 ? 's' : ''} found
|
<span className="text-sm text-muted-foreground">
|
||||||
</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{results.length === 0 ? (
|
)}
|
||||||
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
|
|
||||||
<CardContent className="pt-8 pb-8">
|
<div className="flex gap-6">
|
||||||
<div className="text-center space-y-3">
|
{/* Main Content */}
|
||||||
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
|
<div className="flex-1 space-y-6">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/50" />
|
{/* 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>
|
</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>
|
</div>
|
||||||
</CardContent>
|
<div className="flex flex-wrap gap-2">
|
||||||
</Card>
|
{Object.entries(selectedFilters).map(([facetType, values]) =>
|
||||||
) : (
|
values.map((value: string) => (
|
||||||
<div className="space-y-4">
|
<div key={`${facetType}-${value}`} className="flex items-center gap-1 bg-primary/10 text-primary px-2 py-1 rounded-md text-xs">
|
||||||
{results.map((result, index) => (
|
<span>{value}</span>
|
||||||
<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">
|
<Button
|
||||||
<CardHeader className="pb-3">
|
variant="ghost"
|
||||||
<div className="flex items-center justify-between">
|
size="sm"
|
||||||
<CardTitle className="text-lg flex items-center gap-3">
|
className="h-auto w-auto p-0.5 hover:bg-primary/20"
|
||||||
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
|
onClick={() => handleFilterChange(facetType as keyof SelectedFilters, value, false)}
|
||||||
<FileText className="h-4 w-4 text-blue-400" />
|
>
|
||||||
</div>
|
<X className="h-3 w-3" />
|
||||||
<span className="truncate">{result.filename}</span>
|
</Button>
|
||||||
</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>
|
||||||
</div>
|
))
|
||||||
<CardDescription className="flex items-center gap-4 text-sm">
|
)}
|
||||||
<span className="px-2 py-1 rounded bg-muted/50 text-muted-foreground">
|
</div>
|
||||||
{result.mimetype}
|
</div>
|
||||||
</span>
|
)}
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Page {result.page}
|
{/* Results Section */}
|
||||||
</span>
|
<div>
|
||||||
</CardDescription>
|
{results.length === 0 ? (
|
||||||
</CardHeader>
|
<Card className="bg-muted/20 border-dashed border-muted-foreground/30">
|
||||||
<CardContent>
|
<CardContent className="pt-8 pb-8">
|
||||||
<div className="border-l-2 border-blue-400/50 pl-4 py-2 bg-muted/20 rounded-r-lg">
|
<div className="text-center space-y-3">
|
||||||
<p className="text-sm leading-relaxed text-foreground/90">
|
<div className="mx-auto w-16 h-16 bg-muted/30 rounded-full flex items-center justify-center">
|
||||||
{result.text}
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</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
|
{/* Empty State */}
|
||||||
</p>
|
{!searchPerformed && (
|
||||||
</div>
|
<div className="h-32 flex items-center justify-center">
|
||||||
)}
|
<p className="text-muted-foreground/50 text-sm">
|
||||||
</div>
|
Enter a search query above to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@ async def search(request: Request, search_service, session_manager):
|
||||||
if not query:
|
if not query:
|
||||||
return JSONResponse({"error": "Query is required"}, status_code=400)
|
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
|
user = request.state.user
|
||||||
# Extract JWT token from cookie for OpenSearch OIDC auth
|
# Extract JWT token from cookie for OpenSearch OIDC auth
|
||||||
jwt_token = request.cookies.get("auth_token")
|
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)
|
return JSONResponse(result)
|
||||||
|
|
@ -3,11 +3,14 @@ Authentication context for tool functions.
|
||||||
Uses contextvars to safely pass user auth info through async calls.
|
Uses contextvars to safely pass user auth info through async calls.
|
||||||
"""
|
"""
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import Optional
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
# Context variables for current request authentication
|
# Context variables for current request authentication
|
||||||
_current_user_id: ContextVar[Optional[str]] = ContextVar('current_user_id', default=None)
|
_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_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):
|
def set_auth_context(user_id: str, jwt_token: str):
|
||||||
"""Set authentication context for the current async context"""
|
"""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]]:
|
def get_auth_context() -> tuple[Optional[str], Optional[str]]:
|
||||||
"""Get current authentication context (user_id, jwt_token)"""
|
"""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
|
# Get authentication context from the current async context
|
||||||
user_id, jwt_token = get_auth_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
|
# Embed the query
|
||||||
resp = await clients.patched_async_client.embeddings.create(model=EMBED_MODEL, input=[query])
|
resp = await clients.patched_async_client.embeddings.create(model=EMBED_MODEL, input=[query])
|
||||||
query_embedding = resp.data[0].embedding
|
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 = {
|
search_body = {
|
||||||
"query": {
|
"query": {
|
||||||
"bool": {
|
"bool": {
|
||||||
"must": [
|
"should": [
|
||||||
{
|
{
|
||||||
"knn": {
|
"knn": {
|
||||||
"chunk_embedding": {
|
"chunk_embedding": {
|
||||||
"vector": query_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"],
|
"_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
|
# Authentication required - DLS will handle document filtering automatically
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return {"results": [], "error": "Authentication required"}
|
return {"results": [], "error": "Authentication required"}
|
||||||
|
|
@ -52,7 +121,7 @@ class SearchService:
|
||||||
opensearch_client = clients.create_user_opensearch_client(jwt_token)
|
opensearch_client = clients.create_user_opensearch_client(jwt_token)
|
||||||
results = await opensearch_client.search(index=INDEX_NAME, body=search_body)
|
results = await opensearch_client.search(index=INDEX_NAME, body=search_body)
|
||||||
|
|
||||||
# Transform results
|
# Transform results (keep for backward compatibility)
|
||||||
chunks = []
|
chunks = []
|
||||||
for hit in results["hits"]["hits"]:
|
for hit in results["hits"]["hits"]:
|
||||||
chunks.append({
|
chunks.append({
|
||||||
|
|
@ -64,13 +133,27 @@ class SearchService:
|
||||||
"source_url": hit["_source"].get("source_url"),
|
"source_url": hit["_source"].get("source_url"),
|
||||||
"owner": hit["_source"].get("owner")
|
"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"""
|
"""Public search method for API endpoints"""
|
||||||
# Set auth context if provided (for direct API calls)
|
# Set auth context if provided (for direct API calls)
|
||||||
if user_id and jwt_token:
|
if user_id and jwt_token:
|
||||||
from auth_context import set_auth_context
|
from auth_context import set_auth_context
|
||||||
set_auth_context(user_id, jwt_token)
|
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)
|
return await self.search_tool(query)
|
||||||
Loading…
Add table
Reference in a new issue