feat: add Zod schemas for Surfsense docs and fix citation handling

This commit is contained in:
CREDO23 2026-01-12 18:06:51 +02:00
parent 2c3d625b35
commit 42473fe4ed
4 changed files with 57 additions and 13 deletions

View file

@ -20,7 +20,8 @@ const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g;
// Track chunk IDs to citation numbers mapping for consistent numbering // Track chunk IDs to citation numbers mapping for consistent numbering
// This map is reset when a new message starts rendering // This map is reset when a new message starts rendering
let chunkIdToCitationNumber: Map<number, number> = new Map(); // Uses string keys to differentiate between doc and regular chunks (e.g., "doc-123" vs "123")
let chunkIdToCitationNumber: Map<string, number> = new Map();
let nextCitationNumber = 1; let nextCitationNumber = 1;
/** /**
@ -37,11 +38,11 @@ export function resetCitationCounter() {
*/ */
function getCitationNumber(chunkId: number, isDocsChunk: boolean): number { function getCitationNumber(chunkId: number, isDocsChunk: boolean): number {
const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId); const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId);
const existingNumber = chunkIdToCitationNumber.get(key as unknown as number); const existingNumber = chunkIdToCitationNumber.get(key);
if (existingNumber === undefined) { if (existingNumber === undefined) {
chunkIdToCitationNumber.set(key as unknown as number, nextCitationNumber++); chunkIdToCitationNumber.set(key, nextCitationNumber++);
} }
return chunkIdToCitationNumber.get(key as unknown as number)!; return chunkIdToCitationNumber.get(key)!;
} }
/** /**

View file

@ -21,10 +21,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
} from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
interface SourceDetailPanelProps { interface SourceDetailPanelProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -133,14 +139,16 @@ export function SourceDetailPanel({
data: documentData, data: documentData,
isLoading: isDocumentByChunkFetching, isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError, error: documentByChunkFetchingError,
} = useQuery({ } = useQuery<DocumentData>({
queryKey: isDocsChunk queryKey: isDocsChunk
? cacheKeys.documents.byChunk(`doc-${chunkId}`) ? cacheKeys.documents.byChunk(`doc-${chunkId}`)
: cacheKeys.documents.byChunk(chunkId.toString()), : cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => queryFn: async () => {
isDocsChunk if (isDocsChunk) {
? documentsApiService.getSurfsenseDocByChunk(chunkId) return documentsApiService.getSurfsenseDocByChunk(chunkId);
: documentsApiService.getDocumentByChunk({ chunk_id: chunkId }), }
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
},
enabled: !!chunkId && open, enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
@ -332,7 +340,7 @@ export function SourceDetailPanel({
{documentData?.title || title || "Source Document"} {documentData?.title || title || "Source Document"}
</h2> </h2>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{documentData {documentData && "document_type" in documentData
? formatDocumentType(documentData.document_type) ? formatDocumentType(documentData.document_type)
: sourceType && formatDocumentType(sourceType)} : sourceType && formatDocumentType(sourceType)}
{documentData?.chunks && ( {documentData?.chunks && (
@ -498,7 +506,8 @@ export function SourceDetailPanel({
<ScrollArea className="flex-1" ref={scrollAreaRef}> <ScrollArea className="flex-1" ref={scrollAreaRef}>
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6"> <div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
{/* Document Metadata */} {/* Document Metadata */}
{documentData.document_metadata && {"document_metadata" in documentData &&
documentData.document_metadata &&
Object.keys(documentData.document_metadata).length > 0 && ( Object.keys(documentData.document_metadata).length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}

View file

@ -59,6 +59,26 @@ export const documentWithChunks = document.extend({
), ),
}); });
/**
* Surfsense documentation schemas
* Follows the same pattern as document/documentWithChunks
*/
export const surfsenseDocsChunk = z.object({
id: z.number(),
content: z.string(),
});
export const surfsenseDocsDocument = z.object({
id: z.number(),
title: z.string(),
source: z.string(),
content: z.string(),
});
export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
chunks: z.array(surfsenseDocsChunk),
});
/** /**
* Get documents * Get documents
*/ */
@ -154,6 +174,15 @@ export const getDocumentByChunkRequest = z.object({
export const getDocumentByChunkResponse = documentWithChunks; export const getDocumentByChunkResponse = documentWithChunks;
/**
* Get Surfsense docs by chunk
*/
export const getSurfsenseDocsByChunkRequest = z.object({
chunk_id: z.number(),
});
export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
/** /**
* Update document * Update document
*/ */
@ -193,3 +222,8 @@ export type UpdateDocumentResponse = z.infer<typeof updateDocumentResponse>;
export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>; export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>;
export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>; export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>;
export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>; export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>;
export type SurfsenseDocsChunk = z.infer<typeof surfsenseDocsChunk>;
export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>;
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;

View file

@ -17,6 +17,7 @@ import {
getDocumentsResponse, getDocumentsResponse,
getDocumentTypeCountsRequest, getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse, getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse,
type SearchDocumentsRequest, type SearchDocumentsRequest,
searchDocumentsRequest, searchDocumentsRequest,
searchDocumentsResponse, searchDocumentsResponse,
@ -214,10 +215,9 @@ class DocumentsApiService {
* Used for resolving [citation:doc-XXX] citations * Used for resolving [citation:doc-XXX] citations
*/ */
getSurfsenseDocByChunk = async (chunkId: number) => { getSurfsenseDocByChunk = async (chunkId: number) => {
// Response shape matches getDocumentByChunkResponse structure
return baseApiService.get( return baseApiService.get(
`/api/v1/surfsense-docs/by-chunk/${chunkId}`, `/api/v1/surfsense-docs/by-chunk/${chunkId}`,
getDocumentByChunkResponse getSurfsenseDocsByChunkResponse
); );
}; };