1
48import type { PrismaClient } from "@prisma/client";
49import { z } from "zod";
50import {
51 AiGateway,
52 AnthropicAdapter,
53 BudgetService,
54 CustomAdapter,
55 GoogleAdapter,
56 MockProvider,
57 OpenAIAdapter,
58 OssAdapter,
59 resolveAiConfig,
60 UsageLedger,
61 type ProviderAdapter,
62 type ProviderId,
63 type ProviderRegistry,
64 type ResolvedAiConfig,
65 type UsageRecord,
66} from "@factory/ai";
67import { CONTENT_INDEX_ALIAS } from "@factory/search";
68import type { SearchClient } from "@factory/search";
69import { asJson } from "../lib/prisma.js";
70import { getLogger } from "../logger.js";
71import type { WorkerEnv } from "../config.js";
72import { buildPageDoc, buildRecordDoc, expandChunkDocs } from "./search-index/builders.js";
73import type { IndexerDeps } from "./search-index/builders.js";
74
75const log = getLogger("ai-embedding");
76
77
79const CHUNKABLE_DOC_TYPES = ["page", "record"] as const;
80
81export const embedChunksJobSchema = z.object({
82 type: z.literal("embed:chunks"),
83 workspaceId: z.string().min(1),
84 docType: z.enum(CHUNKABLE_DOC_TYPES),
85 refId: z.string().min(1),
86
91 chunkIds: z.array(z.string().min(1)).default([]),
92});
93export type EmbedChunksJobData = z.infer<typeof embedChunksJobSchema>;
94
95
96export const reembedJobSchema = z.object({
97 type: z.literal("embed:reembed"),
98});
99export type ReembedJobData = z.infer<typeof reembedJobSchema>;
100
101
102export function chunkDocId(docType: string, refId: string, chunkId: string): string {
103 return `${docType}:${refId}#${chunkId}`;
104}
105
106
107export function itemChunkPrefix(docType: string, refId: string): string {
108 return `${docType}:${refId}`;
109}
110
111export interface EmbedChunksResult {
112
113 skipped: boolean;
114 reason?: string;
115
116 embedded: number;
117
118 pruned: number;
119
120 chunkRefs: string[];
121
122 model?: string;
123
124 dimension?: number;
125
126 blocked?: boolean;
127}
128
129
130
131
132
133
144export function createWorkerAiGateway(env: WorkerEnv): AiGateway {
145 const resolveConfig = (workspaceId: string): ResolvedAiConfig => {
146 const provider: ProviderId = env.AI_PROVIDER === "mock" ? "custom" : (env.AI_PROVIDER || "openai") as ProviderId;
147 const config = resolveAiConfig(null, null, {
148 provider,
149 chatModel: env.AI_MODEL,
150 embedModel: env.AI_EMBEDDING_MODEL,
151 ...(env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {}),
152 });
153 config.workspaceId = workspaceId;
154
155
156
157
158 config.budgets = { ...config.budgets, tokensPerMonth: 0, requestsPerMinute: Math.max(1, config.budgets.requestsPerMinute) };
159 return config;
160 };
161 const registry: ProviderRegistry = {
162 get(providerId: ProviderId, config: ResolvedAiConfig): ProviderAdapter {
163 if (env.AI_PROVIDER === "mock") {
164 return new MockProvider(providerId === "anthropic" ? "anthropic" : "custom", env.SEARCH_VECTOR_DIMENSION);
165 }
166 const baseUrl = config.endpoint?.baseUrl ?? "https://api.openai.com/v1";
167 const key = env.AI_API_KEY;
168 switch (providerId) {
169 case "openai":
170 return new OpenAIAdapter({ baseUrl, apiKey: key });
171 case "anthropic":
172 return new AnthropicAdapter({ apiKey: key });
173 case "google":
174 return new GoogleAdapter({ apiKey: key });
175 case "oss":
176 return new OssAdapter({ baseUrl, apiKey: key });
177 default:
178 return new CustomAdapter({
179 baseUrl,
180 apiKey: key,
181 ...(config.endpoint?.extraHeaders ? { extraHeaders: config.endpoint.extraHeaders } : {})
182 });
183 }
184 },
185 };
186 const ledger = new UsageLedger({
187 sink: async (record: UsageRecord) => {
188 log.info(
189 { workspaceId: record.workspaceId, kind: record.kind, model: record.model, provider: record.provider, status: record.status },
190 "ai usage (worker)",
191 );
192 },
193 });
194 return new AiGateway({ resolveConfig, registry, budgets: new BudgetService(ledger), ledger });
195}
196
197
202let workerGateway: AiGateway | null = null;
203let workerGatewayEnv: WorkerEnv | null = null;
204export function workerAiGateway(env: WorkerEnv): AiGateway {
205 if (!workerGateway || workerGatewayEnv !== env) {
206 workerGateway = createWorkerAiGateway(env);
207 workerGatewayEnv = env;
208 }
209 return workerGateway;
210}
211
212
213
214
215
216
217export async function deriveItemChunks(
218 prisma: PrismaClient,
219 docType: "page" | "record",
220 refId: string,
221): Promise<{ docId: string; workspaceId: string; text: string }[] | null> {
222 const deps: IndexerDeps = { prisma };
223 const doc = docType === "page" ? await buildPageDoc(deps, refId) : await buildRecordDoc(deps, refId);
224 if (!doc) return null;
225 const expanded = expandChunkDocs(doc);
226 const out: { docId: string; workspaceId: string; text: string }[] = [];
227
228
229
230 out.push({ docId: doc.docId, workspaceId: doc.workspaceId, text: doc.body });
231 for (const d of expanded) {
232 if (d.type === "chunk") out.push({ docId: d.docId, workspaceId: d.workspaceId, text: d.body });
233 }
234 return out;
235}
236
237
241export async function handleEmbedChunks(opts: {
242 prisma: PrismaClient;
243 env: WorkerEnv;
244 search: SearchClient;
245 job: EmbedChunksJobData;
246
247 jobId: string | undefined;
248
249 gateway?: AiGateway;
250}): Promise<EmbedChunksResult> {
251 const { prisma, env, search, job } = opts;
252 const gateway = opts.gateway ?? workerAiGateway(env);
253 const { workspaceId, docType, refId } = job;
254
255
256
257
258 const chunks = await deriveItemChunks(prisma, docType, refId);
259 if (!chunks) {
260 const pruned = await pruneItemEmbeddings(prisma, workspaceId, docType, refId, []);
261 log.info({ jobId: opts.jobId, workspaceId, docType, refId, pruned }, "embed:chunks — source vanished; registry pruned");
262 return { skipped: true, reason: "source vanished", embedded: 0, pruned, chunkRefs: [] };
263 }
264
265
266
267
268 let result;
269 try {
270 result = await gateway.embed({ workspaceId, texts: chunks.map((c) => c.text), metadata: { capability: "rag.embed" } });
271 } catch (err) {
272
273
274 throw err;
275 }
276
277
278
279
280
281
282 if (result.dimension !== env.SEARCH_VECTOR_DIMENSION) {
283 await emitReembedRequired(prisma, workspaceId, {
284 reason: "dimension_mismatch",
285 detail: `embed model "${result.model}" produced dim ${result.dimension}, index has ${env.SEARCH_VECTOR_DIMENSION}`,
286 });
287 log.error(
288 { jobId: opts.jobId, workspaceId, model: result.model, got: result.dimension, want: env.SEARCH_VECTOR_DIMENSION },
289 "embed BLOCKED — vector dimension mismatch (alias rollover required)",
290 );
291 return {
292 skipped: true,
293 reason: "dimension_mismatch",
294 embedded: 0,
295 pruned: 0,
296 chunkRefs: [],
297 model: result.model,
298 dimension: result.dimension,
299 blocked: true,
300 };
301 }
302
303
304 let embedded = 0;
305 for (let i = 0; i < chunks.length; i++) {
306 const chunk = chunks[i]!;
307 const vector = result.vectors[i];
308 if (!vector) continue;
309 try {
310
311
312 await search.updateDoc(chunk.docId, { vector, vectorModel: result.model });
313 } catch (err) {
314
315
316 if (!(err instanceof Error && /404/.test(err.message))) {
317 log.warn({ err: (err as Error).message, docId: chunk.docId }, "vector merge failed (row still recorded)");
318 }
319 }
320 await prisma.aiEmbedding.upsert({
321 where: { workspaceId_chunkRef: { workspaceId, chunkRef: chunk.docId } },
322 create: {
323 workspaceId,
324 chunkRef: chunk.docId,
325 chunkText: chunk.text,
326 vectorId: chunk.docId,
327 model: result.model,
328 dimension: result.dimension,
329 metadata: asJson({ docType, refId, index: CONTENT_INDEX_ALIAS }),
330 },
331 update: {
332 chunkText: chunk.text,
333 model: result.model,
334 dimension: result.dimension,
335 metadata: asJson({ docType, refId, index: CONTENT_INDEX_ALIAS }),
336 },
337 });
338 embedded++;
339 }
340
341
342 const currentRefs = chunks.map((c) => c.docId);
343 const pruned = await pruneItemEmbeddings(prisma, workspaceId, docType, refId, currentRefs);
344
345 log.info(
346 { jobId: opts.jobId, workspaceId, docType, refId, embedded, pruned, model: result.model, dimension: result.dimension },
347 "embed:chunks done",
348 );
349 return { skipped: false, embedded, pruned, chunkRefs: currentRefs, model: result.model, dimension: result.dimension };
350}
351
352
358async function pruneItemEmbeddings(
359 prisma: PrismaClient,
360 workspaceId: string,
361 docType: string,
362 refId: string,
363 keepRefs: string[],
364): Promise<number> {
365 const prefix = itemChunkPrefix(docType, refId);
366 const res = await prisma.aiEmbedding.deleteMany({
367 where: {
368 workspaceId,
369 OR: [{ chunkRef: prefix }, { chunkRef: { startsWith: `${prefix}#` } }],
370 ...(keepRefs.length > 0 ? { NOT: { chunkRef: { in: keepRefs } } } : {}),
371 },
372 });
373 return res.count;
374}
375
376
377
378
379
380export interface ReembedResult {
381
382 total: number;
383
384 embedded: number;
385
386 failed: number;
387
388 driftAlert: boolean;
389
390 model?: string | undefined;
391
392 blocked?: boolean;
393}
394
395const REEMBED_BATCH = 100;
396const REEMBED_DRIFT_THRESHOLD = 0.02;
397
398
407export async function handleReembed(opts: {
408 prisma: PrismaClient;
409 env: WorkerEnv;
410 search: SearchClient;
411 jobId: string | undefined;
412 gateway?: AiGateway;
413}): Promise<ReembedResult> {
414 const { prisma, env, search } = opts;
415 const gateway = opts.gateway ?? workerAiGateway(env);
416 const t0 = Date.now();
417
418 const rows = await prisma.aiEmbedding.findMany({ orderBy: { workspaceId: "asc" } });
419 if (rows.length === 0) {
420 return { total: 0, embedded: 0, failed: 0, driftAlert: false };
421 }
422
423 let embedded = 0;
424 let failed = 0;
425 let model: string | undefined;
426
427
428
429 let blocked = false;
430 for (let start = 0; start < rows.length; start += REEMBED_BATCH) {
431 const batch = rows.slice(start, start + REEMBED_BATCH);
432 let result;
433 try {
434 result = await gateway.embed({
435 workspaceId: batch[0]!.workspaceId,
436 texts: batch.map((r) => r.chunkText),
437 metadata: { capability: "rag.reembed" },
438 });
439 } catch (err) {
440 log.warn({ err: (err as Error).message, batch: batch.length }, "reembed batch embed failed — counting rows as failed");
441 failed += batch.length;
442 continue;
443 }
444 if (start === 0 && result.dimension !== env.SEARCH_VECTOR_DIMENSION) {
445 const ws0 = batch[0]!.workspaceId;
446 await emitReembedRequired(prisma, ws0, {
447 reason: "dimension_mismatch",
448 detail: `reembed model "${result.model}" produced dim ${result.dimension}, index has ${env.SEARCH_VECTOR_DIMENSION}`,
449 });
450 log.error(
451 { jobId: opts.jobId, model: result.model, got: result.dimension, want: env.SEARCH_VECTOR_DIMENSION },
452 "reembed BLOCKED — dimension mismatch (alias rollover required)",
453 );
454 blocked = true;
455 break;
456 }
457 model = result.model;
458 for (let i = 0; i < batch.length; i++) {
459 const row = batch[i]!;
460 const vector = result.vectors[i];
461 if (!vector) {
462 failed++;
463 continue;
464 }
465 try {
466 await search.updateDoc(row.vectorId, { vector, vectorModel: result.model });
467 await prisma.aiEmbedding.update({
468 where: { id: row.id },
469 data: { model: result.model, dimension: result.dimension, chunkText: row.chunkText },
470 });
471 embedded++;
472 } catch (err) {
473
474
475 failed++;
476 await prisma.aiEmbedding
477 .delete({ where: { id: row.id } })
478 .catch(() => undefined);
479 log.warn({ err: (err as Error).message, vectorId: row.vectorId }, "reembed row failed — orphan pruned");
480 }
481 }
482 }
483
484 const total = rows.length;
485 const drift = total > 0 ? failed / total : 0;
486 const driftAlert = !blocked && drift > REEMBED_DRIFT_THRESHOLD;
487 if (driftAlert) {
488
489
490
491
492 const firstFailedWs = rows.find((r) => true)!.workspaceId;
493 await emitReembedRequired(prisma, firstFailedWs, {
494 reason: "count_parity_drift",
495 detail: `${failed}/${total} chunks failed re-embed (${(drift * 100).toFixed(1)}% drift)`,
496 });
497 }
498 log.info(
499 { jobId: opts.jobId, total, embedded, failed, drift: +(drift * 100).toFixed(2), driftAlert, blocked, model, ms: Date.now() - t0 },
500 "reembed done",
501 );
502 return { total, embedded, failed, driftAlert, model, ...(blocked ? { blocked: true } : {}) };
503}
504
505
506
507
508
509
515export async function emitReembedRequired(
516 prisma: PrismaClient,
517 workspaceId: string,
518 detail: { reason: string; detail: string },
519): Promise<void> {
520 try {
521 await prisma.auditLog.create({
522 data: {
523 actorType: "SYSTEM",
524 actorId: "worker:ai",
525 workspaceId,
526 resourceType: "workspace",
527 resourceId: workspaceId,
528 action: "ai.reembed_required",
529 metadata: asJson({ reason: detail.reason, detail: detail.detail, index: CONTENT_INDEX_ALIAS }),
530 },
531 });
532 } catch (err) {
533 log.warn({ err: (err as Error).message, workspaceId }, "failed to emit ai.reembed_required audit row");
534 }
535 log.warn({ workspaceId, ...detail }, "ai.reembed_required — embedding model/config drifted from the index; run scripts/opensearch-reindex.ts");
536}
537