feat: add ImageHandler with EXIF extraction and comprehensive MIME detection (v5.2.0)

Implements Phase 1.5 (Comprehensive MIME Type Detection) and adds built-in image processing support to IntelligentImportAugmentation.

**New Features:**
- ImageHandler: Extracts image metadata (dimensions, format, color space) using sharp
- EXIF extraction: Camera data, GPS, timestamps using exifr library
- Support for JPEG, PNG, WebP, GIF, TIFF, BMP, SVG, HEIC, AVIF formats
- MimeTypeDetector: Unified MIME type detection with magic byte support
- FormatDetector: Enhanced with image format detection via MIME + magic bytes

**Architecture Fixes:**
- Fixed brain.import() augmentation pipeline integration (src/brainy.ts:3140-3154)
- Added parameter spreading for ImportSource objects to enable augmentation access
- Fixed metadata propagation through ImportCoordinator to final results
- Added augmentation data check in ImportCoordinator.extract()

**Integration:**
- ImageHandler registered as built-in handler alongside CSV, Excel, PDF
- Images import as 'media' entities with 'image' subtype
- Full metadata preserved in knowledge graph entities
- Configuration options: enableImage, extractEXIF, imageDefaults

**Test Coverage:**
- 15 integration tests (image-import.test.ts) - 100% passing
- 27 unit tests (image-handler.test.ts) - 100% passing
- Format detection tests for all supported image types
- Error handling and resilience tests

**Breaking Changes:** None - backward compatible

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
David Snelling 2025-11-03 14:06:17 -08:00
parent 6345f87eb2
commit 1874b77896
26 changed files with 5079 additions and 434 deletions

View file

@ -2,14 +2,17 @@
* Format Detector
*
* Unified format detection for all import types using:
* - MIME type detection (via MimeTypeDetector service)
* - Magic byte signatures (PDF, Excel, images)
* - File extensions
* - File extensions (via MimeTypeDetector)
* - Content analysis (JSON, Markdown, CSV)
*
* NO MOCKS - Production-ready implementation
*/
export type SupportedFormat = 'excel' | 'pdf' | 'csv' | 'json' | 'markdown' | 'yaml' | 'docx'
import { mimeDetector } from '../vfs/MimeTypeDetector.js'
export type SupportedFormat = 'excel' | 'pdf' | 'csv' | 'json' | 'markdown' | 'yaml' | 'docx' | 'image'
export interface DetectionResult {
format: SupportedFormat
@ -38,36 +41,84 @@ export class FormatDetector {
/**
* Detect format from file path
*
* Uses MimeTypeDetector (2000+ types) and maps to SupportedFormat
*/
detectFromPath(path: string): DetectionResult | null {
const ext = this.getExtension(path).toLowerCase()
// Get MIME type from MimeTypeDetector
const mimeType = mimeDetector.detectMimeType(path)
const extensionMap: Record<string, SupportedFormat> = {
'.xlsx': 'excel',
'.xls': 'excel',
'.pdf': 'pdf',
'.csv': 'csv',
'.json': 'json',
'.md': 'markdown',
'.markdown': 'markdown',
'.yaml': 'yaml',
'.yml': 'yaml',
'.docx': 'docx',
'.doc': 'docx'
}
const format = extensionMap[ext]
// Map MIME type to SupportedFormat
const format = this.mimeTypeToFormat(mimeType)
if (format) {
const ext = this.getExtension(path)
return {
format,
confidence: 0.9,
evidence: [`File extension: ${ext}`]
evidence: [`MIME type: ${mimeType}`, `File extension: ${ext}`]
}
}
return null
}
/**
* Map MIME type to SupportedFormat
*
* Supports all variations of Excel, PDF, CSV, JSON, Markdown, YAML, DOCX
*/
private mimeTypeToFormat(mimeType: string): SupportedFormat | null {
// Excel formats (Office Open XML + legacy)
if (
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
mimeType === 'application/vnd.ms-excel' ||
mimeType === 'application/vnd.ms-excel.sheet.macroEnabled.12' ||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.template'
) {
return 'excel'
}
// PDF
if (mimeType === 'application/pdf') {
return 'pdf'
}
// CSV
if (mimeType === 'text/csv') {
return 'csv'
}
// JSON
if (mimeType === 'application/json') {
return 'json'
}
// Markdown
if (mimeType === 'text/markdown' || mimeType === 'text/x-markdown') {
return 'markdown'
}
// YAML
if (mimeType === 'text/yaml' || mimeType === 'text/x-yaml' || mimeType === 'application/x-yaml') {
return 'yaml'
}
// Word documents (Office Open XML)
if (
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
mimeType === 'application/msword'
) {
return 'docx'
}
// Images (v5.2.0: ImageHandler support)
if (mimeType.startsWith('image/')) {
return 'image'
}
return null
}
/**
* Detect format from string content
*/
@ -155,6 +206,55 @@ export class FormatDetector {
}
}
// Image formats (v5.2.0)
// JPEG: FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return {
format: 'image',
confidence: 1.0,
evidence: ['JPEG magic bytes: FF D8 FF']
}
}
// PNG: 89 50 4E 47
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return {
format: 'image',
confidence: 1.0,
evidence: ['PNG magic bytes: 89 50 4E 47']
}
}
// GIF: 47 49 46 38
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
return {
format: 'image',
confidence: 1.0,
evidence: ['GIF magic bytes: GIF8']
}
}
// BMP: 42 4D
if (buffer[0] === 0x42 && buffer[1] === 0x4D) {
return {
format: 'image',
confidence: 1.0,
evidence: ['BMP magic bytes: BM']
}
}
// WebP: RIFF....WEBP
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer.length >= 12) {
const webpCheck = buffer.toString('utf8', 8, 12)
if (webpCheck === 'WEBP') {
return {
format: 'image',
confidence: 1.0,
evidence: ['WebP magic bytes: RIFF...WEBP']
}
}
}
return null
}

