wizards 0.2.17 → 0.2.19

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 (68) hide show
  1. package/apps/runtime/dist/agents/project-assistant.js +20 -2
  2. package/apps/runtime/dist/authoring/guide.js +6 -3
  3. package/apps/runtime/dist/db/migrations/tenant/0009_knowledge.sql +104 -0
  4. package/apps/runtime/dist/db/migrations/tenant/meta/0009_snapshot.json +2837 -0
  5. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  6. package/apps/runtime/dist/db/schema.js +160 -8
  7. package/apps/runtime/dist/engine/steps.js +3 -11
  8. package/apps/runtime/dist/env.js +2 -0
  9. package/apps/runtime/dist/files/storage.js +15 -0
  10. package/apps/runtime/dist/models.js +38 -0
  11. package/apps/runtime/dist/plugins/loader.js +2 -0
  12. package/apps/runtime/dist/routes/studio.js +147 -6
  13. package/apps/runtime/dist/services/cloud.js +59 -1
  14. package/apps/runtime/dist/services/knowledge-model.js +558 -0
  15. package/apps/runtime/dist/services/knowledge.js +721 -0
  16. package/apps/runtime/dist/services/project-files.js +114 -6
  17. package/apps/runtime/dist/services/project-index.js +632 -94
  18. package/apps/runtime/dist/services/relevance.js +90 -0
  19. package/apps/runtime/dist/services/space-categories.js +882 -0
  20. package/apps/runtime/dist/services/space-data.js +466 -38
  21. package/apps/runtime/dist/services/space-origin.js +164 -0
  22. package/apps/runtime/dist/services/spaces.js +155 -1
  23. package/apps/runtime/dist/tools/pages.js +27 -22
  24. package/apps/runtime/dist/tools/project.js +130 -40
  25. package/apps/web/dist/assets/{Account-LVXhagaS.js → Account-O6OTAY88.js} +1 -1
  26. package/apps/web/dist/assets/{ChangelogDialog-DM0KAoy6.js → ChangelogDialog-DLrXuyLL.js} +1 -1
  27. package/apps/web/dist/assets/{ChatPanel-CxOQmtv-.js → ChatPanel-BPD-iQK0.js} +1 -1
  28. package/apps/web/dist/assets/{CreditsDialog-DkW3YT44.js → CreditsDialog-Ckema8Ss.js} +1 -1
  29. package/apps/web/dist/assets/{EditorPage-hbisE1Wf.js → EditorPage-Dtu9j41h.js} +1 -1
  30. package/apps/web/dist/assets/{FlowPreview-v7or29ku.js → FlowPreview-DB-86F8z.js} +1 -1
  31. package/apps/web/dist/assets/{Harness-RZF-bBmb.js → Harness-D-DSa4ip.js} +1 -1
  32. package/apps/web/dist/assets/{HomePage-mxG-a_KU.js → HomePage-ZrCxIiJT.js} +1 -1
  33. package/apps/web/dist/assets/{ImportWizard-CiPyUt3E.js → ImportWizard-DtZH-PgS.js} +1 -1
  34. package/apps/web/dist/assets/{LocalRuntime-Dqp9wkN3.js → LocalRuntime-o1OAvp7c.js} +1 -1
  35. package/apps/web/dist/assets/NewWizardPage-Bbt-w8tE.js +2 -0
  36. package/apps/web/dist/assets/ProjectPage-MHxFqA98.js +5 -0
  37. package/apps/web/dist/assets/{PublicRunner-TGkOwbAP.js → PublicRunner-CYo2F8H4.js} +1 -1
  38. package/apps/web/dist/assets/{RunnerView-DzHGshZj.js → RunnerView-Bqp0paLi.js} +1 -1
  39. package/apps/web/dist/assets/{SettingsPage-CG3Xtfej.js → SettingsPage-XyRrZeMr.js} +1 -1
  40. package/apps/web/dist/assets/{SetupPage-O1GfPlqX.js → SetupPage-CdBYFmh1.js} +1 -1
  41. package/apps/web/dist/assets/{SharePage-CCNUhtry.js → SharePage-B7ON0pe1.js} +1 -1
  42. package/apps/web/dist/assets/{api-DB22_B7G.js → api-CVG-xDEn.js} +1 -1
  43. package/apps/web/dist/assets/changelog-B2U80Wtl.js +1 -0
  44. package/apps/web/dist/assets/{esm-3TT9i_rv.js → esm-DutMJAod.js} +1 -1
  45. package/apps/web/dist/assets/index-Bwyehu0k.css +1 -0
  46. package/apps/web/dist/assets/{index-CLe9Wrpu.js → index-hpNaR92X.js} +3 -3
  47. package/apps/web/dist/assets/{meta-CEsBCPfA.js → meta--kpiTOS-.js} +1 -1
  48. package/apps/web/dist/assets/{settings-menu-pOcjznGJ.js → settings-menu-BXpT5jOB.js} +1 -1
  49. package/apps/web/dist/assets/ui-BPY2pIh0.js +1 -0
  50. package/apps/web/dist/index.html +4 -4
  51. package/apps/web/dist/mcp/flow.html +2 -2
  52. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +164 -0
  53. package/node_modules/@engenty-wizards/shared/dist/knowledge.d.ts +199 -0
  54. package/node_modules/@engenty-wizards/shared/dist/knowledge.js +68 -0
  55. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +13 -1
  56. package/node_modules/@engenty-wizards/shared/dist/marketplace.js +3 -0
  57. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +1 -16
  58. package/node_modules/@engenty-wizards/shared/dist/projects.js +1 -1
  59. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +8 -0
  60. package/node_modules/@engenty-wizards/shared/dist/run.js +5 -0
  61. package/node_modules/@engenty-wizards/shared/dist/space-data.d.ts +20 -0
  62. package/node_modules/@engenty-wizards/shared/dist/space-data.js +2 -1
  63. package/package.json +1 -1
  64. package/apps/web/dist/assets/NewWizardPage-DHwRsi0_.js +0 -2
  65. package/apps/web/dist/assets/ProjectPage-Bb7jN_6n.js +0 -4
  66. package/apps/web/dist/assets/changelog-BkIJdZ7M.js +0 -1
  67. package/apps/web/dist/assets/index-BPpKz65x.css +0 -1
  68. package/apps/web/dist/assets/ui-I7QYMX0F.js +0 -1
