CAPS04-FE-REFACTOR / resources /js /components /admin /admin-vector-retrieval-forms.tsx
wepee's picture
add: information
4c9164d
Raw
History Blame
16.3 kB
import { Save } from 'lucide-react';
import type { FormEvent } from 'react';
import { useState } from 'react';
import {
ConfigError,
Field,
RetrievalNumberField,
} from '@/components/admin/admin-form-parts';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { stringValue } from '@/lib/admin';
import { patchRetrievalConfig, patchVectorDbConfig } from '@/lib/rag-client';
import type {
RetrievalConfig,
RetrievalForm,
VectorDbConfig,
VectorDbForm,
} from '@/types/admin';
export function AdminVectorDbForm({
vectorDb,
}: {
vectorDb?: VectorDbConfig | null;
}) {
const cfg = vectorDb ?? {};
const [formData, setFormData] = useState<VectorDbForm>({
chunk_overlap: stringValue(cfg.chunk_overlap),
chunk_size: stringValue(cfg.chunk_size),
embedding_model: stringValue(cfg.embedding_model),
persist_path: stringValue(cfg.persist_path),
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | undefined>();
function setField<K extends keyof VectorDbForm>(
key: K,
value: VectorDbForm[K],
): void {
setFormData((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(
event: FormEvent<HTMLFormElement>,
): Promise<void> {
event.preventDefault();
setIsSubmitting(true);
setError(undefined);
try {
const payload: Record<string, unknown> = {};
if (formData.embedding_model) payload.embedding_model = formData.embedding_model;
if (formData.persist_path) payload.persist_path = formData.persist_path;
if (formData.chunk_size) payload.chunk_size = parseInt(formData.chunk_size, 10);
if (formData.chunk_overlap) payload.chunk_overlap = parseInt(formData.chunk_overlap, 10);
const result = await patchVectorDbConfig(payload);
setFormData({
chunk_overlap: stringValue(result.chunk_overlap),
chunk_size: stringValue(result.chunk_size),
embedding_model: stringValue(result.embedding_model),
persist_path: stringValue(result.persist_path),
});
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Gagal menyimpan konfigurasi Vector DB.',
);
} finally {
setIsSubmitting(false);
}
}
return (
<Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
<CardContent className="pt-6">
<form
className="grid gap-4"
onSubmit={(e) => {
void handleSubmit(e);
}}
>
<ConfigError message={error} />
<div className="grid gap-3 md:grid-cols-2">
<Field
hint="Model yang mengubah teks menjadi vektor untuk pencarian semantik, contoh: text-embedding-3-small."
label="Embedding Model"
>
<Input
value={formData.embedding_model}
onChange={(event) =>
setField(
'embedding_model',
event.target.value,
)
}
/>
</Field>
<Field
hint="Lokasi folder penyimpanan database vektor di server."
label="Persist Path"
>
<Input
value={formData.persist_path}
onChange={(event) =>
setField('persist_path', event.target.value)
}
/>
</Field>
<Field
hint="Ukuran potongan teks dokumen saat diindeks (dalam karakter). Makin besar, makin banyak konteks per potongan."
label="Chunk Size"
>
<Input
min="1"
type="number"
value={formData.chunk_size}
onChange={(event) =>
setField('chunk_size', event.target.value)
}
/>
</Field>
<Field
hint="Jumlah karakter yang tumpang tindih antar potongan agar konteks tidak terputus di batas potongan."
label="Chunk Overlap"
>
<Input
min="0"
type="number"
value={formData.chunk_overlap}
onChange={(event) =>
setField('chunk_overlap', event.target.value)
}
/>
</Field>
</div>
<div className="flex justify-end">
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? (
<Spinner className="size-4" />
) : (
<Save className="size-4" />
)}
Simpan Vector
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
export function AdminRetrievalForm({
retrieval,
}: {
retrieval?: RetrievalConfig | null;
}) {
const cfg = retrieval ?? {};
const [formData, setFormData] = useState<RetrievalForm>({
bm25_weight: stringValue(cfg.bm25_weight),
candidate_pool_size: stringValue(cfg.candidate_pool_size),
dense_weight: stringValue(cfg.dense_weight),
enable_reranker: Boolean(cfg.enable_reranker),
history_turns: stringValue(cfg.history_turns),
lexical_weight: stringValue(cfg.lexical_weight),
max_context_chars: stringValue(cfg.max_context_chars),
neighbor_window: stringValue(cfg.neighbor_window),
reranker_model: stringValue(cfg.reranker_model),
similarity_threshold: stringValue(cfg.similarity_threshold),
top_k: stringValue(cfg.top_k),
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | undefined>();
function setField<K extends keyof RetrievalForm>(
key: K,
value: RetrievalForm[K],
): void {
setFormData((prev) => ({ ...prev, [key]: value }));
}
async function handleSubmit(
event: FormEvent<HTMLFormElement>,
): Promise<void> {
event.preventDefault();
setIsSubmitting(true);
setError(undefined);
try {
const payload: Record<string, unknown> = {
enable_reranker: formData.enable_reranker,
};
if (formData.top_k) payload.top_k = parseInt(formData.top_k, 10);
if (formData.max_context_chars) payload.max_context_chars = parseInt(formData.max_context_chars, 10);
if (formData.bm25_weight) payload.bm25_weight = parseFloat(formData.bm25_weight);
if (formData.dense_weight) payload.dense_weight = parseFloat(formData.dense_weight);
if (formData.lexical_weight) payload.lexical_weight = parseFloat(formData.lexical_weight);
if (formData.similarity_threshold) payload.similarity_threshold = parseFloat(formData.similarity_threshold);
if (formData.history_turns) payload.history_turns = parseInt(formData.history_turns, 10);
if (formData.candidate_pool_size) payload.candidate_pool_size = parseInt(formData.candidate_pool_size, 10);
if (formData.neighbor_window) payload.neighbor_window = parseInt(formData.neighbor_window, 10);
if (formData.reranker_model) payload.reranker_model = formData.reranker_model;
const result = await patchRetrievalConfig(payload);
setFormData({
bm25_weight: stringValue(result.bm25_weight),
candidate_pool_size: stringValue(result.candidate_pool_size),
dense_weight: stringValue(result.dense_weight),
enable_reranker: Boolean(result.enable_reranker),
history_turns: stringValue(result.history_turns),
lexical_weight: stringValue(result.lexical_weight),
max_context_chars: stringValue(result.max_context_chars),
neighbor_window: stringValue(result.neighbor_window),
reranker_model: stringValue(result.reranker_model),
similarity_threshold: stringValue(result.similarity_threshold),
top_k: stringValue(result.top_k),
});
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Gagal menyimpan konfigurasi retrieval.',
);
} finally {
setIsSubmitting(false);
}
}
return (
<Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
<CardContent className="pt-6">
<form
className="grid gap-4"
onSubmit={(e) => {
void handleSubmit(e);
}}
>
<ConfigError message={error} />
<div className="grid gap-3 md:grid-cols-2">
<RetrievalNumberField
hint="Jumlah dokumen relevan yang diambil dari database per pertanyaan."
label="Top K"
value={formData.top_k}
onChange={(v) => setField('top_k', v)}
/>
<RetrievalNumberField
hint="Batas maksimum karakter konteks dokumen yang dikirim ke model AI."
label="Max Context Chars"
value={formData.max_context_chars}
onChange={(v) => setField('max_context_chars', v)}
/>
<RetrievalNumberField
hint="Bobot pencarian berbasis kata kunci (0–1). Tinggi = lebih mengutamakan kecocokan kata."
label="BM25 Weight"
step="0.01"
value={formData.bm25_weight}
onChange={(v) => setField('bm25_weight', v)}
/>
<RetrievalNumberField
hint="Bobot pencarian berbasis makna/semantik (0–1). Tinggi = lebih mengutamakan kesamaan makna."
label="Dense Weight"
step="0.01"
value={formData.dense_weight}
onChange={(v) => setField('dense_weight', v)}
/>
<RetrievalNumberField
hint="Bobot pencarian leksikal untuk keseimbangan antara kata kunci dan semantik."
label="Lexical Weight"
step="0.01"
value={formData.lexical_weight}
onChange={(v) => setField('lexical_weight', v)}
/>
<RetrievalNumberField
hint="Ambang batas skor kemiripan minimum (0–1). Dokumen di bawah nilai ini diabaikan."
label="Similarity Threshold"
step="0.01"
value={formData.similarity_threshold}
onChange={(v) =>
setField('similarity_threshold', v)
}
/>
<RetrievalNumberField
hint="Jumlah riwayat percakapan sebelumnya yang disertakan sebagai konteks pertanyaan."
label="History Turns"
value={formData.history_turns}
onChange={(v) => setField('history_turns', v)}
/>
<RetrievalNumberField
hint="Jumlah kandidat dokumen awal sebelum proses reranking dilakukan."
label="Candidate Pool"
value={formData.candidate_pool_size}
onChange={(v) =>
setField('candidate_pool_size', v)
}
/>
<RetrievalNumberField
hint="Jumlah dokumen tetangga yang disertakan untuk memperluas konteks jawaban."
label="Neighbor Window"
value={formData.neighbor_window}
onChange={(v) => setField('neighbor_window', v)}
/>
<Field
hint="Nama model yang mengurutkan ulang hasil pencarian agar paling relevan di urutan teratas."
label="Reranker Model"
>
<Input
value={formData.reranker_model}
onChange={(event) =>
setField(
'reranker_model',
event.target.value,
)
}
/>
</Field>
</div>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={formData.enable_reranker}
onCheckedChange={(value) =>
setField('enable_reranker', value === true)
}
/>
<span>
Enable reranker
<span className="ml-1 text-xs text-muted-foreground">
— aktifkan untuk mengurutkan ulang hasil pencarian agar lebih akurat
</span>
</span>
</label>
<div className="flex justify-end">
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? (
<Spinner className="size-4" />
) : (
<Save className="size-4" />
)}
Simpan Retrieval
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
export function AdminVectorRetrievalForms({
retrieval,
vectorDb,
}: {
retrieval?: RetrievalConfig | null;
vectorDb?: VectorDbConfig | null;
}) {
return (
<section className="grid gap-6 xl:grid-cols-2">
<AdminVectorDbForm vectorDb={vectorDb} />
<AdminRetrievalForm retrieval={retrieval} />
</section>
);
}