pgkiln 0.69.0 → 0.70.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/db/migrations/136_vector_search.sql +252 -0
- package/docs/apex-feature-parity.md +5 -4
- package/docs/guide/03-builder.md +69 -0
- package/docs/guide/04-pages-and-regions.md +42 -2
- package/docs/guide/09-reference.md +19 -0
- package/docs/guide/12-development.md +31 -2
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/package.json +1 -1
- package/public/builder.css +3 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +3 -0
- package/src/builder/advisor.ts +41 -1
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/components.ts +21 -3
- package/src/builder/home.ts +1 -0
- package/src/builder/i18n/de.ts +34 -0
- package/src/builder/i18n/en.ts +34 -0
- package/src/builder/i18n/es.ts +34 -0
- package/src/builder/i18n/fr.ts +34 -0
- package/src/builder/i18n/it.ts +34 -0
- package/src/builder/i18n/ja.ts +34 -0
- package/src/builder/i18n/ko.ts +34 -0
- package/src/builder/i18n/nl.ts +34 -0
- package/src/builder/i18n/pt.ts +34 -0
- package/src/builder/i18n/zh-cn.ts +34 -0
- package/src/builder/i18n/zh-tw.ts +34 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/cli/replace.ts +3 -1
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
|
@@ -145,6 +145,12 @@ src/
|
|
|
145
145
|
scramVerifier (SCRAM-SHA-256, SASLprep) and the password slot (maskSql for review and log, withVerifier to run)
|
|
146
146
|
cron-schedule.ts (sprint 101, DA-12) pg_cron schedules without a database: parseSchedule (cron fields, $, n seconds, macros),
|
|
147
147
|
nextRuns in a time zone, describeSchedule in words, the builder's presets and fields
|
|
148
|
+
vector.ts (sprint 136, AP-1) pgvector: pgvectorStatus(), vectorSchema() (operators and the type schema-qualified),
|
|
149
|
+
DISTANCES (cosine/l2/inner_product: operator, HNSW opclass, similarity), vectorColumnDims(); the embedding
|
|
150
|
+
queue (meta.embedding_queue, migration 136): embeddingTick() (groups, FOR UPDATE SKIP LOCKED, batches
|
|
151
|
+
within the provider's per-minute limit, written with devDbForApp(), retries with backoff), drainEmbeddings(),
|
|
152
|
+
kickEmbeddings(), startEmbeddingRunner() (server.ts; EMBEDDINGS=off, EMBEDDING_INTERVAL_S), vectorTriggers()
|
|
153
|
+
(meta.embedding_set / embedding_enqueue triggers and their arguments), backfill(), queueState()
|
|
148
154
|
pgcron.ts (sprint 101, DA-12) pg_cron for SQL Workshop → Scheduling: cronState (installed, loaded, cron.database_name,
|
|
149
155
|
access), jobs and runs, jobSql/activeSql/unscheduleSql/purgeSql (reviewed, then applySql), runNow
|
|
150
156
|
secrets.ts secrets at rest (web credentials): AES-256-GCM with PGKILN_SECRET_KEY (or a key given, for rotate-key);
|
|
@@ -176,6 +182,11 @@ src/
|
|
|
176
182
|
(121) an agent's effort (Claude output_config.effort, OpenAI reasoning_effort) and output limit).
|
|
177
183
|
Providers (121): anthropic, openai, gemini (OpenAI-compatible endpoint, GEMINI_BASE_URL), openai_compatible
|
|
178
184
|
(Ollama, vLLM, Azure OpenAI; base URL required, key optional): service.ts serviceKey(), baseUrlFor(), providerLabel()
|
|
185
|
+
(sprint 136, AP-1) embeddings.ts: vector providers (meta.app.vector_providers: ai_service with an embedding
|
|
186
|
+
model, or a function), vectorProviderProblems(), embedTexts() (the openai SDK's embeddings API, float
|
|
187
|
+
encoding, dimensions checked, daily limits via checkDailyLimit(), meta.ai_usage source "embedding"; a
|
|
188
|
+
function provider through meta.embed on the caller's connection), embedQuery() (search texts remembered
|
|
189
|
+
10 minutes per process), vectorLiteral()
|
|
179
190
|
debug.ts debug messages: DebugLog (levels, timed steps, NOTICEs of meta.debug from appTx), started in
|
|
180
191
|
loadContext, stored after the response (onResponse hook → meta.debug_save), hourly purge
|
|
181
192
|
icons.ts icon helper (sprite in public/icons.svg)
|
|
@@ -255,7 +266,8 @@ src/
|
|
|
255
266
|
definitionProblems), agentFor(), prepareTools() (authorization schemes, navigate pages the user may
|
|
256
267
|
open, strict schemas), runAgentTool() (sql: read-only transaction as the app role with a time limit;
|
|
257
268
|
function: named arguments, committed; rest; javascript: queued for the browser; navigate: URL and
|
|
258
|
-
checksum built here), logToolCall() (meta.ai_tool_call, debug level 4, 30-day purge)
|
|
269
|
+
checksum built here), logToolCall() (meta.ai_tool_call, debug level 4, 30-day purge); (136) retrieve:
|
|
270
|
+
retrieveTool() (QUERY only, search.ts retrieve() in a read-only transaction as the app role)
|
|
259
271
|
ai-filter.ts "Ask in your own words" on a report ("ai_filter", sprint 112: AI interactive reports): the box, the chips
|
|
260
272
|
form (Edit/Remove/Undo, undo history in the session), ask and chips routes; an AI service's structured
|
|
261
273
|
output (planSchema) or the phrase parser, then ir-plan.ts
|
|
@@ -270,6 +282,10 @@ src/
|
|
|
270
282
|
searchable columns), websearch_to_tsquery / ts_rank_cd / ts_headline between private-use markers
|
|
271
283
|
(headlined() escapes, only the markers become <mark>), textSearchConfig() (pg_ts_config names only,
|
|
272
284
|
passed as $1::regconfig; tsConfigFor(language)), trigramSchema() and "did you mean" with pg_trgm
|
|
285
|
+
(sprint 136, migration 136) search types vector (vectorRows: the text embedded by the provider, nearest
|
|
286
|
+
rows by the schema-qualified distance operator, the vector as $1, min_similarity) and hybrid (full-text
|
|
287
|
+
and vector rankings, 50 rows each, reciprocal rank fusion 1/(60+rank) per key_column); retrieve() for
|
|
288
|
+
an agent's Retrieve tool
|
|
273
289
|
url-region.ts URL region (migration 078): literal origins only (urlTarget), &ITEM. URL-encoded, sandbox and height,
|
|
274
290
|
frameOrigins() for the CSP's frame-src (security.ts)
|
|
275
291
|
ajax.ts Ajax callbacks (migration 085: POST …/ajax/:name, point "ajax" processes, x01 … x10 binds) and the
|
|
@@ -360,6 +376,13 @@ src/
|
|
|
360
376
|
fulltext.ts (sprint 135, AP-2) Table Properties › Add full-text search: GET/POST /sql/objects/fulltext?o= (text columns
|
|
361
377
|
with weights A–D, a configuration from pg_ts_config, fulltextDdl(): a generated tsvector column and a GIN
|
|
362
378
|
index), Preview / Apply (one transaction, recorded in the Change Log) / Open in the worksheet
|
|
379
|
+
vector-column.ts (sprint 136, AP-1) Table Properties › Add vector column: GET/POST /sql/objects/vector?o= (a provider of the
|
|
380
|
+
workspace's applications, source columns, distance; vectorDdl(): vector(n), HNSW index, the
|
|
381
|
+
meta.embedding_set / embedding_enqueue trigger), Preview / Apply (Change Log; the rows queued) / worksheet;
|
|
382
|
+
the table's vector columns with progress and Backfill (mode=backfill, all=on)
|
|
383
|
+
vector-providers.ts (sprint 136, AP-1) Shared Components → Vector providers (/apps/:id/vector-providers: list, provider form,
|
|
384
|
+
test, delete) and Workspace utilities → Vector providers (/vector-providers: pgvector status, Create
|
|
385
|
+
extension for administrators, the workspace's providers and vector columns); pgvectorNote()
|
|
363
386
|
data-security.ts (sprint 119) a table's Security: GET/POST /sql/objects/security?o= (policies, roles × columns, masks), Preview / Apply
|
|
364
387
|
object-actions.ts (sprint 94) an object's ⋯ menu (objectMenu) in the worksheet and the Object Browser: Quick DDL (objectDdl), Drop with
|
|
365
388
|
dependents, Add index / trigger / policy as drafts; GET /sql/objects/action, /sql/objects/panel (Open in a panel)
|
|
@@ -505,6 +528,10 @@ test/
|
|
|
505
528
|
fulltext-search.test.ts (sprint 135) full-text search configurations: stemming, rank order, highlights, websearch syntax, the
|
|
506
529
|
configuration fallback, did you mean (pg_trgm when it can be installed), the report search on a tsvector
|
|
507
530
|
column, the Advisor's GIN check, Add full-text search (preview, apply, change log), export/import
|
|
531
|
+
vector-search.test.ts (sprint 136) vector providers and their checks, meta.embed, Add vector column (preview, apply, the queue
|
|
532
|
+
with the mock's embeddings, a change queued again, Backfill, a failing batch), vector and hybrid search
|
|
533
|
+
("time off for a family move" → Relocation leave; RLS), the Advisor, Retrieve tools on page 49, the
|
|
534
|
+
builder pages, export; skips what needs pgvector when it isn't installed
|
|
508
535
|
object-actions.test.ts (sprint 94) object menus: Quick DDL, Drop with dependents, Add index / trigger / policy, the panel, not-found
|
|
509
536
|
data-modeler.test.ts (sprint 98) Data Modeler: "hr core" with its FK lines, saved positions, SVG, objects, DDL, dictionary, geometry
|
|
510
537
|
worksheet.test.ts (sprint 91) the SQL Worksheet: statement at the cursor, Run Script log, plans, notices, history, navigator, Run as
|
|
@@ -566,7 +593,9 @@ test/
|
|
|
566
593
|
find-workspace.test.ts developers' addresses, builder mail, the workspaces mail, the same answer, limits
|
|
567
594
|
ai-builder.test.ts App Builder AI: the builder's service, SQL from a question, explain, describe tables, pages with AI
|
|
568
595
|
ai-script-mock.ts a scripted mock of Claude (SSE, tool_use with a thinking block) and OpenAI (tool calls), reply by reply
|
|
569
|
-
ai-mock.ts a local mock of the Claude Messages API (SSE stream) and OpenAI Chat Completions
|
|
596
|
+
ai-mock.ts a local mock of the Claude Messages API (SSE stream) and OpenAI Chat Completions; (136) OpenAI's
|
|
597
|
+
embeddings (/embeddings, also in ai-script-mock.ts): conceptEmbedding(), eight "topics", the same
|
|
598
|
+
as the HR example's hr.policy_embedding
|
|
570
599
|
helpers.ts a cookie-keeping test browser
|
|
571
600
|
e2e/responsive.test.ts browser tests at phone/tablet/desktop widths (Playwright)
|
|
572
601
|
e2e/builder-i18n.test.ts (sprint 121) Deutsch on the sign-in page, the status bar's language menu, German screens at 390 px, CSP
|
|
@@ -63,7 +63,7 @@ that includes them.
|
|
|
63
63
|
| **[pg_cron](https://github.com/citusdata/pg_cron)** | Run SQL on a schedule inside the database (`select cron.schedule('nightly', '0 2 * * *', 'call hr.close_month()')`) | **Automations**, `DBMS_SCHEDULER` |
|
|
64
64
|
| **[pgaudit](https://github.com/pgaudit/pgaudit)** | Statement-level audit logging (reads, writes, DDL, role changes) to the server log, for compliance | Oracle Unified Auditing |
|
|
65
65
|
| **PostGIS** | Geometry and geography types, spatial indexes and functions: the foundation for a map region | **Map region**, Oracle Spatial |
|
|
66
|
-
| **pgvector** | Vector similarity search (embeddings) for semantic search and retrieval-augmented generation | APEX AI (RAG sources), Oracle AI Vector Search |
|
|
66
|
+
| **pgvector** | Vector similarity search (embeddings) for semantic search and retrieval-augmented generation; pgkiln's vector providers, vector and hybrid search configurations and Retrieve tools use it (sprint 136, [chapter 4](04-pages-and-regions.md#vector-search)) | APEX AI (RAG sources), Oracle AI Vector Search |
|
|
67
67
|
| **[plpgsql_check](https://github.com/okbob/plpgsql_check)** | Static analysis of PL/pgSQL: wrong columns, unused variables, missing `RETURN`, hidden casts. Run it on your app's functions in CI | **Advisor** (for code) |
|
|
68
68
|
|
|
69
69
|
Scheduling example with pg_cron. pg_cron must be in `shared_preload_libraries` (a server setting;
|
package/examples/hr/README.md
CHANGED
|
@@ -43,6 +43,7 @@ Then open http://127.0.0.1:3100/a/hr and sign in as `king`, `blake`, `jones`, `a
|
|
|
43
43
|
| `hr_64_ai_agent.sql` | *HR agent* (page 46): the AI agent `HR_AGENT` (Shared Components → AI Agents) with four tools: `top_earners` (SQL, read-only as `hr_app`), `open_employee` (navigate: a link to the employee form with its checksum, only for users who may open page 3), `give_raise` (the PL/pgSQL function `hr.give_raise`, managers only) and `note` (a JavaScript tool: the custom event `hr_agent_note`). Ask *Who in Sales earns most?* Needs an AI service named `HR_ASSISTANT` (see part 41) |
|
|
44
44
|
| `hr_68_component_details.sql` | *Component details* (page 47): a popup LOV over 6,000 rows that loads more as the dialog scrolls, a wait popup around a two-second process (*Recalculate*) and around an Ajax call (*Check in the background*), and an *Actions* menu whose entries run dynamic actions (event `menu_entry`) |
|
|
45
45
|
| `hr_71_fulltext_search.sql` | Full-text search: `hr.policy` gets a generated `search_vector` column (title weight A, body B, english) with a GIN index, and the search configuration `POLICIES` (search type *fulltext*, *Did you mean* when `pg_trgm` is installed) joins page 42 *Search*: "works home" finds *Working from home*, the matched words highlighted |
|
|
46
|
+
| `hr_72_vector_search.sql` | Search by meaning: `hr.policy` gets *Relocation leave* and a managers-only *Salary bands* with row level security (`audience`); with pgvector, the vector provider `POLICY_EMBEDDINGS` (a stand-in function, `hr.policy_embedding`, eight topics), `hr.policy.embedding vector(8)` with an HNSW index and a trigger, the hybrid search configuration `POLICIES_MEANING` on page 42 (*time off for a family move* finds *Relocation leave*), and *Policy questions* (page 49): the agent `POLICY_AGENT` with the Retrieve tool `policy_search`. Needs an AI service named `HR_ASSISTANT` for page 49 |
|
|
46
47
|
|
|
47
48
|
The files run in order, each once (recorded in `public.pgkiln_seed`), so new ones can be added
|
|
48
49
|
later. pgkiln's own tests use this application as their fixture (`npm test` installs it first).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 72: search by meaning (sprint 136, AP-1; migration
|
|
3
|
+
-- 136; docs/guide/04-pages-and-regions.md "Vector search", 03-builder.md
|
|
4
|
+
-- "Vector providers", 20-ai-agents.md)
|
|
5
|
+
--
|
|
6
|
+
-- hr.policy (parts 42 and 71) gets two more policies and an audience:
|
|
7
|
+
-- "Salary bands" is for managers only, and row level security now shows
|
|
8
|
+
-- hr_app only the policies the user may read. Every search below runs as
|
|
9
|
+
-- hr_app, so an employee never finds the managers' policy.
|
|
10
|
+
--
|
|
11
|
+
-- With pgvector installed (the extension is created here when the server
|
|
12
|
+
-- has it and this connection may; otherwise this part only adds the
|
|
13
|
+
-- policies and says so):
|
|
14
|
+
--
|
|
15
|
+
-- - the vector provider POLICY_EMBEDDINGS (Shared Components → Vector
|
|
16
|
+
-- providers) of type function: hr.policy_embedding(text), a tiny
|
|
17
|
+
-- stand-in for an embedding model that maps words to eight topics
|
|
18
|
+
-- (moving house, time off, home working, sickness, pay, requests,
|
|
19
|
+
-- public holidays, anything else). It keeps the example working without
|
|
20
|
+
-- an AI service; for real use, make the provider an AI service with an
|
|
21
|
+
-- embedding model (e.g. OpenAI text-embedding-3-small, 1536 dimensions,
|
|
22
|
+
-- or Ollama's nomic-embed-text, 768) and add the column again with
|
|
23
|
+
-- Table Properties › Add vector column;
|
|
24
|
+
-- - what Table Properties › Add vector column writes: hr.policy.embedding
|
|
25
|
+
-- vector(8), an HNSW index (vector_cosine_ops) and a trigger
|
|
26
|
+
-- (meta.embedding_set) that embeds a policy when its title or body
|
|
27
|
+
-- changes;
|
|
28
|
+
-- - the search configuration POLICIES_MEANING (search type hybrid:
|
|
29
|
+
-- full-text on search_vector and the embedding, merged by reciprocal
|
|
30
|
+
-- rank fusion) joins page 42 "Search": "time off for a family move"
|
|
31
|
+
-- finds "Relocation leave", which uses none of those words;
|
|
32
|
+
-- - page 49 "Policy questions": an AI assistant region with the agent
|
|
33
|
+
-- POLICY_AGENT, whose Retrieve tool policy_search hands the model the
|
|
34
|
+
-- policies that match a question (as hr_app). Like parts 41, 42 and 64
|
|
35
|
+
-- it needs an AI service named HR_ASSISTANT.
|
|
36
|
+
-- =====================================================================
|
|
37
|
+
|
|
38
|
+
alter table hr.policy add column audience text not null default 'all' check (audience in ('all', 'managers'));
|
|
39
|
+
|
|
40
|
+
insert into hr.policy (title, body, audience) values
|
|
41
|
+
('Relocation leave', 'Employees who relocate to another city for the company get three paid days to settle into the new house, and help with the removal costs.', 'all'),
|
|
42
|
+
('Salary bands', 'Managers review the salary bands every spring; a raise above the band needs the director''s approval.', 'managers');
|
|
43
|
+
|
|
44
|
+
alter table hr.policy enable row level security;
|
|
45
|
+
create policy policy_visible on hr.policy for select
|
|
46
|
+
using (audience = 'all' or meta.has_role('manager') or meta.has_role('admin'));
|
|
47
|
+
|
|
48
|
+
-- a stand-in for an embedding model: each word counts for one of eight topics (unknown words a little for the
|
|
49
|
+
-- last), and the vector is normalised, so the cosine of two texts says how much they share topics
|
|
50
|
+
create function hr.policy_embedding(p_text text) returns real[]
|
|
51
|
+
language sql immutable parallel safe set search_path = pg_catalog as $$
|
|
52
|
+
with w as (select lower(m[1]) as w from regexp_matches(coalesce(p_text, ''), '([[:alpha:]]+)', 'g') m),
|
|
53
|
+
d as (select case
|
|
54
|
+
when w ~ '^(relocat|move|moving|removal|house|settl|city|family)' then 1
|
|
55
|
+
when w ~ '^(leave|time|off|holiday|vacation|days|absence)$' then 2
|
|
56
|
+
when w ~ '^(home|remote|working|work)$' then 3
|
|
57
|
+
when w ~ '^(sick|ill|doctor)' then 4
|
|
58
|
+
when w ~ '^(salar|pay|band|raise|wage|scale)' then 5
|
|
59
|
+
when w ~ '^(request|approv|manager)' then 6
|
|
60
|
+
when w ~ '^(public|closed|office)' then 7
|
|
61
|
+
else 8 end as dim,
|
|
62
|
+
case when w ~ '^(relocat|move|moving|removal|house|settl|city|family|leave|time|off|holiday|vacation|days|absence|home|remote|working|work|sick|ill|doctor|salar|pay|band|raise|wage|scale|request|approv|manager|public|closed|office)'
|
|
63
|
+
then 1.0 else 0.05 end as weight
|
|
64
|
+
from w),
|
|
65
|
+
v as (select i, coalesce((select sum(weight) from d where d.dim = i), 0) as x from generate_series(1, 8) i),
|
|
66
|
+
n as (select sqrt(sum(x * x)) as n from v)
|
|
67
|
+
select array_agg((case when n.n = 0 then (case when i = 8 then 1 else 0 end) else x / n.n end)::real order by i) from v, n
|
|
68
|
+
$$;
|
|
69
|
+
grant execute on function hr.policy_embedding(text) to hr_app;
|
|
70
|
+
|
|
71
|
+
update meta.app set
|
|
72
|
+
vector_providers = vector_providers || $j$[
|
|
73
|
+
{"name": "POLICY_EMBEDDINGS", "type": "function", "function": "hr.policy_embedding", "dimensions": 8,
|
|
74
|
+
"description": "A stand-in for an embedding model (eight topics); use an AI service for real texts"}
|
|
75
|
+
]$j$::jsonb,
|
|
76
|
+
ai_tools = ai_tools || $j$[
|
|
77
|
+
{"name": "policy_search", "type": "retrieve", "search_config": "POLICIES_MEANING", "max_rows": 3,
|
|
78
|
+
"description": "The HR policies that match a question, best first (title and text). Use it for every question about rules, leave, pay or working arrangements."}
|
|
79
|
+
]$j$::jsonb,
|
|
80
|
+
ai_agents = ai_agents || $j$[
|
|
81
|
+
{"name": "POLICY_AGENT", "description": "Answers questions about the HR policies the user may read",
|
|
82
|
+
"service": "HR_ASSISTANT", "effort": "low", "tools": ["policy_search"], "max_steps": 3, "max_tokens": 1500,
|
|
83
|
+
"system": "You answer questions of &APP_USER. about the company's HR policies. Look the policies up with policy_search and answer only from what it returns; say so when no policy covers the question. Name the policy you used. Be brief."}
|
|
84
|
+
]$j$::jsonb
|
|
85
|
+
where alias = 'hr';
|
|
86
|
+
|
|
87
|
+
-- page 49: questions about the policies, answered through the Retrieve tool
|
|
88
|
+
insert into meta.page (app_id, page_no, name, title, parent_page)
|
|
89
|
+
select id, 49, 'Policy questions', 'Policy questions', 1 from meta.app where alias = 'hr';
|
|
90
|
+
|
|
91
|
+
insert into meta.region (page_id, seq, title, type, columns, template, source, condition, config)
|
|
92
|
+
select p.id, r.seq, r.title, r.type, r.cols, 'standard', r.source, r.cond, r.config::jsonb
|
|
93
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
94
|
+
(10, 'Not set up yet', 'static', 12,
|
|
95
|
+
'<p class="alert alert-info">Policy questions need the pgvector extension in the database and an AI service named <b>HR_ASSISTANT</b> (App Builder → Workspace utilities → AI services, allowed for the HR application).</p>',
|
|
96
|
+
'not (meta.ai_available(''HR_ASSISTANT'') and exists (select from pg_catalog.pg_extension where extname = ''vector''))', '{}'),
|
|
97
|
+
(20, 'Ask about the policies', 'ai_assistant', 8, null,
|
|
98
|
+
'meta.ai_available(''HR_ASSISTANT'') and exists (select from pg_catalog.pg_extension where extname = ''vector'')',
|
|
99
|
+
$j${"agent": "POLICY_AGENT",
|
|
100
|
+
"welcome": "Hello! Ask me about the HR policies, e.g. how much time off you get when you move for the company.",
|
|
101
|
+
"placeholder": "e.g. Do I get time off for a family move?",
|
|
102
|
+
"max_turns": 20,
|
|
103
|
+
"error_message": "The policy assistant could not answer just now. Please try again later."}$j$)
|
|
104
|
+
) as r (seq, title, type, cols, source, cond, config)
|
|
105
|
+
where a.alias = 'hr' and p.page_no = 49;
|
|
106
|
+
|
|
107
|
+
insert into meta.nav_entry (app_id, seq, label, icon, target_page)
|
|
108
|
+
select id, 30, 'Policy questions', 'book', 49 from meta.app where alias = 'hr';
|
|
109
|
+
|
|
110
|
+
-- with pgvector: the extension (when this connection may create it), the vector column, the search configuration
|
|
111
|
+
-- and the search page
|
|
112
|
+
do $$
|
|
113
|
+
declare
|
|
114
|
+
v_schema text;
|
|
115
|
+
begin
|
|
116
|
+
if not exists (select from pg_extension where extname = 'vector') then
|
|
117
|
+
if exists (select from pg_available_extensions where name = 'vector') and (select rolsuper from pg_roles where rolname = current_user) then
|
|
118
|
+
create extension vector;
|
|
119
|
+
else
|
|
120
|
+
raise notice 'HR part 72: pgvector is not installed: hr.policy gets no vector column (install pgvector, then add one with Table Properties › Add vector column).';
|
|
121
|
+
return;
|
|
122
|
+
end if;
|
|
123
|
+
end if;
|
|
124
|
+
select n.nspname into v_schema from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'vector';
|
|
125
|
+
execute format('alter table hr.policy add column embedding %I.vector(8)', v_schema);
|
|
126
|
+
execute format('create index policy_embedding_idx on hr.policy using hnsw (embedding %I.vector_cosine_ops)', v_schema);
|
|
127
|
+
execute format($t$create trigger embedding_embed before insert or update of title, body on hr.policy
|
|
128
|
+
for each row execute function meta.embedding_set(%L, 'POLICY_EMBEDDINGS', 'embedding', 'id', 'title', 'body')$t$,
|
|
129
|
+
(select id from meta.app where alias = 'hr'));
|
|
130
|
+
-- the policies there are: embedded now (the trigger does it for new and changed ones)
|
|
131
|
+
execute format($u$update hr.policy set embedding = translate(meta.embed(meta.embedding_text(to_jsonb(policy), '{title,body}'), 'POLICY_EMBEDDINGS', %s)::text, '{}', '[]')::text::%I.vector$u$,
|
|
132
|
+
(select id from meta.app where alias = 'hr'), v_schema);
|
|
133
|
+
insert into meta.search_config (app_id, name, label, seq, source_type, source, search_type, fts_vector, fts_config,
|
|
134
|
+
vector_provider, vector_column, key_column, distance, min_similarity,
|
|
135
|
+
title_column, description_column, icon, max_results)
|
|
136
|
+
select id, 'POLICIES_MEANING', 'Policies by meaning', 26, 'table', 'hr.policy', 'hybrid', 'search_vector', 'english',
|
|
137
|
+
'POLICY_EMBEDDINGS', 'embedding', 'id', 'cosine', 0.6,
|
|
138
|
+
'title', 'body', 'book', 3
|
|
139
|
+
from meta.app where alias = 'hr';
|
|
140
|
+
update meta.region r
|
|
141
|
+
set config = jsonb_set(r.config, '{configs}', (r.config->'configs') || '["POLICIES_MEANING"]'::jsonb)
|
|
142
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
143
|
+
where r.page_id = p.id and a.alias = 'hr' and p.page_no = 42 and r.type = 'search';
|
|
144
|
+
end $$;
|
|
145
|
+
|
|
146
|
+
insert into meta.translation (app_id, language, source, target)
|
|
147
|
+
select a.id, 'nl', t.source, t.target
|
|
148
|
+
from meta.app a, (values
|
|
149
|
+
('Policies by meaning', 'Regelingen op betekenis'),
|
|
150
|
+
('Policy questions', 'Vragen over regelingen'),
|
|
151
|
+
('Ask about the policies', 'Vraag naar de regelingen'),
|
|
152
|
+
('Not set up yet', 'Nog niet ingesteld'),
|
|
153
|
+
('Hello! Ask me about the HR policies, e.g. how much time off you get when you move for the company.',
|
|
154
|
+
'Hallo! Vraag me naar de HR-regelingen, bijv. hoeveel vrij je krijgt als je voor het bedrijf verhuist.'),
|
|
155
|
+
('e.g. Do I get time off for a family move?', 'bijv. Krijg ik vrij voor een verhuizing met het gezin?')
|
|
156
|
+
) as t (source, target)
|
|
157
|
+
where a.alias = 'hr'
|
|
158
|
+
on conflict do nothing;
|
package/package.json
CHANGED
package/public/builder.css
CHANGED
|
@@ -1597,3 +1597,6 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1597
1597
|
--font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", "Noto Sans CJK KR", "Source Han Sans KR", sans-serif; }
|
|
1598
1598
|
:is(.ide-lang-form, .signin-langs) [lang] { font-family: var(--font); }
|
|
1599
1599
|
:is(.ide, .ide-login):lang(ko) { word-break: keep-all; overflow-wrap: break-word; }
|
|
1600
|
+
|
|
1601
|
+
/* (sprint 136) a button inside an alert (e.g. Add vector column without pgvector) wraps on a phone instead of overflowing */
|
|
1602
|
+
.alert .btn { white-space: normal; overflow-wrap: anywhere; max-width: 100%; }
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { openAiError } from './openai.ts';
|
|
4
|
+
import { baseUrlFor, checkDailyLimit, logUsage, serviceForApp, serviceKey, type CallInfo } from './service.ts';
|
|
5
|
+
import { AiError } from './types.ts';
|
|
6
|
+
|
|
7
|
+
// (sprint 136, AP-1; APEX: Vector Providers, APEX_AI.GET_VECTOR_EMBEDDINGS) Vector providers turn text into an
|
|
8
|
+
// embedding. They are a JSON list in meta.app (vector_providers, migration 136), exported with the application:
|
|
9
|
+
//
|
|
10
|
+
// - ai_service: an AI service of the installation (OpenAI, Gemini, or any OpenAI-compatible endpoint such as
|
|
11
|
+
// Ollama) and an embedding model (text-embedding-3-small, gemini-embedding-001, nomic-embed-text …), called with
|
|
12
|
+
// the official openai SDK's embeddings API. The key stays in meta.ai_service (encrypted, write-only, never
|
|
13
|
+
// exported); the application must be allowed to use the service and its daily limits apply; every call is
|
|
14
|
+
// logged in meta.ai_usage (source "embedding": tokens, model, duration, never the text).
|
|
15
|
+
// - function: a SQL or PL/pgSQL function (text → vector, real[] or float8[]), called by meta.embed() as the
|
|
16
|
+
// role that runs the query (security invoker), so its grants apply.
|
|
17
|
+
//
|
|
18
|
+
// The dimensions are part of the definition: every embedding is checked against them, and the builder checks
|
|
19
|
+
// them against the vector column (vector(n)) a search configuration or a vector column helper uses.
|
|
20
|
+
|
|
21
|
+
export type VectorProviderType = 'ai_service' | 'function';
|
|
22
|
+
export const VECTOR_PROVIDER_TYPES: VectorProviderType[] = ['ai_service', 'function'];
|
|
23
|
+
|
|
24
|
+
export interface VectorProvider {
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
type: VectorProviderType;
|
|
28
|
+
/** ai_service: the AI service's name */
|
|
29
|
+
service?: string;
|
|
30
|
+
/** ai_service: the embedding model, exactly as the provider names it */
|
|
31
|
+
model?: string;
|
|
32
|
+
dimensions: number;
|
|
33
|
+
/** function: schema.name */
|
|
34
|
+
function?: string;
|
|
35
|
+
/** ai_service: texts per request (background embedding; default 64) */
|
|
36
|
+
batch_size?: number;
|
|
37
|
+
/** ai_service: requests per minute (background embedding; default 60) */
|
|
38
|
+
per_minute?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PROVIDER_NAME = /^[A-Z][A-Z0-9_]{0,59}$/;
|
|
42
|
+
const SERVICE_NAME = /^[A-Za-z][A-Za-z0-9_]{0,59}$/;
|
|
43
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/;
|
|
44
|
+
export const FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}\.[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
45
|
+
/** pgvector's vector type takes up to 16,000 dimensions (an HNSW index up to 2,000). */
|
|
46
|
+
export const MAX_DIMENSIONS = 16_000;
|
|
47
|
+
export const HNSW_MAX_DIMENSIONS = 2_000;
|
|
48
|
+
export const DEFAULT_BATCH = 64;
|
|
49
|
+
export const DEFAULT_PER_MINUTE = 60;
|
|
50
|
+
/** Characters of one text sent to a provider (longer texts are cut). */
|
|
51
|
+
export const MAX_TEXT_CHARS = 20_000;
|
|
52
|
+
|
|
53
|
+
const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
54
|
+
const intIn = (v: unknown, min: number, max: number) => Number.isInteger(v) && (v as number) >= min && (v as number) <= max;
|
|
55
|
+
|
|
56
|
+
/** Problems with one vector provider (builder and runtime). */
|
|
57
|
+
export function vectorProviderProblems(p: unknown): string[] {
|
|
58
|
+
if (!isObj(p)) return ['A vector provider is an object.'];
|
|
59
|
+
const what = `Vector provider ${typeof p.name === 'string' ? p.name : '?'}`;
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
if (typeof p.name !== 'string' || !PROVIDER_NAME.test(p.name)) out.push(`${what}: the name (static ID) is upper case letters, digits and _ (e.g. DOC_EMBEDDINGS).`);
|
|
62
|
+
if (!VECTOR_PROVIDER_TYPES.includes(p.type)) out.push(`${what}: the type is ai_service or function.`);
|
|
63
|
+
if (p.description !== undefined && p.description !== null && (typeof p.description !== 'string' || p.description.length > 500)) out.push(`${what}: the description is text (at most 500 characters).`);
|
|
64
|
+
if (!intIn(p.dimensions, 1, MAX_DIMENSIONS)) out.push(`${what}: the dimensions are a whole number from 1 to ${MAX_DIMENSIONS}.`);
|
|
65
|
+
if (p.type === 'ai_service') {
|
|
66
|
+
if (typeof p.service !== 'string' || !SERVICE_NAME.test(p.service)) out.push(`${what}: choose an AI service.`);
|
|
67
|
+
if (typeof p.model !== 'string' || !MODEL.test(p.model)) out.push(`${what}: the embedding model is its name, e.g. text-embedding-3-small or nomic-embed-text.`);
|
|
68
|
+
if (p.function !== undefined) out.push(`${what}: an AI service provider has no function.`);
|
|
69
|
+
}
|
|
70
|
+
if (p.type === 'function') {
|
|
71
|
+
if (typeof p.function !== 'string' || !FUNCTION_NAME.test(p.function)) out.push(`${what}: the function is schema.name, e.g. sales.embed.`);
|
|
72
|
+
if (p.service !== undefined || p.model !== undefined) out.push(`${what}: a function provider has no AI service or model.`);
|
|
73
|
+
}
|
|
74
|
+
if (p.batch_size !== undefined && !intIn(p.batch_size, 1, 2048)) out.push(`${what}: texts per request 1 to 2048.`);
|
|
75
|
+
if (p.per_minute !== undefined && !intIn(p.per_minute, 1, 10_000)) out.push(`${what}: requests per minute 1 to 10000.`);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Problems with an application's list: each entry, and names used twice. */
|
|
80
|
+
export function vectorProvidersProblems(list: unknown): string[] {
|
|
81
|
+
if (!Array.isArray(list)) return ['Vector providers are a list.'];
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
const names = new Set<string>();
|
|
84
|
+
for (const p of list) {
|
|
85
|
+
out.push(...vectorProviderProblems(p));
|
|
86
|
+
if (isObj(p) && typeof p.name === 'string') {
|
|
87
|
+
if (names.has(p.name)) out.push(`Vector provider ${p.name} is defined twice.`);
|
|
88
|
+
names.add(p.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The application's vector providers (the owner reads them: the builder may have changed them a moment ago). */
|
|
95
|
+
export async function loadVectorProviders(appId: number): Promise<VectorProvider[]> {
|
|
96
|
+
const r = await owner.one<{ vector_providers: unknown }>('select vector_providers from meta.app where id = $1', [appId]);
|
|
97
|
+
return Array.isArray(r?.vector_providers) ? (r!.vector_providers as VectorProvider[]) : [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One valid provider of an application by its static ID, or an AiError. */
|
|
101
|
+
export async function vectorProviderFor(appId: number, name: string): Promise<VectorProvider> {
|
|
102
|
+
const p = (await loadVectorProviders(appId)).find((x) => x.name === String(name ?? '').toUpperCase());
|
|
103
|
+
if (!p) throw new AiError('config', `There is no vector provider ${String(name ?? '').toUpperCase()} (Shared Components → Vector providers).`);
|
|
104
|
+
const problems = vectorProviderProblems(p);
|
|
105
|
+
if (problems.length) throw new AiError('config', problems.join(' '));
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A vector as pgvector's text input: [0.1,0.2,…] (numbers only). */
|
|
110
|
+
export function vectorLiteral(v: number[]): string {
|
|
111
|
+
if (!Array.isArray(v) || !v.length || v.some((x) => typeof x !== 'number' || !Number.isFinite(x))) throw new AiError('output', 'The embedding is not a list of numbers.');
|
|
112
|
+
return `[${v.join(',')}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
|
|
116
|
+
type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
|
|
117
|
+
|
|
118
|
+
export interface EmbedInfo extends Pick<CallInfo, 'appId' | 'pageNo' | 'user' | 'debug'> {
|
|
119
|
+
appId: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Embed texts with a provider: one vector per text, each with the provider's dimensions. A function provider
|
|
124
|
+
* runs through meta.embed() on `client` (the caller's role and transaction); an AI service is called here.
|
|
125
|
+
*/
|
|
126
|
+
export async function embedTexts(p: VectorProvider, texts: string[], info: EmbedInfo, client?: Queryable): Promise<number[][]> {
|
|
127
|
+
if (!texts.length) return [];
|
|
128
|
+
const input = texts.map((t) => String(t ?? '').slice(0, MAX_TEXT_CHARS));
|
|
129
|
+
if (p.type === 'function') {
|
|
130
|
+
if (!client) throw new AiError('config', `Vector provider ${p.name}: a function provider needs a database connection.`);
|
|
131
|
+
const rows = (await client.query('select meta.embed(t, $2, $3) as v from unnest($1::text[]) with ordinality u(t, n) order by n', [input, p.name, info.appId])).rows;
|
|
132
|
+
return rows.map((r: { v: number[] | null }) => {
|
|
133
|
+
if (!Array.isArray(r.v)) throw new AiError('output', `Vector provider ${p.name}: the function returned no embedding.`);
|
|
134
|
+
return r.v.map(Number);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const { service: s, limits } = await serviceForApp(p.service!, info.appId);
|
|
138
|
+
if (s.provider === 'anthropic') throw new AiError('config', `AI service ${s.name} is a Claude service: Claude has no embeddings API (use an OpenAI, Gemini or OpenAI-compatible service).`);
|
|
139
|
+
const call: CallInfo = { ...info, source: 'embedding' };
|
|
140
|
+
await checkDailyLimit(s, limits, call, p.model!);
|
|
141
|
+
const client2 = new OpenAI({ apiKey: serviceKey(s), baseURL: baseUrlFor(s) || undefined, timeout: s.timeout_s * 1000, maxRetries: 1 });
|
|
142
|
+
const t0 = performance.now();
|
|
143
|
+
info.debug?.(6, `vector provider ${p.name}: ${input.length} text(s) to AI service ${s.name} (${s.provider}, ${p.model})`);
|
|
144
|
+
let tokens = 0;
|
|
145
|
+
try {
|
|
146
|
+
let res: OpenAI.CreateEmbeddingResponse;
|
|
147
|
+
try {
|
|
148
|
+
res = await client2.embeddings.create({
|
|
149
|
+
model: p.model!,
|
|
150
|
+
input,
|
|
151
|
+
encoding_format: 'float',
|
|
152
|
+
// OpenAI's text-embedding-3 models shorten their vectors to the dimensions asked for
|
|
153
|
+
...(s.provider === 'openai' && /^text-embedding-3/.test(p.model!) ? { dimensions: p.dimensions } : {}),
|
|
154
|
+
});
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw openAiError(e);
|
|
157
|
+
}
|
|
158
|
+
tokens = res.usage?.prompt_tokens ?? 0;
|
|
159
|
+
const data = [...(res.data ?? [])].sort((a, b) => a.index - b.index);
|
|
160
|
+
if (data.length !== input.length) throw new AiError('output', `The AI service sent ${data.length} embeddings for ${input.length} texts.`);
|
|
161
|
+
const out = data.map((d) => (Array.isArray(d.embedding) ? d.embedding.map(Number) : []));
|
|
162
|
+
for (const v of out) {
|
|
163
|
+
if (v.length !== p.dimensions)
|
|
164
|
+
throw new AiError('output', `Vector provider ${p.name}: the model returned ${v.length} dimensions, the provider says ${p.dimensions} (change the provider's dimensions).`);
|
|
165
|
+
if (v.some((x) => !Number.isFinite(x))) throw new AiError('output', 'The embedding is not a list of numbers.');
|
|
166
|
+
}
|
|
167
|
+
await logUsage(call, { service: s, model: res.model || p.model!, inputTokens: tokens, outputTokens: 0, ms: performance.now() - t0, status: 'ok', message: null });
|
|
168
|
+
return out;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const err = e instanceof AiError ? e : new AiError('server', 'The embedding request failed.');
|
|
171
|
+
await logUsage(call, { service: s, model: p.model!, inputTokens: tokens, outputTokens: 0, ms: performance.now() - t0, status: 'error', message: err.kind + (err.status ? ` ${err.status}` : '') });
|
|
172
|
+
info.debug?.(1, `vector provider ${p.name}: ${err.kind}: ${err.message}`);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------- the search text's embedding
|
|
178
|
+
|
|
179
|
+
/** Search texts embedded recently (per server process, AI service providers only): a search as you type asks again. */
|
|
180
|
+
const recent = new Map<string, { at: number; v: number[] }>();
|
|
181
|
+
const RECENT_MAX = 500;
|
|
182
|
+
const RECENT_MS = 10 * 60_000;
|
|
183
|
+
|
|
184
|
+
/** The embedding of one search text (AI service providers: kept a few minutes in this process's memory). */
|
|
185
|
+
export async function embedQuery(p: VectorProvider, text: string, info: EmbedInfo, client?: Queryable): Promise<number[]> {
|
|
186
|
+
if (p.type === 'function') return (await embedTexts(p, [text], info, client))[0];
|
|
187
|
+
const key = JSON.stringify([info.appId, p.name, p.service, p.model, p.dimensions, text]);
|
|
188
|
+
const hit = recent.get(key);
|
|
189
|
+
if (hit && Date.now() - hit.at < RECENT_MS) return hit.v;
|
|
190
|
+
const [v] = await embedTexts(p, [text], info, client);
|
|
191
|
+
if (recent.size >= RECENT_MAX) recent.delete(recent.keys().next().value!);
|
|
192
|
+
recent.set(key, { at: Date.now(), v });
|
|
193
|
+
return v;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Forget the remembered search texts (tests; a provider that changed). */
|
|
197
|
+
export const forgetQueries = () => recent.clear();
|
package/src/ai/service.ts
CHANGED
|
@@ -99,7 +99,7 @@ export interface CallInfo {
|
|
|
99
99
|
appId: number | null;
|
|
100
100
|
pageNo?: number | null;
|
|
101
101
|
user?: string | null;
|
|
102
|
-
source: 'process' | 'dynamic_action' | 'sql' | 'builder' | 'assistant' | 'nl2ir';
|
|
102
|
+
source: 'process' | 'dynamic_action' | 'sql' | 'builder' | 'assistant' | 'nl2ir' | 'embedding';
|
|
103
103
|
/** debug messages of the request (level, text) */
|
|
104
104
|
debug?: (level: number, text: string) => void;
|
|
105
105
|
}
|
|
@@ -147,6 +147,17 @@ export async function serviceForApp(name: string, appId: number | null) {
|
|
|
147
147
|
return { service: s, limits };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** The application's daily request and token limits on a service (logged and refused when reached). */
|
|
151
|
+
export async function checkDailyLimit(s: AiService, limits: { max_requests: number | null; max_tokens: string | null } | null, info: CallInfo, model = s.model) {
|
|
152
|
+
if (!limits || info.appId === null || (limits.max_requests === null && limits.max_tokens === null)) return;
|
|
153
|
+
const today = await usageToday(info.appId, s.id);
|
|
154
|
+
const over = (limits.max_requests !== null && today.requests >= limits.max_requests) || (limits.max_tokens !== null && today.tokens >= Number(limits.max_tokens));
|
|
155
|
+
if (over) {
|
|
156
|
+
await logUsage(info, { service: s, model, inputTokens: 0, outputTokens: 0, ms: 0, status: 'limited', message: 'daily limit' });
|
|
157
|
+
throw new AiError('limit', `This application has reached its daily limit for AI service ${s.name}: try again tomorrow.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/**
|
|
151
162
|
* One AI request through a service: checks, daily limits, the call, the
|
|
152
163
|
* usage log. A structured request returns the parsed JSON in `json`.
|
|
@@ -157,14 +168,7 @@ export async function generate(name: string, req: GenerateRequest, info: CallInf
|
|
|
157
168
|
if (req.prompt.length > MAX_PROMPT_CHARS || (req.system?.length ?? 0) > MAX_SYSTEM_CHARS)
|
|
158
169
|
throw new AiError('config', `The prompt is too long (at most ${MAX_PROMPT_CHARS} characters, the system prompt ${MAX_SYSTEM_CHARS}).`);
|
|
159
170
|
if (!req.prompt.trim()) throw new AiError('config', 'The prompt is empty.');
|
|
160
|
-
|
|
161
|
-
const today = await usageToday(info.appId, s.id);
|
|
162
|
-
const over = (limits.max_requests !== null && today.requests >= limits.max_requests) || (limits.max_tokens !== null && today.tokens >= Number(limits.max_tokens));
|
|
163
|
-
if (over) {
|
|
164
|
-
await logUsage(info, { service: s, model: s.model, inputTokens: 0, outputTokens: 0, ms: 0, status: 'limited', message: 'daily limit' });
|
|
165
|
-
throw new AiError('limit', `This application has reached its daily limit for AI service ${s.name}: try again tomorrow.`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
171
|
+
await checkDailyLimit(s, limits, info);
|
|
168
172
|
const provider = providerFor(s);
|
|
169
173
|
const t0 = performance.now();
|
|
170
174
|
info.debug?.(6, `AI service ${s.name} (${s.provider}, ${s.model})${req.schema ? ', structured output' : ''}`);
|
package/src/app.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { diagnosticsRoutes } from './builder/diagnostics.ts';
|
|
|
26
26
|
import { ldapRoutes } from './builder/ldap.ts';
|
|
27
27
|
import { aiRoutes } from './builder/ai.ts';
|
|
28
28
|
import { aiAgentRoutes } from './builder/ai-agents.ts';
|
|
29
|
+
import { vectorProviderRoutes } from './builder/vector-providers.ts';
|
|
29
30
|
import { mailRoutes } from './builder/mail.ts';
|
|
30
31
|
import { backupRoutes } from './builder/backups.ts';
|
|
31
32
|
import { availabilityRoutes } from './builder/availability.ts';
|
|
@@ -236,6 +237,8 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
236
237
|
await app.register(ldapRoutes);
|
|
237
238
|
await app.register(aiRoutes);
|
|
238
239
|
await app.register(aiAgentRoutes);
|
|
240
|
+
// (sprint 136, AP-1) vector providers: Shared Components and Workspace utilities
|
|
241
|
+
await app.register(vectorProviderRoutes);
|
|
239
242
|
await app.register(mailRoutes);
|
|
240
243
|
await app.register(backupRoutes);
|
|
241
244
|
await app.register(availabilityRoutes);
|
package/src/builder/advisor.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { processProblems } from '../runtime/processes.ts';
|
|
|
13
13
|
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
14
14
|
import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } from '../runtime/search.ts';
|
|
15
15
|
import { COMPONENTS } from './components.ts';
|
|
16
|
+
import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
|
|
16
17
|
import { appEntries, type Entry } from './search.ts';
|
|
17
18
|
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
18
19
|
|
|
@@ -242,7 +243,10 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
242
243
|
}
|
|
243
244
|
let cols = configColumns(row).map((x) => `t.${pg.escapeIdentifier(x)}`).join(', ') || '*';
|
|
244
245
|
// (135) a full-text configuration: its tsvector matched with its text search configuration
|
|
245
|
-
const fulltext = row.search_type === 'fulltext';
|
|
246
|
+
const fulltext = row.search_type === 'fulltext' || row.search_type === 'hybrid';
|
|
247
|
+
// (136) a vector or hybrid configuration also reads its vector (and key) column
|
|
248
|
+
const vectorType = row.search_type === 'vector' || row.search_type === 'hybrid';
|
|
249
|
+
if (vectorType) cols = [cols === '*' ? 't.*' : cols, ...[row.vector_column, row.key_column].filter(Boolean).map((x: string) => `t.${pg.escapeIdentifier(x)}`)].join(', ');
|
|
246
250
|
if (fulltext) {
|
|
247
251
|
const cfg = await textSearchConfig(c, row.fts_config, app.language);
|
|
248
252
|
if (row.fts_config && cfg !== row.fts_config)
|
|
@@ -281,6 +285,42 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
281
285
|
});
|
|
282
286
|
}
|
|
283
287
|
}
|
|
288
|
+
// (136) vector and hybrid configurations: pgvector, the provider, the column's dimensions and its index
|
|
289
|
+
for (const { kind, row } of all.filter((x) => x.kind === 'search_config' && (x.row.search_type === 'vector' || x.row.search_type === 'hybrid') && !searchConfigProblems(x.row).length)) {
|
|
290
|
+
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
291
|
+
const schema = await vectorSchema(c);
|
|
292
|
+
if (!schema) {
|
|
293
|
+
findings.push({ severity: 'error', entry, field: 'Search type', message: PGVECTOR_MISSING });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const provider = (Array.isArray(app.vector_providers) ? app.vector_providers : []).find((p: { name: string }) => p.name === row.vector_provider);
|
|
297
|
+
if (!provider) findings.push({ severity: 'error', entry, field: 'Vector provider', message: `There is no vector provider ${row.vector_provider} (Shared Components → Vector providers).` });
|
|
298
|
+
if (row.source_type !== 'table') continue;
|
|
299
|
+
await c.query('savepoint advisor_vec');
|
|
300
|
+
const info = await (async () => {
|
|
301
|
+
const dims = await vectorColumnDims(c, row.source.trim(), row.vector_column);
|
|
302
|
+
const ix = (await c.query<{ ok: boolean; ops: string | null }>(
|
|
303
|
+
`select exists (select from pg_catalog.pg_index i join pg_catalog.pg_class ic on ic.oid = i.indexrelid join pg_catalog.pg_am am on am.oid = ic.relam
|
|
304
|
+
where i.indrelid = $1::regclass and am.amname in ('hnsw', 'ivfflat')
|
|
305
|
+
and (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = $1::regclass and a.attname = $2) = any (i.indkey::int2[])) as ok,
|
|
306
|
+
(select string_agg(o.opcname, ',') from pg_catalog.pg_index i join pg_catalog.pg_opclass o on o.oid = any (i.indclass::oid[])
|
|
307
|
+
where i.indrelid = $1::regclass and (select a.attnum from pg_catalog.pg_attribute a where a.attrelid = $1::regclass and a.attname = $2) = any (i.indkey::int2[])) as ops`,
|
|
308
|
+
[row.source.trim(), row.vector_column])).rows[0];
|
|
309
|
+
return { dims, ix };
|
|
310
|
+
})().catch(async () => {
|
|
311
|
+
await c.query('rollback to savepoint advisor_vec');
|
|
312
|
+
return null;
|
|
313
|
+
});
|
|
314
|
+
if (!info) continue;
|
|
315
|
+
if (info.dims === undefined) findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()} has no column ${row.vector_column}.` });
|
|
316
|
+
else if (info.dims === null) findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} is not a vector(n) column.` });
|
|
317
|
+
else if (provider && info.dims !== provider.dimensions)
|
|
318
|
+
findings.push({ severity: 'error', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} is vector(${info.dims}), vector provider ${provider.name} gives ${provider.dimensions} dimensions.` });
|
|
319
|
+
if (info.dims && !info.ix?.ok)
|
|
320
|
+
findings.push({ severity: 'warning', entry, field: 'Vector column', message: `${row.source.trim()}.${row.vector_column} has no HNSW index: every search reads the whole table (SQL Workshop › Table Properties › Add vector column adds one).` });
|
|
321
|
+
else if (info.dims && info.ix?.ops && !info.ix.ops.split(',').includes(DISTANCES[(row.distance ?? 'cosine') as Distance].opclass))
|
|
322
|
+
findings.push({ severity: 'warning', entry, field: 'Distance', message: `The index on ${row.vector_column} is for ${info.ix.ops}, not ${DISTANCES[(row.distance ?? 'cosine') as Distance].opclass}: the ${row.distance ?? 'cosine'} search can't use it.` });
|
|
323
|
+
}
|
|
284
324
|
// the search regions name existing configurations
|
|
285
325
|
for (const { kind, row } of all.filter((x) => x.kind === 'region' && x.row.type === 'search')) {
|
|
286
326
|
const names: unknown[] = Array.isArray(row.config?.configs) ? row.config.configs : [];
|