/** * Vector DB export — convert crawled screens to embedding-ready chunks. * * Produces a JSONL file where each line is one chunk ready for upsert into * Pinecone, Qdrant, Weaviate, or any vector store. The embedding itself is * NOT generated here (requires an embedding model at the user's end) — we * produce the text chunks + metadata, and the caller embeds them. * * Format per line (JSONL): * { * "id": "-", * "text": "", * "metadata": { * "screenId": "...", * "projectId": "...", * "tenantId": "...", * "url": "...", * "screenName": "...", * "chunkIndex": 0, * "totalChunks": 3, * "crawledAt": "2026-...", * "wordCount": 142 * } * } */ import * as fs from 'fs/promises'; import * as path from 'path'; export interface VectorChunk { id: string; text: string; metadata: { screenId: string; projectId: string; tenantId: string; url: string; screenName: string; chunkIndex: number; totalChunks: number; crawledAt: string; wordCount: number; }; } export interface ScreenRecord { id: string; name: string; url: string; projectId: string; tenantId: string; markdown?: string | null; markdownPath?: string | null; updatedAt?: Date; } function splitIntoChunks(text: string, maxChunkWords = 300): string[] { const paragraphs = text.split(/\n{2,}/).filter(p => p.trim().length > 0); const chunks: string[] = []; let current = ''; let wordCount = 0; for (const para of paragraphs) { const words = para.split(/\s+/).length; if (wordCount + words > maxChunkWords && current.length > 0) { chunks.push(current.trim()); current = ''; wordCount = 0; } current += (current ? '\n\n' : '') + para; wordCount += words; } if (current.trim()) chunks.push(current.trim()); return chunks.length > 0 ? chunks : [text.slice(0, 2000)]; } export async function exportScreensToVectorJsonl( screens: ScreenRecord[], outputPath: string, opts: { maxChunkWords?: number; readMarkdownFromPath?: boolean } = {}, ): Promise<{ totalChunks: number; skipped: number }> { const { maxChunkWords = 300, readMarkdownFromPath = true } = opts; await fs.mkdir(path.dirname(outputPath), { recursive: true }); const handle = await fs.open(outputPath, 'w'); let totalChunks = 0; let skipped = 0; try { for (const screen of screens) { let markdown = screen.markdown ?? ''; if (!markdown && readMarkdownFromPath && screen.markdownPath) { try { markdown = await fs.readFile(screen.markdownPath, 'utf8'); } catch { /* file missing — skip */ } } if (!markdown || markdown.trim().length < 20) { skipped++; continue; } const textChunks = splitIntoChunks(markdown, maxChunkWords); for (let i = 0; i < textChunks.length; i++) { const chunk: VectorChunk = { id: `${screen.id}-${i}`, text: textChunks[i], metadata: { screenId: screen.id, projectId: screen.projectId, tenantId: screen.tenantId, url: screen.url, screenName: screen.name, chunkIndex: i, totalChunks: textChunks.length, crawledAt: screen.updatedAt?.toISOString() ?? new Date().toISOString(), wordCount: textChunks[i].split(/\s+/).length, }, }; await handle.write(JSON.stringify(chunk) + '\n'); totalChunks++; } } } finally { await handle.close(); } return { totalChunks, skipped }; } /** * Pinecone-compatible upsert batch format. * Call this after generating embeddings to produce the final upsert payload. */ export function toPineconeUpsert( chunks: VectorChunk[], embeddings: number[][], namespace = 'zeta-crawl', ): Array<{ id: string; values: number[]; metadata: VectorChunk['metadata'] }> { return chunks.map((chunk, i) => ({ id: chunk.id, values: embeddings[i], metadata: chunk.metadata, })); } /** * Qdrant-compatible point format. */ export function toQdrantPoints( chunks: VectorChunk[], embeddings: number[][], ): Array<{ id: string; vector: number[]; payload: VectorChunk['metadata'] & { text: string } }> { return chunks.map((chunk, i) => ({ id: chunk.id, vector: embeddings[i], payload: { ...chunk.metadata, text: chunk.text }, })); }