233 lines
8.7 KiB
JavaScript
233 lines
8.7 KiB
JavaScript
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);
|
|
}
|