View file

@ -722,6 +722,38 @@ export class ImportCoordinator {
format: SupportedFormat,
options: ImportOptions
): Promise<any> {
// v5.2.0: Check if IntelligentImportAugmentation already extracted data
if ((options as any)._intelligentImport && (options as any)._extractedData) {
const extractedData = (options as any)._extractedData
// Convert extracted data to ExtractedRow format
const rows = extractedData.map((item: any) => ({
entity: {
id: item.id || `entity-${Date.now()}-${Math.random()}`,
name: item.name || item.type || 'Unnamed',
type: item.type || 'unknown',
description: item.description || '',
confidence: 1.0,
metadata: item.metadata || {}
},
relatedEntities: [],
relationships: []
}))
return {
rows,
entities: extractedData,
relationships: [],
metadata: (options as any)._metadata?.intelligentImport || {},
stats: {
byType: {},
byConfidence: {}
},
rowsProcessed: extractedData.length,
entitiesExtracted: extractedData.length,
relationshipsInferred: 0,
processingTime: 0
}
}
const extractOptions = {
enableNeuralExtraction: options.enableNeuralExtraction !== false,
enableRelationshipInference: options.enableRelationshipInference !== false,
@ -792,6 +824,42 @@ export class ImportCoordinator {
: Buffer.from(JSON.stringify(source.data))
return await this.docxImporter.extract(docxBuffer, extractOptions)
case 'image':
// v5.2.0: Images are handled by IntelligentImportAugmentation
// If we reach here, augmentation didn't process it - return minimal result
const imageName = source.filename || 'image'
const imageId = `image-${Date.now()}`
return {
rows: [{
entity: {
id: imageId,
name: imageName,
type: 'media' as any,
description: '',
confidence: 1.0,
metadata: { subtype: 'image' }
},
relatedEntities: [],
relationships: []
}],
entities: [{
id: imageId,
name: imageName,
type: 'media',
metadata: { subtype: 'image' }
}],
relationships: [],
metadata: {},
stats: {
byType: { media: 1 },
byConfidence: { high: 1 }
},
rowsProcessed: 1,
entitiesExtracted: 1,
relationshipsInferred: 0,
processingTime: 0
}
default:
throw new Error(`Unsupported format: ${format}`)
}
@ -811,14 +879,14 @@ export class ImportCoordinator {
},
trackingContext?: TrackingContext // v4.10.0: Import/project tracking
): Promise<{
entities: Array<{ id: string; name: string; type: NounType; vfsPath?: string }>
entities: Array<{ id: string; name: string; type: NounType; vfsPath?: string; metadata?: Record<string, any> }>
relationships: Array<{ id: string; from: string; to: string; type: VerbType }>
merged: number
newEntities: number
documentEntity?: string
provenanceCount?: number
}> {
const entities: Array<{ id: string; name: string; type: NounType; vfsPath?: string }> = []
const entities: Array<{ id: string; name: string; type: NounType; vfsPath?: string; metadata?: Record<string, any> }> = []
const relationships: Array<{ id: string; from: string; to: string; type: VerbType }> = []
let mergedCount = 0
let newCount = 0
@ -969,7 +1037,8 @@ export class ImportCoordinator {
id: entityId,
name: entity.name,
type: entity.type,
vfsPath: vfsFile?.path
vfsPath: vfsFile?.path,
metadata: entity.metadata // v5.2.0: Include metadata in return (for ImageHandler, etc)
})
newCount++
}
@ -1071,7 +1140,8 @@ export class ImportCoordinator {
id: entityId,
name: entity.name,
type: entity.type,
vfsPath: vfsFile?.path
vfsPath: vfsFile?.path,
metadata: entity.metadata // v5.2.0: Include metadata in return (for ImageHandler, etc)
})
// ============================================