diff --git a/src/lib/memory/embedding/types.ts b/src/lib/memory/embedding/types.ts new file mode 100644 index 0000000000..0de75d04bc --- /dev/null +++ b/src/lib/memory/embedding/types.ts @@ -0,0 +1,40 @@ +export type EmbeddingSource = "remote" | "static" | "transformers" | "auto"; + +export interface EmbeddingProviderListing { + provider: string; // e.g. "openai" + hasKey: boolean; + models: Array<{ + id: string; // formato `provider/model`, e.g. "openai/text-embedding-3-small" + name: string; + dimensions: number | null; + }>; +} + +export interface EmbeddingResolution { + /** Fonte ativa após resolveEmbeddingSource(settings). null = nenhuma disponível → degrada p/ FTS5. */ + source: "remote" | "static" | "transformers" | null; + /** Modelo ativo (formato provider/model para remote, "potion-base-8M" para static, "Xenova/all-MiniLM-L6-v2" para transformers). */ + model: string | null; + /** Dimensão do vetor produzido. null antes da 1ª chamada (lazy probe). */ + dimensions: number | null; + /** Assinatura única usada como chave do vectorStore para detectar troca de modelo. */ + signature: string; // ${source}:${model}:${dim} + /** Motivo da escolha (UI exibe no Engine status). */ + reason: string; // e.g. "provider openai com key configurada" +} + +export interface EmbeddingResult { + vector: Float32Array; + source: "remote" | "static" | "transformers"; + model: string; + dimensions: number; + latencyMs: number; + cached: boolean; +} + +export interface EmbeddingError { + source: "remote" | "static" | "transformers"; + model: string | null; + reason: "no_key" | "model_load_failed" | "request_failed" | "rate_limited" | "timeout" | "unknown"; + message: string; // ALWAYS via sanitizeErrorMessage() +} diff --git a/src/shared/schemas/memory.ts b/src/shared/schemas/memory.ts new file mode 100644 index 0000000000..3bb54758fc --- /dev/null +++ b/src/shared/schemas/memory.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; + +/** Schema estendido para PUT /api/settings/memory (D9). */ +export const MemorySettingsExtendedSchema = z + .object({ + // Campos legados (já existem) + enabled: z.boolean().optional(), + maxTokens: z.number().int().min(0).max(16000).optional(), + retentionDays: z.number().int().min(1).max(365).optional(), + strategy: z.enum(["recent", "semantic", "hybrid"]).optional(), + skillsEnabled: z.boolean().optional(), + // Campos novos (D9) + embeddingSource: z.enum(["remote", "static", "transformers", "auto"]).optional(), + embeddingProviderModel: z.string().nullable().optional(), // formato `provider/model` + transformersEnabled: z.boolean().optional(), + staticEnabled: z.boolean().optional(), + rerankEnabled: z.boolean().optional(), + rerankProviderModel: z.string().nullable().optional(), + vectorStore: z.enum(["sqlite-vec", "qdrant", "auto"]).optional(), + }) + .strict(); + +/** PUT /api/memory/[id] body (D6 plano §5.3). */ +export const MemoryUpdatePutSchema = z + .object({ + type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(), + key: z.string().min(1).optional(), + content: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); + +/** POST /api/memory/retrieve-preview body (D6 plano §4.2). */ +export const RetrievePreviewSchema = z + .object({ + query: z.string().min(1), + strategy: z.enum(["exact", "semantic", "hybrid"]).default("hybrid"), + maxTokens: z.number().int().positive().max(16000).default(2000), + apiKeyId: z.string().optional(), // opcional: testa global se ausente + limit: z.number().int().positive().max(100).default(20), + }) + .strict(); + +/** POST /api/memory/reindex body (D21). */ +export const MemoryReindexSchema = z + .object({ + force: z.boolean().default(false), // true = regenera TODOS os vetores + }) + .strict(); + +/** POST /api/memory/summarize body (D19). */ +export const MemorySummarizeSchema = z + .object({ + olderThanDays: z.number().int().positive().max(365).default(30), + apiKeyId: z.string().optional(), + dryRun: z.boolean().default(false), + }) + .strict(); + +/** Response shape do GET /api/memory/embedding-providers (D9 + plano §5.3). */ +export const EmbeddingProviderListingSchema = z.object({ + providers: z.array( + z.object({ + provider: z.string(), + hasKey: z.boolean(), + models: z.array( + z.object({ + id: z.string(), // `provider/model` + name: z.string(), + dimensions: z.number().nullable(), + }), + ), + }), + ), +}); + +/** Response shape do GET /api/memory/engine-status (UI Engine tab — D11). */ +export const MemoryEngineStatusSchema = z.object({ + keyword: z.object({ + available: z.literal(true), + backend: z.literal("FTS5"), + }), + embedding: z.object({ + source: z.enum(["remote", "static", "transformers"]).nullable(), + model: z.string().nullable(), + dimensions: z.number().nullable(), + available: z.boolean(), + reason: z.string(), + cacheStats: z.object({ hits: z.number(), misses: z.number(), size: z.number() }), + }), + vectorStore: z.object({ + backend: z.enum(["sqlite-vec", "qdrant", "none"]), + available: z.boolean(), + rowCount: z.number(), + needsReindex: z.number(), + reason: z.string(), + }), + qdrant: z.object({ + enabled: z.boolean(), + healthy: z.boolean().nullable(), + latencyMs: z.number().nullable(), + error: z.string().nullable(), + }), + rerank: z.object({ + enabled: z.boolean(), + provider: z.string().nullable(), + model: z.string().nullable(), + available: z.boolean(), + reason: z.string(), + }), +}); + +/** Item de resultado do Playground (POST /api/memory/retrieve-preview response). */ +export const RetrievePreviewResultSchema = z.object({ + memories: z.array( + z.object({ + id: z.string(), + type: z.enum(["factual", "episodic", "procedural", "semantic"]), + key: z.string(), + content: z.string(), + score: z.number(), + tokens: z.number(), + tier: z.enum(["fts5", "vector", "hybrid-rrf", "qdrant"]), + vecScore: z.number().nullable(), + ftsScore: z.number().nullable(), + }), + ), + resolution: z.object({ + embeddingSource: z.enum(["remote", "static", "transformers"]).nullable(), + embeddingModel: z.string().nullable(), + vectorStore: z.enum(["sqlite-vec", "qdrant", "none"]), + strategyUsed: z.enum(["exact", "semantic", "hybrid"]), + rerankApplied: z.boolean(), + fallbackReason: z.string().nullable(), + }), + totalTokensUsed: z.number(), + budgetMaxTokens: z.number(), +}); + +export type MemorySettingsExtended = z.infer; +export type MemoryUpdatePut = z.infer; +export type RetrievePreview = z.infer; +export type MemoryReindex = z.infer; +export type MemorySummarize = z.infer; +export type EmbeddingProviderListings = z.infer; +export type MemoryEngineStatus = z.infer; +export type RetrievePreviewResult = z.infer; diff --git a/src/shared/schemas/qdrant.ts b/src/shared/schemas/qdrant.ts new file mode 100644 index 0000000000..c3b6781ae7 --- /dev/null +++ b/src/shared/schemas/qdrant.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const QdrantSettingsSchema = z.object({ + enabled: z.boolean(), + host: z.string().min(0), // string vazia OK quando enabled=false + port: z.number().int().min(1).max(65535).default(6333), + collection: z.string().min(1).default("omniroute_memory"), + embeddingModel: z.string().default("openai/text-embedding-3-small"), + hasApiKey: z.boolean().default(false), + apiKeyMasked: z.string().nullable().default(null), +}); + +export const QdrantSettingsUpdateSchema = z + .object({ + enabled: z.boolean().optional(), + host: z.string().optional(), + port: z.number().int().min(1).max(65535).optional(), + collection: z.string().min(1).optional(), + embeddingModel: z.string().min(1).optional(), + apiKey: z.string().optional(), // string vazia = remove + }) + .strict(); + +export const QdrantSearchSchema = z + .object({ + query: z.string().min(1), + topK: z.number().int().min(1).max(50).default(5), + }) + .strict(); + +export const QdrantHealthResultSchema = z.object({ + ok: z.boolean(), + latencyMs: z.number(), + error: z.string().optional(), +}); + +export type QdrantSettings = z.infer; +export type QdrantSettingsUpdate = z.infer; +export type QdrantSearch = z.infer; +export type QdrantHealthResult = z.infer; diff --git a/tests/unit/memory-schemas-roundtrip.test.ts b/tests/unit/memory-schemas-roundtrip.test.ts new file mode 100644 index 0000000000..cabdef7b57 --- /dev/null +++ b/tests/unit/memory-schemas-roundtrip.test.ts @@ -0,0 +1,411 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + MemorySettingsExtendedSchema, + MemoryUpdatePutSchema, + RetrievePreviewSchema, + MemoryReindexSchema, + MemorySummarizeSchema, + EmbeddingProviderListingSchema, + MemoryEngineStatusSchema, + RetrievePreviewResultSchema, +} from "../../src/shared/schemas/memory.ts"; + +import { + QdrantSettingsSchema, + QdrantSettingsUpdateSchema, + QdrantSearchSchema, + QdrantHealthResultSchema, +} from "../../src/shared/schemas/qdrant.ts"; + +// --------------------------------------------------------------------------- +// 1. MemorySettingsExtendedSchema +// --------------------------------------------------------------------------- + +test("MemorySettingsExtendedSchema: accepts fully-populated valid payload", () => { + const result = MemorySettingsExtendedSchema.safeParse({ + enabled: true, + maxTokens: 4000, + retentionDays: 30, + strategy: "hybrid", + skillsEnabled: false, + embeddingSource: "auto", + embeddingProviderModel: "openai/text-embedding-3-small", + transformersEnabled: false, + staticEnabled: true, + rerankEnabled: false, + rerankProviderModel: null, + vectorStore: "sqlite-vec", + }); + assert.equal(result.success, true, "Should accept all valid fields"); +}); + +test("MemorySettingsExtendedSchema: rejects extra field (strict)", () => { + const result = MemorySettingsExtendedSchema.safeParse({ + enabled: true, + unknownExtraField: "nope", + }); + assert.equal(result.success, false, "Strict schema must reject unknown keys"); +}); + +test("MemorySettingsExtendedSchema: rejects maxTokens above max (16000)", () => { + const result = MemorySettingsExtendedSchema.safeParse({ maxTokens: 16001 }); + assert.equal(result.success, false, "maxTokens 16001 must be rejected"); +}); + +test("MemorySettingsExtendedSchema: rejects invalid embeddingSource value", () => { + const result = MemorySettingsExtendedSchema.safeParse({ embeddingSource: "magic" }); + assert.equal(result.success, false, "Unknown embeddingSource must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 2. MemoryUpdatePutSchema +// --------------------------------------------------------------------------- + +test("MemoryUpdatePutSchema: accepts valid partial update (content only)", () => { + const result = MemoryUpdatePutSchema.safeParse({ content: "updated content" }); + assert.equal(result.success, true, "Should accept partial update with only content"); +}); + +test("MemoryUpdatePutSchema: rejects extra field (strict)", () => { + const result = MemoryUpdatePutSchema.safeParse({ content: "x", extra: true }); + assert.equal(result.success, false, "Strict schema must reject unknown keys"); +}); + +test("MemoryUpdatePutSchema: rejects empty-string key", () => { + const result = MemoryUpdatePutSchema.safeParse({ key: "" }); + assert.equal(result.success, false, "key must be min(1)"); +}); + +test("MemoryUpdatePutSchema: rejects invalid type enum", () => { + const result = MemoryUpdatePutSchema.safeParse({ type: "unknown_type" }); + assert.equal(result.success, false, "Invalid memory type must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 3. RetrievePreviewSchema +// --------------------------------------------------------------------------- + +test("RetrievePreviewSchema: accepts minimal valid payload (query only)", () => { + const result = RetrievePreviewSchema.safeParse({ query: "what is the capital of France?" }); + assert.equal(result.success, true, "Should accept minimal payload with query only"); + if (result.success) { + assert.equal(result.data.strategy, "hybrid", "Default strategy should be hybrid"); + assert.equal(result.data.maxTokens, 2000, "Default maxTokens should be 2000"); + assert.equal(result.data.limit, 20, "Default limit should be 20"); + } +}); + +test("RetrievePreviewSchema: rejects empty query string", () => { + const result = RetrievePreviewSchema.safeParse({ query: "" }); + assert.equal(result.success, false, "Empty query must be rejected"); +}); + +test("RetrievePreviewSchema: rejects limit above 100", () => { + const result = RetrievePreviewSchema.safeParse({ query: "test", limit: 101 }); + assert.equal(result.success, false, "limit > 100 must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 4. MemoryReindexSchema +// --------------------------------------------------------------------------- + +test("MemoryReindexSchema: accepts empty object (all defaults)", () => { + const result = MemoryReindexSchema.safeParse({}); + assert.equal(result.success, true, "Should accept empty object with defaults applied"); + if (result.success) { + assert.equal(result.data.force, false, "Default force should be false"); + } +}); + +test("MemoryReindexSchema: rejects extra field (strict)", () => { + const result = MemoryReindexSchema.safeParse({ force: true, extra: "not allowed" }); + assert.equal(result.success, false, "Strict schema must reject unknown keys"); +}); + +// --------------------------------------------------------------------------- +// 5. MemorySummarizeSchema +// --------------------------------------------------------------------------- + +test("MemorySummarizeSchema: accepts valid payload with all fields", () => { + const result = MemorySummarizeSchema.safeParse({ + olderThanDays: 60, + apiKeyId: "key-abc", + dryRun: true, + }); + assert.equal(result.success, true, "Should accept all valid fields"); +}); + +test("MemorySummarizeSchema: rejects olderThanDays above 365", () => { + const result = MemorySummarizeSchema.safeParse({ olderThanDays: 366 }); + assert.equal(result.success, false, "olderThanDays > 365 must be rejected"); +}); + +test("MemorySummarizeSchema: rejects olderThanDays of 0 (positive required)", () => { + const result = MemorySummarizeSchema.safeParse({ olderThanDays: 0 }); + assert.equal(result.success, false, "olderThanDays 0 must be rejected (must be positive)"); +}); + +// --------------------------------------------------------------------------- +// 6. EmbeddingProviderListingSchema +// --------------------------------------------------------------------------- + +test("EmbeddingProviderListingSchema: accepts valid providers array", () => { + const result = EmbeddingProviderListingSchema.safeParse({ + providers: [ + { + provider: "openai", + hasKey: true, + models: [ + { id: "openai/text-embedding-3-small", name: "text-embedding-3-small", dimensions: 1536 }, + { id: "openai/text-embedding-ada-002", name: "text-embedding-ada-002", dimensions: null }, + ], + }, + ], + }); + assert.equal(result.success, true, "Should accept valid provider listing"); +}); + +test("EmbeddingProviderListingSchema: rejects missing required model fields", () => { + const result = EmbeddingProviderListingSchema.safeParse({ + providers: [ + { + provider: "openai", + hasKey: true, + models: [{ id: "openai/text-embedding-3-small" }], // missing name and dimensions + }, + ], + }); + assert.equal(result.success, false, "Missing model name/dimensions must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 7. MemoryEngineStatusSchema +// --------------------------------------------------------------------------- + +test("MemoryEngineStatusSchema: accepts valid fully-populated status", () => { + const result = MemoryEngineStatusSchema.safeParse({ + keyword: { available: true, backend: "FTS5" }, + embedding: { + source: "remote", + model: "openai/text-embedding-3-small", + dimensions: 1536, + available: true, + reason: "provider openai com key configurada", + cacheStats: { hits: 10, misses: 2, size: 12 }, + }, + vectorStore: { + backend: "sqlite-vec", + available: true, + rowCount: 42, + needsReindex: 0, + reason: "sqlite-vec loaded", + }, + qdrant: { enabled: false, healthy: null, latencyMs: null, error: null }, + rerank: { + enabled: false, + provider: null, + model: null, + available: false, + reason: "no rerank provider configured", + }, + }); + assert.equal(result.success, true, "Should accept fully-populated engine status"); +}); + +test("MemoryEngineStatusSchema: rejects wrong literal for keyword.backend", () => { + const result = MemoryEngineStatusSchema.safeParse({ + keyword: { available: true, backend: "BM25" }, // wrong backend literal + embedding: { + source: null, + model: null, + dimensions: null, + available: false, + reason: "none", + cacheStats: { hits: 0, misses: 0, size: 0 }, + }, + vectorStore: { backend: "none", available: false, rowCount: 0, needsReindex: 0, reason: "" }, + qdrant: { enabled: false, healthy: null, latencyMs: null, error: null }, + rerank: { enabled: false, provider: null, model: null, available: false, reason: "" }, + }); + assert.equal(result.success, false, "backend 'BM25' must be rejected (must be literal 'FTS5')"); +}); + +test("MemoryEngineStatusSchema: rejects invalid vectorStore backend", () => { + const result = MemoryEngineStatusSchema.safeParse({ + keyword: { available: true, backend: "FTS5" }, + embedding: { + source: null, + model: null, + dimensions: null, + available: false, + reason: "", + cacheStats: { hits: 0, misses: 0, size: 0 }, + }, + vectorStore: { backend: "faiss", available: false, rowCount: 0, needsReindex: 0, reason: "" }, + qdrant: { enabled: false, healthy: null, latencyMs: null, error: null }, + rerank: { enabled: false, provider: null, model: null, available: false, reason: "" }, + }); + assert.equal(result.success, false, "backend 'faiss' must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 8. RetrievePreviewResultSchema +// --------------------------------------------------------------------------- + +test("RetrievePreviewResultSchema: accepts valid response with memories", () => { + const result = RetrievePreviewResultSchema.safeParse({ + memories: [ + { + id: "mem-1", + type: "factual", + key: "capital_of_france", + content: "The capital of France is Paris.", + score: 0.95, + tokens: 10, + tier: "hybrid-rrf", + vecScore: 0.93, + ftsScore: 0.88, + }, + ], + resolution: { + embeddingSource: "remote", + embeddingModel: "openai/text-embedding-3-small", + vectorStore: "sqlite-vec", + strategyUsed: "hybrid", + rerankApplied: false, + fallbackReason: null, + }, + totalTokensUsed: 10, + budgetMaxTokens: 2000, + }); + assert.equal(result.success, true, "Should accept valid preview result"); +}); + +test("RetrievePreviewResultSchema: rejects invalid tier value", () => { + const result = RetrievePreviewResultSchema.safeParse({ + memories: [ + { + id: "mem-1", + type: "factual", + key: "k", + content: "c", + score: 0.5, + tokens: 5, + tier: "bm25", // invalid tier + vecScore: null, + ftsScore: null, + }, + ], + resolution: { + embeddingSource: null, + embeddingModel: null, + vectorStore: "none", + strategyUsed: "exact", + rerankApplied: false, + fallbackReason: null, + }, + totalTokensUsed: 5, + budgetMaxTokens: 2000, + }); + assert.equal(result.success, false, "tier 'bm25' must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 9. QdrantSettingsSchema +// --------------------------------------------------------------------------- + +test("QdrantSettingsSchema: accepts valid settings with defaults applied", () => { + const result = QdrantSettingsSchema.safeParse({ + enabled: true, + host: "localhost", + }); + assert.equal(result.success, true, "Should accept minimal settings with defaults"); + if (result.success) { + assert.equal(result.data.port, 6333, "Default port should be 6333"); + assert.equal(result.data.collection, "omniroute_memory", "Default collection"); + assert.equal(result.data.hasApiKey, false, "Default hasApiKey should be false"); + assert.equal(result.data.apiKeyMasked, null, "Default apiKeyMasked should be null"); + } +}); + +test("QdrantSettingsSchema: rejects port above 65535", () => { + const result = QdrantSettingsSchema.safeParse({ + enabled: false, + host: "", + port: 99999, + }); + assert.equal(result.success, false, "Port 99999 must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 10. QdrantSettingsUpdateSchema +// --------------------------------------------------------------------------- + +test("QdrantSettingsUpdateSchema: accepts valid partial update", () => { + const result = QdrantSettingsUpdateSchema.safeParse({ + enabled: true, + host: "qdrant.example.com", + port: 6334, + }); + assert.equal(result.success, true, "Should accept partial update"); +}); + +test("QdrantSettingsUpdateSchema: rejects extra field (strict)", () => { + const result = QdrantSettingsUpdateSchema.safeParse({ + enabled: true, + unknownField: "not allowed", + }); + assert.equal(result.success, false, "Strict schema must reject unknown keys"); +}); + +test("QdrantSettingsUpdateSchema: rejects empty collection string", () => { + const result = QdrantSettingsUpdateSchema.safeParse({ collection: "" }); + assert.equal(result.success, false, "collection min(1) must reject empty string"); +}); + +// --------------------------------------------------------------------------- +// 11. QdrantSearchSchema +// --------------------------------------------------------------------------- + +test("QdrantSearchSchema: accepts valid search payload with default topK", () => { + const result = QdrantSearchSchema.safeParse({ query: "semantic search test" }); + assert.equal(result.success, true, "Should accept query with default topK"); + if (result.success) { + assert.equal(result.data.topK, 5, "Default topK should be 5"); + } +}); + +test("QdrantSearchSchema: rejects topK above 50", () => { + const result = QdrantSearchSchema.safeParse({ query: "test", topK: 51 }); + assert.equal(result.success, false, "topK > 50 must be rejected"); +}); + +test("QdrantSearchSchema: rejects empty query string", () => { + const result = QdrantSearchSchema.safeParse({ query: "" }); + assert.equal(result.success, false, "Empty query must be rejected"); +}); + +// --------------------------------------------------------------------------- +// 12. QdrantHealthResultSchema (bonus — extra coverage) +// --------------------------------------------------------------------------- + +test("QdrantHealthResultSchema: accepts healthy result without error field", () => { + const result = QdrantHealthResultSchema.safeParse({ ok: true, latencyMs: 12 }); + assert.equal(result.success, true, "Healthy result must be accepted"); +}); + +test("QdrantHealthResultSchema: accepts unhealthy result with error field", () => { + const result = QdrantHealthResultSchema.safeParse({ + ok: false, + latencyMs: 0, + error: "connection refused", + }); + assert.equal(result.success, true, "Unhealthy result with error string must be accepted"); +}); + +test("QdrantHealthResultSchema: rejects non-boolean ok field", () => { + const result = QdrantHealthResultSchema.safeParse({ ok: "yes", latencyMs: 10 }); + assert.equal(result.success, false, "ok must be boolean"); +});