@@ -1,21 +1,25 @@
1
+ import { formatTableCell } from "@engenty-wizards/shared/engenty/data-tables";
1
2
  import { embedMany } from "ai";
2
- import { and, eq, inArray, sql } from "drizzle-orm";
3
- import { db, schema } from "../db/client.js";
4
- import { parseDocument } from "../documents/parse.js";
3
+ import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
4
+ import { db, schema, withTenant } from "../db/client.js";
5
5
  import { createSearchChunks } from "../engenty/search-index/chunks.js";
6
6
  import { getBlob, putBlob } from "../files/blobs.js";
7
7
  import { embeddingModel } from "../models.js";
8
8
  import { pluginIdsOf } from "../plugins/registry.js";
9
9
  import { currentTenant } from "../tenants/tenant.js";
10
10
  import { ServiceError } from "./errors.js";
11
+ import { filePaths, pageTree, tablePaths } from "./knowledge.js";
12
+ import { judgeRelevance, MIN_RELEVANCE } from "./relevance.js";
13
+ import { categoryLines, categoryRows, filterItems, itemCategoriesOf, } from "./space-categories.js";
11
14
  const CHUNK = { mode: "paragraph", max_chunk_length: 1200, overlap: 120 };
12
- /** Passages of one document; a longer one keeps its start. */
13
- const MAX_CHUNKS = 2000;
15
+ /** Units of one item; a longer one keeps its start. */
16
+ const MAX_UNITS = 2000;
17
+ /** A section longer than this is cut at paragraphs into parts about PART_CHARS long. */
18
+ const SECTION_CHARS = 2400;
19
+ const PART_CHARS = 1800;
20
+ /** What the keywords and the vectors hand on to the classifier. */
21
+ const CANDIDATES = 24;
14
22
  const vectorBytes = (v) => Buffer.from(new Float32Array(v).buffer);
