pgkiln 0.69.0 → 0.71.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.
Files changed (63) hide show
  1. package/db/migrations/136_vector_search.sql +252 -0
  2. package/db/migrations/137_create_page_types.sql +904 -0
  3. package/db/migrations/138_app_utilities.sql +153 -0
  4. package/docs/apex-feature-parity.md +10 -8
  5. package/docs/guide/03-builder.md +176 -4
  6. package/docs/guide/04-pages-and-regions.md +45 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/06-processing.md +18 -0
  9. package/docs/guide/09-reference.md +34 -1
  10. package/docs/guide/12-development.md +53 -3
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/docs/guide/16-files.md +1 -1
  13. package/examples/hr/README.md +1 -0
  14. package/examples/hr/hr_72_vector_search.sql +158 -0
  15. package/examples/hr/hr_73_configuration_options.sql +17 -0
  16. package/examples/hr/hr_74_app_utilities.sql +22 -0
  17. package/package.json +1 -1
  18. package/public/app.css +9 -0
  19. package/public/builder.css +48 -0
  20. package/src/ai/embeddings.ts +197 -0
  21. package/src/ai/service.ts +13 -9
  22. package/src/app.ts +13 -0
  23. package/src/builder/advisor.ts +43 -3
  24. package/src/builder/ai-agents.ts +13 -5
  25. package/src/builder/app-updates.ts +3 -0
  26. package/src/builder/components.ts +24 -4
  27. package/src/builder/create-page.ts +259 -0
  28. package/src/builder/dependencies.ts +311 -0
  29. package/src/builder/features.ts +313 -0
  30. package/src/builder/home.ts +3 -0
  31. package/src/builder/i18n/de.ts +38 -0
  32. package/src/builder/i18n/en.ts +38 -0
  33. package/src/builder/i18n/es.ts +38 -0
  34. package/src/builder/i18n/fr.ts +38 -0
  35. package/src/builder/i18n/it.ts +38 -0
  36. package/src/builder/i18n/ja.ts +38 -0
  37. package/src/builder/i18n/ko.ts +38 -0
  38. package/src/builder/i18n/nl.ts +38 -0
  39. package/src/builder/i18n/pt.ts +38 -0
  40. package/src/builder/i18n/zh-cn.ts +38 -0
  41. package/src/builder/i18n/zh-tw.ts +38 -0
  42. package/src/builder/login-page.ts +17 -14
  43. package/src/builder/newapp.ts +41 -23
  44. package/src/builder/object-actions.ts +1 -0
  45. package/src/builder/routes.ts +13 -2
  46. package/src/builder/shared.ts +1 -1
  47. package/src/builder/sql.ts +3 -0
  48. package/src/builder/table-editor.ts +1 -0
  49. package/src/builder/ui-defaults.ts +161 -0
  50. package/src/builder/ui.ts +2 -1
  51. package/src/builder/upgrade.ts +154 -0
  52. package/src/builder/utilities.ts +181 -0
  53. package/src/builder/vector-column.ts +229 -0
  54. package/src/builder/vector-providers.ts +286 -0
  55. package/src/builder/wizards.ts +211 -26
  56. package/src/cli/replace.ts +11 -1
  57. package/src/history.ts +155 -0
  58. package/src/runtime/ai-agents.ts +53 -2
  59. package/src/runtime/builtin-components.ts +23 -2
  60. package/src/runtime/items.ts +4 -2
  61. package/src/runtime/search.ts +116 -12
  62. package/src/server.ts +3 -0
  63. package/src/vector.ts +288 -0
@@ -7,6 +7,7 @@ import { checkArgs, toolSchema, type ParamDef, type ToolDef } from './assistant.
7
7
  import { isAuthorized, pageAllowed } from './authz.ts';
8
8
  import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
9
9
  import { txContext } from './routes.ts';
10
+ import { retrieve } from './search.ts';
10
11
 
11
12
  // AI agents and AI tools (migration 121; APEX 26.1 / 26.2: Shared Components
12
13
  // → AI Agents). Both are lists in meta.app (ai_agents, ai_tools), exported
@@ -31,11 +32,18 @@ import { txContext } from './routes.ts';
31
32
  // values of that page; pgkiln refuses a page the user may not open and
32
33
  // builds the URL itself (item values with their checksum, bound to app,
33
34
  // page and user). The link is shown under the answer; nothing opens it.
