Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/images/retrieval-preview-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/retrieval-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions docs/retrieval.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ authentication. Keep the management API behind trusted access controls. Omitting
the channel searches all indexed content; a supplied channel is a search filter,
not an authorisation check. The response contains full chunk text.

The dashboard's **Retrieval** tab calls this endpoint on submission. Enter a
question and optionally a channel ID to inspect ranked chunk text, source
metadata and raw distances alongside the effective top-k and cutoff. An empty
channel searches all indexed content. The tab reports empty results and offers
a retry after failures. Editing either input clears the previous results, and
leaving the tab cancels the pending browser request. It does not generate an
answer or send a Slack message. Cancelling the browser request does not
guarantee that an embedding request already started by the backend is cancelled.

![Retrieval tab showing one selected VPN chunk and its distance](images/retrieval-preview.png)

The screenshot uses local fixture data with fixed embeddings, a channel scope
of `CTARGET` and a distance cutoff of 1. It contains no real workspace content.
The [narrow screen capture](images/retrieval-preview-mobile.png) shows the same
result at a width of 390 pixels.

## Saved Slack threads

Slack thread indexing uses a stable chunk ID built from the channel ID and
Expand Down
16 changes: 14 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from 'react'
import ConversationsPanel from '@/components/conversations-panel'
import RetrievalPanel from '@/components/retrieval-panel'
import {
askQuestion,
getDocument,
Expand All @@ -25,7 +26,7 @@ export default function Home() {
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'ask' | 'documents' | 'conversations'>('ask')
const [activeTab, setActiveTab] = useState<'ask' | 'documents' | 'conversations' | 'retrieval'>('ask')
const [documents, setDocuments] = useState<Document[]>([])
const [documentsTotal, setDocumentsTotal] = useState(0)
const [documentsLoading, setDocumentsLoading] = useState(false)
Expand Down Expand Up @@ -102,7 +103,7 @@ export default function Home() {

{/* Tab Navigation */}
<div className="flex justify-center mb-8">
<div className="bg-white rounded-lg shadow-md p-1 inline-flex">
<div className="bg-white rounded-lg shadow-md p-1 inline-flex flex-wrap justify-center">
<button
onClick={() => setActiveTab('ask')}
className={`px-6 py-2 rounded-md transition-colors ${
Expand Down Expand Up @@ -133,6 +134,16 @@ export default function Home() {
>
Conversations
</button>
<button
onClick={() => setActiveTab('retrieval')}
className={`px-6 py-2 rounded-md transition-colors ${
activeTab === 'retrieval'
? 'bg-primary-500 text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Retrieval
</button>
</div>
</div>

Expand Down Expand Up @@ -289,6 +300,7 @@ export default function Home() {
)}

{activeTab === 'conversations' && <ConversationsPanel />}
{activeTab === 'retrieval' && <RetrievalPanel />}

<footer className="mt-16 text-center text-gray-600">
<p className="mb-2">
Expand Down
93 changes: 90 additions & 3 deletions frontend/bun.lock

Large diffs are not rendered by default.

117 changes: 117 additions & 0 deletions frontend/components/retrieval-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { previewRetrieval, type RetrievalPreview } from '@/lib/api'

const inputClass = 'w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-800 focus:ring-2 focus:ring-primary-500 disabled:bg-gray-50'

export default function RetrievalPanel() {
const [question, setQuestion] = useState('')
const [channel, setChannel] = useState('')
const [result, setResult] = useState<RetrievalPreview | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const request = useRef<AbortController | null>(null)

useEffect(() => () => request.current?.abort(), [])

async function preview() {
if (!question.trim() || request.current) return
const controller = new AbortController()
request.current = controller
setLoading(true)
setError('')
setResult(null)
try {
const data = await previewRetrieval(question, channel, controller.signal)
if (!controller.signal.aborted) setResult(data)
} catch {
if (!controller.signal.aborted) {
setError('Could not preview retrieval. Check the backend is running and try again.')
}
} finally {
if (!controller.signal.aborted) {
request.current = null
setLoading(false)
}
}
}

function edit(field: 'question' | 'channel', value: string) {
if (field === 'question') setQuestion(value)
else setChannel(value)
setResult(null)
setError('')
}

return (
<section aria-label="Retrieval preview" className="max-w-4xl mx-auto bg-white rounded-xl shadow-lg p-6 sm:p-8">
<h2 className="text-2xl font-semibold text-gray-800">Inspect retrieval</h2>
<p className="text-gray-600 mt-2">
See which knowledge base chunks a question retrieves before generating an answer.
Previewing uses the configured embedding provider and may incur embedding costs.
</p>
<form className="mt-6 space-y-4" onSubmit={(event) => { event.preventDefault(); void preview() }}>
<div>
<label htmlFor="retrieval-question" className="block font-medium text-gray-800 mb-2">Question</label>
<textarea id="retrieval-question" required rows={3} value={question} disabled={loading}
onChange={(event) => edit('question', event.target.value)} className={inputClass} />
</div>
<div>
<label htmlFor="retrieval-channel" className="block font-medium text-gray-800 mb-2">Channel ID (optional)</label>
<input id="retrieval-channel" value={channel} disabled={loading} aria-describedby="retrieval-scope"
onChange={(event) => edit('channel', event.target.value)} className={inputClass} />
<p id="retrieval-scope" className="text-sm text-gray-600 mt-2">
Leave blank to search all indexed content. Channel scope filters results; it does not restrict access.
</p>
</div>
<button type="submit" disabled={loading || !question.trim()}
className="bg-primary-600 hover:bg-primary-700 text-white font-semibold py-3 px-6 rounded-lg disabled:bg-gray-400 disabled:cursor-not-allowed">
{loading ? 'Retrieving...' : error ? 'Retry preview' : 'Preview retrieval'}
</button>
</form>

<div aria-live="polite" className="mt-6">
{loading && <p role="status" className="text-gray-600">Retrieving chunks...</p>}
{error && <p role="alert" className="rounded-lg bg-red-50 p-4 text-red-700">{error}</p>}
{!result && !loading && !error && <p className="text-gray-500">Submit a question to inspect its sources.</p>}
{result && (
<section aria-label="Retrieved chunks">
<h3 className="font-semibold text-lg text-gray-800">{result.question}</h3>
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4 rounded-lg bg-gray-50 p-4 text-sm">
<div><dt className="text-gray-500">Search scope</dt><dd className="text-gray-800 break-all mt-1">{result.channel_id ?? 'All indexed content'}</dd></div>
<div><dt className="text-gray-500">Maximum chunks</dt><dd className="text-gray-800 mt-1">{result.top_k}</dd></div>
<div><dt className="text-gray-500">Distance cutoff</dt><dd className="text-gray-800 mt-1">{result.max_distance ?? 'Not set'}</dd></div>
</dl>
<p role="status" className="text-gray-600 mt-4">{result.items.length} {result.items.length === 1 ? 'chunk' : 'chunks'} retrieved.</p>
<p className="text-sm text-gray-500 mt-1">Lower distances mean closer vectors, not a confidence percentage.</p>
{result.items.length === 0 && (
<p className="text-gray-600 mt-4">No chunks matched this question under the current scope and cutoff. Try another question or channel.</p>
)}
<ol className="space-y-4 mt-4">
{result.items.map((item, index) => (
<li key={index} className="border border-gray-200 rounded-lg p-4 sm:p-5">
<div className="flex flex-wrap justify-between gap-2 text-sm">
<h4 className="font-semibold text-gray-800">Chunk {index + 1}</h4>
<span className="font-mono text-primary-800 break-all">Distance {item.distance}</span>
</div>
<p className="text-gray-800 whitespace-pre-wrap break-words mt-3">{item.content}</p>
{Object.keys(item.metadata).length > 0 ? (
<dl className="mt-4 border-t border-gray-100 pt-3 space-y-2 text-xs font-mono">
{Object.entries(item.metadata).map(([key, value]) => (
<div key={key} className="grid sm:grid-cols-[9rem_minmax(0,1fr)] gap-1">
<dt className="text-gray-500 break-all">{key}</dt>
<dd className="text-gray-700 whitespace-pre-wrap break-all">{typeof value === 'string' ? value : JSON.stringify(value)}</dd>
</div>
))}
</dl>
) : <p className="mt-4 text-xs text-gray-500">No source metadata available.</p>}
</li>
))}
</ol>
</section>
)}
</div>
</section>
)
}
28 changes: 28 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ export interface QuestionResponse {
confidence: string
}

export interface RetrievalPreview {
question: string
channel_id: string | null
top_k: number
max_distance: number | null
items: Array<{
content: string
metadata: Record<string, unknown>
distance: number
}>
}

export async function previewRetrieval(
question: string, channelId?: string, signal?: AbortSignal
): Promise<RetrievalPreview> {
const response = await fetch(`${API_BASE_URL}/api/retrieval/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: question.trim(),
channel_id: channelId?.trim() || undefined,
}),
signal,
})
if (!response.ok) throw new Error(`API error: ${response.status}`)
return response.json()
}

export interface Document {
id: number
title: string
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"typescript": "^5"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@testing-library/react": "^16.3.3",
"@types/bun": "^1.4.4",
"eslint": "^9",
Expand Down
5 changes: 5 additions & 0 deletions frontend/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
Loading
Loading