| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { readdirSync, readFileSync, existsSync } from 'fs'; |
| import { join } from 'path'; |
| import { getDb } from '../db'; |
| import { embedPassage, serializeEmbedding, embeddingModel } from './embeddings'; |
| import { clearVectorCache } from './retriever'; |
|
|
| interface SourceDoc { |
| chunk_id?: string; |
| doc_title: string; |
| organization: string; |
| url?: string; |
| version_date?: string; |
| lang?: string; |
| topic?: string; |
| text: string; |
| } |
|
|
| interface PreparedChunk extends Required<Omit<SourceDoc, 'chunk_id'>> { |
| chunk_id: string; |
| } |
|
|
| export interface IngestResult { |
| corpus_version: string; |
| chunk_count: number; |
| embedded: number; |
| embed_model: string; |
| sources: Array<{ organization: string; url: string; version_date: string }>; |
| } |
|
|
| function corpusDir(): string { |
| return process.env.CORPUS_DIR || join(process.cwd(), 'data', 'corpus'); |
| } |
|
|
| function corpusVersion(): string { |
| if (process.env.CORPUS_VERSION) return process.env.CORPUS_VERSION; |
| const d = new Date(); |
| const p = (n: number) => String(n).padStart(2, '0'); |
| return `${d.getUTCFullYear()}.${p(d.getUTCMonth() + 1)}.${p(d.getUTCDate())}.${p(d.getUTCHours())}${p(d.getUTCMinutes())}`; |
| } |
|
|
| function slug(s: string): string { |
| return s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40); |
| } |
|
|
| |
| function splitPassages(text: string, max = 1200): string[] { |
| const clean = text.replace(/\s+/g, ' ').trim(); |
| if (clean.length <= max) return [clean]; |
| const sentences = clean.match(/[^.!?]+[.!?]+|\S+$/g) || [clean]; |
| const out: string[] = []; |
| let buf = ''; |
| for (const s of sentences) { |
| if ((buf + ' ' + s).trim().length > max && buf) { |
| out.push(buf.trim()); |
| buf = s; |
| } else { |
| buf = (buf + ' ' + s).trim(); |
| } |
| } |
| if (buf) out.push(buf.trim()); |
| return out; |
| } |
|
|
| function loadSourceDocs(): SourceDoc[] { |
| const dir = corpusDir(); |
| if (!existsSync(dir)) { |
| throw new Error(`Corpus directory not found: ${dir}`); |
| } |
| const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl') || f.endsWith('.json')); |
| const docs: SourceDoc[] = []; |
| for (const f of files) { |
| const raw = readFileSync(join(dir, f), 'utf8'); |
| if (f.endsWith('.jsonl')) { |
| for (const line of raw.split('\n')) { |
| const t = line.trim(); |
| if (t) docs.push(JSON.parse(t)); |
| } |
| } else { |
| const parsed = JSON.parse(raw); |
| if (Array.isArray(parsed)) docs.push(...parsed); |
| } |
| } |
| return docs.filter((d) => d && d.text && d.doc_title && d.organization); |
| } |
|
|
| function prepare(docs: SourceDoc[], version: string): PreparedChunk[] { |
| const chunks: PreparedChunk[] = []; |
| for (const d of docs) { |
| const passages = splitPassages(d.text); |
| passages.forEach((text, i) => { |
| const base = d.chunk_id || `${slug(d.organization)}_${slug(d.doc_title)}`; |
| const chunk_id = passages.length > 1 ? `${base}_${i}` : base; |
| chunks.push({ |
| chunk_id, |
| doc_title: d.doc_title, |
| organization: d.organization, |
| url: d.url || '', |
| version_date: d.version_date || '', |
| lang: d.lang || 'en', |
| topic: d.topic || '', |
| text, |
| }); |
| }); |
| } |
| return chunks; |
| } |
|
|
| export async function ingestCorpus(): Promise<IngestResult> { |
| const version = corpusVersion(); |
| const model = embeddingModel(); |
| const docs = loadSourceDocs(); |
| const chunks = prepare(docs, version); |
| if (chunks.length === 0) throw new Error('No usable corpus documents found.'); |
|
|
| |
| const embeddings: Array<Buffer | null> = []; |
| let embedded = 0; |
| for (const c of chunks) { |
| const vec = await embedPassage(c.text); |
| if (vec) { |
| embeddings.push(serializeEmbedding(vec)); |
| embedded++; |
| } else { |
| embeddings.push(null); |
| } |
| } |
|
|
| const db = getDb(); |
| const ftsExists = !!db |
| .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='rag_chunks_fts'`) |
| .get(); |
|
|
| const sourceMap = new Map<string, { organization: string; url: string; version_date: string }>(); |
| for (const c of chunks) { |
| sourceMap.set(`${c.organization}|${c.url}`, { |
| organization: c.organization, |
| url: c.url, |
| version_date: c.version_date, |
| }); |
| } |
| const sources = Array.from(sourceMap.values()); |
|
|
| const write = db.transaction(() => { |
| |
| if (ftsExists) { |
| db.prepare( |
| `DELETE FROM rag_chunks_fts WHERE chunk_id IN |
| (SELECT chunk_id FROM rag_chunks WHERE corpus_version = ?)`, |
| ).run(version); |
| } |
| db.prepare(`DELETE FROM rag_chunks WHERE corpus_version = ?`).run(version); |
|
|
| const insChunk = db.prepare( |
| `INSERT INTO rag_chunks |
| (chunk_id, doc_title, organization, url, version_date, lang, topic, text, embedding, corpus_version) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| ); |
| const insFts = ftsExists |
| ? db.prepare(`INSERT INTO rag_chunks_fts (chunk_id, text) VALUES (?, ?)`) |
| : null; |
|
|
| chunks.forEach((c, i) => { |
| insChunk.run( |
| c.chunk_id, c.doc_title, c.organization, c.url, c.version_date, |
| c.lang, c.topic, c.text, embeddings[i], version, |
| ); |
| insFts?.run(c.chunk_id, c.text); |
| }); |
|
|
| |
| db.prepare(`UPDATE rag_corpus_manifest SET is_active = 0`).run(); |
| db.prepare( |
| `INSERT INTO rag_corpus_manifest (corpus_version, embed_model, sources_json, chunk_count, is_active) |
| VALUES (?, ?, ?, ?, 1) |
| ON CONFLICT(corpus_version) DO UPDATE SET |
| embed_model=excluded.embed_model, sources_json=excluded.sources_json, |
| chunk_count=excluded.chunk_count, is_active=1, built_at=datetime('now')`, |
| ).run(version, model, JSON.stringify(sources), chunks.length); |
| }); |
| write(); |
|
|
| clearVectorCache(); |
|
|
| return { |
| corpus_version: version, |
| chunk_count: chunks.length, |
| embedded, |
| embed_model: model, |
| sources, |
| }; |
| } |
|
|