saltcorn-ai-embed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # saltcorn-ai-embed
2
+
3
+ Vector embedding and semantic search for [Saltcorn](https://github.com/saltcorn/saltcorn) — embed any text field using OpenAI and search your table by meaning, not just keywords.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Requirements](#requirements)
10
+ - [Installation](#installation)
11
+ - [How it works](#how-it-works)
12
+ - [Plugin configuration](#plugin-configuration)
13
+ - [Action: ai_embed](#action-ai_embed)
14
+ - [View: SemanticSearch](#view-semanticsearch)
15
+ - [Testing locally](#testing-locally)
16
+ - [License](#license)
17
+
18
+ ---
19
+
20
+ ## Requirements
21
+
22
+ - **Saltcorn** v1.0.0 or later
23
+ - An **OpenAI API key** (for generating embeddings via `text-embedding-3-small` or similar)
24
+
25
+ No pgvector or special database extension required — similarity is computed in JavaScript and works with any Saltcorn database including SQLite.
26
+
27
+ ---
28
+
29
+ ## Installation
30
+
31
+ ### From the plugin store
32
+
33
+ 1. Go to **Settings → Plugins → Plugin Store**
34
+ 2. Search for `saltcorn-ai-embed`
35
+ 3. Click **Install**
36
+ 4. Go to **Settings → Plugins → Configure → saltcorn-ai-embed** and enter your OpenAI API key
37
+
38
+ ### From npm
39
+
40
+ ```bash
41
+ npm install saltcorn-ai-embed
42
+ ```
43
+
44
+ Then in Saltcorn: **Settings → Plugins → Install from npm** → enter `saltcorn-ai-embed`.
45
+
46
+ ---
47
+
48
+ ## How it works
49
+
50
+ 1. **Embed your rows** — use the `ai_embed` action to call the OpenAI embeddings API for each row's text field. The resulting vector (an array of ~1500 floats) is stored as JSON in a dedicated field.
51
+ 2. **Search by meaning** — add a `SemanticSearch` view to any page. When the user types a query, it is embedded the same way, then compared against every stored vector using cosine similarity. The top-N most similar rows are returned.
52
+
53
+ This works well for up to ~50,000 rows. Beyond that, consider offloading similarity search to pgvector.
54
+
55
+ ---
56
+
57
+ ## Plugin configuration
58
+
59
+ Go to **Settings → Plugins → Installed → saltcorn-ai-embed → Configure**.
60
+
61
+ | Field | Required | Description |
62
+ |---|---|---|
63
+ | OpenAI API key | ✅ | Your `sk-...` key from [platform.openai.com](https://platform.openai.com) |
64
+ | Embedding model | — | Model used for all embeddings (default: `text-embedding-3-small`) |
65
+
66
+ **Supported models:**
67
+
68
+ | Model | Dimensions | Best for |
69
+ |---|---|---|
70
+ | `text-embedding-3-small` | 1536 | Fast, cheap, accurate enough for most use cases |
71
+ | `text-embedding-3-large` | 3072 | Higher accuracy, higher cost |
72
+ | `text-embedding-ada-002` | 1536 | Legacy — prefer `3-small` for new projects |
73
+
74
+ > All rows must be embedded with the **same model**. If you change the model, re-run `ai_embed` on all rows.
75
+
76
+ ---
77
+
78
+ ## Action: `ai_embed`
79
+
80
+ Generates a vector embedding for a text field and stores it as a JSON array in another field. Use this to prepare rows for semantic search.
81
+
82
+ | Config field | Required | Description |
83
+ |---|---|---|
84
+ | Source field | ✅ | Text field to embed (the content that will be searched) |
85
+ | Embedding field | ✅ | Field where the vector JSON will be stored — use a **Text** field or a String field with no max length |
86
+
87
+ ### Usage patterns
88
+
89
+ **As a trigger (recommended)** — embed automatically on every insert or update:
90
+
91
+ 1. Go to **Settings → Triggers → Add trigger**
92
+ 2. When: `Insert`, Table: your table, Action: `ai_embed`
93
+ 3. Source field: your text field, Embedding field: your vector field
94
+ 4. Save — every new row is embedded automatically
95
+
96
+ **As a row action in a List view** — bulk-embed existing rows by clicking a button per row.
97
+
98
+ **As a button in an Edit view** — embed on demand before saving.
99
+
100
+ ### Embedding field setup
101
+
102
+ Add a field to your table:
103
+
104
+ | Setting | Value |
105
+ |---|---|
106
+ | Name | `embedding` (or any name) |
107
+ | Type | **Text** |
108
+ | Required | No |
109
+
110
+ Text fields have no length limit in Saltcorn's SQLite and PostgreSQL backends, which is needed because a `text-embedding-3-small` vector serialises to ~9 KB of JSON.
111
+
112
+ ---
113
+
114
+ ## View: `SemanticSearch`
115
+
116
+ Renders a search input. On submit, embeds the query and returns the most similar rows ranked by cosine similarity.
117
+
118
+ ### Configuration
119
+
120
+ | Field | Required | Description |
121
+ |---|---|---|
122
+ | Embedding field | ✅ | Field that stores the pre-computed JSON vectors |
123
+ | Result fields | ✅ | Comma-separated list of fields to display, e.g. `title,body,category` |
124
+ | Max results | — | Maximum number of rows to return (default: 5) |
125
+ | Minimum similarity | — | Cosine similarity threshold 0–1 — rows below this are hidden (default: 0.5) |
126
+ | Search placeholder | — | Placeholder text in the search input |
127
+
128
+ ### Adding to a page
129
+
130
+ 1. Go to **Views → Add view**
131
+ 2. Select your table, template: **SemanticSearch**, give it a name (e.g. `article-search`)
132
+ 3. Set **Embedding field** to the field that holds your vectors
133
+ 4. Set **Result fields** to the fields you want displayed, e.g. `title,summary,category`
134
+ 5. Save
135
+ 6. Go to **Pages**, open any page in the builder, drag a **View** element, select `article-search`
136
+
137
+ ### Results
138
+
139
+ Each result row shows the configured fields plus a **Score** badge (percentage cosine similarity). Results are sorted highest-to-lowest.
140
+
141
+ ---
142
+
143
+ ## Testing locally
144
+
145
+ ### Prerequisites
146
+
147
+ - Node.js 18+
148
+ - Saltcorn installed globally: `npm install -g @saltcorn/cli`
149
+ - An OpenAI API key
150
+
151
+ ### Step 1 — Clone and register
152
+
153
+ ```bash
154
+ git clone https://github.com/satwikjambula/saltcorn-ai-embed
155
+ cd saltcorn-ai-embed
156
+
157
+ sqlite3 ~/Library/Application\ Support/saltcorn/scdb.sqlite \
158
+ "INSERT OR REPLACE INTO _sc_plugins (name, source, location) \
159
+ VALUES ('saltcorn-ai-embed', 'local', '$(pwd)');"
160
+ ```
161
+
162
+ ### Step 2 — Start the server
163
+
164
+ ```bash
165
+ saltcorn serve
166
+ ```
167
+
168
+ ### Step 3 — Configure the plugin
169
+
170
+ 1. Go to **Settings → Plugins → Installed → saltcorn-ai-embed → Configure**
171
+ 2. Enter your OpenAI API key
172
+ 3. Leave model as `text-embedding-3-small`
173
+ 4. Save
174
+
175
+ ### Step 4 — Create a test table
176
+
177
+ Go to **Tables → Add table**, name it `articles`. Add fields:
178
+
179
+ | Field | Type |
180
+ |---|---|
181
+ | `title` | String |
182
+ | `body` | Text |
183
+ | `embedding` | Text |
184
+
185
+ ### Step 5 — Insert test rows
186
+
187
+ Insert a few rows via **Views → Add view → Edit** or directly in the table view. Example body texts to try:
188
+
189
+ - `"The Eiffel Tower is a wrought-iron lattice tower in Paris, France."`
190
+ - `"Python is a high-level programming language known for its readability."`
191
+ - `"Photosynthesis allows plants to convert sunlight into glucose."`
192
+ - `"The French Revolution began in 1789 and transformed European politics."`
193
+
194
+ ### Step 6 — Embed the rows
195
+
196
+ 1. Go to **Settings → Triggers → Add trigger**
197
+ 2. When: `Insert`, Table: `articles`, Action: `ai_embed`
198
+ 3. Source field: `body`, Embedding field: `embedding`
199
+ 4. Save
200
+
201
+ Re-insert (or update) each row — the trigger will call the OpenAI API and populate the `embedding` field.
202
+
203
+ Alternatively, add a row action button to a List view with the `ai_embed` action and click it for each row.
204
+
205
+ ### Step 7 — Create the SemanticSearch view
206
+
207
+ 1. **Views → Add view**, Table: `articles`, Template: **SemanticSearch**, Name: `article-search`
208
+ 2. Embedding field: `embedding`, Result fields: `title,body`, Max results: `5`, Min similarity: `0.4`
209
+ 3. Save
210
+
211
+ ### Step 8 — Search
212
+
213
+ Open the view. Try queries like:
214
+
215
+ - `"Paris architecture"` → should surface the Eiffel Tower row
216
+ - `"biology and nature"` → should surface the photosynthesis row
217
+ - `"coding and software"` → should surface the Python row
218
+
219
+ ### Troubleshooting
220
+
221
+ | Symptom | Fix |
222
+ |---|---|
223
+ | `No OpenAI API key configured` | Go to **Settings → Plugins → Configure → saltcorn-ai-embed** |
224
+ | `OpenAI embeddings error 401` | API key is invalid or expired |
225
+ | `embedding_field not configured` | Re-open the SemanticSearch view config and set the embedding field |
226
+ | All scores are 0 or no results | Rows may not have embeddings yet — run `ai_embed` on them first |
227
+ | Results seem irrelevant | Lower the Min similarity threshold; try `text-embedding-3-large` for better accuracy |
228
+
229
+ ---
230
+
231
+ ## License
232
+
233
+ MIT
package/actions.js ADDED
@@ -0,0 +1,65 @@
1
+ const { embed, getPluginCfg } = require("./embed-util");
2
+
3
+ function fieldOptions(fields) {
4
+ return fields.filter((f) => !f.primary_key).map((f) => f.name);
5
+ }
6
+
7
+ function textFieldOptions(fields) {
8
+ return fields
9
+ .filter(
10
+ (f) =>
11
+ !f.primary_key &&
12
+ (f.type?.name === "String" || f.type?.name === "Text")
13
+ )
14
+ .map((f) => f.name);
15
+ }
16
+
17
+ // ─── ai_embed ─────────────────────────────────────────────────────────────────
18
+
19
+ const ai_embed = {
20
+ name: "ai_embed",
21
+ description:
22
+ "Generate a vector embedding for a text field and store it as JSON in another field. " +
23
+ "Use this to prepare rows for semantic search.",
24
+ configFields: async ({ table }) => {
25
+ const fields = table ? table.getFields() : [];
26
+ return [
27
+ {
28
+ name: "source_field",
29
+ label: "Source field",
30
+ sublabel: "Text field to embed",
31
+ type: "String",
32
+ required: true,
33
+ attributes: { options: textFieldOptions(fields) },
34
+ },
35
+ {
36
+ name: "target_field",
37
+ label: "Embedding field",
38
+ sublabel:
39
+ "Field where the vector will be stored as a JSON array. " +
40
+ "Use a String field with a large max length, or a Text field.",
41
+ type: "String",
42
+ required: true,
43
+ attributes: { options: fieldOptions(fields) },
44
+ },
45
+ ];
46
+ },
47
+ run: async ({ row, table, user, configuration: { source_field, target_field } }) => {
48
+ if (!source_field || !target_field)
49
+ return { error: "source_field and target_field are required" };
50
+
51
+ const text = row?.[source_field];
52
+ if (!text) return { notify: "Source field is empty — nothing to embed" };
53
+
54
+ const cfg = getPluginCfg();
55
+ const vector = await embed(text, cfg);
56
+ const value = JSON.stringify(vector);
57
+
58
+ if (row?.id) {
59
+ await table.updateRow({ [target_field]: value }, row.id, user);
60
+ }
61
+ return { set_fields: { [target_field]: value } };
62
+ },
63
+ };
64
+
65
+ module.exports = { ai_embed };
package/embed-util.js ADDED
@@ -0,0 +1,76 @@
1
+ // Shared utilities: module-cache lookup, OpenAI call, cosine similarity
2
+
3
+ // ─── Saltcorn state ───────────────────────────────────────────────────────────
4
+
5
+ function getState() {
6
+ const key = Object.keys(require.cache).find((k) =>
7
+ /\/@saltcorn\/data\/db\/state(\.[cm]?js)?$/.test(k)
8
+ );
9
+ return key ? require.cache[key].exports.getState?.() : null;
10
+ }
11
+
12
+ function getPluginCfg() {
13
+ return getState()?.plugin_cfg?.["saltcorn-ai-embed"] ?? {};
14
+ }
15
+
16
+ // ─── OpenAI embeddings ────────────────────────────────────────────────────────
17
+
18
+ async function embed(text, cfg) {
19
+ const { openai_api_key, embedding_model = "text-embedding-3-small" } =
20
+ cfg || getPluginCfg();
21
+
22
+ if (!openai_api_key) {
23
+ throw new Error(
24
+ "No OpenAI API key configured. Go to Settings → Plugins → Configure → saltcorn-ai-embed."
25
+ );
26
+ }
27
+
28
+ // Use Node's built-in fetch (Node 18+) — no extra dependencies
29
+ const res = await fetch("https://api.openai.com/v1/embeddings", {
30
+ method: "POST",
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ Authorization: `Bearer ${openai_api_key}`,
34
+ },
35
+ body: JSON.stringify({ input: String(text), model: embedding_model }),
36
+ });
37
+
38
+ if (!res.ok) {
39
+ const err = await res.json().catch(() => ({}));
40
+ throw new Error(
41
+ `OpenAI embeddings error ${res.status}: ${err?.error?.message ?? res.statusText}`
42
+ );
43
+ }
44
+
45
+ const data = await res.json();
46
+ return data.data[0].embedding; // float[]
47
+ }
48
+
49
+ // ─── Cosine similarity ────────────────────────────────────────────────────────
50
+
51
+ function cosine(a, b) {
52
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
53
+ let dot = 0, normA = 0, normB = 0;
54
+ for (let i = 0; i < a.length; i++) {
55
+ dot += a[i] * b[i];
56
+ normA += a[i] * a[i];
57
+ normB += b[i] * b[i];
58
+ }
59
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
60
+ return denom === 0 ? 0 : dot / denom;
61
+ }
62
+
63
+ // ─── Helper: safe JSON parse for stored embeddings ────────────────────────────
64
+
65
+ function parseEmbedding(raw) {
66
+ if (!raw) return null;
67
+ if (Array.isArray(raw)) return raw;
68
+ try {
69
+ const v = JSON.parse(raw);
70
+ return Array.isArray(v) ? v : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ module.exports = { getState, getPluginCfg, embed, cosine, parseEmbedding };
package/index.js ADDED
@@ -0,0 +1,45 @@
1
+ const { ai_embed } = require("./actions");
2
+
3
+ module.exports = {
4
+ sc_plugin_api_version: 1,
5
+ plugin_name: "saltcorn-ai-embed",
6
+
7
+ configuration_workflow: () => ({
8
+ steps: [
9
+ {
10
+ name: "OpenAI settings",
11
+ form: async () => ({
12
+ fields: [
13
+ {
14
+ name: "openai_api_key",
15
+ label: "OpenAI API key",
16
+ sublabel:
17
+ "Required for generating embeddings. Get one at platform.openai.com.",
18
+ type: "String",
19
+ required: true,
20
+ input_type: "password",
21
+ },
22
+ {
23
+ name: "embedding_model",
24
+ label: "Embedding model",
25
+ sublabel:
26
+ "text-embedding-3-small is fast and cheap. text-embedding-3-large is more accurate.",
27
+ type: "String",
28
+ required: false,
29
+ attributes: {
30
+ options: [
31
+ "text-embedding-3-small",
32
+ "text-embedding-3-large",
33
+ "text-embedding-ada-002",
34
+ ],
35
+ },
36
+ },
37
+ ],
38
+ }),
39
+ },
40
+ ],
41
+ }),
42
+
43
+ actions: { ai_embed },
44
+ viewtemplates: [require("./semantic-search")],
45
+ };
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "saltcorn-ai-embed",
3
+ "version": "0.1.0",
4
+ "description": "Vector embedding and semantic search for Saltcorn — embed any text field and search by meaning",
5
+ "main": "index.js",
6
+ "keywords": ["saltcorn", "plugin", "ai", "embeddings", "vector", "semantic-search", "openai"],
7
+ "license": "MIT",
8
+ "peerDependencies": {
9
+ "@saltcorn/data": ">=1.0.0"
10
+ }
11
+ }
@@ -0,0 +1,268 @@
1
+ // SemanticSearch view template
2
+ const { embed, cosine, parseEmbedding, getPluginCfg } = require("./embed-util");
3
+
4
+ function escHtml(str) {
5
+ return String(str ?? "")
6
+ .replace(/&/g, "&amp;")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;");
10
+ }
11
+
12
+ function getTable(table_name) {
13
+ const key = Object.keys(require.cache).find((k) =>
14
+ /\/@saltcorn\/data\/models\/table(\.[cm]?js)?$/.test(k)
15
+ );
16
+ if (!key) throw new Error("Cannot find @saltcorn/data Table model");
17
+ const Table = require.cache[key].exports;
18
+ return Table.findOne ? Table.findOne({ name: table_name }) : null;
19
+ }
20
+
21
+ // ─── view template ────────────────────────────────────────────────────────────
22
+
23
+ const SemanticSearch = {
24
+ name: "SemanticSearch",
25
+ description:
26
+ "Semantic search widget — type a query, find the most similar rows by meaning using vector embeddings.",
27
+
28
+ get_state_fields: async () => [],
29
+
30
+ configuration_workflow: () => ({
31
+ steps: [
32
+ {
33
+ name: "Configure semantic search",
34
+ form: async (context) => {
35
+ // Get fields of the table this view is attached to
36
+ let fieldOpts = [];
37
+ if (context?.table_id) {
38
+ const key = Object.keys(require.cache).find((k) =>
39
+ /\/@saltcorn\/data\/models\/table(\.[cm]?js)?$/.test(k)
40
+ );
41
+ if (key) {
42
+ const Table = require.cache[key].exports;
43
+ const tbl = await Table.findOne({ id: context.table_id });
44
+ if (tbl) {
45
+ const fields = tbl.getFields();
46
+ fieldOpts = fields.filter((f) => !f.primary_key).map((f) => f.name);
47
+ }
48
+ }
49
+ }
50
+ return {
51
+ fields: [
52
+ {
53
+ name: "embedding_field",
54
+ label: "Embedding field",
55
+ sublabel:
56
+ "Field that stores the pre-computed JSON embedding vectors (populated by the ai_embed action)",
57
+ type: "String",
58
+ required: true,
59
+ attributes: { options: fieldOpts },
60
+ },
61
+ {
62
+ name: "display_fields",
63
+ label: "Result fields",
64
+ sublabel:
65
+ "Comma-separated list of fields to show in each result row, e.g. title,body,category",
66
+ type: "String",
67
+ required: true,
68
+ },
69
+ {
70
+ name: "top_n",
71
+ label: "Max results",
72
+ sublabel: "Maximum number of results to return (default: 5)",
73
+ type: "Integer",
74
+ required: false,
75
+ attributes: { default: 5 },
76
+ },
77
+ {
78
+ name: "min_score",
79
+ label: "Minimum similarity (0–1)",
80
+ sublabel:
81
+ "Only show results above this cosine similarity threshold (default: 0.5)",
82
+ type: "Float",
83
+ required: false,
84
+ attributes: { default: 0.5 },
85
+ },
86
+ {
87
+ name: "placeholder",
88
+ label: "Search placeholder",
89
+ type: "String",
90
+ required: false,
91
+ attributes: { placeholder: "Search by meaning…" },
92
+ },
93
+ ],
94
+ };
95
+ },
96
+ },
97
+ ],
98
+ }),
99
+
100
+ // ── render ────────────────────────────────────────────────────────────────
101
+ run: async (table_name, viewname, cfg, state, extra) => {
102
+ const {
103
+ placeholder = "Search by meaning…",
104
+ display_fields = "",
105
+ top_n = 5,
106
+ min_score = 0.5,
107
+ } = cfg || {};
108
+
109
+ const id = "sc_sem_" + Math.random().toString(36).slice(2, 8);
110
+
111
+ return `
112
+ <div class="sc-semsearch-wrap" id="${id}">
113
+ <div class="input-group mb-3">
114
+ <input
115
+ type="text"
116
+ class="form-control"
117
+ id="${id}_q"
118
+ placeholder="${escHtml(placeholder)}"
119
+ />
120
+ <button class="btn btn-primary" id="${id}_btn" type="button">
121
+ <i class="fas fa-search me-1"></i>Search
122
+ </button>
123
+ </div>
124
+ <div id="${id}_status" class="text-muted small mb-2" style="display:none"></div>
125
+ <div id="${id}_results"></div>
126
+ </div>
127
+
128
+ <script>
129
+ (function() {
130
+ var q = document.getElementById(${JSON.stringify(id + "_q")});
131
+ var btn = document.getElementById(${JSON.stringify(id + "_btn")});
132
+ var statusEl = document.getElementById(${JSON.stringify(id + "_status")});
133
+ var resultsEl = document.getElementById(${JSON.stringify(id + "_results")});
134
+ var viewname = ${JSON.stringify(viewname)};
135
+ var displayFields = ${JSON.stringify(
136
+ display_fields.split(",").map((s) => s.trim()).filter(Boolean)
137
+ )};
138
+
139
+ function setStatus(msg) {
140
+ statusEl.textContent = msg;
141
+ statusEl.style.display = msg ? "" : "none";
142
+ }
143
+
144
+ function renderResults(rows) {
145
+ if (!rows.length) {
146
+ resultsEl.innerHTML = '<p class="text-muted">No results found.</p>';
147
+ return;
148
+ }
149
+ var headers = displayFields.length
150
+ ? displayFields
151
+ : Object.keys(rows[0]).filter(function(k) { return k !== "_score" && k !== "_embedding"; });
152
+
153
+ var html = '<table class="table table-hover table-sm sc-semsearch-table"><thead><tr>';
154
+ headers.forEach(function(h) {
155
+ html += "<th>" + h + "</th>";
156
+ });
157
+ html += "<th>Score</th></tr></thead><tbody>";
158
+
159
+ rows.forEach(function(row) {
160
+ html += "<tr>";
161
+ headers.forEach(function(h) {
162
+ var val = row[h] != null ? String(row[h]) : "";
163
+ html += "<td>" + val.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;") + "</td>";
164
+ });
165
+ html += "<td><span class='badge bg-secondary'>" + (row._score * 100).toFixed(1) + "%</span></td>";
166
+ html += "</tr>";
167
+ });
168
+
169
+ html += "</tbody></table>";
170
+ resultsEl.innerHTML = html;
171
+ }
172
+
173
+ async function search() {
174
+ var query = q.value.trim();
175
+ if (!query) return;
176
+
177
+ btn.disabled = true;
178
+ setStatus("Searching…");
179
+ resultsEl.innerHTML = "";
180
+
181
+ try {
182
+ var resp = await fetch("/view/" + viewname + "/search", {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest" },
185
+ body: JSON.stringify({ query: query }),
186
+ });
187
+ var data = await resp.json();
188
+ if (data.error) { setStatus("Error: " + data.error); return; }
189
+ setStatus(data.results.length + " result" + (data.results.length === 1 ? "" : "s"));
190
+ renderResults(data.results);
191
+ } catch(e) {
192
+ setStatus("Network error: " + e.message);
193
+ } finally {
194
+ btn.disabled = false;
195
+ }
196
+ }
197
+
198
+ btn.addEventListener("click", search);
199
+ q.addEventListener("keydown", function(e) {
200
+ if (e.key === "Enter") search();
201
+ });
202
+ })();
203
+ </script>`;
204
+ },
205
+
206
+ // ── route: POST /view/{viewname}/search ───────────────────────────────────
207
+ routes: {
208
+ search: {
209
+ post: async (table_name, viewname, cfg, { query }, { req }) => {
210
+ if (!query) return { json: { error: "query is required" } };
211
+
212
+ const {
213
+ embedding_field,
214
+ display_fields = "",
215
+ top_n = 5,
216
+ min_score = 0.5,
217
+ } = cfg || {};
218
+
219
+ if (!embedding_field)
220
+ return { json: { error: "embedding_field not configured" } };
221
+
222
+ // Embed the query
223
+ let queryVec;
224
+ try {
225
+ queryVec = await embed(query, getPluginCfg());
226
+ } catch (err) {
227
+ return { json: { error: err.message } };
228
+ }
229
+
230
+ // Load all rows that have an embedding
231
+ const table = await getTable(table_name);
232
+ if (!table) return { json: { error: `Table ${table_name} not found` } };
233
+
234
+ const rows = await table.getRows({});
235
+ const displayCols = display_fields
236
+ .split(",")
237
+ .map((s) => s.trim())
238
+ .filter(Boolean);
239
+
240
+ // Score each row
241
+ const scored = rows
242
+ .map((row) => {
243
+ const vec = parseEmbedding(row[embedding_field]);
244
+ if (!vec) return null;
245
+ const score = cosine(queryVec, vec);
246
+ return { row, score };
247
+ })
248
+ .filter((r) => r !== null && r.score >= Number(min_score))
249
+ .sort((a, b) => b.score - a.score)
250
+ .slice(0, Number(top_n));
251
+
252
+ const results = scored.map(({ row, score }) => {
253
+ const out = displayCols.length
254
+ ? Object.fromEntries(displayCols.map((f) => [f, row[f]]))
255
+ : { ...row };
256
+ // remove the raw embedding from output
257
+ delete out[embedding_field];
258
+ out._score = Math.round(score * 1000) / 1000;
259
+ return out;
260
+ });
261
+
262
+ return { json: { results } };
263
+ },
264
+ },
265
+ },
266
+ };
267
+
268
+ module.exports = SemanticSearch;