@theromans/convex-dobty 0.1.9 → 0.1.10

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.
@@ -13,8 +13,10 @@ export const seedKnowledgeChunks = mutation({
13
13
  v.object({
14
14
  topic: v.string(),
15
15
  chunkKey: v.string(),
16
- title: v.string(),
17
- content: v.string(),
16
+ title_English: v.string(),
17
+ title_Deutsch: v.string(),
18
+ content_English: v.string(),
19
+ content_Deutsch: v.string(),
18
20
  }),
19
21
  ),
20
22
  },
@@ -23,6 +25,9 @@ export const seedKnowledgeChunks = mutation({
23
25
  let updated = 0;
24
26
  let skipped = 0;
25
27
 
28
+ // Collect all existing chunkKeys for this topic to detect deletions
29
+ const incomingKeys = new Set(chunks.map((c) => `${c.topic}::${c.chunkKey}`));
30
+
26
31
  for (const chunk of chunks) {
27
32
  const existing = await ctx.db
28
33
  .query("knowledgeChunks")
@@ -32,10 +37,21 @@ export const seedKnowledgeChunks = mutation({
32
37
  .unique();
33
38
 
34
39
  if (existing) {
35
- if (existing.content !== chunk.content || existing.title !== chunk.title) {
40
+ const contentChanged =
41
+ existing.content_English !== chunk.content_English ||
42
+ existing.content_Deutsch !== chunk.content_Deutsch ||
43
+ existing.title_English !== chunk.title_English ||
44
+ existing.title_Deutsch !== chunk.title_Deutsch;
45
+
46
+ if (contentChanged) {
36
47
  await ctx.db.patch(existing._id, {
37
- title: chunk.title,
38
- content: chunk.content,
48
+ title_English: chunk.title_English,
49
+ title_Deutsch: chunk.title_Deutsch,
50
+ content_English: chunk.content_English,
51
+ content_Deutsch: chunk.content_Deutsch,
52
+ // Invalidate both embeddings so they get re-embedded
53
+ embeddedAt_en: undefined,
54
+ embeddedAt_de: undefined,
39
55
  updatedAt: Date.now(),
40
56
  });
41
57
  updated++;
@@ -46,15 +62,27 @@ export const seedKnowledgeChunks = mutation({
46
62
  await ctx.db.insert("knowledgeChunks", {
47
63
  topic: chunk.topic,
48
64
  chunkKey: chunk.chunkKey,
49
- title: chunk.title,
50
- content: chunk.content,
65
+ title_English: chunk.title_English,
66
+ title_Deutsch: chunk.title_Deutsch,
67
+ content_English: chunk.content_English,
68
+ content_Deutsch: chunk.content_Deutsch,
51
69
  updatedAt: Date.now(),
52
70
  });
53
71
  inserted++;
54
72
  }
55
73
  }
56
74
 
57
- return { inserted, updated, skipped };
75
+ // Remove chunks that are no longer in the incoming set (full replacement semantics)
76
+ const allExisting = await ctx.db.query("knowledgeChunks").collect();
77
+ let deleted = 0;
78
+ for (const existing of allExisting) {
79
+ if (!incomingKeys.has(`${existing.topic}::${existing.chunkKey}`)) {
80
+ await ctx.db.delete(existing._id);
81
+ deleted++;
82
+ }
83
+ }
84
+
85
+ return { inserted, updated, skipped, deleted };
58
86
  },
59
87
  });
60
88
 
@@ -79,49 +107,104 @@ export const listTopics = query({
79
107
  },
80
108
  });
81
109
 
82
- /** List chunks that need (re-)embedding. Returns metadata without embedding vectors. */
83
- export const listStaleChunks = query({
110
+ /** List chunks that need (re-)embedding for English. */
111
+ export const listStaleChunksEn = query({
84
112
  args: {},
85
113
  handler: async (ctx) => {
86
114
  const all = await ctx.db.query("knowledgeChunks").collect();
87
115
  return all
88
- .filter((c) => !c.embedding || !c.embeddedAt || c.embeddedAt < c.updatedAt)
116
+ .filter(
117
+ (c) =>
118
+ !c.embedding_en ||
119
+ !c.embeddedAt_en ||
120
+ c.embeddedAt_en < c.updatedAt,
121
+ )
89
122
  .map((c) => ({
90
123
  _id: c._id,
91
124
  topic: c.topic,
92
125
  chunkKey: c.chunkKey,
93
- title: c.title,
94
- content: c.content,
126
+ title: c.title_English,
127
+ content: c.content_English,
95
128
  }));
96
129
  },
97
130
  });
98
131
 
99
- /** Update a chunk's embedding by ID (used by host-side Node.js embedding). */
100
- export const updateChunkEmbedding = mutation({
132
+ /** List chunks that need (re-)embedding for Deutsch. */
133
+ export const listStaleChunksDe = query({
134
+ args: {},
135
+ handler: async (ctx) => {
136
+ const all = await ctx.db.query("knowledgeChunks").collect();
137
+ return all
138
+ .filter(
139
+ (c) =>
140
+ !c.embedding_de ||
141
+ !c.embeddedAt_de ||
142
+ c.embeddedAt_de < c.updatedAt,
143
+ )
144
+ .map((c) => ({
145
+ _id: c._id,
146
+ topic: c.topic,
147
+ chunkKey: c.chunkKey,
148
+ title: c.title_Deutsch,
149
+ content: c.content_Deutsch,
150
+ }));
151
+ },
152
+ });
153
+
154
+ /** Update a chunk's English embedding by ID. */
155
+ export const updateChunkEmbeddingEn = mutation({
101
156
  args: {
102
157
  chunkId: v.id("knowledgeChunks"),
103
158
  embedding: v.array(v.float64()),
104
159
  },
105
160
  handler: async (ctx, { chunkId, embedding }) => {
106
161
  await ctx.db.patch(chunkId, {
107
- embedding,
108
- embeddedAt: Date.now(),
162
+ embedding_en: embedding,
163
+ embeddedAt_en: Date.now(),
164
+ });
165
+ },
166
+ });
167
+
168
+ /** Update a chunk's Deutsch embedding by ID. */
169
+ export const updateChunkEmbeddingDe = mutation({
170
+ args: {
171
+ chunkId: v.id("knowledgeChunks"),
172
+ embedding: v.array(v.float64()),
173
+ },
174
+ handler: async (ctx, { chunkId, embedding }) => {
175
+ await ctx.db.patch(chunkId, {
176
+ embedding_de: embedding,
177
+ embeddedAt_de: Date.now(),
109
178
  });
110
179
  },
111
180
  });
112
181
 
113
182
  // ── Internal functions (called by knowledgeSearch.ts) ──
114
183
 
115
- /** Patch embedding + embeddedAt for a single chunk. */
116
- export const updateEmbedding = internalMutation({
184
+ /** Patch English embedding for a single chunk. */
185
+ export const updateEmbeddingEn = internalMutation({
186
+ args: {
187
+ chunkId: v.id("knowledgeChunks"),
188
+ embedding: v.array(v.float64()),
189
+ },
190
+ handler: async (ctx, { chunkId, embedding }) => {
191
+ await ctx.db.patch(chunkId, {
192
+ embedding_en: embedding,
193
+ embeddedAt_en: Date.now(),
194
+ });
195
+ },
196
+ });
197
+
198
+ /** Patch Deutsch embedding for a single chunk. */
199
+ export const updateEmbeddingDe = internalMutation({
117
200
  args: {
118
201
  chunkId: v.id("knowledgeChunks"),
119
202
  embedding: v.array(v.float64()),
120
203
  },
121
204
  handler: async (ctx, { chunkId, embedding }) => {
122
205
  await ctx.db.patch(chunkId, {
123
- embedding,
124
- embeddedAt: Date.now(),
206
+ embedding_de: embedding,
207
+ embeddedAt_de: Date.now(),
125
208
  });
126
209
  },
127
210
  });
@@ -8,6 +8,7 @@ const EMBEDDING_MODEL = "BAAI/bge-m3";
8
8
  const EMBEDDING_DIMENSIONS = 1024;
9
9
 
10
10
  const EMBEDDING_TIMEOUT_MS = 10_000;
11
+ const DEFAULT_SEARCH_LIMIT = 5;
11
12
 
12
13
  async function getEmbedding(
13
14
  text: string,
@@ -48,110 +49,215 @@ async function getEmbedding(
48
49
  }
49
50
  }
50
51
 
51
- interface KnowledgeResult {
52
+ export interface KnowledgeResult {
52
53
  title: string;
53
54
  content: string;
55
+ title_English: string;
56
+ title_Deutsch: string;
57
+ content_English: string;
58
+ content_Deutsch: string;
54
59
  score: number;
60
+ language: "en" | "de";
61
+ embeddingLanguage: "en" | "de";
55
62
  }
56
63
 
57
- /** Search knowledge chunks by embedding similarity. */
64
+ /**
65
+ * Search knowledge chunks by embedding similarity.
66
+ *
67
+ * - language: the requested language for returned title/content ("en" | "de")
68
+ * - Uses the embedding index for the requested language.
69
+ * - Falls back to the other language's index if the primary has no vectors.
70
+ * - Returns up to `limit` results (default 5).
71
+ */
58
72
  export const searchKnowledge = action({
59
73
  args: {
60
74
  topic: v.string(),
61
75
  query: v.string(),
76
+ language: v.union(v.literal("en"), v.literal("de")),
62
77
  limit: v.optional(v.number()),
63
78
  apiToken: v.string(),
64
79
  },
65
- handler: async (ctx, { topic, query, limit = 3, apiToken }): Promise<KnowledgeResult[]> => {
80
+ handler: async (
81
+ ctx,
82
+ { topic, query, language, limit = DEFAULT_SEARCH_LIMIT, apiToken },
83
+ ): Promise<KnowledgeResult[]> => {
66
84
  const embedding = await getEmbedding(query, apiToken);
67
85
 
68
- const results = await ctx.vectorSearch("knowledgeChunks", "by_embedding", {
69
- vector: embedding,
70
- limit,
71
- filter: (q) => q.eq("topic", topic),
72
- });
73
-
74
- if (results.length === 0) return [];
75
-
76
- const docs: (KnowledgeResult | null)[] = await Promise.all(
77
- results.map(async (r): Promise<KnowledgeResult | null> => {
78
- const doc: any = await ctx.runQuery(internal.knowledge.getChunk, {
79
- chunkId: r._id,
80
- });
81
- return doc ? { title: doc.title, content: doc.content, score: r._score } : null;
82
- }),
83
- );
84
-
85
- return docs.filter((d): d is KnowledgeResult => d !== null);
86
+ return _searchByVector(ctx, { topic, embedding, language, limit });
86
87
  },
87
88
  });
88
89
 
89
- /** Search knowledge chunks by pre-computed embedding vector (no fetch needed). */
90
+ /**
91
+ * Search knowledge chunks by pre-computed embedding vector (no outbound fetch needed).
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).
96
+ */
90
97
  export const searchByVector = action({
91
98
  args: {
92
99
  topic: v.string(),
93
100
  embedding: v.array(v.float64()),
101
+ language: v.union(v.literal("en"), v.literal("de")),
94
102
  limit: v.optional(v.number()),
95
103
  },
96
- handler: async (ctx, { topic, embedding, limit = 3 }): Promise<KnowledgeResult[]> => {
97
- const results = await ctx.vectorSearch("knowledgeChunks", "by_embedding", {
104
+ handler: async (
105
+ ctx,
106
+ { topic, embedding, language, limit = DEFAULT_SEARCH_LIMIT },
107
+ ): Promise<KnowledgeResult[]> => {
108
+ return _searchByVector(ctx, { topic, embedding, language, limit });
109
+ },
110
+ });
111
+
112
+ async function _searchByVector(
113
+ ctx: any,
114
+ {
115
+ topic,
116
+ embedding,
117
+ language,
118
+ limit,
119
+ }: {
120
+ topic: string;
121
+ embedding: number[];
122
+ language: "en" | "de";
123
+ limit: number;
124
+ },
125
+ ): Promise<KnowledgeResult[]> {
126
+ const primaryIndex = language === "en" ? "by_embedding_en" : "by_embedding_de";
127
+ const fallbackIndex = language === "en" ? "by_embedding_de" : "by_embedding_en";
128
+ const fallbackLanguage: "en" | "de" = language === "en" ? "de" : "en";
129
+
130
+ // Try primary language index first
131
+ let results = await ctx.vectorSearch("knowledgeChunks", primaryIndex, {
132
+ vector: embedding,
133
+ limit,
134
+ filter: (q: any) => q.eq("topic", topic),
135
+ });
136
+
137
+ let embeddingLanguage: "en" | "de" = language;
138
+
139
+ // Fall back to the other language's index if primary returned nothing
140
+ if (results.length === 0) {
141
+ results = await ctx.vectorSearch("knowledgeChunks", fallbackIndex, {
98
142
  vector: embedding,
99
143
  limit,
100
- filter: (q) => q.eq("topic", topic),
144
+ filter: (q: any) => q.eq("topic", topic),
101
145
  });
146
+ embeddingLanguage = fallbackLanguage;
147
+ }
102
148
 
103
- if (results.length === 0) return [];
149
+ if (results.length === 0) return [];
104
150
 
105
- const docs: (KnowledgeResult | null)[] = await Promise.all(
106
- results.map(async (r): Promise<KnowledgeResult | null> => {
107
- const doc: any = await ctx.runQuery(internal.knowledge.getChunk, {
108
- chunkId: r._id,
109
- });
110
- return doc ? { title: doc.title, content: doc.content, score: r._score } : null;
111
- }),
112
- );
151
+ const docs: (KnowledgeResult | null)[] = await Promise.all(
152
+ results.map(async (r: any): Promise<KnowledgeResult | null> => {
153
+ const doc: any = await ctx.runQuery(internal.knowledge.getChunk, {
154
+ chunkId: r._id,
155
+ });
156
+ if (!doc) return null;
113
157
 
114
- return docs.filter((d): d is KnowledgeResult => d !== null);
115
- },
116
- });
158
+ const title = language === "en" ? doc.title_English : doc.title_Deutsch;
159
+ const content =
160
+ language === "en" ? doc.content_English : doc.content_Deutsch;
161
+
162
+ return {
163
+ title,
164
+ content,
165
+ title_English: doc.title_English,
166
+ title_Deutsch: doc.title_Deutsch,
167
+ content_English: doc.content_English,
168
+ content_Deutsch: doc.content_Deutsch,
169
+ score: r._score,
170
+ language,
171
+ embeddingLanguage,
172
+ };
173
+ }),
174
+ );
175
+
176
+ return docs.filter((d): d is KnowledgeResult => d !== null);
177
+ }
117
178
 
118
- /** Re-embed stale chunks (embedding missing or content changed since last embed). */
179
+ /**
180
+ * Re-embed stale chunks for both languages.
181
+ * Embeds English and Deutsch separately, storing embedding_en and embedding_de.
182
+ */
119
183
  export const refreshEmbeddings = action({
120
184
  args: {
121
185
  apiToken: v.string(),
122
186
  },
123
- handler: async (ctx, { apiToken }): Promise<{ total: number; stale: number; embedded: number; errors: number }> => {
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
+ }> => {
124
198
  const allChunks: any[] = await ctx.runQuery(
125
199
  internal.knowledge.listAllChunks,
126
200
  {},
127
201
  );
128
202
 
129
- const stale = allChunks.filter(
130
- (c) => !c.embedding || !c.embeddedAt || c.embeddedAt < c.updatedAt,
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,
131
210
  );
132
211
 
133
- let embedded = 0;
212
+ let embeddedEn = 0;
213
+ let embeddedDe = 0;
134
214
  let errors = 0;
135
215
 
136
- for (const chunk of stale) {
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) {
137
236
  try {
138
237
  const embedding = await getEmbedding(
139
- `${chunk.title}\n\n${chunk.content}`,
238
+ `${chunk.title_Deutsch}\n\n${chunk.content_Deutsch}`,
140
239
  apiToken,
141
240
  );
142
- await ctx.runMutation(internal.knowledge.updateEmbedding, {
241
+ await ctx.runMutation(internal.knowledge.updateEmbeddingDe, {
143
242
  chunkId: chunk._id,
144
243
  embedding,
145
244
  });
146
- embedded++;
245
+ embeddedDe++;
147
246
  } catch (e: any) {
148
247
  console.error(
149
- `Failed to embed chunk ${chunk.chunkKey}: ${e.message}`,
248
+ `Failed to embed DE chunk ${chunk.chunkKey}: ${e.message}`,
150
249
  );
151
250
  errors++;
152
251
  }
153
252
  }
154
253
 
155
- return { total: allChunks.length, stale: stale.length, embedded, errors };
254
+ return {
255
+ total: allChunks.length,
256
+ staleEn: staleEn.length,
257
+ staleDe: staleDe.length,
258
+ embeddedEn,
259
+ embeddedDe,
260
+ errors,
261
+ };
156
262
  },
157
263
  });
@@ -215,16 +215,27 @@ export default defineSchema({
215
215
  knowledgeChunks: defineTable({
216
216
  topic: v.string(),
217
217
  chunkKey: v.string(),
218
- title: v.string(),
219
- content: v.string(),
220
- embedding: v.optional(v.array(v.float64())),
221
- embeddedAt: v.optional(v.number()),
218
+ title_English: v.string(),
219
+ title_Deutsch: v.string(),
220
+ content_English: v.string(),
221
+ content_Deutsch: v.string(),
222
+ /** Embedding of title_English + content_English */
223
+ embedding_en: v.optional(v.array(v.float64())),
224
+ /** Embedding of title_Deutsch + content_Deutsch */
225
+ embedding_de: v.optional(v.array(v.float64())),
226
+ embeddedAt_en: v.optional(v.number()),
227
+ embeddedAt_de: v.optional(v.number()),
222
228
  updatedAt: v.number(),
223
229
  })
224
230
  .index("by_topic", ["topic"])
225
231
  .index("by_topic_chunkKey", ["topic", "chunkKey"])
226
- .vectorIndex("by_embedding", {
227
- vectorField: "embedding",
232
+ .vectorIndex("by_embedding_en", {
233
+ vectorField: "embedding_en",
234
+ dimensions: 1024,
235
+ filterFields: ["topic"],
236
+ })
237
+ .vectorIndex("by_embedding_de", {
238
+ vectorField: "embedding_de",
228
239
  dimensions: 1024,
229
240
  filterFields: ["topic"],
230
241
  }),