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ção | Admin | Editor | User |
|---|---|---|---|
| Listar | ✅ | ✅ | ✅ |
| Ver detalhes | ✅ | ✅ | ✅ |
| Upload | ✅ | ✅ | ❌ |
| Editar | ✅ | ✅ | ❌ |
| Ativar/Desativar | ✅ | ❌ | ❌ |
| Eliminar | ✅ | ❌ | ❌ |
Erros
| Código | Erro | Descrição |
|---|---|---|
| 400 | BAD_REQUEST | Formato de ficheiro inválido |
| 401 | UNAUTHORIZED | Não autenticado |
| 403 | FORBIDDEN | Sem permissão |
| 404 | NOT_FOUND | Documento não encontrado |
| 413 | PAYLOAD_TOO_LARGE | Ficheiro excede 10 MB |