Pular para o conteúdo principal

API de Biblioteca

A API de Biblioteca permite gerir documentos que alimentam o conhecimento do assistente IA.

Endpoints REST

Upload de Documento

POST /api/library/upload
Cookie: better-auth.session_token=...
Content-Type: multipart/form-data

file: [binary]
title: "Diretrizes HAP 2024"
description: "Documento oficial com diretrizes europeias"

Resposta de Sucesso (201):

{
"id": "doc_123",
"title": "Diretrizes HAP 2024",
"description": "Documento oficial com diretrizes europeias",
"fileName": "diretrizes-hap-2024.pdf",
"fileType": "application/pdf",
"fileSize": 2458624,
"status": "processing",
"createdAt": "2024-12-02T10:00:00.000Z"
}

Validações:

  • Formatos aceites: PDF, DOCX, TXT, MD
  • Tamanho máximo: 10 MB

tRPC Router

Listar Documentos

// Cliente
const { data } = api.library.list.useQuery({
search: "HAP",
page: 1,
limit: 10,
isActive: true,
});

Input:

{
search?: string; // Pesquisa por título/descrição
page?: number; // Página (default: 1)
limit?: number; // Itens por página (default: 10)
isActive?: boolean; // Filtrar por estado
}

Output:

{
items: Document[];
total: number;
page: number;
totalPages: number;
}

Obter Documento

const { data } = api.library.getById.useQuery({ id: "doc_123" });

Output:

{
id: string;
title: string;
description: string | null;
fileName: string;
fileType: string;
fileSize: number;
filePath: string;
uploadedBy: {
id: string;
name: string;
};
isActive: boolean;
chunksCount: number;
createdAt: Date;
updatedAt: Date;
}

Atualizar Documento

const mutation = api.library.update.useMutation();

await mutation.mutateAsync({
id: "doc_123",
title: "Novo título",
description: "Nova descrição",
isActive: true,
});

Input:

{
id: string;
title?: string;
description?: string;
isActive?: boolean;
}

Eliminar Documento

const mutation = api.library.delete.useMutation();

await mutation.mutateAsync({ id: "doc_123" });

⚠️ Nota: Esta ação elimina permanentemente o documento e todos os seus chunks/embeddings.


Router Completo

// src/server/api/routers/library.ts
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, adminProcedure } from "../trpc";
import { documents, documentChunks } from "@/server/db/schema";
import { eq, ilike, and, desc, count } from "drizzle-orm";

export const libraryRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(10),
isActive: z.boolean().optional(),
}))
.query(async ({ ctx, input }) => {
const { search, page, limit, isActive } = input;
const offset = (page - 1) * limit;

const conditions = [];
if (search) {
conditions.push(ilike(documents.title, `%${search}%`));
}
if (isActive !== undefined) {
conditions.push(eq(documents.isActive, isActive));
}

const where = conditions.length > 0 ? and(...conditions) : undefined;

const [items, [{ total }]] = await Promise.all([
ctx.db.query.documents.findMany({
where,
limit,
offset,
orderBy: desc(documents.createdAt),
with: {
uploadedBy: {
columns: { id: true, name: true },
},
},
}),
ctx.db.select({ total: count() }).from(documents).where(where),
]);

return {
items,
total,
page,
totalPages: Math.ceil(total / limit),
};
}),

getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const doc = await ctx.db.query.documents.findFirst({
where: eq(documents.id, input.id),
with: {
uploadedBy: {
columns: { id: true, name: true },
},
},
});

if (!doc) {
throw new TRPCError({ code: "NOT_FOUND" });
}

// Contar chunks
const [{ chunksCount }] = await ctx.db
.select({ chunksCount: count() })
.from(documentChunks)
.where(eq(documentChunks.documentId, input.id));

return { ...doc, chunksCount };
}),

update: protectedProcedure
.input(z.object({
id: z.string(),
title: z.string().optional(),
description: z.string().optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;

return ctx.db.update(documents)
.set({ ...data, updatedAt: new Date() })
.where(eq(documents.id, id))
.returning();
}),

delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Chunks são eliminados automaticamente (CASCADE)
return ctx.db.delete(documents)
.where(eq(documents.id, input.id));
}),
});

Pipeline de Processamento

Após o upload, o documento passa por um pipeline assíncrono:

1. Upload
└─> Guardar ficheiro em storage
└─> Criar registo na BD (status: "processing")

2. Extração
└─> Extrair texto (PDF/DOCX)
└─> Limpar e normalizar texto

3. Chunking
└─> Dividir em chunks de ~1000 caracteres
└─> Overlap de 200 caracteres

4. Embedding
└─> Gerar vetores com Cohere
└─> Guardar chunks + embeddings na BD

5. Conclusão
└─> Atualizar status para "active"

Busca Semântica

// Buscar documentos relevantes para uma query
async function searchDocuments(query: string, limit = 5) {
const queryEmbedding = await embeddings.embedQuery(query);

return db
.select({
content: documentChunks.content,
documentId: documentChunks.documentId,
documentTitle: documents.title,
})
.from(documentChunks)
.innerJoin(documents, eq(documentChunks.documentId, documents.id))
.where(eq(documents.isActive, true))
.orderBy(cosineDistance(documentChunks.embedding, queryEmbedding))
.limit(limit);
}

Permissões

AçãoAdminEditorUser
Listar
Ver detalhes
Upload
Editar
Ativar/Desativar
Eliminar

Erros

CódigoErroDescrição
400BAD_REQUESTFormato de ficheiro inválido
401UNAUTHORIZEDNão autenticado
403FORBIDDENSem permissão
404NOT_FOUNDDocumento não encontrado
413PAYLOAD_TOO_LARGEFicheiro excede 10 MB