@theromans/convex-dobty 0.1.12 → 0.1.14
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/dist/client/index.d.ts +7 -18
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +8 -19
- package/dist/client/index.js.map +1 -1
- package/dist/component/knowledge.d.ts +2 -28
- package/dist/component/knowledge.d.ts.map +1 -1
- package/dist/component/knowledge.js +24 -66
- package/dist/component/knowledge.js.map +1 -1
- package/dist/component/knowledgeSearch.d.ts +23 -25
- package/dist/component/knowledgeSearch.d.ts.map +1 -1
- package/dist/component/knowledgeSearch.js +115 -71
- package/dist/component/knowledgeSearch.js.map +1 -1
- package/knowledge-chunks.json +4 -4
- package/package.json +1 -1
- package/src/client/index.ts +8 -32
- package/src/component/knowledge.ts +25 -74
- package/src/component/knowledgeSearch.ts +135 -126
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { action } from "./_generated/server.js";
|
|
1
|
+
import { internalAction, internalMutation, action } from "./_generated/server.js";
|
|
2
2
|
import { internal } from "./_generated/api.js";
|
|
3
3
|
import { v } from "convex/values";
|
|
4
4
|
|
|
@@ -6,17 +6,12 @@ const EMBEDDING_ENDPOINT =
|
|
|
6
6
|
"https://openai.inference.de-txl.ionos.com/v1/embeddings";
|
|
7
7
|
const EMBEDDING_MODEL = "BAAI/bge-m3";
|
|
8
8
|
const EMBEDDING_DIMENSIONS = 1024;
|
|
9
|
-
|
|
10
|
-
const EMBEDDING_TIMEOUT_MS = 10_000;
|
|
9
|
+
const EMBEDDING_TIMEOUT_MS = 15_000;
|
|
11
10
|
const DEFAULT_SEARCH_LIMIT = 5;
|
|
12
11
|
|
|
13
|
-
async function getEmbedding(
|
|
14
|
-
text: string,
|
|
15
|
-
apiToken: string,
|
|
16
|
-
): Promise<number[]> {
|
|
12
|
+
async function getEmbedding(text: string, apiToken: string): Promise<number[]> {
|
|
17
13
|
const controller = new AbortController();
|
|
18
14
|
const timer = setTimeout(() => controller.abort(), EMBEDDING_TIMEOUT_MS);
|
|
19
|
-
|
|
20
15
|
try {
|
|
21
16
|
const response = await fetch(EMBEDDING_ENDPOINT, {
|
|
22
17
|
method: "POST",
|
|
@@ -31,14 +26,12 @@ async function getEmbedding(
|
|
|
31
26
|
}),
|
|
32
27
|
signal: controller.signal,
|
|
33
28
|
});
|
|
34
|
-
|
|
35
29
|
if (!response.ok) {
|
|
36
30
|
const body = await response.text();
|
|
37
|
-
throw new Error(`Embedding API
|
|
31
|
+
throw new Error(`Embedding API ${response.status}: ${body}`);
|
|
38
32
|
}
|
|
39
|
-
|
|
40
33
|
const json = await response.json();
|
|
41
|
-
return json.data[0].embedding;
|
|
34
|
+
return json.data[0].embedding as number[];
|
|
42
35
|
} catch (e: any) {
|
|
43
36
|
if (e.name === "AbortError") {
|
|
44
37
|
throw new Error(`Embedding API timed out after ${EMBEDDING_TIMEOUT_MS}ms`);
|
|
@@ -49,6 +42,128 @@ async function getEmbedding(
|
|
|
49
42
|
}
|
|
50
43
|
}
|
|
51
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Embed a single chunk for a single language.
|
|
47
|
+
* Called by the scheduler after a chunk is seeded or updated.
|
|
48
|
+
* Automatically schedules the other language if it is also stale.
|
|
49
|
+
*/
|
|
50
|
+
export const embedChunk = internalAction({
|
|
51
|
+
args: {
|
|
52
|
+
chunkId: v.id("knowledgeChunks"),
|
|
53
|
+
language: v.union(v.literal("en"), v.literal("de")),
|
|
54
|
+
},
|
|
55
|
+
handler: async (ctx, { chunkId, language }) => {
|
|
56
|
+
const apiToken = (globalThis as any).process?.env?.IONOS_API_TOKEN as string | undefined;
|
|
57
|
+
if (!apiToken) {
|
|
58
|
+
console.error("[embedChunk] IONOS_API_TOKEN not set — cannot embed");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const chunk: any = await ctx.runQuery(internal.knowledge.getChunk, { chunkId });
|
|
63
|
+
if (!chunk) {
|
|
64
|
+
console.warn(`[embedChunk] chunk ${chunkId} not found, skipping`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isStaleEn =
|
|
69
|
+
!chunk.embedding_en ||
|
|
70
|
+
!chunk.embeddedAt_en ||
|
|
71
|
+
chunk.embeddedAt_en < chunk.updatedAt;
|
|
72
|
+
const isStaleDe =
|
|
73
|
+
!chunk.embedding_de ||
|
|
74
|
+
!chunk.embeddedAt_de ||
|
|
75
|
+
chunk.embeddedAt_de < chunk.updatedAt;
|
|
76
|
+
|
|
77
|
+
const isStale = language === "en" ? isStaleEn : isStaleDe;
|
|
78
|
+
if (!isStale) {
|
|
79
|
+
// Already up to date — schedule the other language if needed
|
|
80
|
+
const otherLanguage = language === "en" ? "de" : "en";
|
|
81
|
+
const otherStale = language === "en" ? isStaleDe : isStaleEn;
|
|
82
|
+
if (otherStale) {
|
|
83
|
+
await ctx.scheduler.runAfter(0, internal.knowledgeSearch.embedChunk, {
|
|
84
|
+
chunkId,
|
|
85
|
+
language: otherLanguage,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const title = language === "en" ? chunk.title_English : chunk.title_Deutsch;
|
|
92
|
+
const content = language === "en" ? chunk.content_English : chunk.content_Deutsch;
|
|
93
|
+
|
|
94
|
+
if (!title || !content) {
|
|
95
|
+
console.warn(`[embedChunk] chunk ${chunk.chunkKey} missing ${language} content, skipping`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const embedding = await getEmbedding(`${title}\n\n${content}`, apiToken);
|
|
101
|
+
|
|
102
|
+
if (language === "en") {
|
|
103
|
+
await ctx.runMutation(internal.knowledge.updateEmbeddingEn, { chunkId, embedding });
|
|
104
|
+
} else {
|
|
105
|
+
await ctx.runMutation(internal.knowledge.updateEmbeddingDe, { chunkId, embedding });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(`[embedChunk] embedded ${chunk.chunkKey} (${language})`);
|
|
109
|
+
|
|
110
|
+
// Schedule the other language if it is also stale
|
|
111
|
+
const otherLanguage = language === "en" ? "de" : "en";
|
|
112
|
+
const otherStale = language === "en" ? isStaleDe : isStaleEn;
|
|
113
|
+
if (otherStale) {
|
|
114
|
+
await ctx.scheduler.runAfter(0, internal.knowledgeSearch.embedChunk, {
|
|
115
|
+
chunkId,
|
|
116
|
+
language: otherLanguage,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
console.error(`[embedChunk] failed for ${chunk.chunkKey} (${language}): ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Schedule embedding jobs for all chunks that are stale
|
|
127
|
+
* (updatedAt > embeddedAt_en or embeddedAt_de).
|
|
128
|
+
* Called automatically after seedKnowledgeChunks, or can be triggered manually.
|
|
129
|
+
*/
|
|
130
|
+
export const scheduleStaleEmbeddings = internalAction({
|
|
131
|
+
args: {},
|
|
132
|
+
handler: async (ctx) => {
|
|
133
|
+
const all: any[] = await ctx.runQuery(internal.knowledge.listAllChunks, {});
|
|
134
|
+
|
|
135
|
+
let scheduled = 0;
|
|
136
|
+
for (const chunk of all) {
|
|
137
|
+
const staleEn =
|
|
138
|
+
!chunk.embedding_en ||
|
|
139
|
+
!chunk.embeddedAt_en ||
|
|
140
|
+
chunk.embeddedAt_en < chunk.updatedAt;
|
|
141
|
+
const staleDe =
|
|
142
|
+
!chunk.embedding_de ||
|
|
143
|
+
!chunk.embeddedAt_de ||
|
|
144
|
+
chunk.embeddedAt_de < chunk.updatedAt;
|
|
145
|
+
|
|
146
|
+
if (staleEn) {
|
|
147
|
+
await ctx.scheduler.runAfter(0, internal.knowledgeSearch.embedChunk, {
|
|
148
|
+
chunkId: chunk._id,
|
|
149
|
+
language: "en",
|
|
150
|
+
});
|
|
151
|
+
scheduled++;
|
|
152
|
+
} else if (staleDe) {
|
|
153
|
+
// embedChunk for EN will chain to DE when EN is already fresh
|
|
154
|
+
await ctx.scheduler.runAfter(0, internal.knowledgeSearch.embedChunk, {
|
|
155
|
+
chunkId: chunk._id,
|
|
156
|
+
language: "de",
|
|
157
|
+
});
|
|
158
|
+
scheduled++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`[scheduleStaleEmbeddings] scheduled ${scheduled} jobs for ${all.length} chunks`);
|
|
163
|
+
return { total: all.length, scheduled };
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
52
167
|
export interface KnowledgeResult {
|
|
53
168
|
title: string;
|
|
54
169
|
content: string;
|
|
@@ -63,11 +178,9 @@ export interface KnowledgeResult {
|
|
|
63
178
|
|
|
64
179
|
/**
|
|
65
180
|
* Search knowledge chunks by embedding similarity.
|
|
66
|
-
*
|
|
67
|
-
* -
|
|
68
|
-
* -
|
|
69
|
-
* - Falls back to the other language's index if the primary has no vectors.
|
|
70
|
-
* - Returns up to `limit` results (default 5).
|
|
181
|
+
* - language: "en" | "de" — which index to search and which fields to return
|
|
182
|
+
* - Falls back to the other language index if primary has no vectors yet
|
|
183
|
+
* - Returns up to `limit` results (default 5)
|
|
71
184
|
*/
|
|
72
185
|
export const searchKnowledge = action({
|
|
73
186
|
args: {
|
|
@@ -77,22 +190,14 @@ export const searchKnowledge = action({
|
|
|
77
190
|
limit: v.optional(v.number()),
|
|
78
191
|
apiToken: v.string(),
|
|
79
192
|
},
|
|
80
|
-
handler: async (
|
|
81
|
-
ctx,
|
|
82
|
-
{ topic, query, language, limit = DEFAULT_SEARCH_LIMIT, apiToken },
|
|
83
|
-
): Promise<KnowledgeResult[]> => {
|
|
193
|
+
handler: async (ctx, { topic, query, language, limit = DEFAULT_SEARCH_LIMIT, apiToken }) => {
|
|
84
194
|
const embedding = await getEmbedding(query, apiToken);
|
|
85
|
-
|
|
86
195
|
return _searchByVector(ctx, { topic, embedding, language, limit });
|
|
87
196
|
},
|
|
88
197
|
});
|
|
89
198
|
|
|
90
199
|
/**
|
|
91
|
-
* Search
|
|
92
|
-
*
|
|
93
|
-
* - language: the requested language for returned title/content ("en" | "de")
|
|
94
|
-
* - Falls back to the other language's index if primary has no vectors.
|
|
95
|
-
* - Returns up to `limit` results (default 5).
|
|
200
|
+
* Search with a pre-computed embedding vector (no outbound fetch).
|
|
96
201
|
*/
|
|
97
202
|
export const searchByVector = action({
|
|
98
203
|
args: {
|
|
@@ -101,10 +206,7 @@ export const searchByVector = action({
|
|
|
101
206
|
language: v.union(v.literal("en"), v.literal("de")),
|
|
102
207
|
limit: v.optional(v.number()),
|
|
103
208
|
},
|
|
104
|
-
handler: async (
|
|
105
|
-
ctx,
|
|
106
|
-
{ topic, embedding, language, limit = DEFAULT_SEARCH_LIMIT },
|
|
107
|
-
): Promise<KnowledgeResult[]> => {
|
|
209
|
+
handler: async (ctx, { topic, embedding, language, limit = DEFAULT_SEARCH_LIMIT }) => {
|
|
108
210
|
return _searchByVector(ctx, { topic, embedding, language, limit });
|
|
109
211
|
},
|
|
110
212
|
});
|
|
@@ -127,7 +229,6 @@ async function _searchByVector(
|
|
|
127
229
|
const fallbackIndex = language === "en" ? "by_embedding_de" : "by_embedding_en";
|
|
128
230
|
const fallbackLanguage: "en" | "de" = language === "en" ? "de" : "en";
|
|
129
231
|
|
|
130
|
-
// Try primary language index first
|
|
131
232
|
let results = await ctx.vectorSearch("knowledgeChunks", primaryIndex, {
|
|
132
233
|
vector: embedding,
|
|
133
234
|
limit,
|
|
@@ -136,7 +237,6 @@ async function _searchByVector(
|
|
|
136
237
|
|
|
137
238
|
let embeddingLanguage: "en" | "de" = language;
|
|
138
239
|
|
|
139
|
-
// Fall back to the other language's index if primary returned nothing
|
|
140
240
|
if (results.length === 0) {
|
|
141
241
|
results = await ctx.vectorSearch("knowledgeChunks", fallbackIndex, {
|
|
142
242
|
vector: embedding,
|
|
@@ -150,15 +250,10 @@ async function _searchByVector(
|
|
|
150
250
|
|
|
151
251
|
const docs: (KnowledgeResult | null)[] = await Promise.all(
|
|
152
252
|
results.map(async (r: any): Promise<KnowledgeResult | null> => {
|
|
153
|
-
const doc: any = await ctx.runQuery(internal.knowledge.getChunk, {
|
|
154
|
-
chunkId: r._id,
|
|
155
|
-
});
|
|
253
|
+
const doc: any = await ctx.runQuery(internal.knowledge.getChunk, { chunkId: r._id });
|
|
156
254
|
if (!doc) return null;
|
|
157
|
-
|
|
158
255
|
const title = language === "en" ? doc.title_English : doc.title_Deutsch;
|
|
159
|
-
const content =
|
|
160
|
-
language === "en" ? doc.content_English : doc.content_Deutsch;
|
|
161
|
-
|
|
256
|
+
const content = language === "en" ? doc.content_English : doc.content_Deutsch;
|
|
162
257
|
return {
|
|
163
258
|
title,
|
|
164
259
|
content,
|
|
@@ -175,89 +270,3 @@ async function _searchByVector(
|
|
|
175
270
|
|
|
176
271
|
return docs.filter((d): d is KnowledgeResult => d !== null);
|
|
177
272
|
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Re-embed stale chunks for both languages.
|
|
181
|
-
* Embeds English and Deutsch separately, storing embedding_en and embedding_de.
|
|
182
|
-
*/
|
|
183
|
-
export const refreshEmbeddings = action({
|
|
184
|
-
args: {
|
|
185
|
-
apiToken: v.string(),
|
|
186
|
-
},
|
|
187
|
-
handler: async (
|
|
188
|
-
ctx,
|
|
189
|
-
{ apiToken },
|
|
190
|
-
): Promise<{
|
|
191
|
-
total: number;
|
|
192
|
-
staleEn: number;
|
|
193
|
-
staleDe: number;
|
|
194
|
-
embeddedEn: number;
|
|
195
|
-
embeddedDe: number;
|
|
196
|
-
errors: number;
|
|
197
|
-
}> => {
|
|
198
|
-
const allChunks: any[] = await ctx.runQuery(
|
|
199
|
-
internal.knowledge.listAllChunks,
|
|
200
|
-
{},
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
const staleEn = allChunks.filter(
|
|
204
|
-
(c) =>
|
|
205
|
-
!c.embedding_en || !c.embeddedAt_en || c.embeddedAt_en < c.updatedAt,
|
|
206
|
-
);
|
|
207
|
-
const staleDe = allChunks.filter(
|
|
208
|
-
(c) =>
|
|
209
|
-
!c.embedding_de || !c.embeddedAt_de || c.embeddedAt_de < c.updatedAt,
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
let embeddedEn = 0;
|
|
213
|
-
let embeddedDe = 0;
|
|
214
|
-
let errors = 0;
|
|
215
|
-
|
|
216
|
-
for (const chunk of staleEn) {
|
|
217
|
-
try {
|
|
218
|
-
const embedding = await getEmbedding(
|
|
219
|
-
`${chunk.title_English}\n\n${chunk.content_English}`,
|
|
220
|
-
apiToken,
|
|
221
|
-
);
|
|
222
|
-
await ctx.runMutation(internal.knowledge.updateEmbeddingEn, {
|
|
223
|
-
chunkId: chunk._id,
|
|
224
|
-
embedding,
|
|
225
|
-
});
|
|
226
|
-
embeddedEn++;
|
|
227
|
-
} catch (e: any) {
|
|
228
|
-
console.error(
|
|
229
|
-
`Failed to embed EN chunk ${chunk.chunkKey}: ${e.message}`,
|
|
230
|
-
);
|
|
231
|
-
errors++;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
for (const chunk of staleDe) {
|
|
236
|
-
try {
|
|
237
|
-
const embedding = await getEmbedding(
|
|
238
|
-
`${chunk.title_Deutsch}\n\n${chunk.content_Deutsch}`,
|
|
239
|
-
apiToken,
|
|
240
|
-
);
|
|
241
|
-
await ctx.runMutation(internal.knowledge.updateEmbeddingDe, {
|
|
242
|
-
chunkId: chunk._id,
|
|
243
|
-
embedding,
|
|
244
|
-
});
|
|
245
|
-
embeddedDe++;
|
|
246
|
-
} catch (e: any) {
|
|
247
|
-
console.error(
|
|
248
|
-
`Failed to embed DE chunk ${chunk.chunkKey}: ${e.message}`,
|
|
249
|
-
);
|
|
250
|
-
errors++;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
total: allChunks.length,
|
|
256
|
-
staleEn: staleEn.length,
|
|
257
|
-
staleDe: staleDe.length,
|
|
258
|
-
embeddedEn,
|
|
259
|
-
embeddedDe,
|
|
260
|
-
errors,
|
|
261
|
-
};
|
|
262
|
-
},
|
|
263
|
-
});
|