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
14 changes: 9 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@ This roadmap tracks the learning-focused agent system work. It is not user-facin
- Keep Supabase adapter as default implementation
- Expand adapter contract tests for neutrality

### Epic 6: Retrieval Engine Adapter
- Introduce `Retriever` interface (current RAG vs external engines)
- Adapter 1: existing vector+text retrieval
- Adapter 2: LlamaIndex (optional) for top-k + rerank
- Standardize output: citations + similarity + retrieval_source
### Epic 6: RAG v2 (Vector-only, incremental, multi-tenant)
- Remove LlamaIndex adapter + dependency (in-memory index is not scalable)
- Vector retrieval via `match_kb_chunks` only (top_k default 10)
- Selector: dedupe + diversity + 2–4 chunks max
- Optional rerank (listwise) behind feature flag + similarity heuristics
- LLM answer generation from selected chunks with structured citations
- Standardize retriever output: reply + citations + confidence + meta
- Telemetry for rerank/generation timings + top_similarity distribution
- Tests: selector, rerank fallback, generation fallback

### Epic 4b: Adapter Completion (Supabase)
- Migrate remaining endpoints to repo interfaces (KB, tickets, orgs, runs)
Expand Down
45 changes: 41 additions & 4 deletions apps/web/app/api/kb/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ const buildUrl = (path: string) => {

export async function GET(
request: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const docId = encodeURIComponent(params.id);
const { id } = await params;
const docId = encodeURIComponent(id);
const orgId = request.headers.get("x-org-id");
const cookieStore = await cookies();
const token = cookieStore.get("sb_access_token")?.value;
Expand All @@ -42,11 +43,12 @@ export async function GET(

export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const payload = await request.json();
const docId = encodeURIComponent(params.id);
const { id } = await params;
const docId = encodeURIComponent(id);
const orgId = request.headers.get("x-org-id");
const cookieStore = await cookies();
const token = cookieStore.get("sb_access_token")?.value;
Expand Down Expand Up @@ -74,3 +76,38 @@ export async function PATCH(
);
}
}

export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const docId = encodeURIComponent(id);
const orgId = request.headers.get("x-org-id");
const cookieStore = await cookies();
const token = cookieStore.get("sb_access_token")?.value;
const headers: Record<string, string> = {};
if (orgId) {
headers["X-Org-Id"] = orgId;
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(buildUrl(`/v1/kb/${docId}`), {
method: "DELETE",
headers,
cache: "no-store",
});
if (response.status === 204) {
return new NextResponse(null, { status: 204 });
}
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
return NextResponse.json(
{ detail: "agent_unavailable" },
{ status: 502 }
);
}
}
167 changes: 150 additions & 17 deletions apps/web/app/kb/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { Check, Trash2, X } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";

import { readOrgIdCookie } from "../../lib/org";

Expand Down Expand Up @@ -35,6 +37,8 @@ export default function KbPage() {
const [form, setForm] = useState<KbForm>(emptyForm);
const [status, setStatus] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const searchParams = useSearchParams();
const docParam = searchParams.get("doc");

Expand Down Expand Up @@ -80,6 +84,7 @@ export default function KbPage() {
setSelectedId(null);
setForm(emptyForm);
setStatus(null);
setConfirmDeleteId(null);
};

const selectDoc = (doc: KbDoc) => {
Expand All @@ -90,6 +95,7 @@ export default function KbPage() {
tags: doc.tags.join(", "),
});
setStatus(null);
setConfirmDeleteId(null);
};

const saveDoc = async (event: React.FormEvent<HTMLFormElement>) => {
Expand Down Expand Up @@ -132,14 +138,61 @@ export default function KbPage() {
await loadDocs();
if (!selectedId) {
startNew();
toast.success("Article created.");
} else {
setStatus("Saved.");
toast.success("Article updated.");
}
} catch (error) {
setStatus("Could not save the article.");
toast.error("Could not save the article.");
}
};

const deleteDocById = async (docId: string) => {
setStatus(null);
setIsDeleting(true);
try {
const orgId = readOrgIdCookie();
const headers: Record<string, string> = {};
if (orgId) {
headers["X-Org-Id"] = orgId;
}
const response = await fetch(`/api/kb/${docId}`, {
method: "DELETE",
headers,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || "KB delete failed");
}
await loadDocs();
if (selectedId === docId) {
startNew();
}
toast.success("Article deleted.");
} catch (error) {
toast.error("Could not delete the article.");
} finally {
setIsDeleting(false);
setConfirmDeleteId(null);
}
};

const deleteDoc = async () => {
if (!selectedId) {
return;
}
await deleteDocById(selectedId);
};

const requestDelete = (docId: string) => {
setConfirmDeleteId(docId);
setStatus(null);
};

const cancelDelete = () => {
setConfirmDeleteId(null);
};

return (
<div className="mx-auto flex min-h-screen max-w-6xl flex-col gap-8 px-6 py-12">
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
Expand Down Expand Up @@ -180,21 +233,69 @@ export default function KbPage() {
<p>No articles yet. Create the first one.</p>
)}
{docs.map((doc) => (
<button
<div
key={doc.id}
type="button"
onClick={() => selectDoc(doc)}
className={`w-full rounded-2xl border px-4 py-3 text-left transition ${
className={`flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition ${
selectedId === doc.id
? "border-ink bg-white"
: "border-line bg-white/70 hover:border-ink/40"
}`}
>
<p className="font-medium text-ink">{doc.title}</p>
<p className="mt-1 text-xs text-ink/50">
Tags: {doc.tags.length ? doc.tags.join(", ") : "none"}
</p>
</button>
<button
type="button"
onClick={() => selectDoc(doc)}
className="flex-1 text-left"
>
<p className="font-medium text-ink">{doc.title}</p>
<p className="mt-1 text-xs text-ink/50">
Tags: {doc.tags.length ? doc.tags.join(", ") : "none"}
</p>
</button>
<button
type="button"
aria-label={`Delete ${doc.title}`}
onClick={(event) => {
event.stopPropagation();
requestDelete(doc.id);
}}
className={`flex h-8 w-8 items-center justify-center rounded-full border text-ink/60 transition hover:border-ink/40 ${
confirmDeleteId === doc.id
? "hidden"
: "border-line"
}`}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
</button>
{confirmDeleteId === doc.id && (
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`Confirm delete ${doc.title}`}
onClick={(event) => {
event.stopPropagation();
void deleteDocById(doc.id);
}}
className="flex h-8 w-8 items-center justify-center rounded-full border border-red-200 text-red-500 transition hover:border-red-300"
disabled={isDeleting}
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
aria-label={`Cancel delete ${doc.title}`}
onClick={(event) => {
event.stopPropagation();
cancelDelete();
}}
className="flex h-8 w-8 items-center justify-center rounded-full border border-line text-ink/60 transition hover:border-ink/40"
disabled={isDeleting}
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
))}
</div>
</div>
Expand Down Expand Up @@ -248,12 +349,44 @@ export default function KbPage() {
/>
</div>
{status && <p className="text-sm text-ink/60">{status}</p>}
<button
type="submit"
className="h-11 rounded-2xl bg-ink px-6 text-sm font-medium text-paper"
>
{selectedId ? "Save changes" : "Create article"}
</button>
<div className="flex flex-wrap gap-3">
<button
type="submit"
className="h-11 rounded-2xl bg-ink px-6 text-sm font-medium text-paper"
>
{selectedId ? "Save changes" : "Create article"}
</button>
{selectedId && confirmDeleteId !== selectedId && (
<button
type="button"
onClick={() => requestDelete(selectedId)}
className="h-11 rounded-2xl border border-red-200 px-6 text-sm font-medium text-red-500"
disabled={isDeleting}
>
Delete article
</button>
)}
{selectedId && confirmDeleteId === selectedId && (
<>
<button
type="button"
onClick={deleteDoc}
className="h-11 rounded-2xl bg-red-500 px-6 text-sm font-medium text-white"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Confirm delete"}
</button>
<button
type="button"
onClick={cancelDelete}
className="h-11 rounded-2xl border border-line px-6 text-sm font-medium text-ink/70"
disabled={isDeleting}
>
Cancel
</button>
</>
)}
</div>
</form>
</div>
</section>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { DM_Mono, Sora } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
import NavBar from "../components/NavBar";

Expand Down Expand Up @@ -30,6 +31,7 @@ export default function RootLayout({
<body className={`${sora.variable} ${dmMono.variable} antialiased`}>
<NavBar />
{children}
<Toaster position="bottom-right" richColors />
</body>
</html>
);
Expand Down
14 changes: 12 additions & 2 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ type ChatMessage = {
action?: "reply" | "ask_clarifying" | "create_ticket" | "escalate";
confidence?: number;
ticket_id?: string | null;
citations?: { kb_document_id: string; kb_chunk_id?: string }[] | null;
citations?: {
kb_document_id: string;
kb_chunk_id?: string;
source?: string;
score?: number;
}[] | null;
decision_reason?: string | null;
decision_source?: string | null;
guardrail?: string | null;
Expand Down Expand Up @@ -204,7 +209,12 @@ export default function Home() {
action: ChatMessage["action"];
confidence: number;
ticket_id?: string | null;
citations?: { kb_document_id: string; kb_chunk_id?: string }[] | null;
citations?: {
kb_document_id: string;
kb_chunk_id?: string;
source?: string;
score?: number;
}[] | null;
decision_reason?: string | null;
decision_source?: string | null;
guardrail?: string | null;
Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
"lint": "eslint"
},
"dependencies": {
"lucide-react": "^0.539.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"sonner": "^1.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Expand Down
Loading