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>
616 lines
16 KiB
TypeScript
616 lines
16 KiB
TypeScript
/**
|
|
* Smart Markdown Importer
|
|
*
|
|
* Extracts entities and relationships from Markdown files using:
|
|
* - Heading structure for entity organization
|
|
* - Link relationships
|
|
* - NeuralEntityExtractor for entity extraction from text
|
|
* - Section-based grouping
|
|
*
|
|
* 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 SmartMarkdownOptions {
|
|
/** Enable neural entity extraction from text */
|
|
enableNeuralExtraction?: boolean
|
|
|
|
/** Enable relationship inference */
|
|
enableRelationshipInference?: boolean
|
|
|
|
/** Enable concept extraction for tagging */
|
|
enableConceptExtraction?: boolean
|
|
|
|
/** Confidence threshold for entities (0-1) */
|
|
confidenceThreshold?: number
|
|
|
|
/** Extract code blocks as entities */
|
|
extractCodeBlocks?: boolean
|
|
|
|
/** Minimum section text length to process */
|
|
minSectionLength?: number
|
|
|
|
/** Group by heading level */
|
|
groupByHeading?: boolean
|
|
|
|
/** Progress callback */
|
|
onProgress?: (stats: {
|
|
processed: number
|
|
total: number
|
|
entities: number
|
|
relationships: number
|
|
}) => void
|
|
}
|
|
|
|
export interface MarkdownSection {
|
|
/** Section ID */
|
|
id: string
|
|
|
|
/** Heading text (if this section has a heading) */
|
|
heading: string | null
|
|
|
|
/** Heading level (1-6) */
|
|
level: number
|
|
|
|
/** Section content */
|
|
content: string
|
|
|
|
/** Entities extracted from this section */
|
|
entities: Array<{
|
|
id: string
|
|
name: string
|
|
type: NounType
|
|
description: string
|
|
confidence: number
|
|
metadata: Record<string, any>
|
|
}>
|
|
|
|
/** Links found in this section */
|
|
links: Array<{
|
|
text: string
|
|
url: string
|
|
type: 'internal' | 'external'
|
|
}>
|
|
|
|
/** Code blocks in this section */
|
|
codeBlocks?: Array<{
|
|
language: string
|
|
code: string
|
|
}>
|
|
|
|
/** Relationships */
|
|
relationships: Array<{
|
|
from: string
|
|
to: string
|
|
type: VerbType
|
|
confidence: number
|
|
evidence: string
|
|
}>
|
|
|
|
/** Concepts */
|
|
concepts?: string[]
|
|
}
|
|
|
|
export interface SmartMarkdownResult {
|
|
/** Total sections processed */
|
|
sectionsProcessed: number
|
|
|
|
/** Entities extracted */
|
|
entitiesExtracted: number
|
|
|
|
/** Relationships inferred */
|
|
relationshipsInferred: number
|
|
|
|
/** All extracted sections */
|
|
sections: MarkdownSection[]
|
|
|
|
/** Entity ID mapping (name -> ID) */
|
|
entityMap: Map<string, string>
|
|
|
|
/** Processing time in ms */
|
|
processingTime: number
|
|
|
|
/** Extraction statistics */
|
|
stats: {
|
|
byType: Record<string, number>
|
|
byHeadingLevel: Record<number, number>
|
|
byConfidence: {
|
|
high: number // > 0.8
|
|
medium: number // 0.6-0.8
|
|
low: number // < 0.6
|
|
}
|
|
linksFound: number
|
|
codeBlocksFound: number
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SmartMarkdownImporter - Extracts structured knowledge from Markdown files
|
|
*/
|
|
export class SmartMarkdownImporter {
|
|
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 Markdown content
|
|
*/
|
|
async extract(
|
|
markdown: string,
|
|
options: SmartMarkdownOptions = {}
|
|
): Promise<SmartMarkdownResult> {
|
|
const startTime = Date.now()
|
|
|
|
// Set defaults
|
|
const opts: Required<SmartMarkdownOptions> = {
|
|
enableNeuralExtraction: true,
|
|
enableRelationshipInference: true,
|
|
enableConceptExtraction: true,
|
|
confidenceThreshold: 0.6,
|
|
extractCodeBlocks: true,
|
|
minSectionLength: 50,
|
|
groupByHeading: true,
|
|
onProgress: () => {},
|
|
...options
|
|
}
|
|
|
|
// v4.5.0: Report parsing start
|
|
opts.onProgress({
|
|
processed: 0,
|
|
total: 0,
|
|
entities: 0,
|
|
relationships: 0
|
|
})
|
|
|
|
// Parse markdown into sections
|
|
const parsedSections = this.parseMarkdown(markdown, opts)
|
|
|
|
// v4.5.0: Report parsing complete
|
|
opts.onProgress({
|
|
processed: 0,
|
|
total: parsedSections.length,
|
|
entities: 0,
|
|
relationships: 0
|
|
})
|
|
|
|
// Process each section
|
|
const sections: MarkdownSection[] = []
|
|
const entityMap = new Map<string, string>()
|
|
const stats = {
|
|
byType: {} as Record<string, number>,
|
|
byHeadingLevel: {} as Record<number, number>,
|
|
byConfidence: { high: 0, medium: 0, low: 0 },
|
|
linksFound: 0,
|
|
codeBlocksFound: 0
|
|
}
|
|
|
|
for (let i = 0; i < parsedSections.length; i++) {
|
|
const parsed = parsedSections[i]
|
|
|
|
const section = await this.processSection(parsed, opts, stats, entityMap)
|
|
sections.push(section)
|
|
|
|
opts.onProgress({
|
|
processed: i + 1,
|
|
total: parsedSections.length,
|
|
entities: sections.reduce((sum, s) => sum + s.entities.length, 0),
|
|
relationships: sections.reduce((sum, s) => sum + s.relationships.length, 0)
|
|
})
|
|
}
|
|
|
|
// v4.5.0: Report completion
|
|
const totalEntities = sections.reduce((sum, s) => sum + s.entities.length, 0)
|
|
const totalRelationships = sections.reduce((sum, s) => sum + s.relationships.length, 0)
|
|
opts.onProgress({
|
|
processed: sections.length,
|
|
total: sections.length,
|
|
entities: totalEntities,
|
|
relationships: totalRelationships
|
|
})
|
|
|
|
return {
|
|
sectionsProcessed: sections.length,
|
|
entitiesExtracted: totalEntities,
|
|
relationshipsInferred: totalRelationships,
|
|
sections,
|
|
entityMap,
|
|
processingTime: Date.now() - startTime,
|
|
stats
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse markdown into sections
|
|
*/
|
|
private parseMarkdown(
|
|
markdown: string,
|
|
options: SmartMarkdownOptions
|
|
): Array<{
|
|
id: string
|
|
heading: string | null
|
|
level: number
|
|
content: string
|
|
}> {
|
|
const lines = markdown.split('\n')
|
|
const sections: Array<{
|
|
id: string
|
|
heading: string | null
|
|
level: number
|
|
content: string
|
|
}> = []
|
|
|
|
let currentSection: {
|
|
heading: string | null
|
|
level: number
|
|
lines: string[]
|
|
} = {
|
|
heading: null,
|
|
level: 0,
|
|
lines: []
|
|
}
|
|
|
|
let sectionCounter = 0
|
|
|
|
for (const line of lines) {
|
|
// Check for heading
|
|
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/)
|
|
|
|
if (headingMatch) {
|
|
// Save current section if it has content
|
|
if (currentSection.lines.length > 0) {
|
|
const content = currentSection.lines.join('\n').trim()
|
|
if (content.length >= (options.minSectionLength || 50)) {
|
|
sections.push({
|
|
id: `section_${sectionCounter++}`,
|
|
heading: currentSection.heading,
|
|
level: currentSection.level,
|
|
content
|
|
})
|
|
}
|
|
}
|
|
|
|
// Start new section
|
|
const level = headingMatch[1].length
|
|
const heading = headingMatch[2].trim()
|
|
currentSection = {
|
|
heading,
|
|
level,
|
|
lines: []
|
|
}
|
|
} else {
|
|
currentSection.lines.push(line)
|
|
}
|
|
}
|
|
|
|
// Add last section
|
|
if (currentSection.lines.length > 0) {
|
|
const content = currentSection.lines.join('\n').trim()
|
|
if (content.length >= (options.minSectionLength || 50)) {
|
|
sections.push({
|
|
id: `section_${sectionCounter}`,
|
|
heading: currentSection.heading,
|
|
level: currentSection.level,
|
|
content
|
|
})
|
|
}
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
/**
|
|
* Process a single section
|
|
*/
|
|
private async processSection(
|
|
parsed: {
|
|
id: string
|
|
heading: string | null
|
|
level: number
|
|
content: string
|
|
},
|
|
options: SmartMarkdownOptions,
|
|
stats: SmartMarkdownResult['stats'],
|
|
entityMap: Map<string, string>
|
|
): Promise<MarkdownSection> {
|
|
// Track heading level
|
|
stats.byHeadingLevel[parsed.level] = (stats.byHeadingLevel[parsed.level] || 0) + 1
|
|
|
|
// Extract links
|
|
const links = this.extractLinks(parsed.content)
|
|
stats.linksFound += links.length
|
|
|
|
// Extract code blocks
|
|
const codeBlocks = options.extractCodeBlocks ? this.extractCodeBlocks(parsed.content) : []
|
|
stats.codeBlocksFound += codeBlocks.length
|
|
|
|
// Remove code blocks from content for entity extraction
|
|
const contentWithoutCode = this.removeCodeBlocks(parsed.content)
|
|
|
|
// Extract entities
|
|
let extractedEntities: ExtractedEntity[] = []
|
|
if (options.enableNeuralExtraction && contentWithoutCode.length > 0) {
|
|
extractedEntities = await this.extractor.extract(contentWithoutCode, {
|
|
confidence: options.confidenceThreshold || 0.6,
|
|
neuralMatching: true,
|
|
cache: { enabled: true }
|
|
})
|
|
}
|
|
|
|
// If section has a heading, treat it as an entity
|
|
if (parsed.heading) {
|
|
const headingEntity: ExtractedEntity = {
|
|
text: parsed.heading,
|
|
type: this.inferTypeFromHeading(parsed.heading, parsed.level),
|
|
confidence: 0.9,
|
|
position: { start: 0, end: parsed.heading.length }
|
|
}
|
|
extractedEntities.unshift(headingEntity)
|
|
}
|
|
|
|
// Extract concepts
|
|
let concepts: string[] = []
|
|
if (options.enableConceptExtraction && contentWithoutCode.length > 0) {
|
|
try {
|
|
concepts = await this.brain.extractConcepts(contentWithoutCode, { limit: 10 })
|
|
} catch (error) {
|
|
concepts = []
|
|
}
|
|
}
|
|
|
|
// Create entity objects
|
|
const entities = extractedEntities.map(e => {
|
|
const entityId = this.generateEntityId(e.text, parsed.id)
|
|
entityMap.set(e.text.toLowerCase(), entityId)
|
|
|
|
// Update statistics
|
|
this.updateStats(stats, e.type, e.confidence)
|
|
|
|
return {
|
|
id: entityId,
|
|
name: e.text,
|
|
type: e.type,
|
|
description: contentWithoutCode.substring(0, 200),
|
|
confidence: e.confidence,
|
|
metadata: {
|
|
source: 'markdown',
|
|
section: parsed.id,
|
|
heading: parsed.heading,
|
|
level: parsed.level,
|
|
extractedAt: Date.now()
|
|
}
|
|
}
|
|
})
|
|
|
|
// Infer relationships
|
|
const relationships: MarkdownSection['relationships'] = []
|
|
|
|
// Link-based relationships
|
|
if (options.enableRelationshipInference) {
|
|
for (const link of links) {
|
|
// Find entity that might be the source
|
|
const sourceEntity = entities.find(e =>
|
|
contentWithoutCode.toLowerCase().includes(e.name.toLowerCase())
|
|
)
|
|
|
|
if (sourceEntity) {
|
|
// Create relationship to linked entity
|
|
const targetId = this.generateEntityId(link.text, 'link')
|
|
relationships.push({
|
|
from: sourceEntity.id,
|
|
to: link.text,
|
|
type: VerbType.References,
|
|
confidence: 0.85,
|
|
evidence: `Markdown link: [${link.text}](${link.url})`
|
|
})
|
|
}
|
|
}
|
|
|
|
// Entity proximity-based relationships
|
|
for (let i = 0; i < entities.length; i++) {
|
|
for (let j = i + 1; j < entities.length; j++) {
|
|
const entity1 = entities[i]
|
|
const entity2 = entities[j]
|
|
|
|
if (this.entitiesAreRelated(contentWithoutCode, entity1.name, entity2.name)) {
|
|
const verbType = await this.inferRelationship(
|
|
entity1.name,
|
|
entity2.name,
|
|
contentWithoutCode
|
|
)
|
|
|
|
relationships.push({
|
|
from: entity1.id,
|
|
to: entity2.id,
|
|
type: verbType,
|
|
confidence: Math.min(entity1.confidence, entity2.confidence) * 0.8,
|
|
evidence: `Co-occurrence in section: ${parsed.heading || parsed.id}`
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: parsed.id,
|
|
heading: parsed.heading,
|
|
level: parsed.level,
|
|
content: parsed.content,
|
|
entities,
|
|
links,
|
|
codeBlocks,
|
|
relationships,
|
|
concepts
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract markdown links
|
|
*/
|
|
private extractLinks(content: string): Array<{
|
|
text: string
|
|
url: string
|
|
type: 'internal' | 'external'
|
|
}> {
|
|
const links: Array<{ text: string, url: string, type: 'internal' | 'external' }> = []
|
|
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
|
|
|
|
let match
|
|
while ((match = linkRegex.exec(content)) !== null) {
|
|
const text = match[1]
|
|
const url = match[2]
|
|
const type = url.startsWith('http') ? 'external' : 'internal'
|
|
links.push({ text, url, type })
|
|
}
|
|
|
|
return links
|
|
}
|
|
|
|
/**
|
|
* Extract code blocks
|
|
*/
|
|
private extractCodeBlocks(content: string): Array<{
|
|
language: string
|
|
code: string
|
|
}> {
|
|
const codeBlocks: Array<{ language: string, code: string }> = []
|
|
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g
|
|
|
|
let match
|
|
while ((match = codeBlockRegex.exec(content)) !== null) {
|
|
const language = match[1] || 'text'
|
|
const code = match[2].trim()
|
|
codeBlocks.push({ language, code })
|
|
}
|
|
|
|
return codeBlocks
|
|
}
|
|
|
|
/**
|
|
* Remove code blocks from content
|
|
*/
|
|
private removeCodeBlocks(content: string): string {
|
|
return content.replace(/```[\s\S]*?```/g, '')
|
|
}
|
|
|
|
/**
|
|
* Infer type from heading
|
|
*/
|
|
private inferTypeFromHeading(heading: string, level: number): NounType {
|
|
const lower = heading.toLowerCase()
|
|
|
|
if (lower.includes('person') || lower.includes('people') || lower.includes('author') || lower.includes('user')) {
|
|
return NounType.Person
|
|
}
|
|
if (lower.includes('location') || lower.includes('place')) {
|
|
return NounType.Location
|
|
}
|
|
if (lower.includes('organization') || lower.includes('company')) {
|
|
return NounType.Organization
|
|
}
|
|
if (lower.includes('event')) {
|
|
return NounType.Event
|
|
}
|
|
if (lower.includes('project')) {
|
|
return NounType.Project
|
|
}
|
|
if (lower.includes('document') || lower.includes('file')) {
|
|
return NounType.Document
|
|
}
|
|
|
|
// Top-level headings are often concepts/topics
|
|
if (level <= 2) {
|
|
return NounType.Concept
|
|
}
|
|
|
|
return NounType.Thing
|
|
}
|
|
|
|
/**
|
|
* Check if entities are related by proximity
|
|
*/
|
|
private entitiesAreRelated(text: string, entity1: string, entity2: string): boolean {
|
|
const lowerText = text.toLowerCase()
|
|
const index1 = lowerText.indexOf(entity1.toLowerCase())
|
|
const index2 = lowerText.indexOf(entity2.toLowerCase())
|
|
|
|
if (index1 === -1 || index2 === -1) return false
|
|
|
|
return Math.abs(index1 - index2) < 300
|
|
}
|
|
|
|
/**
|
|
* Infer relationship type from context using SmartRelationshipExtractor
|
|
*/
|
|
private async inferRelationship(
|
|
fromEntity: string,
|
|
toEntity: string,
|
|
context: string,
|
|
fromType?: NounType,
|
|
toType?: NounType
|
|
): Promise<VerbType> {
|
|
// Use SmartRelationshipExtractor for robust relationship classification
|
|
const result = await this.relationshipExtractor.infer(
|
|
fromEntity,
|
|
toEntity,
|
|
context,
|
|
{
|
|
subjectType: fromType,
|
|
objectType: toType
|
|
}
|
|
)
|
|
|
|
// Return inferred type or fallback to RelatedTo
|
|
return result?.type || VerbType.RelatedTo
|
|
}
|
|
|
|
/**
|
|
* Generate consistent entity ID
|
|
*/
|
|
private generateEntityId(name: string, section: string): string {
|
|
const normalized = name.toLowerCase().trim().replace(/\s+/g, '_')
|
|
const sectionNorm = section.replace(/\s+/g, '_')
|
|
return `ent_${normalized}_${sectionNorm}_${Date.now()}`
|
|
}
|
|
|
|
/**
|
|
* Update statistics
|
|
*/
|
|
private updateStats(
|
|
stats: SmartMarkdownResult['stats'],
|
|
type: NounType,
|
|
confidence: 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++
|
|
}
|
|
}
|
|
}
|