import * as db from './db'; import { callLLM, cachedSystem } from './llm'; import { extractionLimiter } from './llmRetry'; import { EMIT_KNOWLEDGE_GRAPH_TOOL } from './llmTools'; const MAX_KNOWN_TOPICS_HINT = 200; /** * Build the "already-extracted topics" hint included in every chunk prompt. * Passes both ID and label so the model can match concepts by name and reuse * the exact ID + label rather than inventing near-duplicate variants. */ export function buildKnownIdsHint(topics) { if (!topics || !topics.length) return ''; const recent = topics.slice(-MAX_KNOWN_TOPICS_HINT); return [ 'Already-extracted topics (reuse their ID and label exactly if the same concept appears here):', ...recent.map((t) => `- ${t.id}: "${t.label}"`), '', ].join('\n'); } const EXTRACTION_SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency. You receive a source text. Extract every distinct concept, role, and process from it and emit them through the emit_knowledge_graph tool. CRITICAL INSTRUCTIONS FOR COMPLETENESS: - Extract up to 15 of the most important distinct roles, processes, and concepts described or mentioned in the source text. - Do not exceed 15 topics per chunk to prevent the response from being truncated. - Facts should be integrated into the descriptions of other topics — never extracted as standalone topics. - Keep descriptions concise (max 3 sentences) so the response fits. Topic IDs are lowercase kebab-case slugs specific to the topic (e.g. "software-engineer", "data-quality-review"). Do not use generic IDs like "role-1" or "concept-2". Assign a learning_relevance to every topic: - "core": fundamental company knowledge. - "standard": normal learning topics. - "peripheral": good to know, low priority. - "exclude": pure operational reference (printer guides, wifi passwords) that should never be tested. Relation types: related_to | depends_on | part_of | executed_by. `; /** * Sentence-aware chunker with overlap. * * Targets ~2000 input tokens per chunk (`MAX_CHUNK_CHARS / 4`). Splits on * sentence boundaries first, then falls back to paragraph boundaries, and * hard-splits inside an oversized sentence as a last resort. Adjacent chunks * share `overlapChars` of trailing text to preserve cross-boundary context * for the model. * * Exported for unit tests; callers in this module use it directly. * * @param {string} text * @param {{ maxChars?: number, overlapChars?: number }} [opts] * @returns {string[]} */ export const MAX_CHUNK_CHARS = 8000; export const OVERLAP_CHARS = 800; export function chunkText(text, { maxChars = MAX_CHUNK_CHARS, overlapChars = OVERLAP_CHARS } = {}) { if (typeof text !== 'string' || !text.trim()) return []; const trimmed = text.trim(); if (trimmed.length <= maxChars) return [trimmed]; const units = splitIntoChunkableUnits(trimmed, maxChars); if (units.length === 0) return []; const chunks = []; let buf = ''; let bufLen = 0; // length of new (non-overlap) content added since last flush for (const unit of units) { const wouldOverflow = (buf ? buf.length + 1 + unit.length : unit.length) > maxChars; if (wouldOverflow && bufLen > 0) { chunks.push(buf.trim()); const overlap = buf.length > overlapChars ? buf.slice(-overlapChars) : ''; buf = overlap; bufLen = 0; } // If the overlap + unit still won't fit, drop the overlap so the unit fits cleanly. if (buf && (buf.length + 1 + unit.length) > maxChars) { buf = ''; } buf = buf ? buf + ' ' + unit : unit; bufLen += unit.length + (bufLen > 0 ? 1 : 0); } if (bufLen > 0 && buf.trim()) chunks.push(buf.trim()); return chunks; } function splitIntoChunkableUnits(text, maxChars) { const paragraphs = text.split(/\n\s*\n+/); const units = []; for (const para of paragraphs) { const trimmedPara = para.trim(); if (!trimmedPara) continue; const sentences = trimmedPara.split(/(?<=[.!?])\s+/); for (const s of sentences) { const sentence = s.trim(); if (!sentence) continue; if (sentence.length <= maxChars) { units.push(sentence); } else { for (let i = 0; i < sentence.length; i += maxChars) { units.push(sentence.slice(i, i + maxChars)); } console.warn(`[chunkText] Hard-split a sentence of ${sentence.length} chars (exceeds maxChars=${maxChars}).`); } } } return units; } export async function processSourceText(textContent, sourceName, { signal } = {}) { const existing = await db.getSources(); const alreadyDone = existing.find( s => s.name === sourceName && s.status === 'completed' ); if (alreadyDone) { throw new Error(`"${sourceName}" has already been processed. Delete the existing source first if you want to re-analyse it.`); } const rec = await db.addSource({ name: sourceName, status: 'processing' }); const sourceId = rec.id; try { const chunks = chunkText(textContent); console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`); // Persist initial progress so other sessions/reloads can see it await db.updateSourceProgress(sourceId, { current: 0, total: chunks.length, message: 'Starting extraction...' }); const existingTopics = await db.getTopics(); const knownTopics = existingTopics.map((t) => ({ id: t.id, label: t.label })); const allExtractedTopics = []; const allExtractedRelations = []; for (let i = 0; i < chunks.length; i++) { if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`); // Update progress before each chunk await db.updateSourceProgress(sourceId, { current: i, total: chunks.length, message: `Extracting chunk ${i + 1} of ${chunks.length}...`, }); const hint = buildKnownIdsHint(knownTopics); const result = await callLLM({ task: 'extract.source', tier: 'standard', system: cachedSystem(EXTRACTION_SYSTEM_PROMPT), user: `${hint}Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`, tools: [EMIT_KNOWLEDGE_GRAPH_TOOL], toolChoice: { type: 'tool', name: EMIT_KNOWLEDGE_GRAPH_TOOL.name }, maxTokens: 8192, timeoutMs: 180_000, limiter: extractionLimiter, signal, }); const extractedData = result.toolUses[0]?.input; if (!extractedData) throw new Error(`Extraction did not emit a tool result for chunk ${i + 1}.`); if (Array.isArray(extractedData.topics)) { allExtractedTopics.push(...extractedData.topics); for (const t of extractedData.topics) { if (t?.id && !knownTopics.some((k) => k.id === t.id)) { knownTopics.push({ id: t.id, label: t.label }); } } } if (Array.isArray(extractedData.relations)) { allExtractedRelations.push(...extractedData.relations); } } await db.updateSourceProgress(sourceId, { current: chunks.length, total: chunks.length, message: 'Merging results...' }); await mergeKnowledgeGraph(existingTopics, { topics: allExtractedTopics, relations: allExtractedRelations }); await db.updateSourceStatus(sourceId, 'completed'); return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } }; } catch (error) { const isAbort = error?.name === 'AbortError'; await db.updateSourceStatus(sourceId, isAbort ? 'cancelled' : 'failed', isAbort ? 'cancelled by user' : error.message); throw error; } } async function mergeKnowledgeGraph(existingTopics, newData) { const existingRelations = await db.getRelations(); const topicsMap = new Map(existingTopics.map(t => [t.id, t])); if (newData.topics && Array.isArray(newData.topics)) { for (const t of newData.topics) { if (topicsMap.has(t.id)) { const existing = topicsMap.get(t.id); const merged = { ...existing, ...t, description: t.description || existing.description, }; if (existing.relevance_locked) { merged.learning_relevance = existing.learning_relevance; merged.relevance_locked = true; } topicsMap.set(t.id, merged); } else { topicsMap.set(t.id, t); } } } const newRelations = [...existingRelations]; if (newData.relations && Array.isArray(newData.relations)) { for (const r of newData.relations) { const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type); if (!isDup) { newRelations.push(r); } } } await db.saveTopics(Array.from(topicsMap.values())); await db.saveRelations(newRelations); }