Bladeren bron

feat(webui): RAG search tester

Fszontagh 2 maanden geleden
bovenliggende
commit
0dd0a5af5e
1 gewijzigde bestanden met toevoegingen van 415 en 3 verwijderingen
  1. 415 3
      webui/src/pages/RagTester.tsx

+ 415 - 3
webui/src/pages/RagTester.tsx

@@ -1,8 +1,420 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Plus, Search } from 'lucide-react'
+import { api } from '@/api/client'
+import { useAuthStore } from '@/stores/authStore'
+import { useToastStore } from '@/stores/toastStore'
+import { JsonEditor } from '@/components/JsonEditor'
+import { ApiError } from '@/types'
+import type { CollectionMeta, SearchResult } from '@/types'
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function isEmptyObject(json: string): boolean {
+  try {
+    const parsed = JSON.parse(json) as unknown
+    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false
+    return Object.keys(parsed as Record<string, unknown>).length === 0
+  } catch {
+    return false
+  }
+}
+
+function omitVector(data: Record<string, unknown>): Record<string, unknown> {
+  const result: Record<string, unknown> = {}
+  for (const key of Object.keys(data)) {
+    if (key !== '_vector') {
+      result[key] = data[key]
+    }
+  }
+  return result
+}
+
+// ─── collection picker (vector-only) ─────────────────────────────────────────
+
+interface CollectionPickerProps {
+  project: string
+  selected: string
+  onSelect: (coll: string) => void
+  collections: CollectionMeta[]
+  isLoading: boolean
+}
+
+function CollectionPicker({ selected, onSelect, collections, isLoading }: CollectionPickerProps) {
+  return (
+    <div className="flex items-center gap-3">
+      <label className="text-xs font-medium text-slate-400 shrink-0">Collection</label>
+      <select
+        value={selected}
+        onChange={(e) => onSelect(e.target.value)}
+        disabled={isLoading || collections.length === 0}
+        className="rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 disabled:opacity-50"
+        data-testid="rag-collection-picker"
+      >
+        {isLoading && <option value="">Loading…</option>}
+        {!isLoading && collections.length === 0 && (
+          <option value="">No vector collections</option>
+        )}
+        {collections.map((c) => (
+          <option key={c.name} value={c.name}>
+            {c.name}
+          </option>
+        ))}
+      </select>
+    </div>
+  )
+}
+
+// ─── add item section ──────────────────────────────────────────────────────────
+
+interface AddItemSectionProps {
+  project: string
+  collection: string
+}
+
+function AddItemSection({ project, collection }: AddItemSectionProps) {
+  const push = useToastStore((s) => s.push)
+
+  const [text, setText] = useState('')
+  const [metaJson, setMetaJson] = useState('{}')
+  const [metaValid, setMetaValid] = useState(true)
+  const [showMeta, setShowMeta] = useState(false)
+  const [submitting, setSubmitting] = useState(false)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!text.trim()) {
+      push('Text is required.', 'error')
+      return
+    }
+    if (showMeta && !metaValid) {
+      push('Metadata JSON is invalid.', 'error')
+      return
+    }
+
+    setSubmitting(true)
+    try {
+      const body: { text: string; metadata?: Record<string, unknown> } = { text: text.trim() }
+      if (showMeta && !isEmptyObject(metaJson)) {
+        body.metadata = JSON.parse(metaJson) as Record<string, unknown>
+      }
+      const result = await api.storeVector(project, collection, body)
+      push(`Vector stored (id: ${result.id}).`, 'success')
+      setText('')
+      setMetaJson('{}')
+      setShowMeta(false)
+    } catch (err) {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Store failed: ${msg}`, 'error')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <section className="rounded-xl border border-slate-700/40 bg-slate-800/30 p-5 flex flex-col gap-4">
+      <h2 className="text-base font-semibold text-slate-200">Add Item</h2>
+
+      <form onSubmit={(e) => void handleSubmit(e)} className="flex flex-col gap-4">
+        {/* Text input */}
+        <div>
+          <label className="block text-xs font-medium text-slate-400 mb-1">
+            Text <span className="text-red-400">*</span>
+          </label>
+          <textarea
+            value={text}
+            onChange={(e) => setText(e.target.value)}
+            rows={4}
+            placeholder="Enter text to embed and store…"
+            className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 resize-y"
+            data-testid="rag-text-input"
+          />
+        </div>
+
+        {/* Metadata toggle */}
+        <div>
+          <button
+            type="button"
+            onClick={() => setShowMeta((v) => !v)}
+            className="text-xs text-indigo-400 hover:text-indigo-300 transition"
+          >
+            {showMeta ? '− Hide metadata' : '+ Add metadata (optional)'}
+          </button>
+
+          {showMeta && (
+            <div className="mt-2">
+              <label className="block text-xs font-medium text-slate-400 mb-1">
+                Metadata JSON <span className="text-slate-500">(optional)</span>
+              </label>
+              <JsonEditor
+                value={metaJson}
+                onChange={setMetaJson}
+                onValidityChange={setMetaValid}
+                height={140}
+              />
+              {!metaValid && (
+                <p className="mt-1 text-xs text-red-400">Invalid JSON.</p>
+              )}
+            </div>
+          )}
+        </div>
+
+        {/* Submit */}
+        <div className="flex justify-end">
+          <button
+            type="submit"
+            disabled={submitting || !text.trim() || (showMeta && !metaValid)}
+            className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed"
+            data-testid="rag-add-btn"
+          >
+            <Plus size={15} />
+            {submitting ? 'Storing…' : 'Store Vector'}
+          </button>
+        </div>
+      </form>
+    </section>
+  )
+}
+
+// ─── search section ────────────────────────────────────────────────────────────
+
+interface SearchSectionProps {
+  project: string
+  collection: string
+}
+
+function SearchSection({ project, collection }: SearchSectionProps) {
+  const push = useToastStore((s) => s.push)
+
+  const [queryText, setQueryText] = useState('')
+  const [topK, setTopK] = useState('5')
+  const [minScore, setMinScore] = useState('0')
+  const [searching, setSearching] = useState(false)
+  const [results, setResults] = useState<SearchResult[] | null>(null)
+
+  const handleSearch = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!queryText.trim()) {
+      push('Query text is required.', 'error')
+      return
+    }
+
+    setSearching(true)
+    setResults(null)
+    try {
+      const topKNum = parseInt(topK, 10)
+      const minScoreNum = parseFloat(minScore)
+      const response = await api.search(project, collection, {
+        query_text: queryText.trim(),
+        top_k: isNaN(topKNum) ? 5 : topKNum,
+        min_score: isNaN(minScoreNum) ? 0 : minScoreNum,
+      })
+      setResults(response.results)
+      if (response.results.length === 0) {
+        push('No results found.', 'info')
+      }
+    } catch (err) {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Search failed: ${msg}`, 'error')
+    } finally {
+      setSearching(false)
+    }
+  }
+
+  const inputClass =
+    'rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
+
+  return (
+    <section className="rounded-xl border border-slate-700/40 bg-slate-800/30 p-5 flex flex-col gap-4">
+      <h2 className="text-base font-semibold text-slate-200">Search</h2>
+
+      <form onSubmit={(e) => void handleSearch(e)} className="flex flex-col gap-4">
+        {/* Query text */}
+        <div>
+          <label className="block text-xs font-medium text-slate-400 mb-1">
+            Query <span className="text-red-400">*</span>
+          </label>
+          <input
+            type="text"
+            value={queryText}
+            onChange={(e) => setQueryText(e.target.value)}
+            placeholder="Enter search query…"
+            className={`${inputClass} w-full`}
+            data-testid="rag-query-input"
+          />
+        </div>
+
+        {/* Parameters row */}
+        <div className="flex flex-wrap items-center gap-4">
+          <div className="flex items-center gap-2">
+            <label className="text-xs font-medium text-slate-400 shrink-0">top_k</label>
+            <input
+              type="number"
+              value={topK}
+              onChange={(e) => setTopK(e.target.value)}
+              min={1}
+              max={100}
+              className={`${inputClass} w-20`}
+              data-testid="rag-topk-input"
+            />
+          </div>
+          <div className="flex items-center gap-2">
+            <label className="text-xs font-medium text-slate-400 shrink-0">min_score</label>
+            <input
+              type="number"
+              value={minScore}
+              onChange={(e) => setMinScore(e.target.value)}
+              min={0}
+              max={1}
+              step={0.01}
+              className={`${inputClass} w-24`}
+              data-testid="rag-minscore-input"
+            />
+          </div>
+          <button
+            type="submit"
+            disabled={searching || !queryText.trim()}
+            className="flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed"
+            data-testid="rag-search-btn"
+          >
+            <Search size={14} />
+            {searching ? 'Searching…' : 'Search'}
+          </button>
+        </div>
+      </form>
+
+      {/* Results */}
+      {results !== null && (
+        <div className="flex flex-col gap-3 mt-2">
+          <p className="text-xs text-slate-400">
+            {results.length} result{results.length !== 1 ? 's' : ''}
+          </p>
+
+          {results.length === 0 ? (
+            <div className="rounded-xl border border-slate-700/40 bg-slate-800/20 px-6 py-8 text-center">
+              <p className="text-slate-400 text-sm">No results above the score threshold.</p>
+            </div>
+          ) : (
+            <div className="flex flex-col gap-3">
+              {results.map((result, idx) => {
+                const dataWithoutVector = omitVector(result.data)
+                return (
+                  <div
+                    key={`${result.id}-${idx}`}
+                    className="rounded-xl border border-slate-700/40 bg-slate-900/40 p-4 flex flex-col gap-2"
+                    data-testid="rag-result-item"
+                  >
+                    {/* Header row */}
+                    <div className="flex items-center gap-3 flex-wrap">
+                      <span className="text-xs font-mono text-slate-400">
+                        #{idx + 1}
+                      </span>
+                      <span
+                        className="text-sm font-semibold text-indigo-300"
+                        data-testid="rag-result-score"
+                      >
+                        score: {result.score.toFixed(4)}
+                      </span>
+                      <span className="text-xs font-mono text-slate-500">
+                        id: {result.id}
+                      </span>
+                    </div>
+
+                    {/* Data preview */}
+                    <pre className="text-xs font-mono text-slate-300 bg-slate-800/60 rounded-md p-3 overflow-x-auto whitespace-pre-wrap break-words">
+                      {JSON.stringify(dataWithoutVector, null, 2)}
+                    </pre>
+                  </div>
+                )
+              })}
+            </div>
+          )}
+        </div>
+      )}
+    </section>
+  )
+}
+
+// ─── page ──────────────────────────────────────────────────────────────────────
+
 export default function RagTester() {
+  const currentProject = useAuthStore((s) => s.currentProject)
+
+  const [selectedColl, setSelectedColl] = useState('')
+
+  // Fetch all collections, filter to vector only
+  const { data: collectionsData, isLoading: collectionsLoading } = useQuery<{ collections: CollectionMeta[] }>({
+    queryKey: ['collections', currentProject],
+    queryFn: () => api.listCollections(currentProject!),
+    enabled: !!currentProject,
+  })
+
+  const vectorCollections = (collectionsData?.collections ?? []).filter((c) => c.kind === 'vector')
+  const effectiveColl = selectedColl || vectorCollections[0]?.name || ''
+
+  // ─── guard: no project ────────────────────────────────────────────────────
+
+  if (!currentProject) {
+    return (
+      <div className="flex flex-col gap-8">
+        <div>
+          <h1 className="text-2xl font-semibold text-slate-100">RAG Tester</h1>
+          <p className="text-slate-400 text-sm mt-1">Store and search vectors.</p>
+        </div>
+        <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
+          <p className="text-slate-400 text-sm">
+            No project selected — choose a project from the selector above.
+          </p>
+        </div>
+      </div>
+    )
+  }
+
+  // ─── render ───────────────────────────────────────────────────────────────
+
   return (
-    <div>
-      <h1 className="text-2xl font-semibold text-slate-100 mb-2">RAG Tester</h1>
-      <p className="text-slate-400">Vector search tester — coming in W8.</p>
+    <div className="flex flex-col gap-6">
+      {/* Page header */}
+      <div>
+        <h1 className="text-2xl font-semibold text-slate-100">RAG Tester</h1>
+        <p className="text-slate-400 text-sm mt-1">
+          Project: <span className="text-slate-300 font-mono">{currentProject}</span>
+          {!collectionsLoading && (
+            <span className="ml-2 text-slate-500">
+              — {vectorCollections.length} vector collection{vectorCollections.length !== 1 ? 's' : ''}
+            </span>
+          )}
+        </p>
+      </div>
+
+      {/* Collection selector */}
+      <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-4 py-3">
+        <CollectionPicker
+          project={currentProject}
+          selected={effectiveColl}
+          onSelect={setSelectedColl}
+          collections={vectorCollections}
+          isLoading={collectionsLoading}
+        />
+      </div>
+
+      {/* No vector collections notice */}
+      {!collectionsLoading && vectorCollections.length === 0 && (
+        <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
+          <p className="text-slate-400 text-sm">
+            No vector collections in this project. Create a collection with kind&nbsp;
+            <span className="font-mono text-indigo-300">vector</span> to use the RAG tester.
+          </p>
+        </div>
+      )}
+
+      {/* Add + Search sections (only when a collection is selected) */}
+      {effectiveColl && (
+        <>
+          <AddItemSection project={currentProject} collection={effectiveColl} />
+          <SearchSection project={currentProject} collection={effectiveColl} />
+        </>
+      )}
     </div>
   )
 }