15
- /** The page marks of the document converter, as a line a reader understands. */
16
- function withPageLines(markdown) {
17
- return markdown.replace(/<page-break number="(\d+)"[^>]*><\/page-break>/g, (_m, n) => `\n\n[Seite ${n}]\n\n`);
18
- }
19
23
  async function embed(texts) {
20
24
  const resolved = await embeddingModel();
21
25
  if (!resolved || !texts.length) {
@@ -27,35 +31,339 @@ async function embed(texts) {
27
31
  export async function dropIndex(fileId) {
28
32
  await db.delete(schema.projectChunk).where(eq(schema.projectChunk.fileId, fileId));
29
33
  }
30
- /** Reads a document and puts it into the index; what it found goes back onto the file's row. */
31
- export async function indexDocument(file, data) {
32
- const parsed = await parseDocument({ data, name: file.name, mime: file.mime });
33
- const text = withPageLines(parsed.markdown).trim();
34
- const chunks = createSearchChunks({ doc_id: file.id, text, ...CHUNK }).slice(0, MAX_CHUNKS);
35
- // Without an embedding model, or when it fails, the keywords still find the passage.
36
- const embedded = await embed(chunks.map((c) => c.text)).catch((err) => {
34
+ export async function documentText(file) {
35
+ return file.textHash ? (await getBlob(file.textHash)).toString("utf8") : "";
36
+ }
37
+ /** Keeps a document's text, for a step that reads the file itself. */
38
+ export async function keepText(text) {
39
+ return putBlob(Buffer.from(text, "utf8"));
40
+ }
41
+ /**
42
+ * Replaces the units `scope` held with `units`. A unit whose text has not changed keeps its
43
+ * vector: editing one row of a long table embeds one row.
44
+ */
45
+ async function writeUnits(scope, units) {
46
+ const before = await db
47
+ .select({
48
+ text: schema.projectChunk.text,
49
+ embedding: schema.projectChunk.embedding,
50
+ model: schema.projectChunk.model,
51
+ })
52
+ .from(schema.projectChunk)
53
+ .where(scope);
54
+ const resolved = await embeddingModel().catch(() => null);
55
+ const kept = new Map(before
56
+ .filter((b) => b.embedding && b.model && b.model === resolved?.ref)
57
+ .map((b) => [b.text, b.embedding]));
58
+ const missing = [...new Set(units.map((u) => u.text).filter((t) => !kept.has(t)))];
59
+ // Without an embedding model, or when it fails, the keywords still find the unit.
60
+ const embedded = await embed(missing).catch((err) => {
37
61
  console.error("[project-index] embedding failed", err);
38
62
  return null;
39
63
  });
40
- await dropIndex(file.id);
41
- for (let i = 0; i < chunks.length; i += 100) {
42
- await db.insert(schema.projectChunk).values(chunks.slice(i, i + 100).map((c, j) => ({
64
+ if (embedded) {
65
+ for (const [i, text] of missing.entries()) {
66
+ kept.set(text, vectorBytes(embedded.vectors[i]));
67
+ }
68
+ }
69
+ const model = embedded?.model ?? (kept.size ? (resolved?.ref ?? null) : null);
70
+ await db.delete(schema.projectChunk).where(scope);
71
+ for (let i = 0; i < units.length; i += 100) {
72
+ await db.insert(schema.projectChunk).values(units.slice(i, i + 100).map((u) => {
73
+ const vector = kept.get(u.text) ?? null;
74
+ return { ...u, embedding: vector, model: vector ? model : null };
75
+ }));
76
+ }
77
+ }
78
+ const SHEET = /^\s*<!--\s*S\.\s*(\d+)\s*-->\s*$/;
79
+ /** A page's Markdown cut at its headings; the page marks of its original say where each starts. */
80
+ export function sectionsOf(markdown) {
81
+ const out = [];
82
+ const stack = [];
83
+ let sheet = null;
84
+ let current = { heading: [], sheet: null, body: "", lines: [] };
85
+ let fence = false;
86
+ const close = () => {
87
+ const body = current.lines.join("\n").trim();
88
+ if (body) {
89
+ out.push({ heading: current.heading, sheet: current.sheet, body });
90
+ }
91
+ };
92
+ for (const line of markdown.split("\n")) {
93
+ if (/^\s*(```|~~~)/.test(line)) {
94
+ fence = !fence;
95
+ }
96
+ const mark = fence ? null : SHEET.exec(line);
97
+ if (mark) {
98
+ sheet = Number(mark[1]);
99
+ if (current.lines.join("").trim()) {
100
+ current.lines.push(`[S. ${sheet}]`);
101
+ }
102
+ else {
103
+ current.sheet = sheet;
104
+ }
105
+ continue;
106
+ }
107
+ const h = fence ? null : /^(#{1,4})\s+(.+?)\s*#*\s*$/.exec(line);
108
+ if (h) {
109
+ close();
110
+ const level = h[1].length;
111
+ while (stack.length && stack.at(-1).level >= level) {
112
+ stack.pop();
113
+ }
114
+ stack.push({ level, text: h[2].trim() });
115
+ current = { heading: stack.map((s) => s.text), sheet, body: "", lines: [] };
116
+ continue;
117
+ }
118
+ current.lines.push(line);
119
+ }
120
+ close();
121
+ return out;
122
+ }
123
+ /** A long section in parts at its paragraphs; a paragraph longer than a part at its sentences. */
124
+ function partsOf(body) {
125
+ if (body.length <= SECTION_CHARS) {
126
+ return [body];
127
+ }
128
+ const pieces = body
129
+ .split(/\n\s*\n/)
130
+ .flatMap((p) => p.length <= PART_CHARS ? [p] : (p.match(/[^.!?]+[.!?]+["»“)]?\s*|[^.!?]+$/g) ?? [p]));
131
+ const parts = [];
132
+ let now = "";
133
+ for (const piece of pieces) {
134
+ if (now && now.length + piece.length > PART_CHARS) {
135
+ parts.push(now.trim());
136
+ now = "";
137
+ }
138
+ now += (now ? "\n\n" : "") + piece;
139
+ }
140
+ if (now.trim()) {
141
+ parts.push(now.trim());
142
+ }
143
+ return parts;
144
+ }
145
+ const head = (titles, lines) => [titles.join(" › "), lines.length ? lines.join(" · ") : ""].filter(Boolean).join("\n");
146
+ async function indexPage(pageId) {
147
+ const scope = eq(schema.projectChunk.pageId, pageId);
148
+ const page = await db.query.spacePage.findFirst({ where: eq(schema.spacePage.id, pageId) });
149
+ if (!page || page.wizardId) {
150
+ await db.delete(schema.projectChunk).where(scope);
151
+ return;
152
+ }
153
+ const [tree, categories] = await Promise.all([
154
+ pageTree(page.projectId),
155
+ categoryRows(page.projectId),
156
+ ]);
157
+ const node = tree.get(page.id);
158
+ const lines = categoryLines(categories, (await itemCategoriesOf([{ pageId: page.id }])).get(`p:${page.id}`));
159
+ const prefix = head(node?.titles ?? [page.title], lines);
160
+ const sections = sectionsOf(page.markdown);
161
+ const units = [];
162
+ // A page without text is still found by its title and Kategorien.
163
+ for (const section of sections.length ? sections : [{ heading: [], sheet: null, body: "" }]) {
164
+ // A page's own first heading is its title: the path says it already.
165
+ const path = section.heading[0]?.toLowerCase() === page.title.toLowerCase()
166
+ ? section.heading.slice(1)
167
+ : section.heading;
168
+ const heading = path.join(" › ") || null;
169
+ for (const part of partsOf(section.body)) {
170
+ units.push({
171
+ projectId: page.projectId,
172
+ pageId: page.id,
173
+ heading,
174
+ sheet: section.sheet,
175
+ idx: units.length,
176
+ text: [prefix, heading ? `## ${heading}` : "", part].filter(Boolean).join("\n\n"),
177
+ });
178
+ }
179
+ }
180
+ await writeUnits(scope, units.slice(0, MAX_UNITS));
181
+ }
182
+ async function indexRows(tableId, rowIds) {
183
+ const table = await db.query.spaceTable.findFirst({ where: eq(schema.spaceTable.id, tableId) });
184
+ const scope = rowIds
185
+ ? inArray(schema.projectChunk.rowId, rowIds)
186
+ : eq(schema.projectChunk.tableId, tableId);
187
+ if (!table || table.wizardId) {
188
+ await db.delete(schema.projectChunk).where(scope);
189
+ return;
190
+ }
191
+ const rows = await db.query.spaceTableRow.findMany({
192
+ where: rowIds
193
+ ? and(eq(schema.spaceTableRow.tableId, table.id), inArray(schema.spaceTableRow.id, rowIds))
194
+ : eq(schema.spaceTableRow.tableId, table.id),
195
+ });
196
+ const categories = await categoryRows(table.projectId);
197
+ const cats = await itemCategoriesOf([
198
+ { tableId: table.id },
199
+ ...rows.map((r) => ({ rowId: r.id })),
200
+ ]);
201
+ const tableLines = categoryLines(categories, cats.get(`t:${table.id}`));
202
+ const units = rows.map((row, i) => {
203
+ const own = categoryLines(categories, cats.get(`r:${row.id}`)).filter((l) => !tableLines.includes(l));
204
+ const cells = table.columns
205
+ .map((c) => [c.name, formatTableCell(c, row.cells[c.id], "de")])
206
+ .filter(([, v]) => v);
207
+ const body = table.format === "faq" && cells.length >= 2
208
+ ? `${cells[0][1]}\n\n${cells
209
+ .slice(1)
210
+ .map(([, v]) => v)
211
+ .join("\n\n")}`
212
+ : cells.map(([name, v]) => `${name}: ${v}`).join("\n");
213
+ return {
214
+ projectId: table.projectId,
215
+ tableId: table.id,
216
+ rowId: row.id,
217
+ heading: table.format === "faq" ? (cells[0]?.[1] ?? null) : null,
218
+ idx: i,
219
+ text: `${head([table.title], [...tableLines, ...own])}\n\n${body}`,
220
+ };
221
+ });
222
+ await writeUnits(scope, units);
223
+ }
224
+ async function indexFile(fileId) {
225
+ const file = await db.query.projectFile.findFirst({ where: eq(schema.projectFile.id, fileId) });
226
+ if (file?.kind !== "document" || file.status !== "ready") {
227
+ return;
228
+ }
229
+ const converted = (await db.query.spacePage.findFirst({
230
+ where: eq(schema.spacePage.fileId, file.id),
231
+ columns: { id: true },
232
+ })) ??
233
+ (await db.query.spaceTable.findFirst({
234
+ where: eq(schema.spaceTable.fileId, file.id),
235
+ columns: { id: true },
236
+ }));
237
+ const scope = eq(schema.projectChunk.fileId, file.id);
238
+ if (converted) {
239
+ // Its text is the page's now; the file stands behind it as the original.
240
+ await db.delete(schema.projectChunk).where(scope);
241
+ return;
242
+ }
243
+ const passages = await db
244
+ .select({ heading: schema.projectChunk.heading, idx: schema.projectChunk.idx })
245
+ .from(schema.projectChunk)
246
+ .where(scope);
247
+ // Passages of a document read before Wissen had pages stay until it is read again.
248
+ if (passages.some((p) => p.heading === null)) {
249
+ return;
250
+ }
251
+ const categories = await categoryRows(file.projectId);
252
+ const lines = categoryLines(categories, (await itemCategoriesOf([{ fileId: file.id }])).get(`f:${file.id}`));
253
+ await writeUnits(scope, [
254
+ {
43
255
  projectId: file.projectId,
44
256
  fileId: file.id,
45
- idx: c.chunk_index,
46
- text: c.text,
47
- embedding: embedded ? vectorBytes(embedded.vectors[i + j]) : null,
48
- model: embedded?.model ?? null,
49
- })));
257
+ heading: file.name,
258
+ idx: 0,
259
+ text: [head([file.name], lines), file.description].filter(Boolean).join("\n\n"),
260
+ },
261
+ ]);
262
+ }
263
+ async function indexValue(valueId) {
264
+ const scope = eq(schema.projectChunk.valueId, valueId);
265
+ const value = await db.query.spaceCategoryValue.findFirst({
266
+ where: eq(schema.spaceCategoryValue.id, valueId),
267
+ });
268
+ const category = value
269
+ ? await db.query.spaceCategory.findFirst({
270
+ where: eq(schema.spaceCategory.id, value.categoryId),
271
+ })
272
+ : undefined;
273
+ if (!(value?.summary && category)) {
274
+ await db.delete(schema.projectChunk).where(scope);
275
+ return;
276
+ }
277
+ const units = partsOf(value.summary).map((part, i) => ({
278
+ projectId: category.projectId,
279
+ valueId: value.id,
280
+ heading: `${category.name}: ${value.value}`,
281
+ idx: i,
282
+ text: `${category.name}: ${value.value} — Übersicht\n\n${part}`,
283
+ }));
284
+ await writeUnits(scope, units);
285
+ }
286
+ /** Every page under a page. */
287
+ async function subPages(pageId) {
288
+ const rows = await db.all(sql `
289
+ WITH RECURSIVE tree(id) AS (
290
+ SELECT id FROM space_page WHERE parent_id = ${pageId}
291
+ UNION SELECT p.id FROM space_page p JOIN tree ON p.parent_id = tree.id
292
+ ) SELECT id FROM tree`);
293
+ return rows.map((r) => r.id);
294
+ }
295
+ /**
296
+ * Brings one item's units up to date. `p:` a page, `P:` a page and its sub-pages (its title or
297
+ * Kategorien changed: they stand in theirs), `t:` a table's rows, `r:` one row, `f:` a file, `v:`
298
+ * a value's Übersicht.
299
+ */
300
+ export async function indexItem(key) {
301
+ const id = key.slice(2);
302
+ switch (key[0]) {
303
+ case "p":
304
+ return indexPage(id);
305
+ case "P":
306
+ await indexPage(id);
307
+ for (const sub of await subPages(id)) {
308
+ await indexPage(sub);
309
+ }
310
+ return;
311
+ case "t":
312
+ return indexRows(id);
313
+ case "r": {
314
+ const row = await db.query.spaceTableRow.findFirst({
315
+ where: eq(schema.spaceTableRow.id, id),
316
+ columns: { tableId: true },
317
+ });
318
+ return row ? indexRows(row.tableId, [id]) : undefined;
319
+ }
320
+ case "f":
321
+ return indexFile(id);
322
+ case "v":
323
+ return indexValue(id);
324
+ default:
325
+ return;
50
326
  }
51
- return {
52
- textHash: await putBlob(Buffer.from(text, "utf8")),
53
- pages: parsed.pages,
54
- chars: text.length,
55
- indexed: chunks.length ? (embedded ? "embeddings" : "keywords") : null,
56
- text,
57
- };
58
327
  }
328
+ // --- the queue ------------------------------------------------------------------------
329
+ // A page saves a moment after the person stops typing; the index follows a moment later, once
330
+ // per item however often it changed, one item after the other.
331
+ const INDEX_DELAY_MS = 1500;
332
+ const waiting = new Map();
333
+ let working = Promise.resolve();
334
+ function run(tenant, key) {
335
+ working = working
336
+ .then(() => withTenant(tenant, () => indexItem(key)))
337
+ .catch((err) => console.error(`[project-index] ${key}`, err));
338
+ }
339
+ /** Indexes an item a moment from now; a change before then waits again. */
340
+ export function queueIndex(key) {
341
+ const tenant = currentTenant();
342
+ const id = `${tenant}|${key}`;
343
+ const known = waiting.get(id);
344
+ if (known) {
345
+ clearTimeout(known.timer);
346
+ }
347
+ const timer = setTimeout(() => {
348
+ waiting.delete(id);
349
+ run(tenant, key);
350
+ }, INDEX_DELAY_MS);
351
+ timer.unref?.();
352
+ waiting.set(id, { tenant, key, timer });
353
+ }
354
+ /** Indexes what waits now, and resolves when the index has caught up. */
355
+ export async function flushIndex() {
356
+ while (waiting.size) {
357
+ for (const [id, entry] of [...waiting]) {
358
+ clearTimeout(entry.timer);
359
+ waiting.delete(id);
360
+ run(entry.tenant, entry.key);
361
+ }
362
+ await working;
363
+ }
364
+ await working;
365
+ }
366
+ // --- plugins' texts ---------------------------------------------------------------------
59
367
  /** Puts a plugin's text into the space's index, in the place of what its key held. */
60
368
  export async function putIndexEntry(plugin, entry) {
61
369
  const space = await db.query.project.findFirst({ where: eq(schema.project.id, entry.space) });
@@ -63,25 +371,16 @@ export async function putIndexEntry(plugin, entry) {
63
371
  throw new ServiceError("not_found", `There is no space "${entry.space}".`);
64
372
  }
65
373
  const text = `${entry.title.trim()}\n\n${entry.text.trim()}`;
66
- const chunks = createSearchChunks({ doc_id: `${plugin}:${entry.key}`, text, ...CHUNK }).slice(0, MAX_CHUNKS);
67
- const embedded = await embed(chunks.map((c) => c.text)).catch((err) => {
68
- console.error("[project-index] embedding failed", err);
69
- return null;
70
- });
71
- await removeIndexEntries(plugin, entry.space, entry.key);
72
- for (let i = 0; i < chunks.length; i += 100) {
73
- await db.insert(schema.projectChunk).values(chunks.slice(i, i + 100).map((c, j) => ({
74
- projectId: entry.space,
75
- plugin,
76
- ref: entry.key,
77
- title: entry.title.trim().slice(0, 300),
78
- link: entry.link ?? null,
79
- idx: c.chunk_index,
80
- text: c.text,
81
- embedding: embedded ? vectorBytes(embedded.vectors[i + j]) : null,
82
- model: embedded?.model ?? null,
83
- })));
84
- }
374
+ const chunks = createSearchChunks({ doc_id: `${plugin}:${entry.key}`, text, ...CHUNK }).slice(0, MAX_UNITS);
375
+ await writeUnits(and(eq(schema.projectChunk.projectId, entry.space), eq(schema.projectChunk.plugin, plugin), eq(schema.projectChunk.ref, entry.key)), chunks.map((c) => ({
376
+ projectId: entry.space,
377
+ plugin,
378
+ ref: entry.key,
379
+ title: entry.title.trim().slice(0, 300),
380
+ link: entry.link ?? null,
381
+ idx: c.chunk_index,
382
+ text: c.text,
383
+ })));
85
384
  }
86
385
  /** Takes a plugin's text out of the space's index; without a key, all of the plugin's texts. */
87
386
  export async function removeIndexEntries(plugin, space, key) {
@@ -101,69 +400,308 @@ export async function hasIndexEntries(projectId) {
101
400
  });
102
401
  return Boolean(found);
103
402
  }
104
- export async function documentText(file) {
105
- return file.textHash ? (await getBlob(file.textHash)).toString("utf8") : "";
106
- }
107
- const STOP = new Set("der die das den dem des ein eine einen einem einer und oder aber ist sind war wie was wer wo für von mit auf aus bei zu zum zur im in an am es ich du wir ihr sie nicht auch the a an of to and or is are was for with on at by it this that".split(" "));
403
+ // --- search -------------------------------------------------------------------------------
404
+ const STOP = new Set("der die das den dem des ein eine einen einem einer und oder aber ist sind war wie was wer wo für von mit auf aus bei zu zum zur im in an am es ich du wir ihr sie nicht auch gibt gibt's welche welcher welches the a an of to and or is are was for with on at by it this that what which".split(" "));
405
+ const wordsOf = (query) => [...new Set(query.toLowerCase().match(/[\p{L}\p{N}]{2,}/gu) ?? [])]
406
+ .filter((w) => !STOP.has(w))
407
+ .slice(0, 16);
108
408
  /** The question's words as an FTS5 query: any of them, also as the start of a longer word. */
109
409
  function keywordQuery(query) {
110
- const words = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}]{2,}/gu) ?? [])]
111
- .filter((w) => !STOP.has(w))
112
- .slice(0, 16);
410
+ const words = wordsOf(query);
113
411
  return words.length ? words.map((w) => `"${w}"*`).join(" OR ") : null;
114
412
  }
