فهرست منبع

feat: add kb testing pages

Thomas Zhang 1 ماه پیش
والد
کامیت
bae9a8dc2c

+ 29 - 2
src/lib/api/kbs.ts

@@ -1,10 +1,24 @@
 import { resolve } from '$app/paths';
 
-import { handleResponse } from '$lib/api/types';
+import { handleResponse, type TestResponse } from '$lib/api/types';
 import type { ManagedKBCR } from '$lib/types/custom-resources';
 import type { KnowledgeBase } from '$lib/types/entities';
 import type { Changes, CoreResources } from '$lib/types/workspace';
 
+type KBQueryRequest = {
+	query: string;
+	topK?: number;
+	threshold?: number;
+};
+
+type KBChunk = {
+	score: number;
+	content: string;
+	documentId: string;
+};
+
+type KBQueryResponse = TestResponse<{ chunks: KBChunk[] }>;
+
 const list = (workspaceId: string, fetch = globalThis.fetch): Promise<KnowledgeBase[]> =>
 	fetch(
 		resolve('/api/workspaces/[workspaceId]/[resource=resource]', {
@@ -104,4 +118,17 @@ const remove = (workspaceId: string, id: string, fetch = globalThis.fetch): Prom
 		if (!res.ok) await handleResponse<void>(res);
 	});
 
-export { create, get, getChanges, getResources, getStatus, list, remove, update };
+const testQuery = (
+	workspaceId: string,
+	id: string,
+	request: KBQueryRequest,
+	fetch = globalThis.fetch,
+): Promise<KBQueryResponse> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/kbs/[id]/test', { workspaceId, id }), {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify(request),
+	}).then(handleResponse<KBQueryResponse>);
+
+export type { KBChunk, KBQueryRequest, KBQueryResponse };
+export { create, get, getChanges, getResources, getStatus, list, remove, testQuery, update };

+ 3 - 0
src/lib/components/kbs/testing/index.ts

@@ -0,0 +1,3 @@
+import KBQueryTest from './query.svelte';
+
+export { KBQueryTest };

+ 137 - 0
src/lib/components/kbs/testing/query.svelte

