Progressive intervals adjust dynamically based on current entity count (not total), making them work for both known and unknown totals. **Key Features:** - 0-999 entities: Flush every 100 (frequent early updates for UX) - 1K-9.9K: Flush every 1000 (balanced performance) - 10K+: Flush every 5000 (minimal overhead ~0.3%) **Benefits:** - Works with known totals (file imports) - Works with unknown totals (streaming APIs, database cursors) - Adapts automatically as import grows - Zero configuration required **Implementation:** - Replaced adaptive intervals (requires total count) with progressive - Added interval transition logging for observability - Enhanced documentation to highlight engineering sophistication - Final flush with statistics reporting **Documentation:** - Added "Engineering Insight" section showcasing advanced approach - Updated all interval references from "adaptive" to "progressive" - Added comprehensive examples in streaming-imports.md Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
772 lines
22 KiB
TypeScript
772 lines
22 KiB
TypeScript
/**
|
|
* SmartExtractor - Unified entity type extraction using ensemble of neural signals
|
|
*
|
|
* PRODUCTION-READY: Single orchestration class for all entity type classification
|
|
*
|
|
* Design Philosophy:
|
|
* - Simplicity over complexity (KISS principle)
|
|
* - One class instead of multiple strategy layers
|
|
* - Clear execution path for debugging
|
|
* - Comprehensive format intelligence built-in
|
|
*
|
|
* Ensemble Architecture:
|
|
* - ExactMatchSignal (40%) - Explicit patterns and exact keywords
|
|
* - EmbeddingSignal (35%) - Neural similarity with type embeddings
|
|
* - PatternSignal (20%) - Regex patterns and naming conventions
|
|
* - ContextSignal (5%) - Relationship-based inference
|
|
*
|
|
* Format Intelligence:
|
|
* Supports 7 major formats with automatic hint extraction:
|
|
* - Excel (.xlsx): Column headers, sheet names, "Related Terms" detection
|
|
* - CSV (.csv): Header row patterns, naming conventions
|
|
* - PDF (.pdf): Form field names and labels
|
|
* - YAML (.yaml, .yml): Semantic key names
|
|
* - DOCX (.docx): Heading levels and structure
|
|
* - JSON (.json): Field name patterns
|
|
* - Markdown (.md): Heading hierarchy
|
|
*
|
|
* Performance:
|
|
* - Parallel signal execution (~15ms total)
|
|
* - LRU caching for hot entities
|
|
* - Confidence boosting when signals agree
|
|
* - Graceful degradation on errors
|
|
*/
|
|
|
|
import type { Brainy } from '../brainy.js'
|
|
import type { NounType } from '../types/graphTypes.js'
|
|
import { ExactMatchSignal } from './signals/ExactMatchSignal.js'
|
|
import { PatternSignal } from './signals/PatternSignal.js'
|
|
import { EmbeddingSignal } from './signals/EmbeddingSignal.js'
|
|
import { ContextSignal } from './signals/ContextSignal.js'
|
|
import type { TypeSignal as ExactTypeSignal } from './signals/ExactMatchSignal.js'
|
|
import type { TypeSignal as PatternTypeSignal } from './signals/PatternSignal.js'
|
|
import type { TypeSignal as EmbeddingTypeSignal } from './signals/EmbeddingSignal.js'
|
|
import type { TypeSignal as ContextTypeSignal } from './signals/ContextSignal.js'
|
|
|
|
/**
|
|
* Extraction result with full traceability
|
|
*/
|
|
export interface ExtractionResult {
|
|
type: NounType
|
|
confidence: number
|
|
source: 'ensemble' | 'exact-match' | 'pattern' | 'embedding' | 'context'
|
|
evidence: string
|
|
metadata?: {
|
|
signalResults?: Array<{
|
|
signal: string
|
|
type: NounType
|
|
confidence: number
|
|
weight: number
|
|
}>
|
|
agreementBoost?: number
|
|
formatHints?: string[]
|
|
formatContext?: FormatContext
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format context for classification
|
|
*/
|
|
export interface FormatContext {
|
|
format?: 'excel' | 'csv' | 'pdf' | 'yaml' | 'docx' | 'json' | 'markdown'
|
|
columnHeader?: string // Excel/CSV column header
|
|
fieldName?: string // PDF form field name or JSON field
|
|
yamlKey?: string // YAML key name
|
|
headingLevel?: number // DOCX/Markdown heading level
|
|
sheetName?: string // Excel sheet name
|
|
metadata?: Record<string, any>
|
|
}
|
|
|
|
/**
|
|
* Options for SmartExtractor
|
|
*/
|
|
export interface SmartExtractorOptions {
|
|
minConfidence?: number // Minimum confidence threshold (default: 0.60)
|
|
enableFormatHints?: boolean // Use format-specific hints (default: true)
|
|
enableEnsemble?: boolean // Use ensemble vs single best signal (default: true)
|
|
cacheSize?: number // LRU cache size (default: 2000)
|
|
weights?: { // Custom signal weights (must sum to 1.0)
|
|
exactMatch?: number // Default: 0.40
|
|
embedding?: number // Default: 0.35
|
|
pattern?: number // Default: 0.20
|
|
context?: number // Default: 0.05
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal signal result wrapper
|
|
*/
|
|
interface SignalResult {
|
|
signal: 'exact-match' | 'pattern' | 'embedding' | 'context'
|
|
type: NounType | null
|
|
confidence: number
|
|
weight: number
|
|
evidence: string
|
|
}
|
|
|
|
/**
|
|
* SmartExtractor - Unified entity type classification
|
|
*
|
|
* This is the single entry point for all entity type extraction.
|
|
* It orchestrates all 4 signals, applies format intelligence,
|
|
* and combines results using ensemble weighting.
|
|
*
|
|
* Production features:
|
|
* - Parallel signal execution for performance
|
|
* - Format-specific hint extraction
|
|
* - Ensemble voting with confidence boosting
|
|
* - Comprehensive statistics and observability
|
|
* - LRU caching for hot paths
|
|
* - Graceful error handling
|
|
*/
|
|
export class SmartExtractor {
|
|
private brain: Brainy
|
|
private options: Required<Omit<SmartExtractorOptions, 'weights'>> & { weights: Required<NonNullable<SmartExtractorOptions['weights']>> }
|
|
|
|
// Signal instances
|
|
private exactMatchSignal: ExactMatchSignal
|
|
private patternSignal: PatternSignal
|
|
private embeddingSignal: EmbeddingSignal
|
|
private contextSignal: ContextSignal
|
|
|
|
// LRU cache
|
|
private cache: Map<string, ExtractionResult | null> = new Map()
|
|
private cacheOrder: string[] = []
|
|
|
|
// Statistics
|
|
private stats = {
|
|
calls: 0,
|
|
cacheHits: 0,
|
|
exactMatchWins: 0,
|
|
patternWins: 0,
|
|
embeddingWins: 0,
|
|
contextWins: 0,
|
|
ensembleWins: 0,
|
|
agreementBoosts: 0,
|
|
formatHintsUsed: 0,
|
|
averageConfidence: 0,
|
|
averageSignalsUsed: 0
|
|
}
|
|
|
|
constructor(brain: Brainy, options?: SmartExtractorOptions) {
|
|
this.brain = brain
|
|
|
|
// Set default options
|
|
this.options = {
|
|
minConfidence: options?.minConfidence ?? 0.60,
|
|
enableFormatHints: options?.enableFormatHints ?? true,
|
|
enableEnsemble: options?.enableEnsemble ?? true,
|
|
cacheSize: options?.cacheSize ?? 2000,
|
|
weights: {
|
|
exactMatch: options?.weights?.exactMatch ?? 0.40,
|
|
embedding: options?.weights?.embedding ?? 0.35,
|
|
pattern: options?.weights?.pattern ?? 0.20,
|
|
context: options?.weights?.context ?? 0.05
|
|
}
|
|
}
|
|
|
|
// Validate weights sum to 1.0
|
|
const weightSum = Object.values(this.options.weights).reduce((a, b) => a + b, 0)
|
|
if (Math.abs(weightSum - 1.0) > 0.01) {
|
|
throw new Error(`Signal weights must sum to 1.0, got ${weightSum}`)
|
|
}
|
|
|
|
// Initialize signals
|
|
this.exactMatchSignal = new ExactMatchSignal(brain, {
|
|
minConfidence: 0.50, // Lower threshold, ensemble will filter
|
|
cacheSize: Math.floor(this.options.cacheSize / 4)
|
|
})
|
|
|
|
this.patternSignal = new PatternSignal(brain, {
|
|
minConfidence: 0.50,
|
|
cacheSize: Math.floor(this.options.cacheSize / 4)
|
|
})
|
|
|
|
this.embeddingSignal = new EmbeddingSignal(brain, {
|
|
minConfidence: 0.50,
|
|
checkGraph: true,
|
|
checkHistory: true,
|
|
cacheSize: Math.floor(this.options.cacheSize / 4)
|
|
})
|
|
|
|
this.contextSignal = new ContextSignal(brain, {
|
|
minConfidence: 0.50,
|
|
cacheSize: Math.floor(this.options.cacheSize / 4)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Extract entity type using ensemble of signals
|
|
*
|
|
* Main entry point - orchestrates all signals and combines results
|
|
*
|
|
* @param candidate Entity text to classify
|
|
* @param context Classification context with format hints
|
|
* @returns ExtractionResult with type and confidence
|
|
*/
|
|
async extract(
|
|
candidate: string,
|
|
context?: {
|
|
definition?: string
|
|
formatContext?: FormatContext
|
|
allTerms?: string[]
|
|
metadata?: any
|
|
}
|
|
): Promise<ExtractionResult | null> {
|
|
this.stats.calls++
|
|
|
|
// Check cache first
|
|
const cacheKey = this.getCacheKey(candidate, context)
|
|
const cached = this.getFromCache(cacheKey)
|
|
if (cached !== undefined) {
|
|
this.stats.cacheHits++
|
|
return cached
|
|
}
|
|
|
|
try {
|
|
// Extract format hints if enabled
|
|
const formatHints = this.options.enableFormatHints && context?.formatContext
|
|
? this.extractFormatHints(context.formatContext)
|
|
: []
|
|
|
|
if (formatHints.length > 0) {
|
|
this.stats.formatHintsUsed++
|
|
}
|
|
|
|
// Build enriched context with format hints
|
|
const enrichedContext = {
|
|
definition: context?.definition,
|
|
allTerms: [...(context?.allTerms || []), ...formatHints],
|
|
metadata: context?.metadata
|
|
}
|
|
|
|
// Execute all signals in parallel
|
|
const [exactMatch, patternMatch, embeddingMatch, contextMatch] = await Promise.all([
|
|
this.exactMatchSignal.classify(candidate, enrichedContext).catch(() => null),
|
|
this.patternSignal.classify(candidate, enrichedContext).catch(() => null),
|
|
this.embeddingSignal.classify(candidate, enrichedContext).catch(() => null),
|
|
this.contextSignal.classify(candidate, enrichedContext).catch(() => null)
|
|
])
|
|
|
|
// Wrap results with weights
|
|
const signalResults: SignalResult[] = [
|
|
{
|
|
signal: 'exact-match',
|
|
type: exactMatch?.type || null,
|
|
confidence: exactMatch?.confidence || 0,
|
|
weight: this.options.weights.exactMatch,
|
|
evidence: exactMatch?.evidence || ''
|
|
},
|
|
{
|
|
signal: 'pattern',
|
|
type: patternMatch?.type || null,
|
|
confidence: patternMatch?.confidence || 0,
|
|
weight: this.options.weights.pattern,
|
|
evidence: patternMatch?.evidence || ''
|
|
},
|
|
{
|
|
signal: 'embedding',
|
|
type: embeddingMatch?.type || null,
|
|
confidence: embeddingMatch?.confidence || 0,
|
|
weight: this.options.weights.embedding,
|
|
evidence: embeddingMatch?.evidence || ''
|
|
},
|
|
{
|
|
signal: 'context',
|
|
type: contextMatch?.type || null,
|
|
confidence: contextMatch?.confidence || 0,
|
|
weight: this.options.weights.context,
|
|
evidence: contextMatch?.evidence || ''
|
|
}
|
|
]
|
|
|
|
// Combine using ensemble or best signal
|
|
const result = this.options.enableEnsemble
|
|
? this.combineEnsemble(signalResults, formatHints, context?.formatContext)
|
|
: this.selectBestSignal(signalResults, formatHints, context?.formatContext)
|
|
|
|
// Cache result (including nulls to avoid recomputation)
|
|
this.addToCache(cacheKey, result)
|
|
|
|
// Update statistics
|
|
if (result) {
|
|
this.updateStatistics(result)
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
// Graceful degradation
|
|
console.warn(`SmartExtractor error for "${candidate}":`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract format-specific hints from context
|
|
*
|
|
* Returns array of hint strings that can help with classification
|
|
*/
|
|
private extractFormatHints(formatContext: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
switch (formatContext.format) {
|
|
case 'excel':
|
|
hints.push(...this.extractExcelHints(formatContext))
|
|
break
|
|
|
|
case 'csv':
|
|
hints.push(...this.extractCsvHints(formatContext))
|
|
break
|
|
|
|
case 'pdf':
|
|
hints.push(...this.extractPdfHints(formatContext))
|
|
break
|
|
|
|
case 'yaml':
|
|
hints.push(...this.extractYamlHints(formatContext))
|
|
break
|
|
|
|
case 'docx':
|
|
hints.push(...this.extractDocxHints(formatContext))
|
|
break
|
|
|
|
case 'json':
|
|
hints.push(...this.extractJsonHints(formatContext))
|
|
break
|
|
|
|
case 'markdown':
|
|
hints.push(...this.extractMarkdownHints(formatContext))
|
|
break
|
|
}
|
|
|
|
return hints.filter(h => h && h.trim().length > 0)
|
|
}
|
|
|
|
/**
|
|
* Extract Excel-specific hints
|
|
*/
|
|
private extractExcelHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.columnHeader) {
|
|
hints.push(context.columnHeader)
|
|
|
|
// Extract type keywords from header
|
|
const headerLower = context.columnHeader.toLowerCase()
|
|
const typeKeywords = [
|
|
'person', 'people', 'user', 'author', 'creator', 'employee', 'member',
|
|
'organization', 'company', 'org', 'business',
|
|
'location', 'place', 'city', 'country', 'address',
|
|
'event', 'meeting', 'conference', 'workshop',
|
|
'concept', 'idea', 'term', 'definition',
|
|
'document', 'file', 'report', 'paper',
|
|
'project', 'initiative', 'program',
|
|
'product', 'service', 'offering',
|
|
'date', 'time', 'timestamp', 'when'
|
|
]
|
|
|
|
for (const keyword of typeKeywords) {
|
|
if (headerLower.includes(keyword)) {
|
|
hints.push(keyword)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (context.sheetName) {
|
|
hints.push(context.sheetName)
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Extract CSV-specific hints
|
|
*/
|
|
private extractCsvHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.columnHeader) {
|
|
hints.push(context.columnHeader)
|
|
|
|
// Parse underscore/hyphen patterns
|
|
const headerLower = context.columnHeader.toLowerCase()
|
|
if (headerLower.includes('_') || headerLower.includes('-')) {
|
|
const parts = headerLower.split(/[_-]/)
|
|
hints.push(...parts)
|
|
}
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Extract PDF-specific hints
|
|
*/
|
|
private extractPdfHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.fieldName) {
|
|
hints.push(context.fieldName)
|
|
|
|
// Convert snake_case or camelCase to words
|
|
const words = context.fieldName
|
|
.replace(/([A-Z])/g, ' $1')
|
|
.replace(/[_-]/g, ' ')
|
|
.trim()
|
|
.split(/\s+/)
|
|
|
|
hints.push(...words)
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Extract YAML-specific hints
|
|
*/
|
|
private extractYamlHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.yamlKey) {
|
|
hints.push(context.yamlKey)
|
|
|
|
// Parse key structure
|
|
const keyWords = context.yamlKey
|
|
.replace(/([A-Z])/g, ' $1')
|
|
.replace(/[-_]/g, ' ')
|
|
.trim()
|
|
.split(/\s+/)
|
|
|
|
hints.push(...keyWords)
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Extract DOCX-specific hints
|
|
*/
|
|
private extractDocxHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.headingLevel !== undefined) {
|
|
// Heading 1 = major entities (organizations, projects)
|
|
// Heading 2-3 = sub-entities (people, concepts)
|
|
if (context.headingLevel === 1) {
|
|
hints.push('major entity', 'organization', 'project')
|
|
} else if (context.headingLevel === 2) {
|
|
hints.push('sub entity', 'person', 'concept')
|
|
}
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Extract JSON-specific hints
|
|
*/
|
|
private extractJsonHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.fieldName) {
|
|
hints.push(context.fieldName)
|
|
|
|
// Parse camelCase or snake_case
|
|
const words = context.fieldName
|
|
.replace(/([A-Z])/g, ' $1')
|
|
.replace(/[_-]/g, ' ')
|
|
.trim()
|
|
.split(/\s+/)
|
|
|
|
hints.push(...words)
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Extract Markdown-specific hints
|
|
*/
|
|
private extractMarkdownHints(context: FormatContext): string[] {
|
|
const hints: string[] = []
|
|
|
|
if (context.headingLevel !== undefined) {
|
|
if (context.headingLevel === 1) {
|
|
hints.push('major entity')
|
|
} else if (context.headingLevel === 2) {
|
|
hints.push('sub entity')
|
|
}
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
/**
|
|
* Combine signal results using ensemble voting
|
|
*
|
|
* Applies weighted voting with confidence boosting when signals agree
|
|
*/
|
|
private combineEnsemble(
|
|
signalResults: SignalResult[],
|
|
formatHints: string[],
|
|
formatContext?: FormatContext
|
|
): ExtractionResult | null {
|
|
// Filter out null results
|
|
const validResults = signalResults.filter(r => r.type !== null)
|
|
|
|
if (validResults.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Count votes by type with weighted confidence
|
|
const typeScores = new Map<NounType, { score: number; signals: SignalResult[] }>()
|
|
|
|
for (const result of validResults) {
|
|
if (!result.type) continue
|
|
|
|
const weighted = result.confidence * result.weight
|
|
const existing = typeScores.get(result.type)
|
|
|
|
if (existing) {
|
|
existing.score += weighted
|
|
existing.signals.push(result)
|
|
} else {
|
|
typeScores.set(result.type, { score: weighted, signals: [result] })
|
|
}
|
|
}
|
|
|
|
// Find best type
|
|
let bestType: NounType | null = null
|
|
let bestScore = 0
|
|
let bestSignals: SignalResult[] = []
|
|
|
|
for (const [type, data] of typeScores.entries()) {
|
|
// Apply agreement boost (multiple signals agree)
|
|
let finalScore = data.score
|
|
if (data.signals.length > 1) {
|
|
const agreementBoost = 0.05 * (data.signals.length - 1)
|
|
finalScore += agreementBoost
|
|
this.stats.agreementBoosts++
|
|
}
|
|
|
|
if (finalScore > bestScore) {
|
|
bestScore = finalScore
|
|
bestType = type
|
|
bestSignals = data.signals
|
|
}
|
|
}
|
|
|
|
// Check minimum confidence threshold
|
|
if (!bestType || bestScore < this.options.minConfidence) {
|
|
return null
|
|
}
|
|
|
|
// Track signal contributions
|
|
const usedSignals = bestSignals.length
|
|
this.stats.averageSignalsUsed =
|
|
(this.stats.averageSignalsUsed * (this.stats.calls - 1) + usedSignals) / this.stats.calls
|
|
|
|
// Build evidence string
|
|
const signalNames = bestSignals.map(s => s.signal).join(' + ')
|
|
const evidence = `Ensemble: ${signalNames} (${bestSignals.length} signal${bestSignals.length > 1 ? 's' : ''} agree)`
|
|
|
|
return {
|
|
type: bestType,
|
|
confidence: Math.min(bestScore, 1.0), // Cap at 1.0
|
|
source: 'ensemble',
|
|
evidence,
|
|
metadata: {
|
|
signalResults: bestSignals.map(s => ({
|
|
signal: s.signal,
|
|
type: s.type!,
|
|
confidence: s.confidence,
|
|
weight: s.weight
|
|
})),
|
|
agreementBoost: bestSignals.length > 1 ? 0.05 * (bestSignals.length - 1) : 0,
|
|
formatHints: formatHints.length > 0 ? formatHints : undefined,
|
|
formatContext
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select best single signal (when ensemble is disabled)
|
|
*/
|
|
private selectBestSignal(
|
|
signalResults: SignalResult[],
|
|
formatHints: string[],
|
|
formatContext?: FormatContext
|
|
): ExtractionResult | null {
|
|
// Filter valid results and sort by weighted confidence
|
|
const validResults = signalResults
|
|
.filter(r => r.type !== null)
|
|
.map(r => ({ ...r, weightedScore: r.confidence * r.weight }))
|
|
.sort((a, b) => b.weightedScore - a.weightedScore)
|
|
|
|
if (validResults.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const best = validResults[0]
|
|
|
|
if (best.weightedScore < this.options.minConfidence) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
type: best.type!,
|
|
confidence: best.confidence,
|
|
source: best.signal as any,
|
|
evidence: best.evidence,
|
|
metadata: {
|
|
formatHints: formatHints.length > 0 ? formatHints : undefined,
|
|
formatContext
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update statistics based on result
|
|
*/
|
|
private updateStatistics(result: ExtractionResult): void {
|
|
// Track win counts
|
|
if (result.source === 'ensemble') {
|
|
this.stats.ensembleWins++
|
|
} else if (result.source === 'exact-match') {
|
|
this.stats.exactMatchWins++
|
|
} else if (result.source === 'pattern') {
|
|
this.stats.patternWins++
|
|
} else if (result.source === 'embedding') {
|
|
this.stats.embeddingWins++
|
|
} else if (result.source === 'context') {
|
|
this.stats.contextWins++
|
|
}
|
|
|
|
// Update rolling average confidence
|
|
this.stats.averageConfidence =
|
|
(this.stats.averageConfidence * (this.stats.calls - 1) + result.confidence) / this.stats.calls
|
|
}
|
|
|
|
/**
|
|
* Get cache key from candidate and context
|
|
*/
|
|
private getCacheKey(candidate: string, context?: any): string {
|
|
const normalized = candidate.toLowerCase().trim()
|
|
const defSnippet = context?.definition?.substring(0, 50) || ''
|
|
const format = context?.formatContext?.format || ''
|
|
return `${normalized}:${defSnippet}:${format}`
|
|
}
|
|
|
|
/**
|
|
* Get from LRU cache
|
|
*/
|
|
private getFromCache(key: string): ExtractionResult | null | undefined {
|
|
if (!this.cache.has(key)) return undefined
|
|
|
|
const cached = this.cache.get(key)
|
|
|
|
// Move to end (most recently used)
|
|
this.cacheOrder = this.cacheOrder.filter(k => k !== key)
|
|
this.cacheOrder.push(key)
|
|
|
|
return cached ?? null
|
|
}
|
|
|
|
/**
|
|
* Add to LRU cache with eviction
|
|
*/
|
|
private addToCache(key: string, value: ExtractionResult | null): void {
|
|
this.cache.set(key, value)
|
|
this.cacheOrder.push(key)
|
|
|
|
// Evict oldest if over limit
|
|
if (this.cache.size > this.options.cacheSize) {
|
|
const oldest = this.cacheOrder.shift()
|
|
if (oldest) {
|
|
this.cache.delete(oldest)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get comprehensive statistics
|
|
*/
|
|
getStats() {
|
|
return {
|
|
...this.stats,
|
|
cacheSize: this.cache.size,
|
|
cacheHitRate: this.stats.calls > 0 ? this.stats.cacheHits / this.stats.calls : 0,
|
|
ensembleRate: this.stats.calls > 0 ? this.stats.ensembleWins / this.stats.calls : 0,
|
|
formatHintRate: this.stats.calls > 0 ? this.stats.formatHintsUsed / this.stats.calls : 0,
|
|
signalStats: {
|
|
exactMatch: this.exactMatchSignal.getStats(),
|
|
pattern: this.patternSignal.getStats(),
|
|
embedding: this.embeddingSignal.getStats(),
|
|
context: this.contextSignal.getStats()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset all statistics
|
|
*/
|
|
resetStats(): void {
|
|
this.stats = {
|
|
calls: 0,
|
|
cacheHits: 0,
|
|
exactMatchWins: 0,
|
|
patternWins: 0,
|
|
embeddingWins: 0,
|
|
contextWins: 0,
|
|
ensembleWins: 0,
|
|
agreementBoosts: 0,
|
|
formatHintsUsed: 0,
|
|
averageConfidence: 0,
|
|
averageSignalsUsed: 0
|
|
}
|
|
|
|
this.exactMatchSignal.resetStats()
|
|
this.patternSignal.resetStats()
|
|
this.embeddingSignal.resetStats()
|
|
this.contextSignal.resetStats()
|
|
}
|
|
|
|
/**
|
|
* Clear all caches
|
|
*/
|
|
clearCache(): void {
|
|
this.cache.clear()
|
|
this.cacheOrder = []
|
|
|
|
this.exactMatchSignal.clearCache()
|
|
this.patternSignal.clearCache()
|
|
this.embeddingSignal.clearCache()
|
|
this.contextSignal.clearCache()
|
|
}
|
|
|
|
/**
|
|
* Add entity to historical data (for embedding signal temporal boosting)
|
|
*/
|
|
addToHistory(text: string, type: NounType, vector: number[]): void {
|
|
this.embeddingSignal.addToHistory(text, type, vector)
|
|
}
|
|
|
|
/**
|
|
* Clear historical data
|
|
*/
|
|
clearHistory(): void {
|
|
this.embeddingSignal.clearHistory()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new SmartExtractor instance
|
|
*
|
|
* Convenience factory function
|
|
*/
|
|
export function createSmartExtractor(
|
|
brain: Brainy,
|
|
options?: SmartExtractorOptions
|
|
): SmartExtractor {
|
|
return new SmartExtractor(brain, options)
|
|
}
|