@retinue/agentkit 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -277
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +99 -1
- package/dist/agents/agent.js +215 -16
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +48 -0
- package/dist/agents/engine.js +370 -13
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +26 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +110 -2
- package/dist/core/events.js +33 -2
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/validation.js +19 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +17 -0
- package/dist/entries/knowledge.js +16 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +7 -1
- package/dist/graphql/resolvers.js +20 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +25 -0
- package/dist/knowledge/index.js +37 -1
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +98 -5
- package/dist/knowledge/retrieval.js +158 -28
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +111 -1
- package/dist/models/streaming.js +184 -3
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +55 -0
- package/dist/security/findings.js +18 -9
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/telemetry/spans.js +17 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +31 -1
- package/dist/toolkit/index.js +26 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +316 -0
- package/dist/tools/credentials.js +369 -0
- package/dist/tools/define.d.ts +45 -0
- package/dist/tools/define.js +26 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +35 -2
- package/dist/tools/index.js +11 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +37 -2
- package/dist/tools/library/index.js +47 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +250 -4
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +33 -2
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `KnowledgeStore` and `VectorIndex` conformance (#135) — AC-6.
|
|
3
|
+
*
|
|
4
|
+
* Two harnesses over one fixture, because on pgvector both ports are one table and a suite that opened them
|
|
5
|
+
* separately would never exercise the case that matters: a search seeing what a write just wrote.
|
|
6
|
+
*
|
|
7
|
+
* Both harnesses are gated on the `vector-search` capability. An adapter that cannot hold vectors — Postgres
|
|
8
|
+
* without pgvector installed, which includes the PGlite the suite runs on by default — declares nothing and
|
|
9
|
+
* every case registers as a *named* skip rather than vanishing from the report. `gatedIt` exists for exactly
|
|
10
|
+
* this: an invisible skip is indistinguishable from coverage.
|
|
11
|
+
*
|
|
12
|
+
* The search cases assert the **contract**, not recall. pgvector's HNSW index is *approximate*, so
|
|
13
|
+
* "the nearest chunk comes first" is something the production adapter is permitted to miss on a large corpus —
|
|
14
|
+
* asserting it here would make the harness fail for a reason that is not a bug. What is asserted is what must
|
|
15
|
+
* hold exactly at any size: tenant isolation, permission filtering, the limit, score ordering among returned
|
|
16
|
+
* hits, and that no vector ever comes back. Recall is measured separately against a fixed query set.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect } from "vitest";
|
|
19
|
+
import { gatedIt } from "./capability.js";
|
|
20
|
+
import { asId } from "../../core/ids.js";
|
|
21
|
+
const T1 = asId("conf-knowledge-tenant-1");
|
|
22
|
+
const T2 = asId("conf-knowledge-tenant-2");
|
|
23
|
+
const AT = "2026-08-23T12:00:00.000Z";
|
|
24
|
+
/** 1536, because that is what the pgvector schema declares and a vector of another length is rejected. */
|
|
25
|
+
export const CONFORMANCE_DIMENSIONS = 1536;
|
|
26
|
+
export const MODEL = {
|
|
27
|
+
modelId: "conf-embed",
|
|
28
|
+
version: "1",
|
|
29
|
+
dimensions: CONFORMANCE_DIMENSIONS,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A deterministic vector pointing mostly along one axis.
|
|
33
|
+
*
|
|
34
|
+
* Deterministic because a recall figure that moves between runs is a figure nobody can act on, and axis-aligned
|
|
35
|
+
* because it makes "closer" obvious: a query along axis 3 must rank the chunk built on axis 3 first, with no
|
|
36
|
+
* dependence on floating-point luck.
|
|
37
|
+
*/
|
|
38
|
+
export const axisVector = (axis, magnitude = 1) => {
|
|
39
|
+
const v = new Array(CONFORMANCE_DIMENSIONS).fill(0.0001);
|
|
40
|
+
v[axis % CONFORMANCE_DIMENSIONS] = magnitude;
|
|
41
|
+
return v;
|
|
42
|
+
};
|
|
43
|
+
const chunk = (overrides) => ({
|
|
44
|
+
id: overrides.id ?? `c${overrides.index}`,
|
|
45
|
+
sourceType: overrides.sourceType ?? "file",
|
|
46
|
+
sourceId: overrides.sourceId ?? "file-1",
|
|
47
|
+
chunkIndex: overrides.chunkIndex ?? overrides.index,
|
|
48
|
+
content: overrides.content ?? `chunk ${overrides.index}`,
|
|
49
|
+
tokenCount: overrides.tokenCount ?? 10,
|
|
50
|
+
authSubject: overrides.authSubject ?? "convo-1",
|
|
51
|
+
embeddingModel: overrides.embeddingModel ?? MODEL,
|
|
52
|
+
...(overrides.locator === undefined ? {} : { locator: overrides.locator }),
|
|
53
|
+
createdAt: overrides.createdAt ?? AT,
|
|
54
|
+
embedding: overrides.embedding ?? axisVector(overrides.index),
|
|
55
|
+
});
|
|
56
|
+
export function knowledgeStoreConformance(make, declaration) {
|
|
57
|
+
describe("KnowledgeStore conformance", () => {
|
|
58
|
+
const it = (name, fn) => gatedIt(declaration, "vector-search", name, fn);
|
|
59
|
+
it("writes a source's chunks and reads them back in order", async () => {
|
|
60
|
+
const { store } = await make();
|
|
61
|
+
await store.replaceSource({
|
|
62
|
+
tenantId: T1,
|
|
63
|
+
sourceType: "file",
|
|
64
|
+
sourceId: "file-1",
|
|
65
|
+
chunks: [chunk({ index: 2 }), chunk({ index: 0 }), chunk({ index: 1 })],
|
|
66
|
+
});
|
|
67
|
+
const page = await store.listBySource({
|
|
68
|
+
tenantId: T1,
|
|
69
|
+
sourceType: "file",
|
|
70
|
+
sourceId: "file-1",
|
|
71
|
+
limit: 10,
|
|
72
|
+
});
|
|
73
|
+
// Document order, not insertion order: reading around a hit depends on it being the document's order.
|
|
74
|
+
expect(page.items.map((c) => c.chunkIndex)).toEqual([0, 1, 2]);
|
|
75
|
+
});
|
|
76
|
+
it("never returns the embedding", async () => {
|
|
77
|
+
// Nothing above this layer needs it, and it is by far the largest field -- returning it would make every
|
|
78
|
+
// listing an order of magnitude more expensive for a value nobody reads.
|
|
79
|
+
const { store } = await make();
|
|
80
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "file-1", chunks: [chunk({ index: 0 })] });
|
|
81
|
+
const found = await store.get({ tenantId: T1, id: "c0" });
|
|
82
|
+
expect(found).not.toBeNull();
|
|
83
|
+
expect(Object.keys(found ?? {})).not.toContain("embedding");
|
|
84
|
+
});
|
|
85
|
+
it("records the model that produced each chunk", async () => {
|
|
86
|
+
// AC-1. Per chunk, not per deployment: a global setting cannot tell you which rows are stale, which
|
|
87
|
+
// makes incremental re-indexing impossible.
|
|
88
|
+
const { store } = await make();
|
|
89
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "file-1", chunks: [chunk({ index: 0 })] });
|
|
90
|
+
expect((await store.get({ tenantId: T1, id: "c0" }))?.embeddingModel).toEqual(MODEL);
|
|
91
|
+
});
|
|
92
|
+
it("replaces rather than appends", async () => {
|
|
93
|
+
// A changed document's old chunks must stop being searchable: a stale chunk is a citation pointing at
|
|
94
|
+
// text that is no longer there.
|
|
95
|
+
const { store } = await make();
|
|
96
|
+
await store.replaceSource({
|
|
97
|
+
tenantId: T1,
|
|
98
|
+
sourceType: "file",
|
|
99
|
+
sourceId: "file-1",
|
|
100
|
+
chunks: [chunk({ index: 0 }), chunk({ index: 1 }), chunk({ index: 2 })],
|
|
101
|
+
});
|
|
102
|
+
const result = await store.replaceSource({
|
|
103
|
+
tenantId: T1,
|
|
104
|
+
sourceType: "file",
|
|
105
|
+
sourceId: "file-1",
|
|
106
|
+
chunks: [chunk({ index: 0, content: "rewritten" })],
|
|
107
|
+
});
|
|
108
|
+
expect(result).toEqual({ written: 1, removed: 3 });
|
|
109
|
+
const page = await store.listBySource({ tenantId: T1, sourceType: "file", sourceId: "file-1", limit: 10 });
|
|
110
|
+
expect(page.items.map((c) => c.content)).toEqual(["rewritten"]);
|
|
111
|
+
});
|
|
112
|
+
it("does not touch another source when replacing one", async () => {
|
|
113
|
+
const { store } = await make();
|
|
114
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "a", chunks: [chunk({ index: 0, id: "a0", sourceId: "a" })] });
|
|
115
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "b", chunks: [chunk({ index: 0, id: "b0", sourceId: "b" })] });
|
|
116
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "a", chunks: [] });
|
|
117
|
+
expect((await store.listBySource({ tenantId: T1, sourceType: "file", sourceId: "b", limit: 10 })).items).toHaveLength(1);
|
|
118
|
+
});
|
|
119
|
+
it("treats the same id under a different source type as a different source", async () => {
|
|
120
|
+
// A file and an artifact can share an id. Keying on the id alone would have one delete the other's chunks.
|
|
121
|
+
const { store } = await make();
|
|
122
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "x", chunks: [chunk({ index: 0, id: "f0", sourceId: "x" })] });
|
|
123
|
+
await store.replaceSource({
|
|
124
|
+
tenantId: T1,
|
|
125
|
+
sourceType: "artifact",
|
|
126
|
+
sourceId: "x",
|
|
127
|
+
chunks: [chunk({ index: 0, id: "a0", sourceType: "artifact", sourceId: "x" })],
|
|
128
|
+
});
|
|
129
|
+
expect((await store.listBySource({ tenantId: T1, sourceType: "file", sourceId: "x", limit: 10 })).items).toHaveLength(1);
|
|
130
|
+
expect((await store.listBySource({ tenantId: T1, sourceType: "artifact", sourceId: "x", limit: 10 })).items).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
it("returns null for another tenant's chunk", async () => {
|
|
133
|
+
const { store } = await make();
|
|
134
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "file-1", chunks: [chunk({ index: 0 })] });
|
|
135
|
+
expect(await store.get({ tenantId: T2, id: "c0" })).toBeNull();
|
|
136
|
+
expect((await store.listBySource({ tenantId: T2, sourceType: "file", sourceId: "file-1", limit: 10 })).items).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
it("refuses a vector whose length contradicts its declared dimensions", async () => {
|
|
139
|
+
// A wrong-length vector would score against whatever prefix overlapped, which looks like bad relevance
|
|
140
|
+
// rather than a bug.
|
|
141
|
+
const { store } = await make();
|
|
142
|
+
await expect(store.replaceSource({
|
|
143
|
+
tenantId: T1,
|
|
144
|
+
sourceType: "file",
|
|
145
|
+
sourceId: "file-1",
|
|
146
|
+
chunks: [chunk({ index: 0, embedding: [0.1, 0.2, 0.3] })],
|
|
147
|
+
})).rejects.toThrow();
|
|
148
|
+
});
|
|
149
|
+
it("removes a source's chunks on delete", async () => {
|
|
150
|
+
// A deleted document's content must stop being searchable.
|
|
151
|
+
const { store } = await make();
|
|
152
|
+
await store.replaceSource({
|
|
153
|
+
tenantId: T1,
|
|
154
|
+
sourceType: "file",
|
|
155
|
+
sourceId: "file-1",
|
|
156
|
+
chunks: [chunk({ index: 0 }), chunk({ index: 1 })],
|
|
157
|
+
});
|
|
158
|
+
expect(await store.deleteSource({ tenantId: T1, sourceType: "file", sourceId: "file-1" })).toEqual({ removed: 2 });
|
|
159
|
+
expect((await store.listBySource({ tenantId: T1, sourceType: "file", sourceId: "file-1", limit: 10 })).items).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
it("does not delete another tenant's source", async () => {
|
|
162
|
+
const { store } = await make();
|
|
163
|
+
await store.replaceSource({ tenantId: T1, sourceType: "file", sourceId: "file-1", chunks: [chunk({ index: 0 })] });
|
|
164
|
+
expect(await store.deleteSource({ tenantId: T2, sourceType: "file", sourceId: "file-1" })).toEqual({ removed: 0 });
|
|
165
|
+
expect(await store.get({ tenantId: T1, id: "c0" })).not.toBeNull();
|
|
166
|
+
});
|
|
167
|
+
it("pages a source's chunks without repeating or skipping one", async () => {
|
|
168
|
+
const { store } = await make();
|
|
169
|
+
await store.replaceSource({
|
|
170
|
+
tenantId: T1,
|
|
171
|
+
sourceType: "file",
|
|
172
|
+
sourceId: "file-1",
|
|
173
|
+
chunks: [0, 1, 2, 3, 4].map((i) => chunk({ index: i })),
|
|
174
|
+
});
|
|
175
|
+
const seen = [];
|
|
176
|
+
let cursor;
|
|
177
|
+
do {
|
|
178
|
+
const page = await store.listBySource({
|
|
179
|
+
tenantId: T1,
|
|
180
|
+
sourceType: "file",
|
|
181
|
+
sourceId: "file-1",
|
|
182
|
+
limit: 2,
|
|
183
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
184
|
+
});
|
|
185
|
+
seen.push(...page.items.map((c) => c.chunkIndex));
|
|
186
|
+
cursor = page.nextCursor;
|
|
187
|
+
} while (cursor !== undefined);
|
|
188
|
+
expect(seen).toEqual([0, 1, 2, 3, 4]);
|
|
189
|
+
});
|
|
190
|
+
it("lists exactly the sources a model change made stale", async () => {
|
|
191
|
+
// AC-5's basis: the work list is derived from what is stored, so an interrupted re-index resumes by
|
|
192
|
+
// asking again rather than by remembering where it was.
|
|
193
|
+
const { store } = await make();
|
|
194
|
+
const older = { ...MODEL, version: "0" };
|
|
195
|
+
await store.replaceSource({
|
|
196
|
+
tenantId: T1,
|
|
197
|
+
sourceType: "file",
|
|
198
|
+
sourceId: "stale-1",
|
|
199
|
+
chunks: [chunk({ index: 0, id: "s0", sourceId: "stale-1", embeddingModel: older })],
|
|
200
|
+
});
|
|
201
|
+
await store.replaceSource({
|
|
202
|
+
tenantId: T1,
|
|
203
|
+
sourceType: "file",
|
|
204
|
+
sourceId: "fresh-1",
|
|
205
|
+
chunks: [chunk({ index: 0, id: "f0", sourceId: "fresh-1" })],
|
|
206
|
+
});
|
|
207
|
+
const page = await store.listStaleSources({ tenantId: T1, current: MODEL, limit: 10 });
|
|
208
|
+
expect(page.items).toEqual([{ sourceType: "file", sourceId: "stale-1", chunkCount: 1 }]);
|
|
209
|
+
});
|
|
210
|
+
it("refuses a vector of a different size rather than storing two sizes at once", async () => {
|
|
211
|
+
// A provider that keeps its model id and changes its *output size* is a different case from one that
|
|
212
|
+
// changes its version, and it is not a re-index: a vector column has one width and an index cannot span
|
|
213
|
+
// widths, so a size change is a **migration**. Asserted as a refusal rather than as staleness, because
|
|
214
|
+
// the store genuinely cannot hold both — found by running this harness against real pgvector, which
|
|
215
|
+
// rejected the 768-dimension write the earlier version of this case tried to make.
|
|
216
|
+
const { store } = await make();
|
|
217
|
+
await expect(store.replaceSource({
|
|
218
|
+
tenantId: T1,
|
|
219
|
+
sourceType: "file",
|
|
220
|
+
sourceId: "resized",
|
|
221
|
+
chunks: [
|
|
222
|
+
chunk({
|
|
223
|
+
index: 0,
|
|
224
|
+
id: "r0",
|
|
225
|
+
sourceId: "resized",
|
|
226
|
+
embeddingModel: { ...MODEL, dimensions: 768 },
|
|
227
|
+
embedding: new Array(768).fill(0.1),
|
|
228
|
+
}),
|
|
229
|
+
],
|
|
230
|
+
})).rejects.toThrow();
|
|
231
|
+
});
|
|
232
|
+
it("lists no stale source for another tenant", async () => {
|
|
233
|
+
const { store } = await make();
|
|
234
|
+
await store.replaceSource({
|
|
235
|
+
tenantId: T1,
|
|
236
|
+
sourceType: "file",
|
|
237
|
+
sourceId: "stale-1",
|
|
238
|
+
chunks: [chunk({ index: 0, embeddingModel: { ...MODEL, version: "0" } })],
|
|
239
|
+
});
|
|
240
|
+
expect((await store.listStaleSources({ tenantId: T2, current: MODEL, limit: 10 })).items).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
export function vectorIndexConformance(make, declaration) {
|
|
245
|
+
describe("VectorIndex conformance", () => {
|
|
246
|
+
const it = (name, fn) => gatedIt(declaration, "vector-search", name, fn);
|
|
247
|
+
const seeded = async () => {
|
|
248
|
+
const fixture = await make();
|
|
249
|
+
await fixture.store.replaceSource({
|
|
250
|
+
tenantId: T1,
|
|
251
|
+
sourceType: "file",
|
|
252
|
+
sourceId: "file-1",
|
|
253
|
+
chunks: [
|
|
254
|
+
chunk({ index: 0, id: "own-0", authSubject: "convo-1", embedding: axisVector(0) }),
|
|
255
|
+
chunk({ index: 1, id: "own-1", authSubject: "convo-1", embedding: axisVector(1) }),
|
|
256
|
+
chunk({ index: 2, id: "other-2", authSubject: "convo-2", embedding: axisVector(0) }),
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
await fixture.store.replaceSource({
|
|
260
|
+
tenantId: T2,
|
|
261
|
+
sourceType: "file",
|
|
262
|
+
sourceId: "file-1",
|
|
263
|
+
chunks: [chunk({ index: 0, id: "foreign-0", authSubject: "convo-1", embedding: axisVector(0) })],
|
|
264
|
+
});
|
|
265
|
+
return fixture;
|
|
266
|
+
};
|
|
267
|
+
it("returns the chunk nearest the query", async () => {
|
|
268
|
+
const { index } = await seeded();
|
|
269
|
+
const hits = await index.search({
|
|
270
|
+
tenantId: T1,
|
|
271
|
+
embedding: axisVector(1),
|
|
272
|
+
authSubjects: ["convo-1"],
|
|
273
|
+
limit: 1,
|
|
274
|
+
});
|
|
275
|
+
expect(hits.map((h) => h.chunk.id)).toEqual(["own-1"]);
|
|
276
|
+
expect(hits[0]?.score).toBeGreaterThan(0.5);
|
|
277
|
+
});
|
|
278
|
+
it("excludes a chunk whose subject was not asked for", async () => {
|
|
279
|
+
// AC-3. `other-2` sits on the same axis as `own-0`, so it *would* be a top hit if the filter were
|
|
280
|
+
// applied after retrieval -- which is exactly the leak this asserts against.
|
|
281
|
+
const { index } = await seeded();
|
|
282
|
+
const hits = await index.search({
|
|
283
|
+
tenantId: T1,
|
|
284
|
+
embedding: axisVector(0),
|
|
285
|
+
authSubjects: ["convo-1"],
|
|
286
|
+
limit: 10,
|
|
287
|
+
});
|
|
288
|
+
expect(hits.map((h) => h.chunk.id)).not.toContain("other-2");
|
|
289
|
+
});
|
|
290
|
+
it("returns nothing for an empty subject list", async () => {
|
|
291
|
+
// "No subjects" must mean nothing, not everything. The opposite reading is the worst possible default.
|
|
292
|
+
const { index } = await seeded();
|
|
293
|
+
expect(await index.search({ tenantId: T1, embedding: axisVector(0), authSubjects: [], limit: 10 })).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
it("does not return another tenant's chunk however close it is", async () => {
|
|
296
|
+
const { index } = await seeded();
|
|
297
|
+
const hits = await index.search({
|
|
298
|
+
tenantId: T1,
|
|
299
|
+
embedding: axisVector(0),
|
|
300
|
+
authSubjects: ["convo-1"],
|
|
301
|
+
limit: 10,
|
|
302
|
+
});
|
|
303
|
+
expect(hits.map((h) => h.chunk.id)).not.toContain("foreign-0");
|
|
304
|
+
});
|
|
305
|
+
it("honours the limit", async () => {
|
|
306
|
+
const { index } = await seeded();
|
|
307
|
+
expect(await index.search({ tenantId: T1, embedding: axisVector(0), authSubjects: ["convo-1", "convo-2"], limit: 1 })).toHaveLength(1);
|
|
308
|
+
});
|
|
309
|
+
it("returns hits in descending score order", async () => {
|
|
310
|
+
const { index } = await seeded();
|
|
311
|
+
const hits = await index.search({
|
|
312
|
+
tenantId: T1,
|
|
313
|
+
embedding: axisVector(0),
|
|
314
|
+
authSubjects: ["convo-1", "convo-2"],
|
|
315
|
+
limit: 10,
|
|
316
|
+
});
|
|
317
|
+
const scores = hits.map((h) => h.score);
|
|
318
|
+
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
|
|
319
|
+
});
|
|
320
|
+
it("drops hits below the minimum score", async () => {
|
|
321
|
+
// Prevents a query with no good answer returning noise, which a model then cites.
|
|
322
|
+
const { index } = await seeded();
|
|
323
|
+
const hits = await index.search({
|
|
324
|
+
tenantId: T1,
|
|
325
|
+
embedding: axisVector(1),
|
|
326
|
+
authSubjects: ["convo-1"],
|
|
327
|
+
limit: 10,
|
|
328
|
+
minScore: 0.9,
|
|
329
|
+
});
|
|
330
|
+
expect(hits.map((h) => h.chunk.id)).toEqual(["own-1"]);
|
|
331
|
+
});
|
|
332
|
+
it("filters by source type when asked", async () => {
|
|
333
|
+
const { index } = await seeded();
|
|
334
|
+
expect(await index.search({
|
|
335
|
+
tenantId: T1,
|
|
336
|
+
embedding: axisVector(0),
|
|
337
|
+
authSubjects: ["convo-1"],
|
|
338
|
+
limit: 10,
|
|
339
|
+
sourceTypes: ["artifact"],
|
|
340
|
+
})).toEqual([]);
|
|
341
|
+
});
|
|
342
|
+
it("never returns a vector with a hit", async () => {
|
|
343
|
+
const { index } = await seeded();
|
|
344
|
+
const hits = await index.search({
|
|
345
|
+
tenantId: T1,
|
|
346
|
+
embedding: axisVector(0),
|
|
347
|
+
authSubjects: ["convo-1"],
|
|
348
|
+
limit: 10,
|
|
349
|
+
});
|
|
350
|
+
for (const hit of hits)
|
|
351
|
+
expect(Object.keys(hit.chunk)).not.toContain("embedding");
|
|
352
|
+
});
|
|
353
|
+
it("finds a chunk written a moment ago", async () => {
|
|
354
|
+
// AC-4's mechanism at the store level: an index that needed a refresh step would fail here, and the
|
|
355
|
+
// freshness target would be unmeetable however fast the pipeline ran.
|
|
356
|
+
const { store, index } = await seeded();
|
|
357
|
+
await store.replaceSource({
|
|
358
|
+
tenantId: T1,
|
|
359
|
+
sourceType: "file",
|
|
360
|
+
sourceId: "brand-new",
|
|
361
|
+
chunks: [chunk({ index: 7, id: "new-7", sourceId: "brand-new", authSubject: "convo-1", embedding: axisVector(7) })],
|
|
362
|
+
});
|
|
363
|
+
const hits = await index.search({
|
|
364
|
+
tenantId: T1,
|
|
365
|
+
embedding: axisVector(7),
|
|
366
|
+
authSubjects: ["convo-1"],
|
|
367
|
+
limit: 1,
|
|
368
|
+
});
|
|
369
|
+
expect(hits.map((h) => h.chunk.id)).toEqual(["new-7"]);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
export function keywordIndexConformance(make, declaration) {
|
|
374
|
+
describe("KeywordIndex conformance", () => {
|
|
375
|
+
const it = (name, fn) => gatedIt(declaration, "vector-search", name, fn);
|
|
376
|
+
/**
|
|
377
|
+
* A corpus with an exact identifier, a near-miss, and a distractor.
|
|
378
|
+
*
|
|
379
|
+
* The identifier is the whole reason this port exists: `ERR-4021` embeds as a string that looks like other
|
|
380
|
+
* strings, so no vector index reliably finds it. The near-miss (`ERR-4022`) is what a substring matcher or
|
|
381
|
+
* a stemmed configuration would return alongside it.
|
|
382
|
+
*/
|
|
383
|
+
const seeded = async () => {
|
|
384
|
+
const fixture = await make();
|
|
385
|
+
await fixture.store.replaceSource({
|
|
386
|
+
tenantId: T1,
|
|
387
|
+
sourceType: "file",
|
|
388
|
+
sourceId: "runbook",
|
|
389
|
+
chunks: [
|
|
390
|
+
chunk({
|
|
391
|
+
index: 0,
|
|
392
|
+
id: "exact",
|
|
393
|
+
content: "Error ERR-4021 means the upstream billing service refused the request.",
|
|
394
|
+
authSubject: "convo-1",
|
|
395
|
+
}),
|
|
396
|
+
chunk({
|
|
397
|
+
index: 1,
|
|
398
|
+
id: "near",
|
|
399
|
+
content: "Error ERR-4022 means the upstream billing service timed out.",
|
|
400
|
+
authSubject: "convo-1",
|
|
401
|
+
}),
|
|
402
|
+
chunk({
|
|
403
|
+
index: 2,
|
|
404
|
+
id: "noise",
|
|
405
|
+
content: "The billing service runs in three regions and is monitored continuously.",
|
|
406
|
+
authSubject: "convo-1",
|
|
407
|
+
}),
|
|
408
|
+
chunk({
|
|
409
|
+
index: 3,
|
|
410
|
+
id: "restricted",
|
|
411
|
+
content: "Error ERR-4021 also appears in the confidential incident report.",
|
|
412
|
+
authSubject: "convo-restricted",
|
|
413
|
+
}),
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
await fixture.store.replaceSource({
|
|
417
|
+
tenantId: T2,
|
|
418
|
+
sourceType: "file",
|
|
419
|
+
sourceId: "runbook",
|
|
420
|
+
chunks: [
|
|
421
|
+
chunk({
|
|
422
|
+
index: 0,
|
|
423
|
+
id: "foreign",
|
|
424
|
+
content: "Error ERR-4021 in another tenant's runbook entirely.",
|
|
425
|
+
authSubject: "convo-1",
|
|
426
|
+
}),
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
return fixture;
|
|
430
|
+
};
|
|
431
|
+
const search = async (fixture, input) => (fixture.keyword === undefined ? [] : fixture.keyword.search(input));
|
|
432
|
+
it("finds an exact identifier and ranks it above its near neighbour", async () => {
|
|
433
|
+
// AC-1's exact-term half, and the case that justifies the whole port.
|
|
434
|
+
const fixture = await seeded();
|
|
435
|
+
const hits = await search(fixture, {
|
|
436
|
+
tenantId: T1,
|
|
437
|
+
query: "ERR-4021",
|
|
438
|
+
authSubjects: ["convo-1"],
|
|
439
|
+
limit: 5,
|
|
440
|
+
});
|
|
441
|
+
expect(hits[0]?.chunk.id).toBe("exact");
|
|
442
|
+
// The near-miss must not outrank it, and ideally must not match at all: `ERR-4022` is a different token.
|
|
443
|
+
expect(hits.map((h) => h.chunk.id)).not.toContain("near");
|
|
444
|
+
});
|
|
445
|
+
it("excludes a chunk whose subject was not asked for", async () => {
|
|
446
|
+
// AC-3, for the keyword signal. `restricted` contains the query term verbatim, so it would be a top hit
|
|
447
|
+
// if the filter were applied after the match.
|
|
448
|
+
const fixture = await seeded();
|
|
449
|
+
const hits = await search(fixture, {
|
|
450
|
+
tenantId: T1,
|
|
451
|
+
query: "ERR-4021",
|
|
452
|
+
authSubjects: ["convo-1"],
|
|
453
|
+
limit: 10,
|
|
454
|
+
});
|
|
455
|
+
expect(hits.map((h) => h.chunk.id)).not.toContain("restricted");
|
|
456
|
+
});
|
|
457
|
+
it("returns nothing for an empty subject list", async () => {
|
|
458
|
+
const fixture = await seeded();
|
|
459
|
+
expect(await search(fixture, { tenantId: T1, query: "ERR-4021", authSubjects: [], limit: 10 })).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
it("does not return another tenant's chunk", async () => {
|
|
462
|
+
const fixture = await seeded();
|
|
463
|
+
const hits = await search(fixture, {
|
|
464
|
+
tenantId: T1,
|
|
465
|
+
query: "ERR-4021",
|
|
466
|
+
authSubjects: ["convo-1"],
|
|
467
|
+
limit: 10,
|
|
468
|
+
});
|
|
469
|
+
expect(hits.map((h) => h.chunk.id)).not.toContain("foreign");
|
|
470
|
+
});
|
|
471
|
+
it("returns nothing when no chunk contains the terms", async () => {
|
|
472
|
+
// AC-4 at the port level: an empty result rather than the least-bad match. A keyword index that returned
|
|
473
|
+
// its closest row would hand a model something to cite about a subject the corpus never mentions.
|
|
474
|
+
const fixture = await seeded();
|
|
475
|
+
expect(await search(fixture, {
|
|
476
|
+
tenantId: T1,
|
|
477
|
+
query: "kubernetes helm chart",
|
|
478
|
+
authSubjects: ["convo-1"],
|
|
479
|
+
limit: 10,
|
|
480
|
+
})).toEqual([]);
|
|
481
|
+
});
|
|
482
|
+
it("returns nothing for an empty query rather than everything", async () => {
|
|
483
|
+
const fixture = await seeded();
|
|
484
|
+
expect(await search(fixture, { tenantId: T1, query: " ", authSubjects: ["convo-1"], limit: 10 })).toEqual([]);
|
|
485
|
+
});
|
|
486
|
+
it("returns nothing for a query made only of stopwords", async () => {
|
|
487
|
+
// `simple` text search keeps stopwords — that is the price of not stemming, which is what preserves
|
|
488
|
+
// `ERR-4021`. So they come off the *query*, and a search for "the" is a search for nothing rather than a
|
|
489
|
+
// ranking of whichever document says "the" most often. Pinned in the shared harness because both
|
|
490
|
+
// adapters must agree: found by measuring hybrid retrieval, where a decoy sharing only "was" and "the"
|
|
491
|
+
// outranked the document that answered the question.
|
|
492
|
+
const fixture = await seeded();
|
|
493
|
+
for (const query of ["the", "was the", "what is the", "and or the"]) {
|
|
494
|
+
expect(await search(fixture, { tenantId: T1, query, authSubjects: ["convo-1"], limit: 10 })).toEqual([]);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
it("does not let stopwords in a query decide the ranking", async () => {
|
|
498
|
+
// The same defect from the other side: with stopwords retained, "was the billing service down" ranks by
|
|
499
|
+
// how often a document says "the". The `noise` chunk mentions the billing service and nothing about an
|
|
500
|
+
// error; the `exact` chunk answers the question.
|
|
501
|
+
const fixture = await seeded();
|
|
502
|
+
const hits = await search(fixture, {
|
|
503
|
+
tenantId: T1,
|
|
504
|
+
query: "what was the ERR-4021 error about",
|
|
505
|
+
authSubjects: ["convo-1"],
|
|
506
|
+
limit: 5,
|
|
507
|
+
});
|
|
508
|
+
expect(hits[0]?.chunk.id).toBe("exact");
|
|
509
|
+
});
|
|
510
|
+
it("survives punctuation a user would actually type", async () => {
|
|
511
|
+
// A search box that errors on a stray quote is a search box that errors. Postgres's `to_tsquery` throws
|
|
512
|
+
// on all of these; `websearch_to_tsquery` does not, and that is why the adapter uses it.
|
|
513
|
+
const fixture = await seeded();
|
|
514
|
+
for (const query of ['billing "unclosed', "billing & ", "&&&", "billing |", "-", "billing OR"]) {
|
|
515
|
+
await expect(search(fixture, { tenantId: T1, query, authSubjects: ["convo-1"], limit: 5 })).resolves.toBeInstanceOf(Array);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
it("scores between zero and one, best first", async () => {
|
|
519
|
+
// Normalised so a caller fusing this with a cosine similarity does not have to know that one scale is
|
|
520
|
+
// bounded and the other is not.
|
|
521
|
+
const fixture = await seeded();
|
|
522
|
+
const hits = await search(fixture, {
|
|
523
|
+
tenantId: T1,
|
|
524
|
+
query: "billing service",
|
|
525
|
+
authSubjects: ["convo-1"],
|
|
526
|
+
limit: 10,
|
|
527
|
+
});
|
|
528
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
529
|
+
for (const hit of hits) {
|
|
530
|
+
expect(hit.score).toBeGreaterThanOrEqual(0);
|
|
531
|
+
expect(hit.score).toBeLessThanOrEqual(1);
|
|
532
|
+
}
|
|
533
|
+
const scores = hits.map((h) => h.score);
|
|
534
|
+
expect([...scores].sort((a, b) => b - a)).toEqual(scores);
|
|
535
|
+
});
|
|
536
|
+
it("honours the limit and the minimum score", async () => {
|
|
537
|
+
const fixture = await seeded();
|
|
538
|
+
expect(await search(fixture, {
|
|
539
|
+
tenantId: T1,
|
|
540
|
+
query: "billing service",
|
|
541
|
+
authSubjects: ["convo-1"],
|
|
542
|
+
limit: 1,
|
|
543
|
+
})).toHaveLength(1);
|
|
544
|
+
const strict = await search(fixture, {
|
|
545
|
+
tenantId: T1,
|
|
546
|
+
query: "billing service",
|
|
547
|
+
authSubjects: ["convo-1"],
|
|
548
|
+
limit: 10,
|
|
549
|
+
minScore: 0.99,
|
|
550
|
+
});
|
|
551
|
+
// Every returned hit clears the floor — the actual contract. An earlier version asserted *at most one*
|
|
552
|
+
// hit, which is not the contract: two chunks matching identically tie at the top score, and Postgres's
|
|
553
|
+
// `ts_rank_cd` does exactly that for the two `ERR-40xx` lines. A harness must not encode one adapter's
|
|
554
|
+
// tie-breaking as a guarantee.
|
|
555
|
+
for (const hit of strict)
|
|
556
|
+
expect(hit.score).toBeGreaterThanOrEqual(0.99);
|
|
557
|
+
// And the floor is genuinely applied rather than the loop above being vacuous. Asserted with a floor
|
|
558
|
+
// *above* any normalised score, because "the floor excludes something" depends on the corpus: all three
|
|
559
|
+
// chunks here contain both terms, and Postgres's cover density ranks them identically — which is correct,
|
|
560
|
+
// and which an earlier version of this assertion mistook for the floor not working.
|
|
561
|
+
expect(await search(fixture, {
|
|
562
|
+
tenantId: T1,
|
|
563
|
+
query: "billing service",
|
|
564
|
+
authSubjects: ["convo-1"],
|
|
565
|
+
limit: 10,
|
|
566
|
+
minScore: 1.01,
|
|
567
|
+
})).toEqual([]);
|
|
568
|
+
});
|
|
569
|
+
it("filters by source type when asked", async () => {
|
|
570
|
+
const fixture = await seeded();
|
|
571
|
+
expect(await search(fixture, {
|
|
572
|
+
tenantId: T1,
|
|
573
|
+
query: "billing",
|
|
574
|
+
authSubjects: ["convo-1"],
|
|
575
|
+
limit: 10,
|
|
576
|
+
sourceTypes: ["artifact"],
|
|
577
|
+
})).toEqual([]);
|
|
578
|
+
});
|
|
579
|
+
it("never returns a vector with a hit", async () => {
|
|
580
|
+
const fixture = await seeded();
|
|
581
|
+
const hits = await search(fixture, {
|
|
582
|
+
tenantId: T1,
|
|
583
|
+
query: "billing",
|
|
584
|
+
authSubjects: ["convo-1"],
|
|
585
|
+
limit: 10,
|
|
586
|
+
});
|
|
587
|
+
for (const hit of hits)
|
|
588
|
+
expect(Object.keys(hit.chunk)).not.toContain("embedding");
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
//# sourceMappingURL=knowledge.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent seeding for the conformance suite.
|
|
3
|
+
*
|
|
4
|
+
* An adapter is entitled to enforce referential integrity, and the Postgres schema does: a checkpoint
|
|
5
|
+
* belongs to a run, a message and a binding belong to a conversation. The in-memory reference adapter
|
|
6
|
+
* holds no such constraint, so the shared harnesses never created those parent rows — and every
|
|
7
|
+
* foreign key added to the schema broke them.
|
|
8
|
+
*
|
|
9
|
+
* #95 solved that for `CheckpointStore` with a bespoke fixture type. #96 needed the same thing for
|
|
10
|
+
* `ConversationBindingStore`, which is the point at which a one-off becomes a pattern. This is the
|
|
11
|
+
* shared mechanism: a harness accepts either a bare store (adapters with no constraints, unchanged)
|
|
12
|
+
* or a fixture pairing the store with the seeders its constraints require.
|
|
13
|
+
*
|
|
14
|
+
* The alternative was to drop the foreign keys so the harness would pass, which would have traded a
|
|
15
|
+
* real guarantee — orphan rows are impossible — for a green suite. That is the wrong direction.
|
|
16
|
+
*/
|
|
17
|
+
import type { ConversationId, RunId, TenantId } from "../../core/ids.js";
|
|
18
|
+
/** Creates whatever parent row an adapter's constraints require. Omitted when there are none. */
|
|
19
|
+
export type ParentSeeders = {
|
|
20
|
+
readonly seedRun?: (input: {
|
|
21
|
+
readonly tenantId: TenantId;
|
|
22
|
+
readonly runId: RunId;
|
|
23
|
+
}) => Promise<void>;
|
|
24
|
+
readonly seedConversation?: (input: {
|
|
25
|
+
readonly tenantId: TenantId;
|
|
26
|
+
readonly conversationId: ConversationId;
|
|
27
|
+
}) => Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
/** A store plus the seeders its adapter needs. Harnesses accept this or a bare store. */
|
|
30
|
+
export type Fixture<TStore> = ParentSeeders & {
|
|
31
|
+
readonly store: TStore;
|
|
32
|
+
};
|
|
33
|
+
/** What a harness is handed: either shape. */
|
|
34
|
+
export type FixtureOrStore<TStore> = TStore | Fixture<TStore>;
|
|
35
|
+
/**
|
|
36
|
+
* Normalise whichever shape the adapter supplied. Returns the store and the seeders, so a harness can
|
|
37
|
+
* seed unconditionally without caring which form it was given.
|
|
38
|
+
*/
|
|
39
|
+
export declare const openFixture: <TStore>(made: FixtureOrStore<TStore>) => Fixture<TStore>;
|
|
40
|
+
/** Seed a run if the adapter needs one, then hand back the store. */
|
|
41
|
+
export declare const withRun: <TStore>(made: FixtureOrStore<TStore>, runs: readonly {
|
|
42
|
+
tenantId: TenantId;
|
|
43
|
+
runId: RunId;
|
|
44
|
+
}[]) => Promise<TStore>;
|
|
45
|
+
/** Seed a conversation if the adapter needs one, then hand back the store. */
|
|
46
|
+
export declare const withConversation: <TStore>(made: FixtureOrStore<TStore>, conversations: readonly {
|
|
47
|
+
tenantId: TenantId;
|
|
48
|
+
conversationId: ConversationId;
|
|
49
|
+
}[]) => Promise<TStore>;
|
|
50
|
+
//# sourceMappingURL=parents.d.ts.map
|