Studio: re-ingest existing KBs / threads with new settings (Backfill UX)
Closes the upgrade-path gap from Phase 3: a KB or thread whose chunks
were ingested under one strategy can now be rebuilt under a different
one without losing the uploaded files.
Backend
- routes/rag.py:
- POST /api/rag/knowledge-bases/{kb_id}/reingest takes optional
chunking_strategy / mode / embedding_model in the body. Validates
the (multimodal, late) constraint via _validate_mode_combo, updates
the rag_knowledge_bases row, wipes scope artifacts (sqlite chunks
via cascade, Qdrant collection, bm25), and re-INSERTs a fresh
rag_documents row + ingestion job per stored file. Returns the new
job IDs so callers can stream progress via the existing SSE.
- POST /api/rag/threads/{thread_id}/reingest is the simpler thread
variant — no body, rebuilds with current defaults.
- Shared _reingest_scope helper strips the UUID upload prefix when
re-naming docs so users see the original filenames again.
Frontend
- rag-api.ts: reingestKnowledgeBase(kbId, opts) and
reingestThreadDocuments(threadId) wrappers + ReingestResponse type.
- rag-store.ts: reingestKB / reingestThread actions refresh the KB +
doc lists and subscribe to every returned job so the existing
IngestionProgress chips render without further wiring.
- kb-reconfigure-dialog.tsx (new): mirrors KBCreateDialog but
pre-fills with the KB's current strategy / mode / embedder, enforces
the same (multimodal + late) constraint with disabled options, and
confirms before submitting. Submit label flips between "Re-index"
(no settings change) and "Reconfigure & re-index".
- kb-detail-panel.tsx: header gains the chunking + mode summary and
a "Reconfigure…" button that opens the dialog. Button is disabled
when the KB has no documents.
- chat-settings-sheet.tsx Retrieval section: "Re-index" button beside
the existing "Clear thread index" when the thread has documents.
Tests
- test_rag_reingest.py: ReingestKBRequest accepts optional fields,
rejects unknown enum values via Pydantic, and the shared mode-combo
guard still bites on the reingest path.
This commit is contained in:
parent
68114fd223
commit
d9dfc7db80
7 changed files with 564 additions and 24 deletions
|
|
@ -407,6 +407,162 @@ def list_knowledge_bases(
|
|||
return KBListResponse(knowledge_bases = [_row_to_kb(r) for r in rows])
|
||||
|
||||
|
||||
class ReingestKBRequest(BaseModel):
|
||||
"""All fields optional — omitting one keeps the KB's current value."""
|
||||
chunking_strategy: ChunkingStrategy | None = None
|
||||
mode: KBMode | None = None
|
||||
embedding_model: str | None = None
|
||||
|
||||
|
||||
class ReingestResponse(BaseModel):
|
||||
job_ids: list[str]
|
||||
document_ids: list[str]
|
||||
|
||||
|
||||
def _reingest_scope(
|
||||
*,
|
||||
kb_id: str | None,
|
||||
thread_id: str | None,
|
||||
chunking_strategy: str,
|
||||
mode: str,
|
||||
embedding_model: str,
|
||||
) -> ReingestResponse:
|
||||
"""Wipe scope artifacts and re-enqueue every stored document.
|
||||
|
||||
The chat_settings / per-thread defaults aren't touched — caller is
|
||||
responsible for updating any associated metadata before calling.
|
||||
"""
|
||||
scope = kb_scope(kb_id) if kb_id else thread_scope(thread_id) # type: ignore[arg-type]
|
||||
with get_connection() as conn:
|
||||
if kb_id:
|
||||
rows = conn.execute(
|
||||
"SELECT id, stored_path FROM rag_documents WHERE kb_id = ?",
|
||||
(kb_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, stored_path FROM rag_documents WHERE thread_id = ?",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
# Delete the rag_documents rows (cascade drops chunks); the
|
||||
# uploaded file on disk is preserved so we can re-ingest from
|
||||
# it. We re-INSERT a fresh row per stored_path below.
|
||||
doc_ids = [r["id"] for r in rows]
|
||||
if doc_ids:
|
||||
placeholders = ",".join("?" for _ in doc_ids)
|
||||
conn.execute(
|
||||
f"DELETE FROM rag_documents WHERE id IN ({placeholders})",
|
||||
doc_ids,
|
||||
)
|
||||
conn.commit()
|
||||
ingestion.delete_scope_artifacts(scope)
|
||||
|
||||
job_ids: list[str] = []
|
||||
new_doc_ids: list[str] = []
|
||||
for row in rows:
|
||||
stored_path = Path(row["stored_path"])
|
||||
if not stored_path.is_file():
|
||||
continue
|
||||
filename = stored_path.name
|
||||
# Strip the UUID prefix we attached at upload time so the
|
||||
# re-inserted document carries the original name.
|
||||
if "_" in filename:
|
||||
_uuid_prefix, _, original = filename.partition("_")
|
||||
if original:
|
||||
filename = original
|
||||
upload = _start_ingestion(
|
||||
filename = filename,
|
||||
stored_path = stored_path,
|
||||
byte_size = stored_path.stat().st_size,
|
||||
content_type = None,
|
||||
kb_id = kb_id,
|
||||
thread_id = thread_id,
|
||||
embedding_model = embedding_model,
|
||||
chunking_strategy = chunking_strategy,
|
||||
mode = mode,
|
||||
)
|
||||
job_ids.append(upload.job_id)
|
||||
new_doc_ids.append(upload.document_id)
|
||||
return ReingestResponse(job_ids = job_ids, document_ids = new_doc_ids)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/knowledge-bases/{kb_id}/reingest",
|
||||
response_model = ReingestResponse,
|
||||
)
|
||||
def reingest_knowledge_base(
|
||||
kb_id: str,
|
||||
payload: ReingestKBRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ReingestResponse:
|
||||
from utils.rag.config import resolve_embedder
|
||||
|
||||
kb_row = _kb_or_404(kb_id)
|
||||
keys = kb_row.keys() if hasattr(kb_row, "keys") else ()
|
||||
current_strategy = (
|
||||
kb_row["chunking_strategy"]
|
||||
if "chunking_strategy" in keys
|
||||
else "standard"
|
||||
)
|
||||
current_mode = kb_row["mode"] if "mode" in keys else "text"
|
||||
current_embedder = kb_row["embedding_model"]
|
||||
|
||||
new_strategy = payload.chunking_strategy or current_strategy
|
||||
new_mode = payload.mode or current_mode
|
||||
_validate_mode_combo(new_mode, new_strategy)
|
||||
|
||||
new_embedder = (
|
||||
payload.embedding_model
|
||||
or (
|
||||
current_embedder
|
||||
if (new_strategy == current_strategy and new_mode == current_mode)
|
||||
else resolve_embedder(new_mode, new_strategy)
|
||||
)
|
||||
)
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE rag_knowledge_bases
|
||||
SET chunking_strategy = ?, mode = ?, embedding_model = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(new_strategy, new_mode, new_embedder, kb_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return _reingest_scope(
|
||||
kb_id = kb_id,
|
||||
thread_id = None,
|
||||
chunking_strategy = new_strategy,
|
||||
mode = new_mode,
|
||||
embedding_model = new_embedder,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/threads/{thread_id}/reingest",
|
||||
response_model = ReingestResponse,
|
||||
)
|
||||
def reingest_thread_documents(
|
||||
thread_id: str,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ReingestResponse:
|
||||
"""Rebuild a thread's RAG index using the current defaults.
|
||||
|
||||
No body — per-thread strategy/mode overrides aren't exposed in v1.
|
||||
"""
|
||||
from utils.rag.config import RAG_EMBEDDING_MODEL
|
||||
|
||||
return _reingest_scope(
|
||||
kb_id = None,
|
||||
thread_id = thread_id,
|
||||
chunking_strategy = "standard",
|
||||
mode = "text",
|
||||
embedding_model = RAG_EMBEDDING_MODEL,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/knowledge-bases/{kb_id}")
|
||||
def delete_knowledge_base(
|
||||
kb_id: str,
|
||||
|
|
|
|||
|
|
@ -447,6 +447,7 @@ export function ChatSettingsPanel({
|
|||
ragSource.kind === "thread" ? activeThreadId : null,
|
||||
);
|
||||
const clearThreadIndex = useRagStore((s) => s.clearThreadIndex);
|
||||
const reingestThread = useRagStore((s) => s.reingestThread);
|
||||
const [kbCreateOpen, setKbCreateOpen] = useState(false);
|
||||
const ragEnabled = ragSource.kind !== "off";
|
||||
const activeKbId = ragSource.kind === "kb" ? ragSource.kbId : null;
|
||||
|
|
@ -1332,22 +1333,39 @@ export function ChatSettingsPanel({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Delete all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} from this thread? This cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
void clearThreadIndex(activeThreadId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear thread index
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Re-index all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"}? Existing chunks will be deleted and rebuilt; search will be unavailable until ingestion finishes.`,
|
||||
)
|
||||
) {
|
||||
void reingestThread(activeThreadId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Re-index
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Delete all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} from this thread? This cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
void clearThreadIndex(activeThreadId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear thread index
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -206,6 +206,46 @@ export async function clearThreadDocuments(threadId: string): Promise<void> {
|
|||
await throwOnError(response);
|
||||
}
|
||||
|
||||
export interface ReingestResponse {
|
||||
job_ids: string[];
|
||||
document_ids: string[];
|
||||
}
|
||||
|
||||
export interface ReingestKBOptions {
|
||||
chunking_strategy?: ChunkingStrategy;
|
||||
mode?: KBMode;
|
||||
embedding_model?: string;
|
||||
}
|
||||
|
||||
export async function reingestKnowledgeBase(
|
||||
kbId: string,
|
||||
opts: ReingestKBOptions = {},
|
||||
): Promise<ReingestResponse> {
|
||||
const response = await authFetch(
|
||||
`/api/rag/knowledge-bases/${encodeURIComponent(kbId)}/reingest`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(opts),
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow<ReingestResponse>(response);
|
||||
}
|
||||
|
||||
export async function reingestThreadDocuments(
|
||||
threadId: string,
|
||||
): Promise<ReingestResponse> {
|
||||
const response = await authFetch(
|
||||
`/api/rag/threads/${encodeURIComponent(threadId)}/reingest`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow<ReingestResponse>(response);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Search
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useState } from "react";
|
||||
|
|
@ -9,12 +10,14 @@ import { useKBDocuments } from "../hooks/use-kb-documents";
|
|||
import { DocumentRow } from "./document-row";
|
||||
import { DocumentUploadDropzone } from "./document-upload-dropzone";
|
||||
import { IngestionProgress } from "./ingestion-progress";
|
||||
import { KBReconfigureDialog } from "./kb-reconfigure-dialog";
|
||||
|
||||
export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
|
||||
const { documents, loading, error, upload, remove } = useKBDocuments(kb.id);
|
||||
const [activeJobsByDoc, setActiveJobsByDoc] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [reconfigureOpen, setReconfigureOpen] = useState(false);
|
||||
|
||||
const handleFiles = async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
|
|
@ -29,14 +32,31 @@ export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold">{kb.name}</h2>
|
||||
{kb.description ? (
|
||||
<p className="text-sm text-muted-foreground">{kb.description}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Embedding model: <code>{kb.embedding_model}</code>
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold">{kb.name}</h2>
|
||||
{kb.description ? (
|
||||
<p className="text-sm text-muted-foreground">{kb.description}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""}
|
||||
{kb.chunking_strategy === "late" ? "⚡ Late · " : ""}
|
||||
Embedder: <code>{kb.embedding_model}</code>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReconfigureOpen(true)}
|
||||
disabled={documents.length === 0}
|
||||
title={
|
||||
documents.length === 0
|
||||
? "Upload at least one document before re-indexing"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Reconfigure…
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DocumentUploadDropzone onFiles={handleFiles} />
|
||||
|
|
@ -82,6 +102,13 @@ export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
|
|||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<KBReconfigureDialog
|
||||
open={reconfigureOpen}
|
||||
onOpenChange={setReconfigureOpen}
|
||||
kb={kb}
|
||||
documentCount={documents.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
ChunkingStrategy,
|
||||
KBMode,
|
||||
KnowledgeBase,
|
||||
} from "../api/rag-api";
|
||||
import { useRagStore } from "../stores/rag-store";
|
||||
|
||||
export function KBReconfigureDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
kb,
|
||||
documentCount,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kb: KnowledgeBase;
|
||||
documentCount: number;
|
||||
}) {
|
||||
const reingestKB = useRagStore((s) => s.reingestKB);
|
||||
const [chunkingStrategy, setChunkingStrategy] = useState<ChunkingStrategy>(
|
||||
kb.chunking_strategy,
|
||||
);
|
||||
const [mode, setMode] = useState<KBMode>(kb.mode);
|
||||
const [embeddingModel, setEmbeddingModel] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-sync when the dialog opens against a different KB.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setChunkingStrategy(kb.chunking_strategy);
|
||||
setMode(kb.mode);
|
||||
setEmbeddingModel("");
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [open, kb.id, kb.chunking_strategy, kb.mode]);
|
||||
|
||||
const lateDisabled = mode === "multimodal";
|
||||
const multimodalDisabled = chunkingStrategy === "late";
|
||||
|
||||
const placeholderEmbedder =
|
||||
mode === "multimodal"
|
||||
? `Current: ${kb.embedding_model}`
|
||||
: chunkingStrategy === "late"
|
||||
? `Current: ${kb.embedding_model}`
|
||||
: `Current: ${kb.embedding_model}`;
|
||||
|
||||
const changedSettings =
|
||||
chunkingStrategy !== kb.chunking_strategy ||
|
||||
mode !== kb.mode ||
|
||||
embeddingModel.trim() !== "";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
const verb = changedSettings ? "Reconfigure and re-index" : "Re-index";
|
||||
if (
|
||||
!window.confirm(
|
||||
`${verb} ${documentCount} document${documentCount === 1 ? "" : "s"}? ` +
|
||||
`Existing chunks will be deleted and rebuilt from the original files. ` +
|
||||
`Search will be unavailable until ingestion finishes.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await reingestKB(kb.id, {
|
||||
chunking_strategy: chunkingStrategy,
|
||||
mode,
|
||||
embedding_model: embeddingModel.trim() || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reconfigure “{kb.name}”</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the chunking strategy, mode, or embedder for this KB.
|
||||
All {documentCount} document{documentCount === 1 ? "" : "s"}{" "}
|
||||
will be re-ingested from the originals on disk.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reconf-mode">Mode</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as KBMode)}
|
||||
>
|
||||
<SelectTrigger id="reconf-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text only</SelectItem>
|
||||
<SelectItem
|
||||
value="multimodal"
|
||||
disabled={multimodalDisabled}
|
||||
title={
|
||||
multimodalDisabled
|
||||
? "Multimodal cannot be combined with late chunking"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Multimodal — text + images
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reconf-strategy">Chunking strategy</Label>
|
||||
<Select
|
||||
value={chunkingStrategy}
|
||||
onValueChange={(v) => setChunkingStrategy(v as ChunkingStrategy)}
|
||||
>
|
||||
<SelectTrigger id="reconf-strategy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">
|
||||
Standard — heading-aware recursive splitter
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="late"
|
||||
disabled={lateDisabled}
|
||||
title={
|
||||
lateDisabled
|
||||
? "Late chunking cannot be combined with multimodal mode"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Late chunking — single-pass embedder
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reconf-model">Embedding model (optional)</Label>
|
||||
<Input
|
||||
id="reconf-model"
|
||||
value={embeddingModel}
|
||||
onChange={(e) => setEmbeddingModel(e.target.value)}
|
||||
placeholder={placeholderEmbedder}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave blank to keep the current model (or pick the matrix
|
||||
default when mode/strategy changes).
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? "Re-indexing…"
|
||||
: changedSettings
|
||||
? "Reconfigure & re-index"
|
||||
: "Re-index"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
listThreadDocuments,
|
||||
listThreadIndexes,
|
||||
type RagDocument,
|
||||
type ReingestKBOptions,
|
||||
reingestKnowledgeBase as apiReingestKB,
|
||||
reingestThreadDocuments as apiReingestThread,
|
||||
subscribeToJobEvents,
|
||||
type ThreadIndexSummary,
|
||||
uploadKBDocument,
|
||||
|
|
@ -51,6 +54,9 @@ interface RagStoreState {
|
|||
loadThreadIndexes: () => Promise<void>;
|
||||
clearThreadIndex: (threadId: string) => Promise<void>;
|
||||
|
||||
reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise<string[]>;
|
||||
reingestThread: (threadId: string) => Promise<string[]>;
|
||||
|
||||
subscribeJob: (jobId: string, onComplete?: () => void) => void;
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +226,35 @@ export const useRagStore = create<RagStoreState>((set, get) => ({
|
|||
});
|
||||
},
|
||||
|
||||
async reingestKB(kbId, opts) {
|
||||
const response = await apiReingestKB(kbId, opts ?? {});
|
||||
// Refresh the KB list so updated chunking_strategy / mode / embedder
|
||||
// values flow back into the UI, and the doc list so old chunk
|
||||
// counts reset to 0 until each job completes.
|
||||
void get().loadKnowledgeBases();
|
||||
void get().loadKBDocuments(kbId);
|
||||
// Subscribe to every new job so progress chips render and the doc
|
||||
// list refreshes on completion (mirrors uploadDocument's pattern).
|
||||
for (const jobId of response.job_ids) {
|
||||
get().subscribeJob(jobId, () => {
|
||||
void get().loadKBDocuments(kbId);
|
||||
});
|
||||
}
|
||||
return response.job_ids;
|
||||
},
|
||||
|
||||
async reingestThread(threadId) {
|
||||
const response = await apiReingestThread(threadId);
|
||||
void get().loadThreadDocuments(threadId);
|
||||
void get().loadThreadIndexes();
|
||||
for (const jobId of response.job_ids) {
|
||||
get().subscribeJob(jobId, () => {
|
||||
void get().loadThreadDocuments(threadId);
|
||||
});
|
||||
}
|
||||
return response.job_ids;
|
||||
},
|
||||
|
||||
subscribeJob(jobId, onComplete) {
|
||||
const existing = get().jobUnsubscribers[jobId];
|
||||
if (existing) return;
|
||||
|
|
|
|||
57
tests/python/test_rag_reingest.py
Normal file
57
tests/python/test_rag_reingest.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Reingest endpoint tests (Backfill UX).
|
||||
|
||||
Full end-to-end reingest needs a running studio + a real embedder; that's
|
||||
covered manually via the curl smoke flow in the plan. Here we cover the
|
||||
parts that are testable without external models: payload validation and
|
||||
the (multimodal, late) constraint propagation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STUDIO_BACKEND = REPO_ROOT / "studio" / "backend"
|
||||
if str(STUDIO_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(STUDIO_BACKEND))
|
||||
|
||||
|
||||
def test_reingest_request_accepts_all_optional_fields():
|
||||
from routes.rag import ReingestKBRequest
|
||||
|
||||
empty = ReingestKBRequest()
|
||||
assert empty.chunking_strategy is None
|
||||
assert empty.mode is None
|
||||
assert empty.embedding_model is None
|
||||
|
||||
partial = ReingestKBRequest(chunking_strategy = "late")
|
||||
assert partial.chunking_strategy == "late"
|
||||
assert partial.mode is None
|
||||
|
||||
|
||||
def test_reingest_request_rejects_unknown_strategy():
|
||||
from routes.rag import ReingestKBRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ReingestKBRequest(chunking_strategy = "telekinetic")
|
||||
|
||||
|
||||
def test_reingest_request_rejects_unknown_mode():
|
||||
from routes.rag import ReingestKBRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ReingestKBRequest(mode = "augmented")
|
||||
|
||||
|
||||
def test_constraint_still_enforced_for_reingest_combos():
|
||||
"""The combination guard is shared with create — verify it still bites."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routes.rag import _validate_mode_combo
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
_validate_mode_combo("multimodal", "late")
|
||||
assert excinfo.value.status_code == 400
|
||||
Loading…
Add table
Add a link
Reference in a new issue