@@ -0,0 +1,137 @@
+<script lang="ts">
+	import { CircleCheckBig } from '@lucide/svelte';
+
+	import * as kbsApi from '$lib/api/kbs';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { TestResult } from '$lib/components/common/test-result';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import { Optional } from '$lib/components/forms/optional';
+	import type { KnowledgeBase } from '$lib/types/entities';
+
+	let { workspaceId, kb }: { workspaceId: string; kb: KnowledgeBase } = $props();
+
+	let query = $state('');
+	let topK = $state(5);
+	let threshold = $state(0.7);
+
+	let response = $state<kbsApi.KBQueryResponse | undefined>(undefined);
+	let loading = $state(false);
+	let error = $state<{ type: string; message: string } | undefined>(undefined);
+	const queryResponse = $derived(response?.response);
+
+	$effect(() => {
+		if (kb) {
+			query = '';
+			topK = 5;
+			threshold = 0.7;
+			response = undefined;
+			error = undefined;
+		}
+	});
+
+	const runQuery = async () => {
+		if (!query || !query.trim() || loading) return;
+		error = undefined;
+		response = undefined;
+		loading = true;
+		try {
+			response = await kbsApi.testQuery(workspaceId, kb.id, {
+				query: query.trim(),
+				topK,
+				threshold,
+			});
+		} catch (err) {
+			error =
+				err instanceof Error
+					? { type: 'Request Error', message: err.message }
+					: { type: 'Unknown Error', message: 'An error occurred' };
+		} finally {
+			loading = false;
+		}
+	};
+
+	const scoreColor = (score: number) =>
+		score >= 0.7
+			? 'bg-green-500/20 text-green-300 border-green-500/30'
+			: score >= 0.4
+				? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
+				: 'bg-red-500/20 text-red-300 border-red-500/30';
+</script>
+
+<GlassPane class="overflow-hidden bg-black/25">
+	<div class="bg-white/5 p-4">
+		<div class="mb-4 flex items-center justify-between">
+			<p class="text-lg font-thin">Query</p>
+			<Button disabled={!query.trim() || loading} onclick={() => runQuery()}>Query</Button>
+		</div>
+
+		<Optional title="Parameters">
+			<div class="grid grid-cols-2 gap-3">
+				<Field.Field>
+					<Field.Label for="topK">Top K</Field.Label>
+					<Input id="topK" max={100} min={1} type="number" bind:value={topK} />
+				</Field.Field>
+				<Field.Field>
+					<Field.Label for="threshold">Threshold</Field.Label>
+					<Input id="threshold" max={1} min={0} step={0.05} type="number" bind:value={threshold} />
+				</Field.Field>
+			</div>
+		</Optional>
+	</div>
+
+	<textarea
+		class="h-full w-full resize-none p-4 font-mono text-sm outline-none"
+		placeholder="Enter your query..."
+		rows={5}
+		bind:value={query}
+	>
+	</textarea>
+
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<span class="text-lg font-thin">Result</span>
+			{#if response}
+				<div class="flex items-center gap-2 text-sm">
+					<CircleCheckBig class="text-green-500" size={14} />
+					{#if response.latencyMs}
+						<span class="text-white/25">Latency:</span>
+						<span class="text-white/75">{response.latencyMs}ms</span>
+					{/if}
+					{#if queryResponse}
+						<span class="text-white/25">Chunks:</span>
+						<span class="text-white/75">{queryResponse.chunks.length}</span>
+					{/if}
+				</div>
+			{/if}
+		</div>
+	</div>
+
+	<TestResult class="bg-black/20" {error} {loading} {response}>
+		{#snippet result(resp)}
+			{#if resp}
+				{#if resp.chunks.length === 0}
+					<p class="text-center text-xs text-white/20">No results</p>
+				{:else}
+					<div>
+						{#each resp.chunks as chunk, rank (rank)}
+							<div class="border-b border-white/10 p-4 last:border-0">
+								<div class="mb-2 flex items-center gap-2">
+									<span class="shrink-0 font-mono text-sm text-white/50">#{rank + 1}</span>
+									<span
+										class={`shrink-0 rounded border px-2 py-1 font-mono text-xs ${scoreColor(chunk.score)}`}
+									>
+										{chunk.score.toFixed(3)}
+									</span>
+									<span class="truncate text-sm text-white/75">{chunk.documentId}</span>
+								</div>
+								<p class="text-sm text-white/75">{chunk.content}</p>
+							</div>
+						{/each}
+					</div>
+				{/if}
+			{/if}
+		{/snippet}
+	</TestResult>
+</GlassPane>

+ 74 - 0
src/routes/api/workspaces/[workspaceId]/kbs/[id]/test/+server.ts

@@ -0,0 +1,74 @@
+import { json } from '@sveltejs/kit';
+
+import type { KBChunk, KBQueryRequest } from '$lib/api/kbs';
+import { ApiError, type TestResponse } from '$lib/api/types';
+import { NotFoundError, server } from '$lib/server';
+
+import type { RequestHandler } from './$types';
+
+const POST: RequestHandler = async ({ locals, params, request }) => {
+	const workspaceId = locals.workspaceId || '';
+	const kbs = await server.catalog.getWorkspaceCatalog(workspaceId).kbs.list();
+	const kb = kbs.find((k) => k.id === params.id);
+	if (!kb) throw new NotFoundError('Knowledge base', params.id);
+
+	const status = await server.resources
+		.getWorkspaceResources(workspaceId)
+		.kbs.getManagedKBStatus(params.id);
+
+	const endpoint = status?.endpoint;
+	if (!endpoint) {
+		return json(
+			{ code: 'ENDPOINT_UNAVAILABLE', message: 'Knowledge base endpoint is not yet available' },
+			{ status: 503 },
+		);
+	}
+
+	const input = (await request.json()) as KBQueryRequest;
+
+	const testResp: TestResponse<{ chunks: KBChunk[] }> = {};
+	const beginMs = Date.now();
+
+	try {
+		const kbResp = await fetch(new URL('/query', endpoint), {
+			method: 'POST',
+			headers: { 'Content-Type': 'application/json' },
+			body: JSON.stringify({
+				query: input.query,
+				top_k: input.topK,
+			}),
+		});
+
+		if (!kbResp.ok) {
+			throw new ApiError(kbResp.status, 'KB_QUERY_ERROR', await kbResp.text());
+		}
+
+		const data = (await kbResp.json()) as {
+			results?: {
+				score: number;
+				payload: {
+					content: string;
+					docId: string;
+				};
+			}[];
+		};
+		testResp.response = {
+			chunks: (data.results ?? []).map((r) => ({
+				score: r.score,
+				content: r.payload.content,
+				documentId: r.payload.docId,
+			})),
+		};
+		testResp.latencyMs = Date.now() - beginMs;
+	} catch (err) {
+		if (err instanceof ApiError) {
+			testResp.error = { type: err.code, message: err.message };
+		} else {
+			throw err;
+		}
+	}
+
+	return json(testResp);
+};
+
+export { POST };

+ 13 - 0
src/routes/utilities/testing/kb/+page.server.ts

@@ -0,0 +1,13 @@
+import * as kbsApi from '$lib/api/kbs';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, url, locals }) => {
+	const { workspaceId } = locals;
+	if (!workspaceId) return { kbs: [], selectedKbId: null };
+	const kbs = await kbsApi.list(workspaceId, fetch);
+	const selectedKbId = url.searchParams.get('kb');
+	return { kbs, selectedKbId };
+};
+
+export { load };

+ 77 - 0
src/routes/utilities/testing/kb/+page.svelte

@@ -0,0 +1,77 @@
+<script lang="ts">
+	import { BookSearch } from '@lucide/svelte';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { Button } from '$lib/components/controls/button';
+	import { KBSelectDialog } from '$lib/components/kbs/select';
+	import { KBQueryTest } from '$lib/components/kbs/testing';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import type { KnowledgeBase } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Knowledge Bases',
+			pages: [{ name: 'Retrieval', href: resolve('/utilities/testing/kb') }],
+		});
+	});
+
+	let kbSelectionShown = $state(false);
+	let selectedKb = $state<KnowledgeBase | undefined>(undefined);
+
+	$effect.root(() => {
+		if (data.selectedKbId) {
+			const found = data.kbs.find((k) => k.id === data.selectedKbId);
+			if (found) selectedKb = found;
+		}
+	});
+
+	const onselect = (kbs: KnowledgeBase[]): KnowledgeBase[] => {
+		selectedKb = kbs[0];
+		kbSelectionShown = false;
+		return [];
+	};
+</script>
+
+<svelte:head>
+	<title>Retrieval - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl p-6">
+	<div class="mb-4 flex items-center gap-2 text-xl font-semibold text-white/90">
+		<BookSearch opacity={0.5} size={18} strokeWidth={3} />
+		Retrieval
+	</div>
+	<div class="mb-4">
+		<Button size="sm" variant="outline" onclick={() => (kbSelectionShown = true)}>
+			{#if selectedKb}
+				<Icon icon={selectedKb.icon} size={14} />
+				<div>{selectedKb.name}</div>
+			{:else}
+				Select knowledge base...
+			{/if}
+		</Button>
+	</div>
+
+	{#if !selectedKb}
+		<GlassPane class="flex items-center justify-center bg-black/50 p-16">
+			<p class="text-sm text-white/30">Select a knowledge base above to begin testing.</p>
+		</GlassPane>
+	{:else}
+		<KBQueryTest kb={selectedKb} workspaceId={data.workspaceId!} />
+	{/if}
+</div>
+
+<KBSelectDialog
+	multiple={false}
+	workspaceId={data.workspaceId!}
+	bind:open={kbSelectionShown}
+	{onselect}
+/>