115
- /** A passage of a document, or of a plugin's text while the tenant has that plugin. */
116
- function searchable(plugins, alias = "") {
117
- const column = sql.raw(`${alias}plugin`);
413
+ /**
414
+ * The question as a trigram query: each word of three letters or more anywhere in a unit, so
415
+ * "Förderhöhe" finds "Höchstförderhöhe"; a code with digits also as it was written ("WIN 6").
416
+ */
417
+ function trigramQuery(query) {
418
+ const words = wordsOf(query).filter((w) => w.length >= 3);
419
+ const codes = (query.match(/[\p{L}\p{N}][\p{L}\p{N}\-./ ]*\d[\p{L}\p{N}\-./]*/gu) ?? [])
420
+ .map((c) => c.trim())
421
+ .filter((c) => c.length >= 3 && c.includes(" "));
422
+ const terms = [...new Set([...codes, ...words])].map((t) => `"${t.replace(/"/g, "")}"`);
423
+ return terms.length ? terms.join(" OR ") : null;
424
+ }
425
+ const COLUMNS = sql.raw("c.id, c.file_id, c.page_id, c.row_id, c.table_id, c.value_id, c.heading, c.sheet, c.plugin, c.ref, c.title, c.link, c.idx, c.text");
426
+ /** A unit of the space's own Wissen, or of a plugin's text while the tenant has that plugin. */
427
+ function searchable(plugins) {
118
428
  return plugins.length
119
- ? sql `(${column} IS NULL OR ${column} IN (${sql.join(plugins.map((p) => sql `${p}`), sql `, `)}))`
120
- : sql `${column} IS NULL`;
429
+ ? sql `(c.plugin IS NULL OR c.plugin IN (${sql.join(plugins.map((p) => sql `${p}`), sql `, `)}))`
430
+ : sql `c.plugin IS NULL`;
121
431
  }
122
- /** The passages of the project's documents, and of the texts its plugins put in, that fit a question best. */
123
- export async function searchProject(projectId, query, limit = 6) {
432
+ const IN_LIMIT = 2000;
433
+ /** The units a filter leaves, as SQL while the lists are short; else null and checked after. */
434
+ function filtered(f) {
435
+ const lists = [
436
+ ["c.page_id", f.pages],
437
+ ["c.row_id", f.rows],
438
+ ["c.file_id", f.files],
439
+ ];
440
+ if (lists.reduce((n, [, s]) => n + s.size, 0) > IN_LIMIT) {
441
+ return null;
442
+ }
443
+ const parts = lists
444
+ .filter(([, s]) => s.size)
445
+ .map(([column, s]) => sql `${sql.raw(column)} IN (${sql.join([...s].map((id) => sql `${id}`), sql `, `)})`);
446
+ return parts.length ? sql `(${sql.join(parts, sql ` OR `)})` : sql `0`;
447
+ }
448
+ const leaves = (f, u) => (u.page_id !== null && f.pages.has(u.page_id)) ||
449
+ (u.row_id !== null && f.rows.has(u.row_id)) ||
450
+ (u.file_id !== null && f.files.has(u.file_id));
451
+ /** The key of the item a unit belongs to: hits are grouped by it. */
452
+ function itemOf(u) {
453
+ if (u.page_id) {
454
+ return `p:${u.page_id}`;
455
+ }
456
+ if (u.row_id) {
457
+ return `r:${u.row_id}`;
458
+ }
459
+ if (u.value_id) {
460
+ return `v:${u.value_id}`;
461
+ }
462
+ if (u.file_id) {
463
+ return `f:${u.file_id}`;
464
+ }
465
+ return `x:${u.plugin}:${u.ref}`;
466
+ }
467
+ /**
468
+ * Searches Wissen: items whose Kategorien pass `where`, then the units the keywords, the
469
+ * trigrams and the vectors rank best, merged by rank (RRF), then the classifier's judgement of
470
+ * each of the CANDIDATES best. Gives up to `limit` items with their best section. `check: false`
471
+ * leaves the classifier out.
472
+ */
473
+ export async function searchKnowledge(projectId, query, opts = {}) {
474
+ const limit = Math.min(Math.max(1, opts.limit ?? 6), 12);
475
+ const filter = await filterItems(projectId, opts.where);
476
+ if (filter && !filter.size) {
477
+ return { hits: [], candidates: 0, checked: false, filtered: 0 };
478
+ }
124
479
  const plugins = [...(await pluginIdsOf(currentTenant()))];
480
+ const inFilter = filter ? filtered(filter) : null;
481
+ // A filter keeps plugins' texts and Übersichten out: they have no Kategorien.
482
+ const where = and(sql `c.project_id = ${projectId}`, searchable(plugins), filter
483
+ ? (inFilter ?? sql `(c.page_id IS NOT NULL OR c.row_id IS NOT NULL OR c.file_id IS NOT NULL)`)
484
+ : undefined);
485
+ const take = filter && !inFilter ? 300 : CANDIDATES;
125
486
  const lists = [];
126
- const keywords = keywordQuery(query);
127
- if (keywords) {
487
+ const words = keywordQuery(query);
488
+ if (words) {
128
489
  lists.push(await db.all(sql `
129
- SELECT c.id, c.file_id, c.plugin, c.ref, c.title, c.link, c.idx, c.text
130
- FROM project_chunk_fts f JOIN project_chunk c ON c.id = f.rowid
131
- WHERE project_chunk_fts MATCH ${keywords} AND c.project_id = ${projectId}
132
- AND ${searchable(plugins, "c.")}
133
- ORDER BY bm25(project_chunk_fts) LIMIT 24`));
490
+ SELECT ${COLUMNS} FROM project_chunk_fts f JOIN project_chunk c ON c.id = f.rowid
491
+ WHERE project_chunk_fts MATCH ${words} AND ${where}
492
+ ORDER BY bm25(project_chunk_fts) LIMIT ${take}`));
493
+ }
494
+ const grams = trigramQuery(query);
495
+ if (grams) {
496
+ lists.push(await db
497
+ .all(sql `
498
+ SELECT ${COLUMNS} FROM project_chunk_tri f JOIN project_chunk c ON c.id = f.rowid
499
+ WHERE project_chunk_tri MATCH ${grams} AND ${where}
500
+ ORDER BY bm25(project_chunk_tri) LIMIT ${take}`)
501
+ .catch(() => []));
134
502
  }
135
503
  const embedded = await embed([query]).catch(() => null);
136
504
  if (embedded) {
137
505
  lists.push(await db.all(sql `
138
- SELECT id, file_id, plugin, ref, title, link, idx, text
139
- FROM project_chunk
140
- WHERE project_id = ${projectId} AND model = ${embedded.model} AND ${searchable(plugins)}
141
- ORDER BY vector_distance_cos(embedding, ${vectorBytes(embedded.vectors[0])}) LIMIT 24`));
506
+ SELECT ${COLUMNS} FROM project_chunk c
507
+ WHERE ${where} AND c.model = ${embedded.model}
508
+ ORDER BY vector_distance_cos(c.embedding, ${vectorBytes(embedded.vectors[0])}) LIMIT ${take}`));
142
509
  }
143
- // Reciprocal rank fusion: a passage both lists rank high comes first.
510
+ // Reciprocal rank fusion: a unit the lists rank high comes first.
144
511
  const scored = new Map();
145
512
  for (const list of lists) {
146
- list.forEach((hit, rank) => {
147
- const entry = scored.get(hit.id) ?? { hit, score: 0 };
513
+ list
514
+ .filter((u) => !filter || inFilter || leaves(filter, u))
515
+ .slice(0, CANDIDATES)
516
+ .forEach((unit, rank) => {
517
+ const entry = scored.get(unit.id) ?? { unit, score: 0 };
148
518
  entry.score += 1 / (60 + rank);
149
- scored.set(hit.id, entry);
519
+ scored.set(unit.id, entry);
150
520
  });
151
521
  }
152
- const top = [...scored.values()].sort((a, b) => b.score - a.score).slice(0, limit);
153
- if (!top.length) {
154
- return [];
522
+ const candidates = [...scored.values()].sort((a, b) => b.score - a.score).slice(0, CANDIDATES);
523
+ if (!candidates.length) {
524
+ return { hits: [], candidates: 0, checked: false, filtered: filter?.size ?? null };
155
525
  }
156
- const fileIds = [...new Set(top.map((t) => t.hit.file_id).filter((id) => id !== null))];
157
- const files = fileIds.length
158
- ? await db.query.projectFile.findMany({ where: inArray(schema.projectFile.id, fileIds) })
526
+ const described = await describe(projectId, candidates.map((c) => c.unit));
527
+ const judged = opts.check === false
528
+ ? null
529
+ : await judgeRelevance(query, described.map((d) => ({
530
+ title: d.title,
531
+ path: d.path,
532
+ categories: d.categories,
533
+ heading: d.heading,
534
+ text: d.text,
535
+ })), opts.signal);
536
+ const ranked = described
537
+ .map((hit, i) => ({ hit: { ...hit, relevance: judged?.[i] ?? null }, rank: i }))
538
+ .filter(({ hit }) => hit.relevance === null || hit.relevance >= MIN_RELEVANCE)
539
+ .sort((a, b) => judged
540
+ ? (b.hit.relevance ?? MIN_RELEVANCE) - (a.hit.relevance ?? MIN_RELEVANCE) || a.rank - b.rank
541
+ : a.rank - b.rank);
542
+ // One hit per item: its best section.
543
+ const seen = new Set();
544
+ const hits = [];
545
+ for (const { hit } of ranked) {
546
+ if (seen.has(hit.key)) {
547
+ continue;
548
+ }
549
+ seen.add(hit.key);
550
+ hits.push(hit);
551
+ if (hits.length >= limit) {
552
+ break;
553
+ }
554
+ }
555
+ return {
556
+ hits,
557
+ candidates: candidates.length,
558
+ checked: judged !== null,
559
+ filtered: filter?.size ?? null,
560
+ };
561
+ }
562
+ /** What a step and the studio see of each unit: the item's path, title and Kategorien. */
563
+ async function describe(projectId, units) {
564
+ const pageIds = [...new Set(units.flatMap((u) => (u.page_id ? [u.page_id] : [])))];
565
+ const rowIds = [...new Set(units.flatMap((u) => (u.row_id ? [u.row_id] : [])))];
566
+ const fileIds = [...new Set(units.flatMap((u) => (u.file_id ? [u.file_id] : [])))];
567
+ const valueIds = [...new Set(units.flatMap((u) => (u.value_id ? [u.value_id] : [])))];
568
+ const [tree, tPaths, fPaths, categories, rows, files, values] = await Promise.all([
569
+ pageIds.length ? pageTree(projectId) : new Map(),
570
+ rowIds.length ? tablePaths(projectId) : new Map(),
571
+ fileIds.length ? filePaths(projectId) : new Map(),
572
+ categoryRows(projectId),
573
+ rowIds.length
574
+ ? db.query.spaceTableRow.findMany({
575
+ where: inArray(schema.spaceTableRow.id, rowIds),
576
+ columns: { id: true, tableId: true },
577
+ })
578
+ : [],
579
+ fileIds.length
580
+ ? db.query.projectFile.findMany({
581
+ where: inArray(schema.projectFile.id, fileIds),
582
+ columns: { id: true, name: true },
583
+ })
584
+ : [],
585
+ valueIds.length
586
+ ? db.query.spaceCategoryValue.findMany({
587
+ where: inArray(schema.spaceCategoryValue.id, valueIds),
588
+ })
589
+ : [],
590
+ ]);
591
+ const tableIds = [...new Set(rows.map((r) => r.tableId))];
592
+ const tables = tableIds.length
593
+ ? await db.query.spaceTable.findMany({
594
+ where: inArray(schema.spaceTable.id, tableIds),
595
+ columns: { id: true, title: true },
596
+ })
159
597
  : [];
160
- return top.map(({ hit, score }) => ({
161
- fileId: hit.file_id,
162
- plugin: hit.plugin,
163
- name: hit.file_id ? (files.find((f) => f.id === hit.file_id)?.name ?? "") : (hit.title ?? ""),
164
- link: hit.link,
165
- index: hit.idx,
166
- text: hit.text,
167
- score: Math.round(score * 10_000) / 10_000,
168
- }));
598
+ const cats = await itemCategoriesOf([
599
+ ...pageIds.map((pageId) => ({ pageId })),
600
+ ...rowIds.map((rowId) => ({ rowId })),
601
+ ...tableIds.map((tableId) => ({ tableId })),
602
+ ...fileIds.map((fileId) => ({ fileId })),
603
+ ]);
604
+ // The unit's text without the head it was indexed with: the step gets that as fields.
605
+ const bodyOf = (u) => {
606
+ const parts = u.text.split("\n\n");
607
+ if (!(u.page_id || u.row_id || u.value_id) || parts.length < 2) {
608
+ return u.text;
609
+ }
610
+ const rest = parts.slice(1);
611
+ return (rest[0].startsWith("## ") ? rest.slice(1) : rest).join("\n\n");
612
+ };
613
+ return units.map((u) => {
614
+ const base = {
615
+ key: itemOf(u),
616
+ heading: u.heading,
617
+ sheet: u.sheet,
618
+ rowId: u.row_id,
619
+ text: bodyOf(u),
620
+ relevance: null,
621
+ link: u.link,
622
+ plugin: u.plugin,
623
+ };
624
+ if (u.page_id) {
625
+ const node = tree.get(u.page_id);
626
+ return {
627
+ ...base,
628
+ kind: "page",
629
+ open: `p:${u.page_id}`,
630
+ path: node?.path ?? "",
631
+ title: node?.titles.join(" › ") ?? "",
632
+ categories: categoryLines(categories, cats.get(`p:${u.page_id}`)),
633
+ };
634
+ }
635
+ if (u.row_id) {
636
+ const tableId = rows.find((r) => r.id === u.row_id)?.tableId ?? "";
637
+ return {
638
+ ...base,
639
+ kind: "table",
640
+ open: `t:${tableId}`,
641
+ path: tPaths.get(tableId) ?? "",
642
+ title: tables.find((t) => t.id === tableId)?.title ?? "",
643
+ categories: [
644
+ ...categoryLines(categories, cats.get(`t:${tableId}`)),
645
+ ...categoryLines(categories, cats.get(`r:${u.row_id}`)),
646
+ ].filter((l, i, all) => all.indexOf(l) === i),
647
+ };
648
+ }
649
+ if (u.value_id) {
650
+ const value = values.find((v) => v.id === u.value_id);
651
+ const category = categories.find((c) => c.id === value?.categoryId);
652
+ return {
653
+ ...base,
654
+ kind: "summary",
655
+ open: `v:${u.value_id}`,
656
+ path: "",
657
+ title: `${category?.name}: ${value?.value} — Übersicht`,
658
+ categories: [],
659
+ };
660
+ }
661
+ if (u.file_id) {
662
+ return {
663
+ ...base,
664
+ kind: "file",
665
+ open: `f:${u.file_id}`,
666
+ path: fPaths.get(u.file_id) ?? "",
667
+ title: files.find((f) => f.id === u.file_id)?.name ?? "",
668
+ categories: categoryLines(categories, cats.get(`f:${u.file_id}`)),
669
+ };
670
+ }
671
+ return { ...base, kind: "plugin", open: null, path: "", title: u.title ?? "", categories: [] };
672
+ });
673
+ }
674
+ /** Puts every item of a space into the index again: after a sync, or a change of embedding model. */
675
+ export async function reindexSpace(projectId) {
676
+ const [pages, tables, files, values] = await Promise.all([
677
+ db.query.spacePage.findMany({
678
+ where: and(eq(schema.spacePage.projectId, projectId), isNull(schema.spacePage.wizardId)),
679
+ columns: { id: true },
680
+ }),
681
+ db.query.spaceTable.findMany({
682
+ where: and(eq(schema.spaceTable.projectId, projectId), isNull(schema.spaceTable.wizardId)),
683
+ columns: { id: true },
684
+ }),
685
+ db.query.projectFile.findMany({
686
+ where: and(eq(schema.projectFile.projectId, projectId), eq(schema.projectFile.kind, "document")),
687
+ columns: { id: true },
688
+ }),
689
+ db
690
+ .select({ id: schema.spaceCategoryValue.id })
691
+ .from(schema.spaceCategoryValue)
692
+ .innerJoin(schema.spaceCategory, eq(schema.spaceCategory.id, schema.spaceCategoryValue.categoryId))
693
+ .where(and(eq(schema.spaceCategory.projectId, projectId), isNotNull(schema.spaceCategoryValue.summary))),
694
+ ]);
695
+ for (const p of pages) {
696
+ queueIndex(`p:${p.id}`);
697
+ }
698
+ for (const t of tables) {
699
+ queueIndex(`t:${t.id}`);
700
+ }
701
+ for (const f of files) {
702
+ queueIndex(`f:${f.id}`);
703
+ }
704
+ for (const v of values) {
705
+ queueIndex(`v:${v.id}`);
706
+ }
169
707
  }