mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
40
src/lib/memory/embedding/types.ts
Normal file
40
src/lib/memory/embedding/types.ts
Normal 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()
|
||||
}
|
||||
147
src/shared/schemas/memory.ts
Normal file
147
src/shared/schemas/memory.ts
Normal 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>;
|
||||
40
src/shared/schemas/qdrant.ts
Normal file
40
src/shared/schemas/qdrant.ts
Normal 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>;
|
||||
411
tests/unit/memory-schemas-roundtrip.test.ts
Normal file
411
tests/unit/memory-schemas-roundtrip.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user