feat: comprehensive import progress tracking for all 7 formats
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>
This commit is contained in:
parent
e7ea9c4e4b
commit
d5576ffb56
20 changed files with 3967 additions and 52 deletions
|
|
@ -34,9 +34,19 @@ export class CSVHandler extends BaseFormatHandler {
|
|||
|
||||
async process(data: Buffer | string, options: FormatHandlerOptions): Promise<ProcessedData> {
|
||||
const startTime = Date.now()
|
||||
const progressHooks = options.progressHooks
|
||||
|
||||
// Convert to buffer if string
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf-8')
|
||||
const totalBytes = buffer.length
|
||||
|
||||
// v4.5.0: Report total bytes for progress tracking
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(0)
|
||||
}
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem('Detecting CSV encoding and delimiter...')
|
||||
}
|
||||
|
||||
// Detect encoding
|
||||
const detectedEncoding = options.encoding || this.detectEncodingSafe(buffer)
|
||||
|
|
@ -45,6 +55,11 @@ export class CSVHandler extends BaseFormatHandler {
|
|||
// Detect delimiter if not specified
|
||||
const delimiter = options.csvDelimiter || this.detectDelimiter(text)
|
||||
|
||||
// v4.5.0: Report progress - parsing started
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`Parsing CSV rows (delimiter: "${delimiter}")...`)
|
||||
}
|
||||
|
||||
// Parse CSV
|
||||
const hasHeaders = options.csvHeaders !== false
|
||||
const maxRows = options.maxRows
|
||||
|
|
@ -60,23 +75,47 @@ export class CSVHandler extends BaseFormatHandler {
|
|||
cast: false // We'll do type inference ourselves
|
||||
})
|
||||
|
||||
// v4.5.0: Report bytes processed (entire file parsed)
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(totalBytes)
|
||||
}
|
||||
|
||||
// Convert to array of objects
|
||||
const data = Array.isArray(records) ? records : [records]
|
||||
|
||||
// v4.5.0: Report data extraction progress
|
||||
if (progressHooks?.onDataExtracted) {
|
||||
progressHooks.onDataExtracted(data.length, data.length)
|
||||
}
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`Extracted ${data.length} rows, inferring types...`)
|
||||
}
|
||||
|
||||
// Infer types and convert values
|
||||
const fields = data.length > 0 ? Object.keys(data[0]) : []
|
||||
const types = this.inferFieldTypes(data)
|
||||
|
||||
const convertedData = data.map(row => {
|
||||
const convertedData = data.map((row, index) => {
|
||||
const converted: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
converted[key] = this.convertValue(value, types[key] || 'string')
|
||||
}
|
||||
|
||||
// v4.5.0: Report progress every 1000 rows
|
||||
if (progressHooks?.onCurrentItem && index > 0 && index % 1000 === 0) {
|
||||
progressHooks.onCurrentItem(`Converting types: ${index}/${data.length} rows...`)
|
||||
}
|
||||
|
||||
return converted
|
||||
})
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
|
||||
// v4.5.0: Final progress update
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`CSV processing complete: ${convertedData.length} rows`)
|
||||
}
|
||||
|
||||
return {
|
||||
format: this.format,
|
||||
data: convertedData,
|
||||
|
|
|
|||
|
|
@ -21,9 +21,19 @@ export class ExcelHandler extends BaseFormatHandler {
|
|||
|
||||
async process(data: Buffer | string, options: FormatHandlerOptions): Promise<ProcessedData> {
|
||||
const startTime = Date.now()
|
||||
const progressHooks = options.progressHooks
|
||||
|
||||
// Convert to buffer if string (though Excel should always be binary)
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary')
|
||||
const totalBytes = buffer.length
|
||||
|
||||
// v4.5.0: Report start
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(0)
|
||||
}
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem('Loading Excel workbook...')
|
||||
}
|
||||
|
||||
try {
|
||||
// Read workbook
|
||||
|
|
@ -37,11 +47,25 @@ export class ExcelHandler extends BaseFormatHandler {
|
|||
// Determine which sheets to process
|
||||
const sheetsToProcess = this.getSheetsToProcess(workbook, options)
|
||||
|
||||
// v4.5.0: Report workbook loaded
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`Processing ${sheetsToProcess.length} sheets...`)
|
||||
}
|
||||
|
||||
// Extract data from sheets
|
||||
const allData: Array<Record<string, any>> = []
|
||||
const sheetMetadata: Record<string, any> = {}
|
||||
|
||||
for (const sheetName of sheetsToProcess) {
|
||||
for (let sheetIndex = 0; sheetIndex < sheetsToProcess.length; sheetIndex++) {
|
||||
const sheetName = sheetsToProcess[sheetIndex]
|
||||
|
||||
// v4.5.0: Report current sheet
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(
|
||||
`Reading sheet: ${sheetName} (${sheetIndex + 1}/${sheetsToProcess.length})`
|
||||
)
|
||||
}
|
||||
|
||||
const sheet = workbook.Sheets[sheetName]
|
||||
if (!sheet) continue
|
||||
|
||||
|
|
@ -92,6 +116,25 @@ export class ExcelHandler extends BaseFormatHandler {
|
|||
columnCount: headers.length,
|
||||
headers
|
||||
}
|
||||
|
||||
// v4.5.0: Estimate bytes processed (sheets are sequential)
|
||||
const bytesProcessed = Math.floor(((sheetIndex + 1) / sheetsToProcess.length) * totalBytes)
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(bytesProcessed)
|
||||
}
|
||||
|
||||
// v4.5.0: Report extraction progress
|
||||
if (progressHooks?.onDataExtracted) {
|
||||
progressHooks.onDataExtracted(allData.length, undefined) // Total unknown until complete
|
||||
}
|
||||
}
|
||||
|
||||
// v4.5.0: Report data extraction complete
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`Extracted ${allData.length} rows, inferring types...`)
|
||||
}
|
||||
if (progressHooks?.onDataExtracted) {
|
||||
progressHooks.onDataExtracted(allData.length, allData.length)
|
||||
}
|
||||
|
||||
// Infer types (excluding _sheet field)
|
||||
|
|
@ -99,7 +142,7 @@ export class ExcelHandler extends BaseFormatHandler {
|
|||
const types = this.inferFieldTypes(allData)
|
||||
|
||||
// Convert values to appropriate types
|
||||
const convertedData = allData.map(row => {
|
||||
const convertedData = allData.map((row, index) => {
|
||||
const converted: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (key === '_sheet') {
|
||||
|
|
@ -108,11 +151,29 @@ export class ExcelHandler extends BaseFormatHandler {
|
|||
converted[key] = this.convertValue(value, types[key] || 'string')
|
||||
}
|
||||
}
|
||||
|
||||
// v4.5.0: Report progress every 1000 rows (avoid spam)
|
||||
if (progressHooks?.onCurrentItem && index > 0 && index % 1000 === 0) {
|
||||
progressHooks.onCurrentItem(`Converting types: ${index}/${allData.length} rows...`)
|
||||
}
|
||||
|
||||
return converted
|
||||
})
|
||||
|
||||
// v4.5.0: Final progress - all bytes processed
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(totalBytes)
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
|
||||
// v4.5.0: Report completion
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(
|
||||
`Excel complete: ${sheetsToProcess.length} sheets, ${convertedData.length} rows`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
format: this.format,
|
||||
data: convertedData,
|
||||
|
|
|
|||
|
|
@ -46,9 +46,19 @@ export class PDFHandler extends BaseFormatHandler {
|
|||
|
||||
async process(data: Buffer | string, options: FormatHandlerOptions): Promise<ProcessedData> {
|
||||
const startTime = Date.now()
|
||||
const progressHooks = options.progressHooks
|
||||
|
||||
// Convert to buffer
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary')
|
||||
const totalBytes = buffer.length
|
||||
|
||||
// v4.5.0: Report start
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(0)
|
||||
}
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem('Loading PDF document...')
|
||||
}
|
||||
|
||||
try {
|
||||
// Load PDF document
|
||||
|
|
@ -64,12 +74,22 @@ export class PDFHandler extends BaseFormatHandler {
|
|||
const metadata = await pdfDoc.getMetadata()
|
||||
const numPages = pdfDoc.numPages
|
||||
|
||||
// v4.5.0: Report document loaded
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`Processing ${numPages} pages...`)
|
||||
}
|
||||
|
||||
// Extract text and structure from all pages
|
||||
const allData: Array<Record<string, any>> = []
|
||||
let totalTextLength = 0
|
||||
let detectedTables = 0
|
||||
|
||||
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
|
||||
// v4.5.0: Report current page
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(`Processing page ${pageNum} of ${numPages}`)
|
||||
}
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum)
|
||||
const textContent = await page.getTextContent()
|
||||
|
||||
|
|
@ -110,10 +130,36 @@ export class PDFHandler extends BaseFormatHandler {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// v4.5.0: Estimate bytes processed (pages are sequential)
|
||||
const bytesProcessed = Math.floor((pageNum / numPages) * totalBytes)
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(bytesProcessed)
|
||||
}
|
||||
|
||||
// v4.5.0: Report extraction progress
|
||||
if (progressHooks?.onDataExtracted) {
|
||||
progressHooks.onDataExtracted(allData.length, undefined) // Total unknown until complete
|
||||
}
|
||||
}
|
||||
|
||||
// v4.5.0: Final progress - all bytes processed
|
||||
if (progressHooks?.onBytesProcessed) {
|
||||
progressHooks.onBytesProcessed(totalBytes)
|
||||
}
|
||||
if (progressHooks?.onDataExtracted) {
|
||||
progressHooks.onDataExtracted(allData.length, allData.length)
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
|
||||
// v4.5.0: Report completion
|
||||
if (progressHooks?.onCurrentItem) {
|
||||
progressHooks.onCurrentItem(
|
||||
`PDF complete: ${numPages} pages, ${allData.length} items extracted`
|
||||
)
|
||||
}
|
||||
|
||||
// Get all unique fields (excluding metadata fields)
|
||||
const fields = allData.length > 0
|
||||
? Object.keys(allData[0]).filter(k => !k.startsWith('_'))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,34 @@
|
|||
* Handles Excel, PDF, and CSV import with intelligent extraction
|
||||
*/
|
||||
|
||||
import { ImportProgressTracker } from '../../utils/import-progress-tracker.js'
|
||||
|
||||
/**
|
||||
* Progress hooks for format handlers
|
||||
*
|
||||
* Handlers call these hooks to report progress during processing.
|
||||
* This enables real-time progress tracking for any file format.
|
||||
*/
|
||||
export interface FormatHandlerProgressHooks {
|
||||
/**
|
||||
* Report bytes processed
|
||||
* Call this as you read/parse the file
|
||||
*/
|
||||
onBytesProcessed?: (bytes: number) => void
|
||||
|
||||
/**
|
||||
* Set current processing context
|
||||
* Examples: "Processing page 5", "Reading sheet: Q2 Sales"
|
||||
*/
|
||||
onCurrentItem?: (item: string) => void
|
||||
|
||||
/**
|
||||
* Report structured data extraction progress
|
||||
* Examples: "Extracted 100 rows", "Parsed 50 paragraphs"
|
||||
*/
|
||||
onDataExtracted?: (count: number, total?: number) => void
|
||||
}
|
||||
|
||||
export interface FormatHandler {
|
||||
/**
|
||||
* Format name (e.g., 'csv', 'xlsx', 'pdf')
|
||||
|
|
@ -58,6 +86,18 @@ export interface FormatHandlerOptions {
|
|||
|
||||
/** Whether to stream large files */
|
||||
streaming?: boolean
|
||||
|
||||
/**
|
||||
* Progress hooks (v4.5.0)
|
||||
* Handlers call these to report progress during processing
|
||||
*/
|
||||
progressHooks?: FormatHandlerProgressHooks
|
||||
|
||||
/**
|
||||
* Total file size in bytes (v4.5.0)
|
||||
* Used for progress percentage calculation
|
||||
*/
|
||||
totalBytes?: number
|
||||
}
|
||||
|
||||
export interface ProcessedData {
|
||||
|
|
|
|||
|
|
@ -2039,9 +2039,27 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* groupBy: 'type', // Organize by entity type
|
||||
* preserveSource: true, // Keep original file
|
||||
*
|
||||
* // Progress tracking
|
||||
* onProgress: (p) => console.log(p.message)
|
||||
* // Progress tracking (v4.5.0 - STANDARDIZED FOR ALL 7 FORMATS!)
|
||||
* onProgress: (p) => {
|
||||
* console.log(`[${p.stage}] ${p.message}`)
|
||||
* console.log(`Entities: ${p.entities || 0}, Rels: ${p.relationships || 0}`)
|
||||
* if (p.throughput) console.log(`Rate: ${p.throughput.toFixed(1)}/sec`)
|
||||
* }
|
||||
* })
|
||||
* // THIS SAME HANDLER WORKS FOR CSV, PDF, Excel, JSON, Markdown, YAML, DOCX!
|
||||
* ```
|
||||
*
|
||||
* @example Universal Progress Handler (v4.5.0)
|
||||
* ```typescript
|
||||
* // ONE handler for ALL 7 formats - no format-specific code needed!
|
||||
* const universalProgress = (p) => {
|
||||
* updateUI(p.stage, p.message, p.entities, p.relationships)
|
||||
* }
|
||||
*
|
||||
* await brain.import(csvBuffer, { onProgress: universalProgress })
|
||||
* await brain.import(pdfBuffer, { onProgress: universalProgress })
|
||||
* await brain.import(excelBuffer, { onProgress: universalProgress })
|
||||
* // Works for JSON, Markdown, YAML, DOCX too!
|
||||
* ```
|
||||
*
|
||||
* @example Performance Tuning (Large Files)
|
||||
|
|
@ -2066,6 +2084,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
*
|
||||
* @see {@link https://brainy.dev/docs/api/import API Documentation}
|
||||
* @see {@link https://brainy.dev/docs/guides/migrating-to-v4 Migration Guide}
|
||||
* @see {@link https://brainy.dev/docs/guides/standard-import-progress Standard Progress API (v4.5.0)}
|
||||
*
|
||||
* @remarks
|
||||
* **⚠️ Breaking Changes from v3.x:**
|
||||
|
|
@ -2098,7 +2117,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
async import(
|
||||
source: Buffer | string | object,
|
||||
options?: {
|
||||
format?: 'excel' | 'pdf' | 'csv' | 'json' | 'markdown'
|
||||
format?: 'excel' | 'pdf' | 'csv' | 'json' | 'markdown' | 'yaml' | 'docx'
|
||||
vfsPath?: string
|
||||
groupBy?: 'type' | 'sheet' | 'flat' | 'custom'
|
||||
customGrouping?: (entity: any) => string
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ interface AddOptions extends CoreOptions {
|
|||
id?: string
|
||||
metadata?: string
|
||||
type?: string
|
||||
confidence?: string
|
||||
weight?: string
|
||||
}
|
||||
|
||||
interface SearchOptions extends CoreOptions {
|
||||
|
|
@ -35,6 +37,7 @@ interface SearchOptions extends CoreOptions {
|
|||
via?: string
|
||||
explain?: boolean
|
||||
includeRelations?: boolean
|
||||
includeVfs?: boolean
|
||||
fusion?: string
|
||||
vectorWeight?: string
|
||||
graphWeight?: string
|
||||
|
|
@ -163,24 +166,40 @@ export const coreCommands = {
|
|||
}
|
||||
|
||||
// Add with explicit type
|
||||
const result = await brain.add({
|
||||
const addParams: any = {
|
||||
data: text,
|
||||
type: nounType,
|
||||
metadata
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// v4.3.x: Add confidence and weight if provided
|
||||
if (options.confidence) {
|
||||
addParams.confidence = parseFloat(options.confidence)
|
||||
}
|
||||
if (options.weight) {
|
||||
addParams.weight = parseFloat(options.weight)
|
||||
}
|
||||
|
||||
const result = await brain.add(addParams)
|
||||
|
||||
spinner.succeed('Added successfully')
|
||||
|
||||
|
||||
if (!options.json) {
|
||||
console.log(chalk.green(`✓ Added with ID: ${result}`))
|
||||
if (options.type) {
|
||||
console.log(chalk.dim(` Type: ${options.type}`))
|
||||
}
|
||||
if (options.confidence) {
|
||||
console.log(chalk.dim(` Confidence: ${options.confidence}`))
|
||||
}
|
||||
if (options.weight) {
|
||||
console.log(chalk.dim(` Weight: ${options.weight}`))
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
console.log(chalk.dim(` Metadata: ${JSON.stringify(metadata)}`))
|
||||
}
|
||||
} else {
|
||||
formatOutput({ id: result, metadata }, options)
|
||||
formatOutput({ id: result, metadata, confidence: addParams.confidence, weight: addParams.weight }, options)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (spinner) spinner.fail('Failed to add data')
|
||||
|
|
@ -328,6 +347,11 @@ export const coreCommands = {
|
|||
searchParams.includeRelations = true
|
||||
}
|
||||
|
||||
// Include VFS files (v4.4.0 - find excludes VFS by default)
|
||||
if (options.includeVfs) {
|
||||
searchParams.includeVFS = true
|
||||
}
|
||||
|
||||
// Triple Intelligence Fusion - custom weighting
|
||||
if (options.fusion || options.vectorWeight || options.graphWeight || options.fieldWeight) {
|
||||
searchParams.fusion = {
|
||||
|
|
|
|||
|
|
@ -149,23 +149,28 @@ export const importCommands = {
|
|||
options.recursive = answer.recursive
|
||||
}
|
||||
|
||||
spinner = ora('Initializing neural import...').start()
|
||||
spinner = ora('Initializing import...').start()
|
||||
const brain = getBrainy()
|
||||
|
||||
// Load UniversalImportAPI
|
||||
const { UniversalImportAPI } = await import('../../api/UniversalImportAPI.js')
|
||||
const universalImport = new UniversalImportAPI(brain)
|
||||
await universalImport.init()
|
||||
|
||||
spinner.text = 'Processing import...'
|
||||
|
||||
// Handle different source types
|
||||
let result: any
|
||||
|
||||
if (isURL) {
|
||||
// URL import
|
||||
// URL import - fetch first
|
||||
spinner.text = `Fetching from ${source}...`
|
||||
result = await universalImport.importFromURL(source)
|
||||
const response = await fetch(source)
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
|
||||
spinner.text = 'Importing...'
|
||||
result = await brain.import(buffer, {
|
||||
enableNeuralExtraction: options.extractEntities !== false,
|
||||
enableRelationshipInference: options.detectRelationships !== false,
|
||||
enableConceptExtraction: options.extractConcepts || false,
|
||||
confidenceThreshold: options.confidence ? parseFloat(options.confidence) : 0.6,
|
||||
onProgress: options.progress ? (p) => {
|
||||
spinner.text = `${p.message}${p.entities ? ` (${p.entities} entities)` : ''}`
|
||||
} : undefined
|
||||
})
|
||||
} else if (isDirectory) {
|
||||
// Directory import - process each file
|
||||
spinner.text = `Scanning directory: ${source}...`
|
||||
|
|
@ -202,34 +207,45 @@ export const importCommands = {
|
|||
|
||||
spinner.succeed(`Found ${files.length} files`)
|
||||
|
||||
// Process files in batches
|
||||
const batchSize = options.batchSize ? parseInt(options.batchSize) : 100
|
||||
// Process files with progress
|
||||
let totalEntities = 0
|
||||
let totalRelationships = 0
|
||||
let filesProcessed = 0
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize)
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (options.progress) {
|
||||
spinner = ora(`[${filesProcessed + 1}/${files.length}] Importing ${file}...`).start()
|
||||
}
|
||||
|
||||
if (options.progress) {
|
||||
spinner = ora(`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)} (${filesProcessed}/${files.length} files)...`).start()
|
||||
}
|
||||
const fileResult = await brain.import(file, {
|
||||
enableNeuralExtraction: options.extractEntities !== false,
|
||||
enableRelationshipInference: options.detectRelationships !== false,
|
||||
enableConceptExtraction: options.extractConcepts || false,
|
||||
confidenceThreshold: options.confidence ? parseFloat(options.confidence) : 0.6,
|
||||
onProgress: options.progress ? (p) => {
|
||||
spinner.text = `[${filesProcessed + 1}/${files.length}] ${p.message}`
|
||||
} : undefined
|
||||
})
|
||||
|
||||
for (const file of batch) {
|
||||
try {
|
||||
const fileResult = await universalImport.importFromFile(file)
|
||||
totalEntities += fileResult.stats.entitiesCreated
|
||||
totalRelationships += fileResult.stats.relationshipsCreated
|
||||
filesProcessed++
|
||||
} catch (error: any) {
|
||||
if (options.verbose) {
|
||||
console.log(chalk.yellow(`⚠️ Failed to import ${file}: ${error.message}`))
|
||||
}
|
||||
totalEntities += fileResult.entities.length
|
||||
totalRelationships += fileResult.relationships.length
|
||||
filesProcessed++
|
||||
|
||||
if (options.progress) {
|
||||
spinner.succeed(`[${filesProcessed}/${files.length}] ${file}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (options.verbose) {
|
||||
if (spinner) spinner.fail(`Failed: ${file}`)
|
||||
console.log(chalk.yellow(`⚠️ ${error.message}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
entities: [],
|
||||
relationships: [],
|
||||
stats: {
|
||||
filesProcessed,
|
||||
entitiesCreated: totalEntities,
|
||||
|
|
@ -238,10 +254,22 @@ export const importCommands = {
|
|||
}
|
||||
}
|
||||
|
||||
spinner.succeed('Directory import complete')
|
||||
spinner = ora().succeed(`Directory import complete: ${filesProcessed} files`)
|
||||
} else {
|
||||
// File import
|
||||
result = await universalImport.importFromFile(source)
|
||||
// File import with progress
|
||||
result = await brain.import(source, {
|
||||
format: options.format as any,
|
||||
enableNeuralExtraction: options.extractEntities !== false,
|
||||
enableRelationshipInference: options.detectRelationships !== false,
|
||||
enableConceptExtraction: options.extractConcepts || false,
|
||||
confidenceThreshold: options.confidence ? parseFloat(options.confidence) : 0.6,
|
||||
onProgress: options.progress ? (p) => {
|
||||
spinner.text = `${p.message}${p.entities ? ` (${p.entities} entities, ${p.relationships || 0} relationships)` : ''}`
|
||||
if (p.throughput && p.eta) {
|
||||
spinner.text += ` - ${p.throughput.toFixed(1)}/sec, ETA: ${Math.round(p.eta / 1000)}s`
|
||||
}
|
||||
} : undefined
|
||||
})
|
||||
}
|
||||
|
||||
spinner.succeed('Import complete')
|
||||
|
|
@ -323,18 +351,26 @@ export const importCommands = {
|
|||
console.log(chalk.cyan('\n📊 Import Results:\n'))
|
||||
|
||||
console.log(chalk.bold('Statistics:'))
|
||||
console.log(` Entities created: ${chalk.green(result.stats.entitiesCreated)}`)
|
||||
const entitiesCount = result.stats?.entitiesCreated || result.entities?.length || 0
|
||||
const relationshipsCount = result.stats?.relationshipsCreated || result.relationships?.length || 0
|
||||
|
||||
if (result.stats.relationshipsCreated > 0) {
|
||||
console.log(` Relationships created: ${chalk.green(result.stats.relationshipsCreated)}`)
|
||||
console.log(` Entities created: ${chalk.green(entitiesCount)}`)
|
||||
|
||||
if (relationshipsCount > 0) {
|
||||
console.log(` Relationships created: ${chalk.green(relationshipsCount)}`)
|
||||
}
|
||||
|
||||
if (result.stats.filesProcessed) {
|
||||
if (result.stats?.filesProcessed) {
|
||||
console.log(` Files processed: ${chalk.green(result.stats.filesProcessed)}`)
|
||||
}
|
||||
|
||||
console.log(` Average confidence: ${chalk.yellow((result.stats.averageConfidence * 100).toFixed(1))}%`)
|
||||
console.log(` Processing time: ${chalk.dim(result.stats.processingTimeMs)}ms`)
|
||||
if (result.stats?.averageConfidence) {
|
||||
console.log(` Average confidence: ${chalk.yellow((result.stats.averageConfidence * 100).toFixed(1))}%`)
|
||||
}
|
||||
|
||||
if (result.stats?.processingTimeMs) {
|
||||
console.log(` Processing time: ${chalk.dim(result.stats.processingTimeMs)}ms`)
|
||||
}
|
||||
|
||||
if (options.verbose && result.entities && result.entities.length > 0) {
|
||||
console.log(chalk.bold('\n📦 Imported Entities (first 10):'))
|
||||
|
|
|
|||
|
|
@ -169,10 +169,44 @@ export class SmartCSVImporter {
|
|||
}
|
||||
|
||||
// Parse CSV using existing handler
|
||||
// v4.5.0: Pass progress hooks to handler for file parsing progress
|
||||
const processedData = await this.csvHandler.process(buffer, {
|
||||
...options,
|
||||
csvDelimiter: opts.csvDelimiter,
|
||||
csvHeaders: opts.csvHeaders
|
||||
csvHeaders: opts.csvHeaders,
|
||||
totalBytes: buffer.length,
|
||||
progressHooks: {
|
||||
onBytesProcessed: (bytes) => {
|
||||
// Handler reports bytes processed during parsing
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: 0,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: `Parsing CSV (${Math.round((bytes / buffer.length) * 100)}%)`
|
||||
})
|
||||
},
|
||||
onCurrentItem: (message) => {
|
||||
// Handler reports current processing step
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: 0,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: message
|
||||
})
|
||||
},
|
||||
onDataExtracted: (count, total) => {
|
||||
// Handler reports rows extracted
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: total || count,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: `Extracted ${count} rows`
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const rows = processedData.data
|
||||
|
||||
|
|
|
|||
|
|
@ -187,12 +187,26 @@ export class SmartDOCXImporter {
|
|||
await this.init()
|
||||
}
|
||||
|
||||
// v4.5.0: Report parsing start
|
||||
options.onProgress?.({
|
||||
processed: 0,
|
||||
entities: 0,
|
||||
relationships: 0
|
||||
})
|
||||
|
||||
// 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 })
|
||||
|
||||
// v4.5.0: Report parsing complete
|
||||
options.onProgress?.({
|
||||
processed: 0,
|
||||
entities: 0,
|
||||
relationships: 0
|
||||
})
|
||||
|
||||
// Process the document
|
||||
const result = await this.extractFromContent(
|
||||
textResult.value,
|
||||
|
|
|
|||
|
|
@ -184,7 +184,43 @@ export class SmartExcelImporter {
|
|||
}
|
||||
|
||||
// Parse Excel using existing handler
|
||||
const processedData = await this.excelHandler.process(buffer, options)
|
||||
// v4.5.0: Pass progress hooks to handler for file parsing progress
|
||||
const processedData = await this.excelHandler.process(buffer, {
|
||||
...options,
|
||||
totalBytes: buffer.length,
|
||||
progressHooks: {
|
||||
onBytesProcessed: (bytes) => {
|
||||
// Handler reports bytes processed during parsing
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: 0,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: `Parsing Excel (${Math.round((bytes / buffer.length) * 100)}%)`
|
||||
})
|
||||
},
|
||||
onCurrentItem: (message) => {
|
||||
// Handler reports current processing step (e.g., "Reading sheet: Sales (1/3)")
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: 0,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: message
|
||||
})
|
||||
},
|
||||
onDataExtracted: (count, total) => {
|
||||
// Handler reports rows extracted
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: total || count,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: `Extracted ${count} rows from Excel`
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const rows = processedData.data
|
||||
|
||||
if (rows.length === 0) {
|
||||
|
|
|
|||
|
|
@ -167,6 +167,13 @@ export class SmartJSONImporter {
|
|||
...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') {
|
||||
|
|
@ -179,6 +186,13 @@ export class SmartJSONImporter {
|
|||
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[] = []
|
||||
|
|
@ -214,6 +228,13 @@ export class SmartJSONImporter {
|
|||
}
|
||||
)
|
||||
|
||||
// v4.5.0: Report completion
|
||||
opts.onProgress({
|
||||
processed: nodesProcessed,
|
||||
entities: entities.length,
|
||||
relationships: relationships.length
|
||||
})
|
||||
|
||||
return {
|
||||
nodesProcessed,
|
||||
entitiesExtracted: entities.length,
|
||||
|
|
|
|||
|
|
@ -174,9 +174,25 @@ export class SmartMarkdownImporter {
|
|||
...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>()
|
||||
|
|
@ -202,10 +218,20 @@ export class SmartMarkdownImporter {
|
|||
})
|
||||
}
|
||||
|
||||
// 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: sections.reduce((sum, s) => sum + s.entities.length, 0),
|
||||
relationshipsInferred: sections.reduce((sum, s) => sum + s.relationships.length, 0),
|
||||
entitiesExtracted: totalEntities,
|
||||
relationshipsInferred: totalRelationships,
|
||||
sections,
|
||||
entityMap,
|
||||
processingTime: Date.now() - startTime,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,43 @@ export class SmartPDFImporter {
|
|||
}
|
||||
|
||||
// Parse PDF using existing handler
|
||||
const processedData = await this.pdfHandler.process(buffer, options)
|
||||
// v4.5.0: Pass progress hooks to handler for file parsing progress
|
||||
const processedData = await this.pdfHandler.process(buffer, {
|
||||
...options,
|
||||
totalBytes: buffer.length,
|
||||
progressHooks: {
|
||||
onBytesProcessed: (bytes) => {
|
||||
// Handler reports bytes processed during parsing
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: 0,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: `Parsing PDF (${Math.round((bytes / buffer.length) * 100)}%)`
|
||||
})
|
||||
},
|
||||
onCurrentItem: (message) => {
|
||||
// Handler reports current processing step (e.g., "Processing page 5 of 23")
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: 0,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: message
|
||||
})
|
||||
},
|
||||
onDataExtracted: (count, total) => {
|
||||
// Handler reports items extracted (paragraphs + tables)
|
||||
opts.onProgress?.({
|
||||
processed: 0,
|
||||
total: total || count,
|
||||
entities: 0,
|
||||
relationships: 0,
|
||||
phase: `Extracted ${count} items from PDF`
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const data = processedData.data
|
||||
const pdfMetadata = processedData.metadata.additionalInfo?.pdfMetadata || {}
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,13 @@ export class SmartYAMLImporter {
|
|||
): Promise<SmartYAMLResult> {
|
||||
const startTime = Date.now()
|
||||
|
||||
// v4.5.0: Report parsing start
|
||||
options.onProgress?.({
|
||||
processed: 0,
|
||||
entities: 0,
|
||||
relationships: 0
|
||||
})
|
||||
|
||||
// Parse YAML to JavaScript object
|
||||
const yamlString = typeof yamlContent === 'string'
|
||||
? yamlContent
|
||||
|
|
@ -171,6 +178,13 @@ export class SmartYAMLImporter {
|
|||
throw new Error(`Failed to parse YAML: ${error.message}`)
|
||||
}
|
||||
|
||||
// v4.5.0: Report parsing complete
|
||||
options.onProgress?.({
|
||||
processed: 0,
|
||||
entities: 0,
|
||||
relationships: 0
|
||||
})
|
||||
|
||||
// Process as JSON-like structure
|
||||
const result = await this.extractFromData(data, options)
|
||||
result.processingTime = Date.now() - startTime
|
||||
|
|
|
|||
|
|
@ -385,6 +385,138 @@ export interface BatchResult<T = any> {
|
|||
duration: number // Time taken in ms
|
||||
}
|
||||
|
||||
// ============= Import Progress (v4.5.0) =============
|
||||
|
||||
/**
|
||||
* Import stage enumeration
|
||||
*/
|
||||
export type ImportStage =
|
||||
| 'detecting' // Detecting file format
|
||||
| 'reading' // Reading file from disk/network
|
||||
| 'parsing' // Parsing file structure (CSV rows, PDF pages, Excel sheets)
|
||||
| 'extracting' // Extracting entities using AI
|
||||
| 'indexing' // Creating graph nodes and relationships
|
||||
| 'completing' // Final cleanup and stats
|
||||
|
||||
/**
|
||||
* Overall import status
|
||||
*/
|
||||
export type ImportStatus =
|
||||
| 'starting' // Initializing import
|
||||
| 'processing' // Actively importing
|
||||
| 'completing' // Finalizing
|
||||
| 'done' // Complete
|
||||
|
||||
/**
|
||||
* Comprehensive import progress information
|
||||
*
|
||||
* Provides multi-dimensional progress tracking:
|
||||
* - Bytes processed (always deterministic)
|
||||
* - Entities extracted and indexed
|
||||
* - Stage-specific progress
|
||||
* - Time estimates
|
||||
* - Performance metrics
|
||||
*
|
||||
* @since v4.5.0
|
||||
*/
|
||||
export interface ImportProgress {
|
||||
// Overall Progress
|
||||
overall_progress: number // 0-100 weighted estimate across all stages
|
||||
overall_status: ImportStatus // High-level status
|
||||
|
||||
// Current Stage
|
||||
stage: ImportStage // What's happening now
|
||||
stage_progress: number // 0-100 within current stage (0 if unknown)
|
||||
stage_message: string // Human-readable: "Extracting entities from PDF..."
|
||||
|
||||
// Bytes (Always Available - most deterministic metric)
|
||||
bytes_processed: number // Bytes read/processed so far
|
||||
total_bytes: number // Total file size (0 if streaming/unknown)
|
||||
bytes_percentage: number // Convenience: bytes_processed / total_bytes * 100
|
||||
bytes_per_second?: number // Processing rate (relevant during parsing)
|
||||
|
||||
// Entities (Available when extraction starts)
|
||||
entities_extracted: number // Entities found during AI extraction
|
||||
entities_indexed: number // Entities added to Brainy graph
|
||||
entities_per_second?: number // Entities/sec (relevant during extraction/indexing)
|
||||
estimated_total_entities?: number // Estimated final count
|
||||
estimation_confidence?: number // 0-1 confidence in estimation
|
||||
|
||||
// Timing
|
||||
elapsed_ms: number // Time since import started
|
||||
estimated_remaining_ms?: number // Estimated time remaining
|
||||
estimated_total_ms?: number // Estimated total time
|
||||
|
||||
// Context (helps users understand what's happening)
|
||||
current_item?: string // "Processing page 5 of 23"
|
||||
current_file?: string // "Sheet: Q2 Sales Data"
|
||||
file_number?: number // 3 (when importing multiple files)
|
||||
total_files?: number // 10
|
||||
|
||||
// Performance Metrics (for debugging/optimization)
|
||||
metrics?: {
|
||||
parsing_rate_mbps?: number // MB/s during parsing
|
||||
extraction_rate_entities_per_sec?: number // Entities/s during extraction
|
||||
indexing_rate_entities_per_sec?: number // Entities/s during indexing
|
||||
memory_usage_mb?: number // Current memory usage
|
||||
peak_memory_mb?: number // Peak memory usage
|
||||
}
|
||||
|
||||
// Backwards Compatibility (for legacy code)
|
||||
current: number // Alias for entities_indexed
|
||||
total: number // Alias for estimated_total_entities or 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Import progress callback - backwards compatible
|
||||
*
|
||||
* Supports both legacy (current, total) and new (ImportProgress object) signatures
|
||||
*/
|
||||
export type ImportProgressCallback =
|
||||
| ((progress: ImportProgress) => void)
|
||||
| ((current: number, total: number) => void)
|
||||
|
||||
/**
|
||||
* Stage weight configuration for overall progress calculation
|
||||
*
|
||||
* These weights reflect the typical time distribution across stages.
|
||||
* Extraction is typically the slowest stage (60% of time).
|
||||
*/
|
||||
export interface StageWeights {
|
||||
detecting: number // Default: 0.01 (1%)
|
||||
reading: number // Default: 0.05 (5%)
|
||||
parsing: number // Default: 0.10 (10%)
|
||||
extracting: number // Default: 0.60 (60% - slowest!)
|
||||
indexing: number // Default: 0.20 (20%)
|
||||
completing: number // Default: 0.04 (4%)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import result statistics
|
||||
*/
|
||||
export interface ImportStats {
|
||||
graphNodesCreated: number // Entities added to graph
|
||||
graphEdgesCreated: number // Relationships created
|
||||
vfsFilesCreated: number // VFS files created
|
||||
duration: number // Total time in ms
|
||||
bytesProcessed: number // Total bytes read
|
||||
averageRate: number // Average entities/sec
|
||||
peakMemoryMB?: number // Peak memory usage
|
||||
}
|
||||
|
||||
/**
|
||||
* Import operation result
|
||||
*/
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
stats: ImportStats
|
||||
errors?: Array<{
|
||||
stage: ImportStage
|
||||
message: string
|
||||
error?: any
|
||||
}>
|
||||
}
|
||||
|
||||
// ============= Advanced Operations =============
|
||||
|
||||
/**
|
||||
|
|
|
|||
541
src/utils/import-progress-tracker.ts
Normal file
541
src/utils/import-progress-tracker.ts
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
/**
|
||||
* Import Progress Tracker (v4.5.0)
|
||||
*
|
||||
* Comprehensive progress tracking for imports with:
|
||||
* - Multi-dimensional progress (bytes, entities, stages, timing)
|
||||
* - Smart estimation (entity count, time remaining)
|
||||
* - Stage-specific metrics (bytes/sec vs entities/sec)
|
||||
* - Throttled callbacks (avoid spam)
|
||||
* - Weighted overall progress
|
||||
*
|
||||
* @since v4.5.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ImportProgress,
|
||||
ImportStage,
|
||||
ImportStatus,
|
||||
StageWeights,
|
||||
ImportProgressCallback
|
||||
} from '../types/brainy.types.js'
|
||||
|
||||
/**
|
||||
* Default stage weights (reflect typical time distribution)
|
||||
*/
|
||||
const DEFAULT_STAGE_WEIGHTS: StageWeights = {
|
||||
detecting: 0.01, // 1% - very fast
|
||||
reading: 0.05, // 5% - reading file
|
||||
parsing: 0.10, // 10% - parsing structure
|
||||
extracting: 0.60, // 60% - AI extraction (slowest!)
|
||||
indexing: 0.20, // 20% - creating graph
|
||||
completing: 0.04 // 4% - cleanup
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage ordering for progress calculation
|
||||
*/
|
||||
const STAGE_ORDER: ImportStage[] = [
|
||||
'detecting',
|
||||
'reading',
|
||||
'parsing',
|
||||
'extracting',
|
||||
'indexing',
|
||||
'completing'
|
||||
]
|
||||
|
||||
/**
|
||||
* Progress tracker for imports
|
||||
*/
|
||||
export class ImportProgressTracker {
|
||||
// Configuration
|
||||
private readonly stageWeights: StageWeights
|
||||
private readonly throttleMs: number
|
||||
private readonly callback?: ImportProgressCallback
|
||||
|
||||
// Tracking state
|
||||
private startTime: number
|
||||
private lastEmitTime: number = 0
|
||||
private currentStage: ImportStage = 'detecting'
|
||||
private completedStages: Set<ImportStage> = new Set()
|
||||
|
||||
// Metrics
|
||||
private totalBytes: number = 0
|
||||
private bytesProcessed: number = 0
|
||||
private entitiesExtracted: number = 0
|
||||
private entitiesIndexed: number = 0
|
||||
private parseStartTime?: number
|
||||
private extractStartTime?: number
|
||||
private indexStartTime?: number
|
||||
|
||||
// Estimation
|
||||
private lastBytesCheckpoint: number = 0
|
||||
private lastBytesCheckpointTime: number = 0
|
||||
private lastEntitiesCheckpoint: number = 0
|
||||
private lastEntitiesCheckpointTime: number = 0
|
||||
|
||||
// Context
|
||||
private currentItem?: string
|
||||
private currentFile?: string
|
||||
private fileNumber?: number
|
||||
private totalFiles?: number
|
||||
|
||||
// Memory tracking
|
||||
private peakMemoryMB: number = 0
|
||||
|
||||
constructor(options: {
|
||||
totalBytes?: number
|
||||
stageWeights?: Partial<StageWeights>
|
||||
throttleMs?: number
|
||||
callback?: ImportProgressCallback
|
||||
} = {}) {
|
||||
this.stageWeights = { ...DEFAULT_STAGE_WEIGHTS, ...options.stageWeights }
|
||||
this.throttleMs = options.throttleMs ?? 100 // 100ms default
|
||||
this.callback = options.callback
|
||||
this.totalBytes = options.totalBytes ?? 0
|
||||
this.startTime = Date.now()
|
||||
this.lastBytesCheckpointTime = this.startTime
|
||||
this.lastEntitiesCheckpointTime = this.startTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Set total file size (if known later)
|
||||
*/
|
||||
setTotalBytes(bytes: number): void {
|
||||
this.totalBytes = bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current stage
|
||||
*/
|
||||
setStage(stage: ImportStage, message?: string): void {
|
||||
// Mark previous stage as complete
|
||||
if (this.currentStage !== stage) {
|
||||
this.completedStages.add(this.currentStage)
|
||||
}
|
||||
|
||||
this.currentStage = stage
|
||||
if (message) {
|
||||
this.setStageMessage(message)
|
||||
}
|
||||
|
||||
// Track stage start times
|
||||
const now = Date.now()
|
||||
switch (stage) {
|
||||
case 'parsing':
|
||||
this.parseStartTime = now
|
||||
break
|
||||
case 'extracting':
|
||||
this.extractStartTime = now
|
||||
break
|
||||
case 'indexing':
|
||||
this.indexStartTime = now
|
||||
break
|
||||
}
|
||||
|
||||
// Force emit on stage change
|
||||
this.emit(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bytes processed
|
||||
*/
|
||||
updateBytes(bytes: number): void {
|
||||
this.bytesProcessed = bytes
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment bytes processed
|
||||
*/
|
||||
addBytes(bytes: number): void {
|
||||
this.bytesProcessed += bytes
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entities extracted
|
||||
*/
|
||||
updateEntitiesExtracted(count: number): void {
|
||||
this.entitiesExtracted = count
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment entities extracted
|
||||
*/
|
||||
addEntitiesExtracted(count: number): void {
|
||||
this.entitiesExtracted += count
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entities indexed
|
||||
*/
|
||||
updateEntitiesIndexed(count: number): void {
|
||||
this.entitiesIndexed = count
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment entities indexed
|
||||
*/
|
||||
addEntitiesIndexed(count: number): void {
|
||||
this.entitiesIndexed += count
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set context information
|
||||
*/
|
||||
setContext(context: {
|
||||
currentItem?: string
|
||||
currentFile?: string
|
||||
fileNumber?: number
|
||||
totalFiles?: number
|
||||
}): void {
|
||||
if (context.currentItem !== undefined) this.currentItem = context.currentItem
|
||||
if (context.currentFile !== undefined) this.currentFile = context.currentFile
|
||||
if (context.fileNumber !== undefined) this.fileNumber = context.fileNumber
|
||||
if (context.totalFiles !== undefined) this.totalFiles = context.totalFiles
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set stage message
|
||||
*/
|
||||
private setStageMessage(message: string): void {
|
||||
this.currentItem = message
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stage progress (0-100 within current stage)
|
||||
*/
|
||||
private calculateStageProgress(): number {
|
||||
switch (this.currentStage) {
|
||||
case 'detecting':
|
||||
case 'completing':
|
||||
// These are quick, assume 100% once started
|
||||
return 100
|
||||
|
||||
case 'reading':
|
||||
case 'parsing':
|
||||
// Use bytes as proxy for progress
|
||||
if (this.totalBytes === 0) return 0
|
||||
return Math.min(100, (this.bytesProcessed / this.totalBytes) * 100)
|
||||
|
||||
case 'extracting':
|
||||
// Extraction progress is hard to estimate (AI is unpredictable)
|
||||
// We can't reliably say % complete, so return 0
|
||||
return 0
|
||||
|
||||
case 'indexing':
|
||||
// If we have estimated total entities, use that
|
||||
if (this.entitiesExtracted > 0) {
|
||||
return Math.min(100, (this.entitiesIndexed / this.entitiesExtracted) * 100)
|
||||
}
|
||||
return 0
|
||||
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall progress (0-100 weighted across all stages)
|
||||
*/
|
||||
private calculateOverallProgress(): number {
|
||||
// Calculate progress of completed stages
|
||||
let completedWeight = 0
|
||||
for (const stage of this.completedStages) {
|
||||
completedWeight += this.stageWeights[stage]
|
||||
}
|
||||
|
||||
// Calculate progress of current stage
|
||||
const stageProgress = this.calculateStageProgress()
|
||||
const currentStageContribution = this.stageWeights[this.currentStage] * (stageProgress / 100)
|
||||
|
||||
// Overall = completed stages + current stage contribution
|
||||
const overall = (completedWeight + currentStageContribution) * 100
|
||||
|
||||
return Math.min(100, Math.max(0, overall))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bytes per second
|
||||
*/
|
||||
private calculateBytesPerSecond(): number | undefined {
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.lastBytesCheckpointTime
|
||||
|
||||
// Need at least 1 second of data
|
||||
if (elapsed < 1000) return undefined
|
||||
|
||||
const bytesDelta = this.bytesProcessed - this.lastBytesCheckpoint
|
||||
const bytesPerSec = (bytesDelta / elapsed) * 1000
|
||||
|
||||
// Update checkpoint
|
||||
this.lastBytesCheckpoint = this.bytesProcessed
|
||||
this.lastBytesCheckpointTime = now
|
||||
|
||||
return bytesPerSec > 0 ? bytesPerSec : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate entities per second
|
||||
*/
|
||||
private calculateEntitiesPerSecond(): number | undefined {
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.lastEntitiesCheckpointTime
|
||||
|
||||
// Need at least 1 second of data
|
||||
if (elapsed < 1000) return undefined
|
||||
|
||||
// Use appropriate counter based on stage
|
||||
const currentCount = this.currentStage === 'indexing'
|
||||
? this.entitiesIndexed
|
||||
: this.entitiesExtracted
|
||||
|
||||
const entitiesDelta = currentCount - this.lastEntitiesCheckpoint
|
||||
const entitiesPerSec = (entitiesDelta / elapsed) * 1000
|
||||
|
||||
// Update checkpoint
|
||||
this.lastEntitiesCheckpoint = currentCount
|
||||
this.lastEntitiesCheckpointTime = now
|
||||
|
||||
return entitiesPerSec > 0 ? entitiesPerSec : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total entities
|
||||
*/
|
||||
private estimateTotalEntities(): { count: number, confidence: number } | undefined {
|
||||
// Only estimate if we've processed some bytes and extracted some entities
|
||||
if (this.bytesProcessed === 0 || this.entitiesExtracted === 0 || this.totalBytes === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Estimate based on entities per byte
|
||||
const bytesPercentage = this.bytesProcessed / this.totalBytes
|
||||
const estimatedTotal = Math.ceil(this.entitiesExtracted / bytesPercentage)
|
||||
|
||||
// Confidence increases with more data
|
||||
const confidence = Math.min(0.95, bytesPercentage)
|
||||
|
||||
return { count: estimatedTotal, confidence }
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate remaining time
|
||||
*/
|
||||
private estimateRemainingTime(): number | undefined {
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.startTime
|
||||
|
||||
// Need at least 5 seconds of data for reasonable estimate
|
||||
if (elapsed < 5000) return undefined
|
||||
|
||||
const overallProgress = this.calculateOverallProgress()
|
||||
if (overallProgress === 0) return undefined
|
||||
|
||||
// Estimate total time based on current progress
|
||||
const estimatedTotalMs = (elapsed / overallProgress) * 100
|
||||
const remainingMs = estimatedTotalMs - elapsed
|
||||
|
||||
return remainingMs > 0 ? remainingMs : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current memory usage
|
||||
*/
|
||||
private getCurrentMemoryMB(): number | undefined {
|
||||
if (typeof process === 'undefined' || !process.memoryUsage) return undefined
|
||||
|
||||
const usage = process.memoryUsage()
|
||||
const currentMB = usage.heapUsed / 1024 / 1024
|
||||
|
||||
// Track peak
|
||||
this.peakMemoryMB = Math.max(this.peakMemoryMB, currentMB)
|
||||
|
||||
return currentMB
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete progress object
|
||||
*/
|
||||
private buildProgress(): ImportProgress {
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.startTime
|
||||
|
||||
const stageProgress = this.calculateStageProgress()
|
||||
const overallProgress = this.calculateOverallProgress()
|
||||
const bytesPerSec = this.calculateBytesPerSecond()
|
||||
const entitiesPerSec = this.calculateEntitiesPerSecond()
|
||||
const entityEstimate = this.estimateTotalEntities()
|
||||
const remainingMs = this.estimateRemainingTime()
|
||||
const currentMemoryMB = this.getCurrentMemoryMB()
|
||||
|
||||
// Determine overall status
|
||||
let overallStatus: ImportStatus
|
||||
if (overallProgress === 0) {
|
||||
overallStatus = 'starting'
|
||||
} else if (overallProgress === 100) {
|
||||
overallStatus = 'done'
|
||||
} else if (this.currentStage === 'completing') {
|
||||
overallStatus = 'completing'
|
||||
} else {
|
||||
overallStatus = 'processing'
|
||||
}
|
||||
|
||||
// Stage message
|
||||
let stageMessage: string
|
||||
if (this.currentItem) {
|
||||
stageMessage = this.currentItem
|
||||
} else {
|
||||
// Default messages
|
||||
switch (this.currentStage) {
|
||||
case 'detecting':
|
||||
stageMessage = 'Detecting file format...'
|
||||
break
|
||||
case 'reading':
|
||||
stageMessage = 'Reading file...'
|
||||
break
|
||||
case 'parsing':
|
||||
stageMessage = 'Parsing file structure...'
|
||||
break
|
||||
case 'extracting':
|
||||
stageMessage = 'Extracting entities using AI...'
|
||||
break
|
||||
case 'indexing':
|
||||
stageMessage = 'Creating graph nodes...'
|
||||
break
|
||||
case 'completing':
|
||||
stageMessage = 'Finalizing import...'
|
||||
break
|
||||
default:
|
||||
stageMessage = 'Processing...'
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate bytes percentage
|
||||
const bytesPercentage = this.totalBytes > 0
|
||||
? (this.bytesProcessed / this.totalBytes) * 100
|
||||
: 0
|
||||
|
||||
// Build metrics object
|
||||
const metrics: ImportProgress['metrics'] = {
|
||||
parsing_rate_mbps: this.currentStage === 'parsing' && bytesPerSec
|
||||
? bytesPerSec / 1_000_000
|
||||
: undefined,
|
||||
extraction_rate_entities_per_sec: this.currentStage === 'extracting'
|
||||
? entitiesPerSec
|
||||
: undefined,
|
||||
indexing_rate_entities_per_sec: this.currentStage === 'indexing'
|
||||
? entitiesPerSec
|
||||
: undefined,
|
||||
memory_usage_mb: currentMemoryMB,
|
||||
peak_memory_mb: this.peakMemoryMB > 0 ? this.peakMemoryMB : undefined
|
||||
}
|
||||
|
||||
const progress: ImportProgress = {
|
||||
// Overall
|
||||
overall_progress: overallProgress,
|
||||
overall_status: overallStatus,
|
||||
|
||||
// Stage
|
||||
stage: this.currentStage,
|
||||
stage_progress: stageProgress,
|
||||
stage_message: stageMessage,
|
||||
|
||||
// Bytes
|
||||
bytes_processed: this.bytesProcessed,
|
||||
total_bytes: this.totalBytes,
|
||||
bytes_percentage: bytesPercentage,
|
||||
bytes_per_second: bytesPerSec,
|
||||
|
||||
// Entities
|
||||
entities_extracted: this.entitiesExtracted,
|
||||
entities_indexed: this.entitiesIndexed,
|
||||
entities_per_second: entitiesPerSec,
|
||||
estimated_total_entities: entityEstimate?.count,
|
||||
estimation_confidence: entityEstimate?.confidence,
|
||||
|
||||
// Timing
|
||||
elapsed_ms: elapsed,
|
||||
estimated_remaining_ms: remainingMs,
|
||||
estimated_total_ms: remainingMs ? elapsed + remainingMs : undefined,
|
||||
|
||||
// Context
|
||||
current_item: this.currentItem,
|
||||
current_file: this.currentFile,
|
||||
file_number: this.fileNumber,
|
||||
total_files: this.totalFiles,
|
||||
|
||||
// Metrics
|
||||
metrics,
|
||||
|
||||
// Backwards compatibility
|
||||
current: this.entitiesIndexed,
|
||||
total: entityEstimate?.count ?? 0
|
||||
}
|
||||
|
||||
return progress
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit progress (throttled)
|
||||
*/
|
||||
private emit(force: boolean = false): void {
|
||||
if (!this.callback) return
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastEmit = now - this.lastEmitTime
|
||||
|
||||
// Throttle unless forced
|
||||
if (!force && timeSinceLastEmit < this.throttleMs) {
|
||||
return
|
||||
}
|
||||
|
||||
const progress = this.buildProgress()
|
||||
|
||||
// Handle both callback types (legacy and new)
|
||||
if (this.callback.length === 2) {
|
||||
// Legacy callback: (current, total) => void
|
||||
;(this.callback as (current: number, total: number) => void)(
|
||||
progress.current,
|
||||
progress.total
|
||||
)
|
||||
} else {
|
||||
// New callback: (progress: ImportProgress) => void
|
||||
;(this.callback as (progress: ImportProgress) => void)(progress)
|
||||
}
|
||||
|
||||
this.lastEmitTime = now
|
||||
}
|
||||
|
||||
/**
|
||||
* Force emit (for completion or critical updates)
|
||||
*/
|
||||
forceEmit(): void {
|
||||
this.emit(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current progress (without emitting)
|
||||
*/
|
||||
getProgress(): ImportProgress {
|
||||
return this.buildProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as complete
|
||||
*/
|
||||
complete(): ImportProgress {
|
||||
this.currentStage = 'completing'
|
||||
this.completedStages.add('completing')
|
||||
|
||||
const progress = this.buildProgress()
|
||||
this.forceEmit()
|
||||
|
||||
return progress
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue