Studio: render only LLM-cited RAG chunks as Source badges; globally unique chunk IDs
This commit is contained in:
parent
d6a5abb4ba
commit
7c1f8efe99
5 changed files with 244 additions and 21 deletions
|
|
@ -9,12 +9,19 @@ so the model never sees KB UUIDs.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Literal
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Per-request chunk-id counter. Task-local (FastAPI runs each request in
|
||||
# its own asyncio task → its own Context). Lets the model cite chunks
|
||||
# unambiguously when multiple search_knowledge_base calls run in the
|
||||
# same chat turn: call 1 returns ids 1..N, call 2 returns N+1..N+M, etc.
|
||||
_chunk_id_counter: ContextVar[int] = ContextVar("rag_chunk_id_counter", default = 0)
|
||||
|
||||
|
||||
SEARCH_KNOWLEDGE_BASE_TOOL = {
|
||||
"type": "function",
|
||||
|
|
@ -27,7 +34,9 @@ SEARCH_KNOWLEDGE_BASE_TOOL = {
|
|||
"knowledge until you have called this tool with a focused query "
|
||||
"derived from the user's latest message. Returns chunks wrapped in "
|
||||
'<chunk id="N" source="..." page="..." score="...">...</chunk> '
|
||||
"tags; cite them in your reply as [1], [2], etc."
|
||||
"tags. CITE each chunk you use with its LITERAL id attribute, "
|
||||
'e.g. `<chunk id="7">` is cited as `[7]`. IDs are unique across '
|
||||
"all calls in this turn — never renumber, never reuse."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
@ -65,8 +74,12 @@ def _xml_attr(value: Any) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _format_hits_for_llm(hits: list[dict]) -> str:
|
||||
"""Render hits as fenced <chunk> blocks with metadata."""
|
||||
def _format_hits_for_llm(hits: list[dict], start_id: int = 0) -> str:
|
||||
"""Render hits as fenced <chunk> blocks with metadata.
|
||||
|
||||
``start_id`` offsets the citation id so multiple calls in the same
|
||||
request produce globally unique ids (call 1: 1..N, call 2: N+1..N+M).
|
||||
"""
|
||||
if not hits:
|
||||
return (
|
||||
"No matching chunks were found in the attached documents. "
|
||||
|
|
@ -74,7 +87,7 @@ def _format_hits_for_llm(hits: list[dict]) -> str:
|
|||
"have been ingested yet."
|
||||
)
|
||||
blocks: list[str] = []
|
||||
for index, hit in enumerate(hits, start = 1):
|
||||
for index, hit in enumerate(hits, start = start_id + 1):
|
||||
attrs = [
|
||||
f'id="{index}"',
|
||||
f'source="{_xml_attr(hit.get("filename") or "unknown")}"',
|
||||
|
|
@ -244,4 +257,7 @@ def search_knowledge_base(
|
|||
"chunk_index": hit.chunk_index,
|
||||
}
|
||||
)
|
||||
return _format_hits_for_llm(formatted)
|
||||
start_id = _chunk_id_counter.get()
|
||||
rendered = _format_hits_for_llm(formatted, start_id = start_id)
|
||||
_chunk_id_counter.set(start_id + len(formatted))
|
||||
return rendered
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type ComponentProps,
|
||||
type FC,
|
||||
} from "react";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import { useMessage } from "@assistant-ui/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge, badgeVariants, type BadgeProps } from "./badge";
|
||||
|
|
@ -126,13 +127,31 @@ function Source({
|
|||
|
||||
// ── Source badge with hover card ─────────────────────────────
|
||||
|
||||
interface SourceData {
|
||||
interface UrlSourceData {
|
||||
kind: "url";
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
|
||||
interface DocSourceData {
|
||||
kind: "document";
|
||||
chunkId: string;
|
||||
filename: string;
|
||||
page?: string;
|
||||
score?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type SourceData = UrlSourceData | DocSourceData;
|
||||
|
||||
function sourceKey(source: SourceData): string {
|
||||
return source.kind === "url"
|
||||
? `url:${source.url}`
|
||||
: `doc:${source.chunkId}`;
|
||||
}
|
||||
|
||||
const SourceBadge: FC<{ source: UrlSourceData }> = ({ source }) => {
|
||||
const domain = extractDomain(source.url);
|
||||
const displayTitle = source.title || domain;
|
||||
|
||||
|
|
@ -171,6 +190,54 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const DocumentSourceBadge: FC<{ source: DocSourceData }> = ({ source }) => {
|
||||
const metaParts: string[] = [];
|
||||
if (source.page) metaParts.push(`page ${source.page}`);
|
||||
if (source.score) metaParts.push(`score ${source.score}`);
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full cursor-default inline-flex items-center gap-1.5 outline-none"
|
||||
>
|
||||
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
|
||||
[{source.chunkId}]
|
||||
</span>
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<SourceTitle>{source.filename}</SourceTitle>
|
||||
</Badge>
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="!bg-black !text-white !w-72 !p-3 !rounded-2xl !shadow-md !ring-0 !duration-0"
|
||||
style={{ animation: "none" }}
|
||||
>
|
||||
<div className="flex gap-2.5">
|
||||
<FileTextIcon className="size-4 mt-0.5 shrink-0 text-white/70" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-semibold leading-tight truncate">
|
||||
{source.filename}
|
||||
</p>
|
||||
{metaParts.length > 0 ? (
|
||||
<p className="text-xs text-white/60 truncate">
|
||||
{metaParts.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-white/70 leading-relaxed line-clamp-3 whitespace-pre-wrap">
|
||||
{source.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Grouped sources with 2-row collapse ─────────────────────
|
||||
|
||||
const SourcesGroup: FC = () => {
|
||||
|
|
@ -179,7 +246,7 @@ const SourcesGroup: FC = () => {
|
|||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Extract source parts from the message
|
||||
// Extract source parts (both URL and document) from the message
|
||||
const sources: SourceData[] = [];
|
||||
if (message.content) {
|
||||
for (const part of message.content) {
|
||||
|
|
@ -191,11 +258,34 @@ const SourcesGroup: FC = () => {
|
|||
part.url
|
||||
) {
|
||||
sources.push({
|
||||
kind: "url",
|
||||
url: part.url as string,
|
||||
title: (part as { title?: string }).title || "",
|
||||
description: (part as { metadata?: { description?: string } })
|
||||
.metadata?.description,
|
||||
});
|
||||
} else if (
|
||||
part.type === "source" &&
|
||||
"sourceType" in part &&
|
||||
(part as { sourceType?: string }).sourceType === "document"
|
||||
) {
|
||||
const docPart = part as {
|
||||
chunkId?: string;
|
||||
filename?: string;
|
||||
page?: string;
|
||||
score?: string;
|
||||
text?: string;
|
||||
};
|
||||
if (docPart.chunkId && docPart.filename) {
|
||||
sources.push({
|
||||
kind: "document",
|
||||
chunkId: docPart.chunkId,
|
||||
filename: docPart.filename,
|
||||
page: docPart.page,
|
||||
score: docPart.score,
|
||||
text: docPart.text ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -258,20 +348,39 @@ const SourcesGroup: FC = () => {
|
|||
className="flex w-full flex-wrap gap-1 invisible absolute pointer-events-none"
|
||||
>
|
||||
{sources.map((source) => (
|
||||
<span key={source.url} className="inline-block">
|
||||
<Source href={source.url}>
|
||||
<SourceIcon url={source.url} />
|
||||
<SourceTitle>{source.title || extractDomain(source.url)}</SourceTitle>
|
||||
</Source>
|
||||
<span key={sourceKey(source)} className="inline-block">
|
||||
{source.kind === "url" ? (
|
||||
<Source href={source.url}>
|
||||
<SourceIcon url={source.url} />
|
||||
<SourceTitle>
|
||||
{source.title || extractDomain(source.url)}
|
||||
</SourceTitle>
|
||||
</Source>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
|
||||
[{source.chunkId}]
|
||||
</span>
|
||||
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<SourceTitle>{source.filename}</SourceTitle>
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Visible container */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayedSources.map((source) => (
|
||||
<SourceBadge key={source.url} source={source} />
|
||||
))}
|
||||
{displayedSources.map((source) =>
|
||||
source.kind === "url" ? (
|
||||
<SourceBadge key={sourceKey(source)} source={source} />
|
||||
) : (
|
||||
<DocumentSourceBadge key={sourceKey(source)} source={source} />
|
||||
),
|
||||
)}
|
||||
{shouldCollapse && !expanded && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -326,5 +435,6 @@ export {
|
|||
Source,
|
||||
SourceIcon,
|
||||
SourceTitle,
|
||||
DocumentSourceBadge,
|
||||
badgeVariants as sourceVariants,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
ToolFallbackTrigger,
|
||||
} from "./tool-fallback";
|
||||
|
||||
interface ParsedChunk {
|
||||
export interface ParsedChunk {
|
||||
id: string;
|
||||
source: string;
|
||||
page?: string;
|
||||
|
|
@ -39,7 +39,7 @@ function decodeXml(value: string): string {
|
|||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function parseChunks(raw: string): ParsedChunk[] {
|
||||
export function parseChunks(raw: string): ParsedChunk[] {
|
||||
if (!raw) return [];
|
||||
const out: ParsedChunk[] = [];
|
||||
let match: RegExpExecArray | null = CHUNK_RE.exec(raw);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ import {
|
|||
type SearchRequest,
|
||||
search as ragSearch,
|
||||
} from "@/features/rag/api/rag-api";
|
||||
import {
|
||||
type ParsedChunk,
|
||||
parseChunks,
|
||||
} from "@/components/assistant-ui/tool-ui-search-knowledge-base";
|
||||
import type { RagMode, RagSource } from "./chat-settings-api";
|
||||
import {
|
||||
createOpenAIContainer,
|
||||
|
|
@ -241,6 +245,64 @@ function parseSourcesFromResult(raw: string): {
|
|||
return sources;
|
||||
}
|
||||
|
||||
interface DocumentSourcePart {
|
||||
type: "source";
|
||||
sourceType: "document";
|
||||
id: string;
|
||||
chunkId: string;
|
||||
filename: string;
|
||||
page?: string;
|
||||
score?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Pull every `[N]` token the model wrote in its reply.
|
||||
* Naive: regex over the whole text. False positives (e.g. `[1]` inside a
|
||||
* code fence or list marker) are tolerated — the worst case is a stray
|
||||
* badge for an id that exists in the retrieval set. */
|
||||
const CITATION_RE = /\[(\d+)\]/g;
|
||||
function extractCitedIds(text: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
let match: RegExpExecArray | null = CITATION_RE.exec(text);
|
||||
while (match !== null) {
|
||||
ids.add(match[1]);
|
||||
match = CITATION_RE.exec(text);
|
||||
}
|
||||
CITATION_RE.lastIndex = 0;
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Build doc-shaped source parts for chunks the model actually cited.
|
||||
* `allChunks` is the flat union of every search_knowledge_base tool
|
||||
* result in this turn (deduped by id). Returns one part per unique
|
||||
* cited id that maps to a real chunk; hallucinated `[99]` refs without
|
||||
* a matching chunk are silently dropped. */
|
||||
function buildDocumentSourceParts(
|
||||
allChunks: ParsedChunk[],
|
||||
citedIds: Set<string>,
|
||||
): DocumentSourcePart[] {
|
||||
const byId = new Map<string, ParsedChunk>();
|
||||
for (const chunk of allChunks) {
|
||||
if (!byId.has(chunk.id)) byId.set(chunk.id, chunk);
|
||||
}
|
||||
const out: DocumentSourcePart[] = [];
|
||||
for (const id of citedIds) {
|
||||
const chunk = byId.get(id);
|
||||
if (!chunk) continue;
|
||||
out.push({
|
||||
type: "source",
|
||||
sourceType: "document",
|
||||
id: `rag-${id}`,
|
||||
chunkId: id,
|
||||
filename: chunk.source,
|
||||
...(chunk.page ? { page: chunk.page } : {}),
|
||||
...(chunk.score ? { score: chunk.score } : {}),
|
||||
text: chunk.text,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function estimateTokenCount(text: string): number | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -1013,8 +1075,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
"tool calls). Phrase each query as a focused question, not a " +
|
||||
"keyword bag.\n" +
|
||||
"3. After the tool calls return, ground your reply in the " +
|
||||
"returned <chunk> blocks and cite them as [1], [2], etc. Do not " +
|
||||
"make additional tool calls beyond the planned 3.",
|
||||
"returned <chunk> blocks. CITE each chunk you use with its " +
|
||||
'LITERAL id attribute — e.g. `<chunk id="7">` is cited as ' +
|
||||
"`[7]`. IDs are unique across the whole turn; never renumber, " +
|
||||
"never reuse a different number. Do not make additional tool " +
|
||||
"calls beyond the planned 3.",
|
||||
);
|
||||
}
|
||||
if (systemPromptParts.length > 0) {
|
||||
|
|
@ -2021,7 +2086,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// tool calls. Both emit the same `Title:` / `URL:` / `Snippet:`
|
||||
// block shape from the Anthropic backend, so the parser does
|
||||
// not need to branch on tool name.
|
||||
const sourceParts = toolCallParts.flatMap((tc) => {
|
||||
const urlSourceParts = toolCallParts.flatMap((tc) => {
|
||||
if (
|
||||
(tc.toolName !== "web_search" && tc.toolName !== "web_fetch") ||
|
||||
!tc.result
|
||||
|
|
@ -2033,6 +2098,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
);
|
||||
});
|
||||
|
||||
// RAG: flatten chunks across every search_knowledge_base call this
|
||||
// turn, then emit doc-source parts only for ids the model actually
|
||||
// cited as [N] in its final reply.
|
||||
const ragChunks = toolCallParts.flatMap((tc) => {
|
||||
if (tc.toolName !== "search_knowledge_base" || !tc.result) {
|
||||
return [];
|
||||
}
|
||||
return parseChunks(typeof tc.result === "string" ? tc.result : "");
|
||||
});
|
||||
const documentSourceParts =
|
||||
ragChunks.length > 0
|
||||
? buildDocumentSourceParts(
|
||||
ragChunks,
|
||||
extractCitedIds(cumulativeText),
|
||||
)
|
||||
: [];
|
||||
|
||||
const sourceParts = [...urlSourceParts, ...documentSourceParts];
|
||||
|
||||
const meta = serverMetadata;
|
||||
const finalTokenCount =
|
||||
meta?.usage?.completion_tokens ?? estimateTokenCount(cumulativeText);
|
||||
|
|
|
|||
|
|
@ -140,6 +140,19 @@ def test_format_hits_handles_unknown_source():
|
|||
assert "\norphan\n</chunk>" in result
|
||||
|
||||
|
||||
def test_format_hits_offsets_ids_by_start_id():
|
||||
from core.rag.tool import _format_hits_for_llm
|
||||
|
||||
hits = [
|
||||
{"filename": "a.pdf", "text": "first"},
|
||||
{"filename": "b.pdf", "text": "second"},
|
||||
]
|
||||
result = _format_hits_for_llm(hits, start_id = 5)
|
||||
assert '<chunk id="6"' in result
|
||||
assert '<chunk id="7"' in result
|
||||
assert '<chunk id="1"' not in result
|
||||
|
||||
|
||||
def test_format_hits_escapes_xml_in_source():
|
||||
from core.rag.tool import _format_hits_for_llm
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue