brainy/src/importers/SmartDOCXImporter.ts
David Snelling 52782898a3 feat: implement progressive flush intervals for streaming imports
Progressive intervals adjust dynamically based on current entity count
(not total), making them work for both known and unknown totals.

**Key Features:**
- 0-999 entities: Flush every 100 (frequent early updates for UX)
- 1K-9.9K: Flush every 1000 (balanced performance)
- 10K+: Flush every 5000 (minimal overhead ~0.3%)

**Benefits:**
- Works with known totals (file imports)
- Works with unknown totals (streaming APIs, database cursors)
- Adapts automatically as import grows
- Zero configuration required

**Implementation:**
- Replaced adaptive intervals (requires total count) with progressive
- Added interval transition logging for observability
- Enhanced documentation to highlight engineering sophistication
- Final flush with statistics reporting

**Documentation:**
- Added "Engineering Insight" section showcasing advanced approach
- Updated all interval references from "adaptive" to "progressive"
- Added comprehensive examples in streaming-imports.md

Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 17:36:27 -07:00

404 lines
11 KiB
TypeScript

/**
* Smart DOCX Importer
*
* Extracts entities and relationships from Word documents using:
* - Mammoth parser for DOCX → HTML/text conversion
* - Heading extraction for document structure
* - Table extraction for structured data
* - NeuralEntityExtractor for entity extraction from paragraphs
* - NaturalLanguageProcessor for relationship inference
* - Hierarchical relationship creation based on heading hierarchy
*
* v4.2.0: New format handler
* NO MOCKS - Production-ready implementation
*/
import { Brainy } from '../brainy.js'
import { NeuralEntityExtractor, ExtractedEntity } from '../neural/entityExtractor.js'
import { NaturalLanguageProcessor } from '../neural/naturalLanguageProcessor.js'
import { SmartRelationshipExtractor } from '../neural/SmartRelationshipExtractor.js'
import { NounType, VerbType } from '../types/graphTypes.js'
// Mammoth type definitions (no @types package available)
interface MammothResult {
value: string
messages: Array<{
type: string
message: string
}>
}
interface Mammoth {
extractRawText(options: { buffer: Buffer }): Promise<MammothResult>
convertToHtml(options: { buffer: Buffer }): Promise<MammothResult>
}
// Dynamic import for mammoth (ESM compatibility)
let mammoth: Mammoth
export interface SmartDOCXOptions {
/** Enable neural entity extraction from paragraphs */
enableNeuralExtraction?: boolean
/** Enable hierarchical relationship creation based on headings */
enableHierarchicalRelationships?: boolean
/** Enable concept extraction for tagging */
enableConceptExtraction?: boolean
/** Confidence threshold for entities (0-1) */
confidenceThreshold?: number
/** Minimum paragraph length to process */
minParagraphLength?: number
/** Progress callback */
onProgress?: (stats: {
processed: number
entities: number
relationships: number
}) => void
}
export interface ExtractedDOCXEntity {
/** Entity ID */
id: string
/** Entity name */
name: string
/** Entity type */
type: NounType
/** Entity description/context */
description: string
/** Confidence score */
confidence: number
/** Weight/importance score */
weight?: number
/** Section/heading context */
section: string | null
/** Paragraph index in document */
paragraphIndex: number
/** Metadata */
metadata: Record<string, any>
}
export interface ExtractedDOCXRelationship {
from: string
to: string
type: VerbType
confidence: number
weight?: number
evidence: string
}
export interface SmartDOCXResult {
/** Total paragraphs processed */
paragraphsProcessed: number
/** Entities extracted */
entitiesExtracted: number
/** Relationships inferred */
relationshipsInferred: number
/** All extracted entities */
entities: ExtractedDOCXEntity[]
/** All relationships */
relationships: ExtractedDOCXRelationship[]
/** Entity ID mapping (index -> ID) */
entityMap: Map<string, string>
/** Processing time in ms */
processingTime: number
/** Document structure */
structure: {
headings: Array<{ level: number; text: string; index: number }>
paragraphCount: number
tableCount: number
}
/** Extraction statistics */
stats: {
byType: Record<string, number>
bySection: Record<string, number>
byConfidence: {
high: number // > 0.8
medium: number // 0.6-0.8
low: number // < 0.6
}
}
}
/**
* SmartDOCXImporter - Extracts structured knowledge from Word documents
*/
export class SmartDOCXImporter {
private brain: Brainy
private extractor: NeuralEntityExtractor
private nlp: NaturalLanguageProcessor
private relationshipExtractor: SmartRelationshipExtractor
private mammothLoaded = false
constructor(brain: Brainy) {
this.brain = brain
this.extractor = new NeuralEntityExtractor(brain)
this.nlp = new NaturalLanguageProcessor(brain)
this.relationshipExtractor = new SmartRelationshipExtractor(brain)
}
/**
* Initialize the importer
*/
async init(): Promise<void> {
await this.nlp.init()
// Lazy load mammoth
if (!this.mammothLoaded) {
try {
mammoth = await import('mammoth')
this.mammothLoaded = true
} catch (error: any) {
throw new Error(`Failed to load mammoth parser: ${error.message}`)
}
}
}
/**
* Extract entities and relationships from DOCX buffer
*/
async extract(
buffer: Buffer,
options: SmartDOCXOptions = {}
): Promise<SmartDOCXResult> {
const startTime = Date.now()
// Ensure mammoth is loaded
if (!this.mammothLoaded) {
await this.init()
}
// Extract raw text for entity extraction
const textResult = await mammoth.extractRawText({ buffer })
// Extract HTML for structure analysis (headings, tables)
const htmlResult = await mammoth.convertToHtml({ buffer })
// Process the document
const result = await this.extractFromContent(
textResult.value,
htmlResult.value,
options
)
result.processingTime = Date.now() - startTime
return result
}
/**
* Extract entities and relationships from parsed DOCX content
*/
private async extractFromContent(
rawText: string,
html: string,
options: SmartDOCXOptions
): Promise<SmartDOCXResult> {
const opts = {
enableNeuralExtraction: options.enableNeuralExtraction !== false,
enableHierarchicalRelationships: options.enableHierarchicalRelationships !== false,
enableConceptExtraction: options.enableConceptExtraction !== false,
confidenceThreshold: options.confidenceThreshold || 0.6,
minParagraphLength: options.minParagraphLength || 20
}
const entities: ExtractedDOCXEntity[] = []
const relationships: ExtractedDOCXRelationship[] = []
const entityMap = new Map<string, string>()
const stats = {
byType: {} as Record<string, number>,
bySection: {} as Record<string, number>,
byConfidence: { high: 0, medium: 0, low: 0 }
}
// Parse document structure from HTML
const structure = this.parseStructure(html)
// Split into paragraphs
const paragraphs = rawText.split(/\n\n+/).filter(p => p.trim().length >= opts.minParagraphLength)
let currentSection = 'Introduction'
let headingIndex = 0
// Process each paragraph
for (let i = 0; i < paragraphs.length; i++) {
const paragraph = paragraphs[i].trim()
// Check if this paragraph is a heading
if (headingIndex < structure.headings.length) {
const heading = structure.headings[headingIndex]
if (paragraph.startsWith(heading.text) || heading.text.includes(paragraph.substring(0, 50))) {
currentSection = heading.text
headingIndex++
stats.bySection[currentSection] = 0
continue
}
}
// Extract entities from paragraph
if (opts.enableNeuralExtraction) {
const extractedEntities = await this.extractor.extract(paragraph, {
confidence: opts.confidenceThreshold
})
for (const extracted of extractedEntities) {
const entityId = `para${i}:${extracted.text}`
const entity: ExtractedDOCXEntity = {
id: entityId,
name: extracted.text,
type: extracted.type,
description: paragraph,
confidence: extracted.confidence,
weight: extracted.weight || 1.0,
section: currentSection,
paragraphIndex: i,
metadata: {
position: extracted.position,
headingContext: currentSection
}
}
entities.push(entity)
entityMap.set(entityId, entityId)
// Update stats
stats.byType[entity.type] = (stats.byType[entity.type] || 0) + 1
stats.bySection[currentSection] = (stats.bySection[currentSection] || 0) + 1
if (entity.confidence > 0.8) stats.byConfidence.high++
else if (entity.confidence >= 0.6) stats.byConfidence.medium++
else stats.byConfidence.low++
}
}
// Report progress
if (options.onProgress && i % 10 === 0) {
options.onProgress({
processed: i,
entities: entities.length,
relationships: relationships.length
})
}
}
// Create hierarchical relationships based on sections
if (opts.enableHierarchicalRelationships) {
const entitiesBySection = new Map<string, ExtractedDOCXEntity[]>()
for (const entity of entities) {
const section = entity.section || 'Unknown'
if (!entitiesBySection.has(section)) {
entitiesBySection.set(section, [])
}
entitiesBySection.get(section)!.push(entity)
}
// Create relationships within sections
for (const [section, sectionEntities] of entitiesBySection) {
for (let i = 0; i < sectionEntities.length - 1; i++) {
for (let j = i + 1; j < Math.min(i + 3, sectionEntities.length); j++) {
const entityA = sectionEntities[i]
const entityB = sectionEntities[j]
// Infer relationship type using SmartRelationshipExtractor
// Combine entity descriptions for better context
const context = `In section "${section}": ${entityA.description.substring(0, 150)}... ${entityB.description.substring(0, 150)}...`
const inferredRelationship = await this.relationshipExtractor.infer(
entityA.name,
entityB.name,
context,
{
subjectType: entityA.type,
objectType: entityB.type
}
)
relationships.push({
from: entityA.id,
to: entityB.id,
type: inferredRelationship?.type || VerbType.RelatedTo, // Fallback to RelatedTo for co-occurrence
confidence: inferredRelationship?.confidence || 0.7,
weight: inferredRelationship?.weight || 0.8,
evidence: inferredRelationship?.evidence || `Both entities appear in section: ${section}`
})
}
}
}
}
// Final progress report
if (options.onProgress) {
options.onProgress({
processed: paragraphs.length,
entities: entities.length,
relationships: relationships.length
})
}
return {
paragraphsProcessed: paragraphs.length,
entitiesExtracted: entities.length,
relationshipsInferred: relationships.length,
entities,
relationships,
entityMap,
processingTime: 0, // Will be set by caller
structure,
stats
}
}
/**
* Parse document structure from HTML
*/
private parseStructure(html: string): {
headings: Array<{ level: number; text: string; index: number }>
paragraphCount: number
tableCount: number
} {
const headings: Array<{ level: number; text: string; index: number }> = []
// Extract headings (h1-h6)
const headingRegex = /<h([1-6])>(.*?)<\/h\1>/gi
let match
let index = 0
while ((match = headingRegex.exec(html)) !== null) {
const level = parseInt(match[1])
const text = match[2].replace(/<[^>]+>/g, '').trim() // Strip HTML tags
headings.push({ level, text, index: index++ })
}
// Count paragraphs
const paragraphCount = (html.match(/<p>/g) || []).length
// Count tables
const tableCount = (html.match(/<table>/g) || []).length
return {
headings,
paragraphCount,
tableCount
}
}
}