@ultimat3/ai 1.0.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.
@@ -0,0 +1,179 @@
1
+ // The production vector store: pgvector in the same Postgres as everything else, no second
2
+ // datastore. It is the only store that runs in front of real traffic, so it is the one place
3
+ // the tenant and policy envelope has to be un-bypassable — every statement it emits is built on
4
+ // `conditionsSql`, and the fusion happens in SQL rather than after the rows are already loaded.
5
+
6
+ import { type DbClient, db, type SqlFragment } from '@ultimat3/db';
7
+ import { VectorDimMismatchError } from './errors';
8
+ import {
9
+ ddlSql,
10
+ deleteSql,
11
+ hybridSql,
12
+ type PgHybridArgs,
13
+ type PgVectorTable,
14
+ searchSql,
15
+ textSql,
16
+ upsertSql,
17
+ } from './pg-vector-sql';
18
+ import type {
19
+ HybridSearchInput,
20
+ MetadataFilter,
21
+ SearchHit,
22
+ VectorRecord,
23
+ VectorStore,
24
+ } from './vector';
25
+ import { narrowScope, tenantOf, UNSCOPED, type VectorScope } from './vector-scope';
26
+
27
+ export interface PgVectorStoreInput {
28
+ /** The table. Also the store's name in errors, and the stem of every index name. */
29
+ readonly name: string;
30
+ readonly dimension: number;
31
+ /** Defaults to the ambient `db()`, so a store inside `withTransaction` joins it. */
32
+ readonly client?: DbClient | undefined;
33
+ /** FTS `regconfig`. Changing it after `ddl()` has run needs a migration, not a restart. */
34
+ readonly language?: string | undefined;
35
+ /** The envelope this instance is bound to. `scoped()` is how request paths narrow it. */
36
+ readonly scope?: VectorScope | undefined;
37
+ }
38
+
39
+ /** The row every statement projects. `metadata` arrives as jsonb; drivers differ on parsing it. */
40
+ interface HitRow {
41
+ readonly id: string;
42
+ readonly content: string;
43
+ readonly metadata: unknown;
44
+ readonly score: unknown;
45
+ }
46
+
47
+ export class PgVectorStore implements VectorStore {
48
+ readonly name: string;
49
+ readonly dimension: number;
50
+ readonly scope: VectorScope;
51
+ private readonly input: PgVectorStoreInput;
52
+ private readonly target: PgVectorTable;
53
+
54
+ constructor(input: PgVectorStoreInput) {
55
+ this.input = input;
56
+ this.name = input.name;
57
+ this.dimension = input.dimension;
58
+ this.scope = input.scope ?? UNSCOPED;
59
+ this.target = {
60
+ table: input.name,
61
+ dimension: input.dimension,
62
+ language: input.language ?? 'english',
63
+ };
64
+ }
65
+
66
+ /** `x db gen` emits this; kept next to the queries so the index choice is reviewable. */
67
+ ddl(): string {
68
+ return ddlSql(this.target);
69
+ }
70
+
71
+ /**
72
+ * A view of the same table through a narrower envelope. The unscoped store is the backfill
73
+ * path; a request handler derives from it and can never widen back out.
74
+ */
75
+ scoped(scope: VectorScope): PgVectorStore {
76
+ return new PgVectorStore({
77
+ ...this.input,
78
+ scope: narrowScope(this.name, this.scope, scope),
79
+ });
80
+ }
81
+
82
+ async upsert(records: readonly VectorRecord[]): Promise<void> {
83
+ if (records.length === 0) return;
84
+ for (const record of records) this.assertDimension(record.vector.length);
85
+ await this.client().execute(
86
+ upsertSql(
87
+ this.target,
88
+ tenantOf(this.scope),
89
+ records.map((record) => ({
90
+ id: record.id,
91
+ vector: record.vector,
92
+ text: record.text,
93
+ metadata: record.metadata ?? {},
94
+ })),
95
+ ),
96
+ );
97
+ }
98
+
99
+ async search(
100
+ vector: Float32Array,
101
+ k: number,
102
+ filter?: MetadataFilter,
103
+ ): Promise<readonly SearchHit[]> {
104
+ this.assertDimension(vector.length);
105
+ return this.run(searchSql(this.target, vector, { scope: this.scope, filter, k }));
106
+ }
107
+
108
+ async searchText(
109
+ query: string,
110
+ k: number,
111
+ filter?: MetadataFilter,
112
+ ): Promise<readonly SearchHit[]> {
113
+ return this.run(textSql(this.target, query, { scope: this.scope, filter, k }));
114
+ }
115
+
116
+ async hybrid(input: HybridSearchInput): Promise<readonly SearchHit[]> {
117
+ this.assertDimension(input.vector.length);
118
+ const args: PgHybridArgs = {
119
+ scope: this.scope,
120
+ filter: input.filter,
121
+ k: input.k,
122
+ candidates: input.candidates ?? Math.max(input.k * 4, 20),
123
+ rrfK: input.rrfK ?? 60,
124
+ };
125
+ return this.run(hybridSql(this.target, input.query, input.vector, args));
126
+ }
127
+
128
+ async delete(ids: readonly string[]): Promise<void> {
129
+ if (ids.length === 0) return;
130
+ await this.client().execute(deleteSql(this.target, this.scope, ids));
131
+ }
132
+
133
+ private client(): DbClient {
134
+ return this.input.client ?? db();
135
+ }
136
+
137
+ private async run(statement: SqlFragment): Promise<readonly SearchHit[]> {
138
+ const rows = await this.client().query<HitRow>(statement);
139
+ return rows.map(toHit);
140
+ }
141
+
142
+ private assertDimension(received: number): void {
143
+ if (received !== this.dimension) {
144
+ throw new VectorDimMismatchError({
145
+ store: this.name,
146
+ expected: this.dimension,
147
+ received,
148
+ });
149
+ }
150
+ }
151
+ }
152
+
153
+ function toHit(row: HitRow): SearchHit {
154
+ return {
155
+ id: row.id,
156
+ score: Number(row.score),
157
+ text: row.content,
158
+ metadata: toMetadata(row.metadata),
159
+ };
160
+ }
161
+
162
+ /** jsonb comes back parsed on Bun.SQL and as text on some pools. Accept both, invent neither. */
163
+ function toMetadata(value: unknown): Readonly<Record<string, string>> {
164
+ const parsed = typeof value === 'string' ? safeParse(value) : value;
165
+ if (typeof parsed !== 'object' || parsed === null) return {};
166
+ const metadata: Record<string, string> = {};
167
+ for (const [key, entry] of Object.entries(parsed)) {
168
+ if (entry !== null && entry !== undefined) metadata[key] = String(entry);
169
+ }
170
+ return metadata;
171
+ }
172
+
173
+ function safeParse(value: string): unknown {
174
+ try {
175
+ return JSON.parse(value);
176
+ } catch {
177
+ return {};
178
+ }
179
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,169 @@
1
+ // Prompts as versioned artifacts.
2
+ //
3
+ // A prompt is code, so it gets the same treatment: an id, a declared version, and a content
4
+ // hash over everything that changes the model's behaviour. The hash is what makes an eval
5
+ // result meaningful — "score 0.94" is worthless unless you can say which exact prompt
6
+ // produced it, and a prompt edited in place under the same version silently invalidates
7
+ // every score ever recorded against it.
8
+ //
9
+ // So: edit the template, bump the version. Re-registering a version whose hash moved is a
10
+ // build error, not a warning.
11
+
12
+ import { AiPromptRenderError, AiPromptVersionError } from './errors';
13
+ import type { Effort, ModelId, ThinkingMode } from './models';
14
+ import type { JsonSchema } from './tools';
15
+
16
+ /** Template variables. Values are stringified at render time with no formatting magic. */
17
+ export type PromptVars = Readonly<Record<string, string | number | boolean>>;
18
+
19
+ export interface DefinePromptInput<V extends PromptVars> {
20
+ readonly id: string;
21
+ /** Semver-ish, author-assigned. Must change whenever `template` changes. */
22
+ readonly version: string;
23
+ /** `{{name}}` placeholders. Every key in `V` must appear; unfilled ones throw. */
24
+ readonly template: string;
25
+ /** Optional system prompt. Part of the hash — it changes behaviour. */
26
+ readonly system?: string;
27
+ /** Schema of the variables, for the manifest and for `x ai prompts`. */
28
+ readonly input?: JsonSchema;
29
+ /** Expected output shape, fed to `output_config.format` when the caller opts in. */
30
+ readonly output?: JsonSchema;
31
+ readonly model?: ModelId;
32
+ readonly effort?: Effort;
33
+ readonly thinking?: ThinkingMode;
34
+ /** Phantom marker so `V` is inferable from a call site that passes no runtime value. */
35
+ readonly vars?: (vars: V) => void;
36
+ }
37
+
38
+ export interface Prompt<V extends PromptVars = PromptVars> {
39
+ readonly id: string;
40
+ readonly version: string;
41
+ /** sha256 over id + version + system + template + schemas + model settings. */
42
+ readonly hash: string;
43
+ readonly template: string;
44
+ readonly system: string | undefined;
45
+ readonly input: JsonSchema | undefined;
46
+ readonly output: JsonSchema | undefined;
47
+ readonly model: ModelId | undefined;
48
+ readonly effort: Effort | undefined;
49
+ readonly thinking: ThinkingMode | undefined;
50
+ /** Substitute variables. Throws on an unfilled placeholder. */
51
+ render(vars: V): string;
52
+ /** `id@version` — the identity an eval result is filed under. */
53
+ readonly ref: string;
54
+ }
55
+
56
+ const registry = new Map<string, Prompt>();
57
+
58
+ export function definePrompt<V extends PromptVars>(input: DefinePromptInput<V>): Prompt<V> {
59
+ const hash = contentHash(input);
60
+ const key = `${input.id}@${input.version}`;
61
+ const existing = registry.get(key);
62
+ if (existing !== undefined && existing.hash !== hash) {
63
+ throw new AiPromptVersionError({
64
+ id: input.id,
65
+ requested: input.version,
66
+ available: [...registry.keys()].filter((k) => k.startsWith(`${input.id}@`)),
67
+ });
68
+ }
69
+
70
+ const prompt: Prompt<V> = {
71
+ id: input.id,
72
+ version: input.version,
73
+ hash,
74
+ template: input.template,
75
+ system: input.system,
76
+ input: input.input,
77
+ output: input.output,
78
+ model: input.model,
79
+ effort: input.effort,
80
+ thinking: input.thinking,
81
+ ref: key,
82
+ render: (vars) => render(input.template, vars, key),
83
+ };
84
+ registry.set(key, prompt as Prompt);
85
+ return prompt;
86
+ }
87
+
88
+ /** Look one up by id and version. Throws with the available versions listed. */
89
+ export function getPrompt(id: string, version: string): Prompt {
90
+ const found = registry.get(`${id}@${version}`);
91
+ if (found === undefined) {
92
+ throw new AiPromptVersionError({
93
+ id,
94
+ requested: version,
95
+ available: promptVersions(id),
96
+ });
97
+ }
98
+ return found;
99
+ }
100
+
101
+ export function promptVersions(id: string): readonly string[] {
102
+ return [...registry.values()]
103
+ .filter((p) => p.id === id)
104
+ .map((p) => p.version)
105
+ .sort();
106
+ }
107
+
108
+ /** Every registered prompt, stably ordered — consumed by `x manifest`. */
109
+ export function describePrompts(): readonly Prompt[] {
110
+ return [...registry.values()].sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
111
+ }
112
+
113
+ /** Test-only reset. Module-level registries otherwise leak between test files. */
114
+ export function resetPrompts(): void {
115
+ registry.clear();
116
+ }
117
+
118
+ const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
119
+
120
+ function render(template: string, vars: PromptVars, ref: string): string {
121
+ const missing: string[] = [];
122
+ const out = template.replace(PLACEHOLDER, (_match, name: string) => {
123
+ const value = vars[name];
124
+ if (value === undefined) {
125
+ missing.push(name);
126
+ return '';
127
+ }
128
+ return String(value);
129
+ });
130
+ if (missing.length > 0) {
131
+ // Loud, like an i18n miss: a silently blank variable is a prompt that reads fine and
132
+ // means something else.
133
+ throw new AiPromptRenderError({ ref, missing });
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * Canonical serialisation then sha256. Keys are written in a fixed order rather than
140
+ * `JSON.stringify(object)` so the hash never depends on property insertion order.
141
+ */
142
+ export function contentHash<V extends PromptVars>(input: DefinePromptInput<V>): string {
143
+ const canonical = [
144
+ `id:${input.id}`,
145
+ `version:${input.version}`,
146
+ `system:${input.system ?? ''}`,
147
+ `template:${input.template}`,
148
+ `input:${stableJson(input.input)}`,
149
+ `output:${stableJson(input.output)}`,
150
+ `model:${input.model ?? ''}`,
151
+ `effort:${input.effort ?? ''}`,
152
+ `thinking:${input.thinking ?? ''}`,
153
+ ].join('\n');
154
+ const hasher = new Bun.CryptoHasher('sha256');
155
+ hasher.update(canonical);
156
+ return hasher.digest('hex').slice(0, 32);
157
+ }
158
+
159
+ /** Sorted-key JSON so two structurally equal schemas hash identically. */
160
+ function stableJson(value: unknown): string {
161
+ if (value === undefined) return '';
162
+ return JSON.stringify(value, (_key, val: unknown) => {
163
+ if (typeof val !== 'object' || val === null || Array.isArray(val)) return val;
164
+ const record = val as Record<string, unknown>;
165
+ const sorted: Record<string, unknown> = {};
166
+ for (const key of Object.keys(record).sort()) sorted[key] = record[key];
167
+ return sorted;
168
+ });
169
+ }