35
+ // - retrieve (136): RAG. The model gives a text (QUERY); pgkiln runs the
36
+ // tool's search configuration (vector, hybrid, full-text or standard) as
37
+ // the application's role in a read-only transaction, so row level
38
+ // security decides which rows the model sees, and returns the best rows'
39
+ // title, text and score (max_rows, default 5).
34
40
  // Every tool call is logged with its arguments (meta.ai_tool_call, the
35
41
  // Activity → AI usage page) and in the page's debug messages.
36
42
 
37
- export type AiToolType = 'sql' | 'function' | 'rest' | 'javascript' | 'navigate';
38
- export const AI_TOOL_TYPES: AiToolType[] = ['sql', 'function', 'rest', 'javascript', 'navigate'];
43
+ export type AiToolType = 'sql' | 'function' | 'rest' | 'javascript' | 'navigate' | 'retrieve';
44
+ export const AI_TOOL_TYPES: AiToolType[] = ['sql', 'function', 'rest', 'javascript', 'navigate', 'retrieve'];
45
+ /** (136) Rows a Retrieve tool returns by default. */
46
+ const RETRIEVE_ROWS = 5;
39
47
  export const EFFORTS = ['low', 'medium', 'high'] as const;
40
48
 
41
49
  export interface AiTool {
@@ -47,6 +55,8 @@ export interface AiTool {
47
55
  source?: string;
48
56
  action?: string;
49
57
  pages?: number[];
58
+ /** (136) retrieve: the search configuration (static ID) */
59
+ search_config?: string;
50
60
  parameters?: Record<string, ParamDef>;
51
61
  max_rows?: number;
52
62
  timeout_s?: number;
@@ -116,6 +126,9 @@ export function aiToolProblems(t: unknown): string[] {
116
126
  if (!Array.isArray(t.pages) || !t.pages.length || t.pages.length > 50 || t.pages.some((p: unknown) => !intIn(p, 1, 2_147_483_647)))
117
127
  out.push(`${what}: "pages" lists the page numbers the agent may offer (1 to 50 pages).`);
118
128
  if (t.parameters !== undefined) out.push(`${what}: a navigate tool has no parameters (the model picks a page, a link text and item values).`);
129
+ } else if (t.type === 'retrieve') {
130
+ if (typeof t.search_config !== 'string' || !/^[A-Z][A-Z0-9_]{0,59}$/.test(t.search_config)) out.push(`${what}: "search_config" names a search configuration (e.g. POLICIES).`);
131
+ if (t.parameters !== undefined) out.push(`${what}: a retrieve tool has no parameters (the model gives the text to look for).`);
119
132
  } else out.push(...paramProblems(what, t.parameters));
120
133
  if (t.max_rows !== undefined && !intIn(t.max_rows, 1, 500)) out.push(`${what}: maximum rows 1 to 500.`);
121
134
  if (t.timeout_s !== undefined && !intIn(t.timeout_s, 1, 60)) out.push(`${what}: time limit 1 to 60 seconds.`);
@@ -201,6 +214,14 @@ export interface PreparedTool {
201
214
 
202
215
  const asToolDef = (t: AiTool): ToolDef => ({ name: t.name, description: t.description, parameters: t.parameters, max_rows: t.max_rows });
203
216
 
217
+ /** (136) The one parameter of a Retrieve tool. */
218
+ const RETRIEVE_SCHEMA = {
219
+ type: 'object',
220
+ properties: { QUERY: { type: 'string', description: 'What to look for, in your own words (a question or a few words); the search finds rows by meaning.' } },
221
+ required: ['QUERY'],
222
+ additionalProperties: false,
223
+ };
224
+
204
225
  /**
205
226
  * The agent's tools this user may use (their authorization schemes), with their schemas. Navigate tools keep
206
227
  * only the pages the user may open (checked here, in the caller's transaction). Needs ctx.client.
@@ -209,6 +230,10 @@ export async function prepareTools(ctx: PageContext, tools: AiTool[]): Promise<P
209
230
  const out: PreparedTool[] = [];
210
231
  for (const tool of tools) {
211
232
  if (!(await isAuthorized(ctx, tool.authz))) continue;
233
+ if (tool.type === 'retrieve') {
234
+ out.push({ tool, chat: { name: tool.name, description: `${tool.description} Returns the best matching rows (title, text, score) the user may read.`, parameters: RETRIEVE_SCHEMA } });
235
+ continue;
236
+ }
212
237
  if (tool.type !== 'navigate') {
213
238
  const extra = tool.type === 'javascript' ? ' It runs in the user\'s browser after your answer; it returns no data.' : '';
214
239
  out.push({ tool, chat: { name: tool.name, description: tool.description + extra, parameters: toolSchema(asToolDef(tool)) } });
@@ -309,6 +334,7 @@ export async function runAgentTool(ctx: PageContext, p: PreparedTool, input: Rec
309
334
  const { tool } = p;
310
335
  if (tool.type === 'navigate') return navigate(ctx, p, input, extras);
311
336
  if (tool.type === 'rest') return rest({ ...asToolDef(tool), type: 'rest', source: tool.source }, input);
337
+ if (tool.type === 'retrieve') return retrieveTool(ctx, tool, input);
312
338
  const checked = checkArgs(asToolDef(tool), input);
313
339
  if ('error' in checked) return { content: checked.error, isError: true };
314
340
  if (tool.type === 'javascript') {
@@ -336,6 +362,31 @@ export async function runAgentTool(ctx: PageContext, p: PreparedTool, input: Rec
336
362
  }
337
363
  }
338
364
 
365
+ /**
366
+ * (136) A Retrieve tool: the search configuration as the application's role in a read-only transaction with the
367
+ * tool's time limit (row level security applies), its best rows as JSON for the model.
368
+ */
369
+ async function retrieveTool(ctx: PageContext, tool: AiTool, input: Record<string, unknown>): Promise<AgentOutcome> {
370
+ for (const k of Object.keys(input)) if (k !== 'QUERY') return { content: `Unknown argument "${k}".`, isError: true };
371
+ const query = typeof input.QUERY === 'string' ? input.QUERY.replace(/\s+/g, ' ').trim().slice(0, 1000) : '';
372
+ if (!query) return { content: 'QUERY is the text to look for.', isError: true };
373
+ const max = tool.max_rows ?? RETRIEVE_ROWS;
374
+ const before = ctx.client;
375
+ try {
376
+ const found = await appTx(txContext(ctx), async (c) => {
377
+ await c.query(`select set_config('statement_timeout', $1, true)`, [`${tool.timeout_s ?? DEFAULT_TIMEOUT_S}s`]);
378
+ await c.query('set transaction read only');
379
+ ctx.client = c;
380
+ return retrieve(ctx, tool.search_config!, query, max);
381
+ });
382
+ return { content: JSON.stringify(found) };
383
+ } catch (e) {
384
+ return { content: await publicError(ctx, e, `AI tool ${tool.name}`), isError: true };
385
+ } finally {
386
+ ctx.client = before;
387
+ }
388
+ }
389
+
339
390
  // ---------------------------------------------------------------- the log
340
391
 
341
392
  const MAX_ARG_CHARS = 4000;
@@ -1,8 +1,8 @@
1
1
  import type { TemplateComponent } from './template-components.ts';
2
2
 
3
3
  // Built-in template components (APEX: the Universal Theme's template
4
- // components: Avatar, Badge, Comments, Media List, Timeline, and 26.1's
5
- // Metric Card). Every application can use them in a template_component
4
+ // components: Avatar, Badge, Comments, Content Row (137), Media List,
5
+ // Timeline, and 26.1's Metric Card). Every application can use them in a template_component
6
6
  // region or as a report column template without importing anything; a
7
7
  // component of the application with the same static id takes its place
8
8
  // (Shared Components → Template components → Copy into this application).
@@ -53,6 +53,27 @@ export const BUILTIN_COMPONENTS: readonly TemplateComponent[] = [
53
53
  css_classes: [],
54
54
  attributes: [text('USER', 'User'), text('INITIALS', 'Initials'), text('DATE', 'Date'), text('COMMENT', 'Comment'), text('ACTIONS', 'Extra text', 'Optional, e.g. "edited".')],
55
55
  },
56
+ {
57
+ // (137) APEX: Content Row: an avatar, a title and description, and on the right a secondary text and a badge
58
+ static_id: 'ut_content_row',
59
+ name: 'Content row',
60
+ description: 'Rows with initials or a picture, a title (linked when the region has a link) and a description, and on the right a secondary text and a badge. Show the region as "multiple".',
61
+ template:
62
+ '<li class="tc-content-row">{if ?IMAGE/}<img class="tc-avatar tc-avatar-md" src="#IMAGE#" alt="" loading="lazy">{else/}<span class="tc-avatar tc-avatar-md" aria-hidden="true">#INITIALS#</span>{endif/}' +
63
+ '<div class="tc-stack"><p class="tc-title">{if ?LINK/}<a href="#LINK#">#TITLE#</a>{else/}#TITLE#{endif/}</p>{if ?DESCRIPTION/}<p class="tc-meta">#DESCRIPTION#</p>{endif/}</div>' +
64
+ '<div class="tc-content-row-end">{if ?MISC/}<span class="tc-meta">#MISC#</span>{endif/}{if ?BADGE/}<span class="tc-badge tc-badge-' + STATE + '">#BADGE#</span>{endif/}</div></li>',
65
+ wrapper: '<ul class="tc-content-rows">#APEX$ROWS#</ul>',
66
+ css_classes: [],
67
+ attributes: [
68
+ text('TITLE', 'Title'),
69
+ text('DESCRIPTION', 'Description'),
70
+ text('MISC', 'Secondary text', 'Shown on the right, e.g. a date or an amount.'),
71
+ text('INITIALS', 'Initials', 'Shown without a picture.'),
72
+ text('IMAGE', 'Picture URL', 'Optional: a relative or https URL.'),
73
+ text('BADGE', 'Badge'),
74
+ text('STATE', 'Badge state', 'success, warning, danger, info or neutral.'),
75
+ ],
76
+ },
56
77
  {
57
78
  static_id: 'ut_media_list',
58
79
  name: 'Media list',
@@ -309,9 +309,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
309
309
  case 'checkbox_group': {
310
310
  useLegend = true;
311
311
  const selected = new Set(splitValues(value));
312
- control = html`<div class="radio-group">${options.map(
312
+ // (137) {"switches": true}: each value as a switch (the Configuration page's build options)
313
+ const switches = item.config?.switches === true;
314
+ control = html`<div class="radio-group${switches ? ' switch-group' : ''}">${options.map(
313
315
  (o, i) =>
314
- html`<label class="check"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
316
+ html`<label class="check${switches ? ' switch' : ''}"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${switches ? raw(' role="switch"') : ''}${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
315
317
  )}</div>`;
316
318
  break;
317
319
  }
@@ -18,7 +18,14 @@
18
18
  // (between private-use markers that only then become <mark>, everything
19
19
  // else escaped); the text search configuration is checked against
20
20
  // pg_ts_config and passed as a parameter. With pg_trgm installed, a
21
- // configuration may suggest the closest words when nothing is found. The
21
+ // configuration may suggest the closest words when nothing is found. Search
22
+ // type vector (136): the search text embedded by the configuration's vector
23
+ // provider (src/ai/embeddings.ts), the rows nearest by the vector column
24
+ // (pgvector's cosine, L2 or inner product distance, operators
25
+ // schema-qualified, the vector a query parameter), at least the minimum
26
+ // similarity. Search type hybrid (136): the full-text and the vector
27
+ // rankings merged by reciprocal rank fusion (1 / (60 + rank), summed per
28
+ // key). Without pgvector both say what is missing. The
22
29
  // queries run as the application's role in the page's transaction, each in a
23
30
  // savepoint, so one configuration that fails shows its error and the others
24
31
  // still answer. Without JavaScript the field is a GET form; app.js searches
@@ -29,7 +36,9 @@ import { runtime, savepoint } from '../db.ts';
29
36
  import { html, raw, type Raw } from '../html.ts';
30
37
  import { icon } from '../icons.ts';
31
38
  import type { Region } from '../metadata.ts';
32
- import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
39
+ import { embedQuery, vectorLiteral, vectorProviderFor } from '../ai/embeddings.ts';
40
+ import { DISTANCES, distanceSql, isDistance, PGVECTOR_MISSING, vectorSchema, type Distance } from '../vector.ts';
41
+ import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
33
42
  import { fillItems, linkAttrs } from './links.ts';
34
43
  import { listTree, type ListNode } from './lists.ts';
35
44
 
@@ -49,14 +58,24 @@ export interface SearchConfig {
49
58
  icon: string | null;
50
59
  link: { page?: number; items?: Record<string, string> } | null;
51
60
  max_results: number;
52
- /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search (later: vector, hybrid) */
53
- search_type?: 'standard' | 'fulltext';
61
+ /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search · (136) vector, hybrid */
62
+ search_type?: (typeof SEARCH_TYPES)[number];
54
63
  /** (135) fulltext: a tsvector column or expression; empty: to_tsvector over the searchable columns */
55
64
  fts_vector?: string | null;
56
65
  /** (135) fulltext: the text search configuration; empty: the application language's */
57
66
  fts_config?: string | null;
58
67
  /** (135) with pg_trgm: suggest the closest words when nothing is found */
59
68
  did_you_mean?: boolean;
69
+ /** (136) vector, hybrid: the vector provider (static ID) that embeds the search text */
70
+ vector_provider?: string | null;
71
+ /** (136) vector, hybrid: the source's vector column */
72
+ vector_column?: string | null;
73
+ /** (136) hybrid: the key that matches the full-text and the vector results */
74
+ key_column?: string | null;
75
+ /** (136) vector, hybrid: cosine, l2 or inner_product */
76
+ distance?: Distance;
77
+ /** (136) vector, hybrid: at least this similarity */
78
+ min_similarity?: number | null;
60
79
  }
61
80
 
62
81
  export interface SearchRegionConfig {
@@ -76,8 +95,11 @@ const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
76
95
  /** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
77
96
  type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
78
97
 
79
- /** (135) The search types (later: vector, hybrid). */
80
- export const SEARCH_TYPES = ['standard', 'fulltext'] as const;
98
+ /** (135) The search types; (136) vector and hybrid. */
99
+ export const SEARCH_TYPES = ['standard', 'fulltext', 'vector', 'hybrid'] as const;
100
+ /** (136) Rows each ranking of a hybrid search contributes, and reciprocal rank fusion's constant. */
101
+ const HYBRID_DEPTH = 50;
102
+ const RRF_K = 60;
81
103
  const TS_CONFIG = /^[a-z_][a-z0-9_]{0,62}$/;
82
104
 
83
105
  /** (135) The text search configuration of a language: pgkiln's languages that PostgreSQL has a stemmer for. */
@@ -156,6 +178,20 @@ export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
156
178
  if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
157
179
  if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
158
180
  }
181
+ if (c.search_type === 'vector' || c.search_type === 'hybrid') {
182
+ if (c.source_type === 'list') out.push('Vector search needs a table or SQL source.');
183
+ if (!c.vector_provider || !/^[A-Z][A-Z0-9_]{0,59}$/.test(c.vector_provider)) out.push('Choose the vector provider that embeds the search text.');
184
+ if (!c.vector_column || !COLUMN.test(c.vector_column)) out.push('Name the vector column of the source (e.g. embedding).');
185
+ if (c.distance !== undefined && c.distance !== null && !isDistance(c.distance)) out.push('The distance is cosine, l2 or inner_product.');
186
+ if (c.min_similarity !== undefined && c.min_similarity !== null && !(Number.isFinite(Number(c.min_similarity)) && Number(c.min_similarity) >= -1 && Number(c.min_similarity) <= 1))
187
+ out.push('The minimum similarity is a number from -1 to 1 (empty: none).');
188
+ }
189
+ if (c.search_type === 'hybrid') {
190
+ if (!c.key_column || !COLUMN.test(c.key_column)) out.push('A hybrid search needs the key column that matches full-text and vector results (e.g. id).');
191
+ if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
192
+ if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
193
+ }
194
+ if (c.key_column && !COLUMN.test(c.key_column)) out.push(`"${c.key_column}" is not a column name.`);
159
195
  return out;
160
196
  }
161
197
 
@@ -167,7 +203,8 @@ async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
167
203
  await runtime.query<SearchConfig>(
168
204
  `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
169
205
  badge_column, icon, link, max_results,
170
- search_type, fts_vector, fts_config, did_you_mean
206
+ search_type, fts_vector, fts_config, did_you_mean,
207
+ vector_provider, vector_column, key_column, distance, min_similarity
171
208
  from meta.search_config
172
209
  where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
173
210
  order by seq, name`,
@@ -185,6 +222,8 @@ interface Hit {
185
222
  href: Raw | null;
186
223
  /** (135) full-text search: title, subtitle and description as highlighted by ts_headline */
187
224
  headlines?: { title: Raw; subtitle: Raw | null; description: Raw | null };
225
+ /** (136) vector: the similarity; hybrid: the fused score */
226
+ score?: number;
188
227
  }
189
228
 
190
229
  const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
@@ -225,7 +264,9 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], qu
225
264
  const from = await sourceFrom(ctx, c);
226
265
  let rows: Record<string, unknown>[];
227
266
  const fulltext = c.search_type === 'fulltext';
228
- if (fulltext) {
267
+ if (c.search_type === 'vector' || c.search_type === 'hybrid') {
268
+ rows = await vectorRows(ctx, c, from, query);
269
+ } else if (fulltext) {
229
270
  // $1 the text search configuration (a name from pg_ts_config), $2 the user's text, $3 the headline options,
230
271
  // $4 the marker characters (taken out of the values first, so only ts_headline's markers become markup)
231
272
  const cfg = await textSearchConfig(ctx.client!, c.fts_config, ctx.app.language);
@@ -261,10 +302,71 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], qu
261
302
  ? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
262
303
  : null,
263
304
  ...(fulltext ? { headlines: { title: h(row, 'title') ?? html`${col(row, c.title_column) ?? ''}`, subtitle: h(row, 'subtitle'), description: h(row, 'description') } } : {}),
305
+ ...(typeof row['pgkiln$score'] === 'number' ? { score: Math.round((row['pgkiln$score'] as number) * 10_000) / 10_000 } : {}),
264
306
  })),
265
307
  };
266
308
  }
267
309
 
310
+ /**
311
+ * (136) A vector or hybrid configuration's rows (up to max_results + 1), best first, each with "pgkiln$score"
312
+ * (vector: the similarity; hybrid: the fused score). Runs on ctx.client: the application's role, through RLS.
313
+ */
314
+ async function vectorRows(ctx: PageContext, c: SearchConfig, from: string, query: string): Promise<Record<string, unknown>[]> {
315
+ const client = ctx.client!;
316
+ const schema = await vectorSchema(client);
317
+ if (!schema) throw new Error(PGVECTOR_MISSING);
318
+ const provider = await vectorProviderFor(ctx.app.id, c.vector_provider!);
319
+ const vector = await embedQuery(provider, query, { appId: ctx.app.id, pageNo: ctx.page?.page_no ?? null, user: ctx.user, debug: (l, x) => dbg(ctx, l, 'search', x) }, client);
320
+ const distance: Distance = isDistance(c.distance) ? c.distance : 'cosine';
321
+ const vcol = `t.${ident(c.vector_column!)}`;
322
+ const d = distanceSql(schema, distance, vcol, `$1::text::${ident(schema)}.vector`);
323
+ const minSim = c.min_similarity === null || c.min_similarity === undefined ? null : Number(c.min_similarity);
324
+ const sim = DISTANCES[distance].similarity;
325
+ if (c.search_type === 'vector') {
326
+ // the nearest rows (an HNSW index serves "order by distance limit n"), then those similar enough
327
+ const sql = `select s.*, ${sim('s."pgkiln$distance"')}::float8 as "pgkiln$score"
328
+ from (select t.*, ${d} as "pgkiln$distance" from ${from} where ${vcol} is not null order by ${d} limit ${c.max_results + 1}) s
329
+ where $2::float8 is null or ${sim('s."pgkiln$distance"')} >= $2::float8
330
+ order by s."pgkiln$distance"`;
331
+ return (await client.query(sql, [vectorLiteral(vector), minSim])).rows;
332
+ }
333
+ // hybrid: each ranking's best HYBRID_DEPTH rows, fused by reciprocal rank (1 / (60 + rank)) per key
334
+ const cfg = await textSearchConfig(client, c.fts_config, ctx.app.language);
335
+ const vec = tsvectorSql(c, '$3::regconfig');
336
+ const key = ident(c.key_column!);
337
+ const sql = `with v as (select k, row_number() over (order by dist) as r
338
+ from (select t.${key} as k, ${d} as dist from ${from} where ${vcol} is not null order by ${d} limit ${HYBRID_DEPTH}) x
339
+ where $2::float8 is null or ${sim('dist')} >= $2::float8),
340
+ f as (select k, row_number() over (order by rank desc) as r
341
+ from (select t.${key} as k, ts_rank_cd(${vec}, q.query) as rank from ${from}, websearch_to_tsquery($3::regconfig, $4) q(query)
342
+ where ${vec} @@ q.query order by 2 desc limit ${HYBRID_DEPTH}) y),
343
+ s as (select k, sum(1.0 / (${RRF_K} + r))::float8 as score from (select k, r from v union all select k, r from f) z group by k)
344
+ select t.*, s.score as "pgkiln$score" from ${from} join s on s.k = t.${key}
345
+ order by s.score desc${c.title_column ? `, t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
346
+ return (await client.query(sql, [vectorLiteral(vector), minSim, cfg, query])).rows;
347
+ }
348
+
349
+ /**
350
+ * (136) The Retrieve tool of an AI agent: a table or SQL configuration's best rows for a text, as the application's
351
+ * role (ctx.client, in the caller's read-only transaction): title, subtitle, description and badge, and the score.
352
+ */
353
+ export async function retrieve(ctx: PageContext, name: string, query: string, max: number) {
354
+ const c = (await runtime.query<SearchConfig>(
355
+ `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
356
+ badge_column, icon, link, max_results, search_type, fts_vector, fts_config, did_you_mean,
357
+ vector_provider, vector_column, key_column, distance, min_similarity
358
+ from meta.search_config where app_id = $1 and name = $2 and meta.build_option_on(app_id, build_option)`, [ctx.app.id, name.toUpperCase()])).rows[0];
359
+ if (!c) throw new Error(`There is no search configuration ${name.toUpperCase()}.`);
360
+ if (c.source_type === 'list') throw new Error(`Search configuration ${c.name} searches a list: a Retrieve tool needs a table or SQL source.`);
361
+ const problems = searchConfigProblems(c);
362
+ if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
363
+ const { hits, more } = await searchRows(ctx, { ...c, max_results: max }, searchWords(query), query.slice(0, MAX_TEXT).trim());
364
+ return {
365
+ rows: hits.map((h) => ({ title: h.title, ...(h.subtitle ? { subtitle: h.subtitle } : {}), ...(h.description ? { text: h.description } : {}), ...(h.badge ? { badge: h.badge } : {}), ...(h.score !== undefined ? { score: h.score } : {}) })),
366
+ ...(more ? { more_rows: true } : {}),
367
+ };
368
+ }
369
+
268
370
  /**
269
371
  * (135) "Did you mean": with pg_trgm installed, each search word that is not a word of the configuration's
270
372
  * searched columns replaced by the most similar one (similarity 0.3 or more), from the first 10,000 rows of the
@@ -350,14 +452,16 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
350
452
  if (!hits.length) continue;
351
453
  body = html`<ul class="search-hits">${hits.map((h) => {
352
454
  // (135) full-text results: ts_headline's highlights (stemmed words: "running" marks "run"); else the words
353
- const title = h.headlines?.title ?? marked(h.title, words);
354
- const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, words)) : null;
355
- const description = h.description ? (h.headlines?.description ?? marked(h.description, words)) : null;
455
+ // (136) results by meaning: no word is marked (they need not contain the words)
456
+ const by = c.search_type === 'vector' || c.search_type === 'hybrid' ? [] : words;
457
+ const title = h.headlines?.title ?? marked(h.title, by);
458
+ const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, by)) : null;
459
+ const description = h.description ? (h.headlines?.description ?? marked(h.description, by)) : null;
356
460
  const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
357
461
  <span class="search-text"><span class="search-title">${title}</span>${
358
462
  subtitle ? html`<span class="search-subtitle">${subtitle}</span>` : ''}${
359
463
  description ? html`<span class="search-description">${description}</span>` : ''}</span>
360
- ${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
464
+ ${h.badge ? html`<span class="badge-pill">${marked(h.badge, by)}</span>` : ''}`;
361
465
  return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
362
466
  })}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
363
467
  } catch (e) {
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { startWorkflowRunner } from './workflow.ts';
5
5
  import { startProcessJobRunner } from './process-jobs.ts';
6
6
  import { startPushListener } from './push.ts';
7
7
  import { startMailListener } from './mail/mail.ts';
8
+ import { startEmbeddingRunner } from './vector.ts';
8
9
 
9
10
  const app = await buildApp();
10
11
  await app.listen({ port: Number(process.env.PORT ?? 3100), host: process.env.HOST ?? '127.0.0.1' });
@@ -14,6 +15,8 @@ await startWorkflowRunner();
14
15
  await startProcessJobRunner();
15
16
  await startPushListener();
16
17
  await startMailListener();
18
+ // (sprint 136) rows queued for their embeddings (EMBEDDINGS=off on servers that should not embed)
19
+ startEmbeddingRunner();
17
20
  // (sprint 133, SEC-6) say at start-up what could lock people out: a changed instance key or address, the clock
18
21
  try {
19
22
  const { instanceChecks } = await import('./instance-checks.ts');