Add real-time progress reporting throughout the entire import pipeline
with a standardized API that works across all supported formats.
Workshop Team Feature Request:
- Eliminates "0% complete" hangs during AI extraction
- Shows continuous progress with entities/sec, throughput, ETA
- Reports contextual messages ("Processing page 5 of 23")
- Standardized progress API for CSV, PDF, Excel, JSON, Markdown, YAML, DOCX
Core Changes:
- Add FormatHandlerProgressHooks interface for extensible progress
- Wire up all 3 binary format handlers (CSV, PDF, Excel) with 7+ progress points
- Wire up all 4 text format importers (JSON, Markdown, YAML, DOCX)
- Add ImportProgress interface with stage, message, counts, throughput, ETA
- ImportCoordinator normalizes all format progress to standard interface
CLI Improvements:
- Import command now uses brain.import() directly with full progress
- Add --include-vfs flag to find command (v4.4.0 compatibility)
- Add --confidence and --weight options to add command
Documentation:
- docs/guides/standard-import-progress.md - Universal API guide
- docs/guides/import-progress-implementation.md - Developer guide
- docs/guides/import-progress-examples.md - Practical examples
- JSDoc on brain.import() with universal handler examples
Result: ONE progress handler works for ALL 7 formats with zero format-specific code!
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
591 lines
16 KiB
TypeScript
591 lines
16 KiB
TypeScript
/**
|
|
* Smart JSON Importer
|
|
*
|
|
* Extracts entities and relationships from JSON files using:
|
|
* - Recursive traversal of nested structures
|
|
* - NeuralEntityExtractor for entity extraction from text values
|
|
* - NaturalLanguageProcessor for relationship inference
|
|
* - Hierarchical relationship creation (parent-child, contains, etc.)
|
|
*
|
|
* 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'
|
|
|
|
export interface SmartJSONOptions {
|
|
/** Enable neural entity extraction from string values */
|
|
enableNeuralExtraction?: boolean
|
|
|
|
/** Enable hierarchical relationship creation */
|
|
enableHierarchicalRelationships?: boolean
|
|
|
|
/** Enable concept extraction for tagging */
|
|
enableConceptExtraction?: boolean
|
|
|
|
/** Confidence threshold for entities (0-1) */
|
|
confidenceThreshold?: number
|
|
|
|
/** Maximum depth to traverse */
|
|
maxDepth?: number
|
|
|
|
/** Minimum string length to process for entity extraction */
|
|
minStringLength?: number
|
|
|
|
/** Keys that indicate entity names */
|
|
nameKeys?: string[]
|
|
|
|
/** Keys that indicate entity descriptions */
|
|
descriptionKeys?: string[]
|
|
|
|
/** Keys that indicate entity types */
|
|
typeKeys?: string[]
|
|
|
|
/** Progress callback */
|
|
onProgress?: (stats: {
|
|
processed: number
|
|
entities: number
|
|
relationships: number
|
|
}) => void
|
|
}
|
|
|
|
export interface ExtractedJSONEntity {
|
|
/** Entity ID */
|
|
id: string
|
|
|
|
/** Entity name */
|
|
name: string
|
|
|
|
/** Entity type */
|
|
type: NounType
|
|
|
|
/** Entity description/value */
|
|
description: string
|
|
|
|
/** Confidence score */
|
|
confidence: number
|
|
|
|
/** JSON path to this entity */
|
|
path: string
|
|
|
|
/** Parent path in JSON hierarchy */
|
|
parentPath: string | null
|
|
|
|
/** Metadata */
|
|
metadata: Record<string, any>
|
|
}
|
|
|
|
export interface ExtractedJSONRelationship {
|
|
from: string
|
|
to: string
|
|
type: VerbType
|
|
confidence: number
|
|
evidence: string
|
|
}
|
|
|
|
export interface SmartJSONResult {
|
|
/** Total nodes processed */
|
|
nodesProcessed: number
|
|
|
|
/** Entities extracted */
|
|
entitiesExtracted: number
|
|
|
|
/** Relationships inferred */
|
|
relationshipsInferred: number
|
|
|
|
/** All extracted entities */
|
|
entities: ExtractedJSONEntity[]
|
|
|
|
/** All relationships */
|
|
relationships: ExtractedJSONRelationship[]
|
|
|
|
/** Entity ID mapping (path -> ID) */
|
|
entityMap: Map<string, string>
|
|
|
|
/** Processing time in ms */
|
|
processingTime: number
|
|
|
|
/** Extraction statistics */
|
|
stats: {
|
|
byType: Record<string, number>
|
|
byDepth: Record<number, number>
|
|
byConfidence: {
|
|
high: number // > 0.8
|
|
medium: number // 0.6-0.8
|
|
low: number // < 0.6
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SmartJSONImporter - Extracts structured knowledge from JSON files
|
|
*/
|
|
export class SmartJSONImporter {
|
|
private brain: Brainy
|
|
private extractor: NeuralEntityExtractor
|
|
private nlp: NaturalLanguageProcessor
|
|
private relationshipExtractor: SmartRelationshipExtractor
|
|
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* Extract entities and relationships from JSON data
|
|
*/
|
|
async extract(
|
|
data: any,
|
|
options: SmartJSONOptions = {}
|
|
): Promise<SmartJSONResult> {
|
|
const startTime = Date.now()
|
|
|
|
// Set defaults
|
|
const opts: Required<SmartJSONOptions> = {
|
|
enableNeuralExtraction: true,
|
|
enableHierarchicalRelationships: true,
|
|
enableConceptExtraction: true,
|
|
confidenceThreshold: 0.6,
|
|
maxDepth: 10,
|
|
minStringLength: 20,
|
|
nameKeys: ['name', 'title', 'label', 'id', 'key'],
|
|
descriptionKeys: ['description', 'desc', 'details', 'text', 'content', 'summary'],
|
|
typeKeys: ['type', 'kind', 'category', 'class'],
|
|
onProgress: () => {},
|
|
...options
|
|
}
|
|
|
|
// v4.5.0: Report parsing start
|
|
opts.onProgress({
|
|
processed: 0,
|
|
entities: 0,
|
|
relationships: 0
|
|
})
|
|
|
|
// Parse JSON if string
|
|
let jsonData: any
|
|
if (typeof data === 'string') {
|
|
try {
|
|
jsonData = JSON.parse(data)
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
} else {
|
|
jsonData = data
|
|
}
|
|
|
|
// v4.5.0: Report parsing complete, starting traversal
|
|
opts.onProgress({
|
|
processed: 0,
|
|
entities: 0,
|
|
relationships: 0
|
|
})
|
|
|
|
// Traverse and extract
|
|
const entities: ExtractedJSONEntity[] = []
|
|
const relationships: ExtractedJSONRelationship[] = []
|
|
const entityMap = new Map<string, string>()
|
|
const stats = {
|
|
byType: {} as Record<string, number>,
|
|
byDepth: {} as Record<number, number>,
|
|
byConfidence: { high: 0, medium: 0, low: 0 }
|
|
}
|
|
|
|
let nodesProcessed = 0
|
|
|
|
// Recursive traversal
|
|
await this.traverseJSON(
|
|
jsonData,
|
|
'',
|
|
null,
|
|
0,
|
|
opts,
|
|
entities,
|
|
relationships,
|
|
entityMap,
|
|
stats,
|
|
() => {
|
|
nodesProcessed++
|
|
if (nodesProcessed % 10 === 0) {
|
|
opts.onProgress({
|
|
processed: nodesProcessed,
|
|
entities: entities.length,
|
|
relationships: relationships.length
|
|
})
|
|
}
|
|
}
|
|
)
|
|
|
|
// v4.5.0: Report completion
|
|
opts.onProgress({
|
|
processed: nodesProcessed,
|
|
entities: entities.length,
|
|
relationships: relationships.length
|
|
})
|
|
|
|
return {
|
|
nodesProcessed,
|
|
entitiesExtracted: entities.length,
|
|
relationshipsInferred: relationships.length,
|
|
entities,
|
|
relationships,
|
|
entityMap,
|
|
processingTime: Date.now() - startTime,
|
|
stats
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively traverse JSON structure
|
|
*/
|
|
private async traverseJSON(
|
|
node: any,
|
|
path: string,
|
|
parentPath: string | null,
|
|
depth: number,
|
|
options: Required<SmartJSONOptions>,
|
|
entities: ExtractedJSONEntity[],
|
|
relationships: ExtractedJSONRelationship[],
|
|
entityMap: Map<string, string>,
|
|
stats: SmartJSONResult['stats'],
|
|
onNode: () => void
|
|
): Promise<void> {
|
|
// Stop if max depth reached
|
|
if (depth > options.maxDepth) return
|
|
|
|
onNode()
|
|
stats.byDepth[depth] = (stats.byDepth[depth] || 0) + 1
|
|
|
|
// Handle null/undefined
|
|
if (node === null || node === undefined) return
|
|
|
|
// Handle arrays
|
|
if (Array.isArray(node)) {
|
|
for (let i = 0; i < node.length; i++) {
|
|
await this.traverseJSON(
|
|
node[i],
|
|
`${path}[${i}]`,
|
|
path,
|
|
depth + 1,
|
|
options,
|
|
entities,
|
|
relationships,
|
|
entityMap,
|
|
stats,
|
|
onNode
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle objects
|
|
if (typeof node === 'object') {
|
|
// Extract entity from this object
|
|
const entity = await this.extractEntityFromObject(
|
|
node,
|
|
path,
|
|
parentPath,
|
|
depth,
|
|
options,
|
|
stats
|
|
)
|
|
|
|
if (entity) {
|
|
entities.push(entity)
|
|
entityMap.set(path, entity.id)
|
|
|
|
// Create hierarchical relationship if parent exists
|
|
if (options.enableHierarchicalRelationships && parentPath && entityMap.has(parentPath)) {
|
|
const parentId = entityMap.get(parentPath)!
|
|
|
|
// Extract parent and child names from paths
|
|
const parentName = parentPath.split('.').pop()?.replace(/\[(\d+)\]/, 'item $1') || 'parent'
|
|
const childName = entity.name
|
|
|
|
// Infer relationship type using SmartRelationshipExtractor
|
|
const context = `Hierarchical JSON structure: ${parentName} contains ${childName}. Parent path: ${parentPath}, Child path: ${path}`
|
|
|
|
const inferredRelationship = await this.relationshipExtractor.infer(
|
|
parentName,
|
|
childName,
|
|
context,
|
|
{
|
|
objectType: entity.type // Pass child entity type as hint
|
|
}
|
|
)
|
|
|
|
relationships.push({
|
|
from: parentId,
|
|
to: entity.id,
|
|
type: inferredRelationship?.type || VerbType.Contains, // Fallback to Contains for hierarchical relationships
|
|
confidence: inferredRelationship?.confidence || 0.95,
|
|
evidence: inferredRelationship?.evidence || `Hierarchical relationship: ${parentPath} contains ${path}`
|
|
})
|
|
}
|
|
}
|
|
|
|
// Traverse child properties
|
|
for (const [key, value] of Object.entries(node)) {
|
|
const childPath = path ? `${path}.${key}` : key
|
|
await this.traverseJSON(
|
|
value,
|
|
childPath,
|
|
path,
|
|
depth + 1,
|
|
options,
|
|
entities,
|
|
relationships,
|
|
entityMap,
|
|
stats,
|
|
onNode
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle primitive values (strings)
|
|
if (typeof node === 'string' && node.length >= options.minStringLength) {
|
|
// Extract entities from text
|
|
if (options.enableNeuralExtraction) {
|
|
const extractedEntities = await this.extractor.extract(node, {
|
|
confidence: options.confidenceThreshold,
|
|
neuralMatching: true,
|
|
cache: { enabled: true }
|
|
})
|
|
|
|
for (const extracted of extractedEntities) {
|
|
const entity: ExtractedJSONEntity = {
|
|
id: this.generateEntityId(extracted.text, path),
|
|
name: extracted.text,
|
|
type: extracted.type,
|
|
description: node,
|
|
confidence: extracted.confidence,
|
|
path,
|
|
parentPath,
|
|
metadata: {
|
|
source: 'json',
|
|
depth,
|
|
extractedAt: Date.now()
|
|
}
|
|
}
|
|
|
|
entities.push(entity)
|
|
this.updateStats(stats, entity.type, entity.confidence, depth)
|
|
|
|
// Link to parent if exists
|
|
if (options.enableHierarchicalRelationships && parentPath && entityMap.has(parentPath)) {
|
|
const parentId = entityMap.get(parentPath)!
|
|
|
|
// Extract parent name from path
|
|
const parentName = parentPath.split('.').pop()?.replace(/\[(\d+)\]/, 'item $1') || 'parent'
|
|
const childName = entity.name
|
|
|
|
// Infer relationship type using SmartRelationshipExtractor
|
|
// Context: entity was extracted from string value within parent container
|
|
const context = `Entity "${childName}" found in text value at path ${path} within parent "${parentName}". Full text: "${node.substring(0, 200)}..."`
|
|
|
|
const inferredRelationship = await this.relationshipExtractor.infer(
|
|
parentName,
|
|
childName,
|
|
context,
|
|
{
|
|
objectType: entity.type // Pass extracted entity type as hint
|
|
}
|
|
)
|
|
|
|
relationships.push({
|
|
from: parentId,
|
|
to: entity.id,
|
|
type: inferredRelationship?.type || VerbType.RelatedTo, // Fallback to RelatedTo for text extraction
|
|
confidence: inferredRelationship?.confidence || (extracted.confidence * 0.9),
|
|
evidence: inferredRelationship?.evidence || `Found in: ${path}`
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract entity from JSON object
|
|
*/
|
|
private async extractEntityFromObject(
|
|
obj: Record<string, any>,
|
|
path: string,
|
|
parentPath: string | null,
|
|
depth: number,
|
|
options: Required<SmartJSONOptions>,
|
|
stats: SmartJSONResult['stats']
|
|
): Promise<ExtractedJSONEntity | null> {
|
|
// Find name
|
|
const name = this.findValue(obj, options.nameKeys)
|
|
if (!name) return null
|
|
|
|
// Find description
|
|
const description = this.findValue(obj, options.descriptionKeys) || name
|
|
|
|
// Find type
|
|
const typeString = this.findValue(obj, options.typeKeys)
|
|
const type = typeString ? this.mapTypeString(typeString) : this.inferTypeFromStructure(obj)
|
|
|
|
// Extract concepts if enabled
|
|
let concepts: string[] = []
|
|
if (options.enableConceptExtraction && description.length > 0) {
|
|
try {
|
|
concepts = await this.brain.extractConcepts(description, { limit: 10 })
|
|
} catch (error) {
|
|
concepts = []
|
|
}
|
|
}
|
|
|
|
const entity: ExtractedJSONEntity = {
|
|
id: this.generateEntityId(name, path),
|
|
name,
|
|
type,
|
|
description,
|
|
confidence: 0.9, // Objects with explicit structure have high confidence
|
|
path,
|
|
parentPath,
|
|
metadata: {
|
|
source: 'json',
|
|
depth,
|
|
originalObject: obj,
|
|
concepts,
|
|
extractedAt: Date.now()
|
|
}
|
|
}
|
|
|
|
this.updateStats(stats, entity.type, entity.confidence, depth)
|
|
|
|
return entity
|
|
}
|
|
|
|
/**
|
|
* Find value in object by key patterns
|
|
*/
|
|
private findValue(obj: Record<string, any>, keys: string[]): string | null {
|
|
for (const key of keys) {
|
|
if (obj[key] !== undefined && obj[key] !== null) {
|
|
const value = String(obj[key]).trim()
|
|
if (value.length > 0) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try case-insensitive match
|
|
for (const key of keys) {
|
|
const found = Object.keys(obj).find(k => k.toLowerCase() === key.toLowerCase())
|
|
if (found && obj[found] !== undefined && obj[found] !== null) {
|
|
const value = String(obj[found]).trim()
|
|
if (value.length > 0) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Infer type from JSON structure
|
|
*/
|
|
private inferTypeFromStructure(obj: Record<string, any>): NounType {
|
|
const keys = Object.keys(obj).map(k => k.toLowerCase())
|
|
|
|
// Check for common patterns
|
|
if (keys.some(k => k.includes('person') || k.includes('user') || k.includes('author'))) {
|
|
return NounType.Person
|
|
}
|
|
if (keys.some(k => k.includes('location') || k.includes('place') || k.includes('address'))) {
|
|
return NounType.Location
|
|
}
|
|
if (keys.some(k => k.includes('organization') || k.includes('company') || k.includes('org'))) {
|
|
return NounType.Organization
|
|
}
|
|
if (keys.some(k => k.includes('event') || k.includes('date') || k.includes('time'))) {
|
|
return NounType.Event
|
|
}
|
|
if (keys.some(k => k.includes('project') || k.includes('task'))) {
|
|
return NounType.Project
|
|
}
|
|
if (keys.some(k => k.includes('document') || k.includes('file') || k.includes('url'))) {
|
|
return NounType.Document
|
|
}
|
|
|
|
return NounType.Thing
|
|
}
|
|
|
|
/**
|
|
* Map type string to NounType
|
|
*/
|
|
private mapTypeString(typeString: string): NounType {
|
|
const normalized = typeString.toLowerCase().trim()
|
|
|
|
const mapping: Record<string, NounType> = {
|
|
'person': NounType.Person,
|
|
'user': NounType.Person,
|
|
'character': NounType.Person,
|
|
'place': NounType.Location,
|
|
'location': NounType.Location,
|
|
'organization': NounType.Organization,
|
|
'company': NounType.Organization,
|
|
'org': NounType.Organization,
|
|
'concept': NounType.Concept,
|
|
'idea': NounType.Concept,
|
|
'event': NounType.Event,
|
|
'product': NounType.Product,
|
|
'item': NounType.Product,
|
|
'document': NounType.Document,
|
|
'file': NounType.File,
|
|
'project': NounType.Project,
|
|
'thing': NounType.Thing
|
|
}
|
|
|
|
return mapping[normalized] || NounType.Thing
|
|
}
|
|
|
|
/**
|
|
* Generate consistent entity ID
|
|
*/
|
|
private generateEntityId(name: string, path: string): string {
|
|
const normalized = name.toLowerCase().trim().replace(/\s+/g, '_')
|
|
const pathNorm = path.replace(/[^a-zA-Z0-9]/g, '_')
|
|
return `ent_${normalized}_${pathNorm}_${Date.now()}`
|
|
}
|
|
|
|
/**
|
|
* Update statistics
|
|
*/
|
|
private updateStats(
|
|
stats: SmartJSONResult['stats'],
|
|
type: NounType,
|
|
confidence: number,
|
|
depth: number
|
|
): void {
|
|
// Track by type
|
|
const typeName = String(type)
|
|
stats.byType[typeName] = (stats.byType[typeName] || 0) + 1
|
|
|
|
// Track by confidence
|
|
if (confidence > 0.8) {
|
|
stats.byConfidence.high++
|
|
} else if (confidence >= 0.6) {
|
|
stats.byConfidence.medium++
|
|
} else {
|
|
stats.byConfidence.low++
|
|
}
|
|
}
|
|
}
|