feat(memory): add shared foundation types, Zod schemas, and roundtrip tests (plan 21 F1)

- src/lib/memory/embedding/types.ts — EmbeddingSource, EmbeddingProviderListing, EmbeddingResolution, EmbeddingResult, EmbeddingError (verbatim §3.1)
- src/shared/schemas/memory.ts — 7 Zod schemas (MemorySettingsExtended, MemoryUpdatePut, RetrievePreview, MemoryReindex, MemorySummarize, EmbeddingProviderListing, MemoryEngineStatus, RetrievePreviewResult) + z.infer types (verbatim §3.2)
- src/shared/schemas/qdrant.ts — 4 Zod schemas (QdrantSettings, QdrantSettingsUpdate, QdrantSearch, QdrantHealthResult) + z.infer types (verbatim §3.3)
- tests/unit/memory-schemas-roundtrip.test.ts — 34 assertions (≥22 required); all pass
This commit is contained in:
diegosouzapw
2026-05-27 22:54:18 -03:00
parent 722e9f41cd
commit 6236b604ec
4 changed files with 638 additions and 0 deletions

View File

@@ -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()
}

View File

@@ -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<typeof MemorySettingsExtendedSchema>;
export type MemoryUpdatePut = z.infer<typeof MemoryUpdatePutSchema>;
export type RetrievePreview = z.infer<typeof RetrievePreviewSchema>;
export type MemoryReindex = z.infer<typeof MemoryReindexSchema>;
export type MemorySummarize = z.infer<typeof MemorySummarizeSchema>;
export type EmbeddingProviderListings = z.infer<typeof EmbeddingProviderListingSchema>;
export type MemoryEngineStatus = z.infer<typeof MemoryEngineStatusSchema>;
export type RetrievePreviewResult = z.infer<typeof RetrievePreviewResultSchema>;

View File

@@ -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<typeof QdrantSettingsSchema>;
export type QdrantSettingsUpdate = z.infer<typeof QdrantSettingsUpdateSchema>;
export type QdrantSearch = z.infer<typeof QdrantSearchSchema>;
export type QdrantHealthResult = z.infer<typeof QdrantHealthResultSchema>;

View File

@@ -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");
});