feat: add structured content extraction and batch embedding optimization to highlight()
Fix highlight() hanging on structured text input by addressing 3 root causes:
1. embedBatch() now uses native WASM batch API (single forward pass instead
of N individual embed() calls via Promise.all)
2. highlight() auto-detects content type (plain text, rich-text JSON, HTML,
Markdown) and extracts meaningful text segments. Supports TipTap, Slate.js,
Lexical, Draft.js, and Quill Delta formats. New contentType hint and
contentExtractor callback for custom parsers.
3. Semantic matching phase has 10s timeout - falls back to text-only matches
instead of hanging indefinitely.
Also fixes extractTextContent() array check: uses type-based detection
(typeof data[0] === 'number') instead of length-based (data.length > 10)
so arrays of objects are properly indexed for text search.
New types: ContentType, ContentCategory, ExtractedSegment
New fields: HighlightParams.contentType, HighlightParams.contentExtractor,
Highlight.contentCategory
This commit is contained in:
parent
2b901974ed
commit
cca1cd8ce2
12 changed files with 1341 additions and 37 deletions
|
|
@ -26,6 +26,7 @@ import { TripleIntelligenceSystem } from './triple/TripleIntelligenceSystem.js'
|
|||
import { VirtualFileSystem } from './vfs/VirtualFileSystem.js'
|
||||
import { VersioningAPI } from './versioning/VersioningAPI.js'
|
||||
import { MetadataIndexManager } from './utils/metadataIndex.js'
|
||||
import { detectContentType, extractForHighlighting } from './utils/contentExtractor.js'
|
||||
import { GraphAdjacencyIndex } from './graph/graphAdjacencyIndex.js'
|
||||
import { CommitBuilder } from './storage/cow/CommitObject.js'
|
||||
import { BlobStorage } from './storage/cow/BlobStorage.js'
|
||||
|
|
@ -4663,13 +4664,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return []
|
||||
}
|
||||
|
||||
// Use the underlying embedder for each text
|
||||
// The WASM engine handles batching internally
|
||||
const embeddings = await Promise.all(
|
||||
texts.map(text => this.embedder(text))
|
||||
)
|
||||
|
||||
return embeddings
|
||||
// Use native WASM batch API: single forward pass instead of N individual calls
|
||||
return await embeddingManager.embedBatch(texts)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4734,14 +4730,38 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
async highlight(params: import('./types/brainy.types.js').HighlightParams): Promise<import('./types/brainy.types.js').Highlight[]> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
const { query, text, granularity = 'word', threshold = 0.5 } = params
|
||||
const { query, text, granularity = 'word', threshold = 0.5, contentType, contentExtractor } = params
|
||||
|
||||
if (!query || !text) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Split text into chunks based on granularity
|
||||
const allChunks = this.splitForHighlighting(text, granularity)
|
||||
// v7.9.0: Extract text from structured content (JSON, HTML, Markdown)
|
||||
// Custom extractor takes priority, then built-in detection
|
||||
type ChunkWithCategory = { text: string, position: [number, number], contentCategory?: import('./types/brainy.types.js').ContentCategory }
|
||||
|
||||
let segments: import('./types/brainy.types.js').ExtractedSegment[]
|
||||
if (contentExtractor) {
|
||||
segments = contentExtractor(text)
|
||||
} else {
|
||||
segments = extractForHighlighting(text, contentType)
|
||||
}
|
||||
|
||||
// Build concatenated text from segments for position tracking
|
||||
// and split each segment into chunks based on granularity
|
||||
const allChunks: ChunkWithCategory[] = []
|
||||
let offset = 0
|
||||
for (const segment of segments) {
|
||||
const segmentChunks = this.splitForHighlighting(segment.text, granularity)
|
||||
for (const chunk of segmentChunks) {
|
||||
allChunks.push({
|
||||
text: chunk.text,
|
||||
position: [chunk.position[0] + offset, chunk.position[1] + offset],
|
||||
contentCategory: segment.contentCategory
|
||||
})
|
||||
}
|
||||
offset += segment.text.length + 1 // +1 for space between segments
|
||||
}
|
||||
|
||||
if (allChunks.length === 0) {
|
||||
return []
|
||||
|
|
@ -4749,7 +4769,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
|
||||
// v7.8.0 Production safety: Limit chunks to prevent memory explosion
|
||||
// At 500 words × 384 dimensions × 4 bytes = 768KB temp memory (acceptable)
|
||||
// At 10,000 words = 15MB temp memory (too much for concurrent requests)
|
||||
const MAX_HIGHLIGHT_CHUNKS = 500
|
||||
const chunks = allChunks.slice(0, MAX_HIGHLIGHT_CHUNKS)
|
||||
|
||||
|
|
@ -4768,16 +4787,50 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
text: chunk.text,
|
||||
score: 1.0,
|
||||
position: chunk.position,
|
||||
matchType: 'text'
|
||||
matchType: 'text',
|
||||
contentCategory: chunk.contentCategory
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// === PHASE 2: Find semantic matches (score varies, matchType = 'semantic') ===
|
||||
// === PHASE 2: Find semantic matches with timeout fallback ===
|
||||
const SEMANTIC_TIMEOUT_MS = 10_000
|
||||
try {
|
||||
const semanticResult = await Promise.race([
|
||||
this.highlightSemanticPhase(query, chunks, threshold, highlightMap),
|
||||
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), SEMANTIC_TIMEOUT_MS))
|
||||
])
|
||||
|
||||
if (semanticResult === 'timeout') {
|
||||
// Return Phase 1 text-only results on timeout
|
||||
const textHighlights = Array.from(highlightMap.values())
|
||||
return textHighlights.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
} catch {
|
||||
// On any error in semantic phase, return Phase 1 results
|
||||
const textHighlights = Array.from(highlightMap.values())
|
||||
return textHighlights.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
// Sort by score descending (text matches will be first with score=1.0)
|
||||
const highlights = Array.from(highlightMap.values())
|
||||
return highlights.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 of highlight(): semantic matching with batch embedding
|
||||
* @internal
|
||||
*/
|
||||
private async highlightSemanticPhase(
|
||||
query: string,
|
||||
chunks: Array<{ text: string, position: [number, number], contentCategory?: import('./types/brainy.types.js').ContentCategory }>,
|
||||
threshold: number,
|
||||
highlightMap: Map<string, import('./types/brainy.types.js').Highlight>
|
||||
): Promise<void> {
|
||||
// Get query embedding
|
||||
const queryVector = await this.embed(query)
|
||||
|
||||
// Batch embed all chunks (efficient!)
|
||||
// Batch embed all chunks using native WASM batch API
|
||||
const chunkTexts = chunks.map(c => c.text)
|
||||
const chunkVectors = await this.embedBatch(chunkTexts)
|
||||
|
||||
|
|
@ -4796,14 +4849,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
text: chunks[i].text,
|
||||
score: similarity,
|
||||
position: chunks[i].position,
|
||||
matchType: 'semantic'
|
||||
matchType: 'semantic',
|
||||
contentCategory: chunks[i].contentCategory
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending (text matches will be first with score=1.0)
|
||||
const highlights = Array.from(highlightMap.values())
|
||||
return highlights.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -206,6 +206,35 @@ export class EmbeddingManager {
|
|||
return vector
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch embed multiple texts using native WASM batch API
|
||||
*
|
||||
* Uses the engine's embedBatch() for a single WASM forward pass
|
||||
* instead of N individual embed() calls.
|
||||
*
|
||||
* @param texts Array of strings to embed
|
||||
* @returns Array of embedding vectors (384 dimensions each)
|
||||
*/
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
const isTestMode =
|
||||
process.env.BRAINY_UNIT_TEST === 'true' ||
|
||||
(globalThis as any).__BRAINY_UNIT_TEST__
|
||||
|
||||
if (isTestMode) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('CRITICAL: Mock embeddings in production!')
|
||||
}
|
||||
return texts.map(t => this.getMockEmbedding(t))
|
||||
}
|
||||
|
||||
await this.init()
|
||||
const results = await this.engine.embedBatch(texts)
|
||||
this.embedCount += texts.length
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embedding function for compatibility
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -789,25 +789,71 @@ export interface NeuralAnomalyParams {
|
|||
returnScores?: boolean // Return anomaly scores
|
||||
}
|
||||
|
||||
// ============= Content Extraction Types (v7.9.0) =============
|
||||
|
||||
/**
|
||||
* Detected content type for smart text extraction
|
||||
*
|
||||
* @since v7.9.0
|
||||
*/
|
||||
export type ContentType = 'plaintext' | 'richtext-json' | 'html' | 'markdown'
|
||||
|
||||
/**
|
||||
* Content category for extracted segments
|
||||
*
|
||||
* Allows UI to apply different styling based on content role:
|
||||
* - 'prose': Regular text content (paragraphs, list items)
|
||||
* - 'heading': Heading/title text (h1-h6)
|
||||
* - 'code': Code blocks or inline code
|
||||
* - 'label': Labels, captions, or metadata-like text
|
||||
*
|
||||
* @since v7.9.0
|
||||
*/
|
||||
export type ContentCategory = 'prose' | 'heading' | 'code' | 'label'
|
||||
|
||||
/**
|
||||
* A segment of extracted text with its content category
|
||||
*
|
||||
* @since v7.9.0
|
||||
*/
|
||||
export interface ExtractedSegment {
|
||||
/** The extracted text content */
|
||||
text: string
|
||||
/** What role this text plays in the document */
|
||||
contentCategory: ContentCategory
|
||||
}
|
||||
|
||||
// ============= Semantic Highlighting Types (v7.8.0) =============
|
||||
|
||||
/**
|
||||
* Parameters for hybrid highlighting (v7.8.0)
|
||||
* Parameters for hybrid highlighting (v7.8.0, enhanced v7.9.0)
|
||||
*
|
||||
* Zero-config highlighting that returns both text (exact) and semantic (concept) matches.
|
||||
* Perfect for UI highlighting at different levels.
|
||||
*
|
||||
* v7.9.0: Added contentType hint and contentExtractor callback for structured text
|
||||
* (rich-text JSON, HTML, Markdown). Auto-detects format when not specified.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Plain text (unchanged from v7.8.0)
|
||||
* const highlights = await brain.highlight({
|
||||
* query: "david the warrior",
|
||||
* text: "David Smith is a brave fighter who battles dragons"
|
||||
* })
|
||||
* // Returns: [
|
||||
* // { text: "David", score: 1.0, position: [0, 5], matchType: 'text' }, // Exact match
|
||||
* // { text: "fighter", score: 0.78, position: [25, 32], matchType: 'semantic' }, // Concept match
|
||||
* // { text: "battles", score: 0.72, position: [37, 44], matchType: 'semantic' } // Concept match
|
||||
* // ]
|
||||
*
|
||||
* // Rich-text JSON (auto-detected)
|
||||
* const highlights = await brain.highlight({
|
||||
* query: "david the warrior",
|
||||
* text: JSON.stringify(tiptapDocument)
|
||||
* })
|
||||
*
|
||||
* // Custom extractor (for proprietary formats)
|
||||
* const highlights = await brain.highlight({
|
||||
* query: "function",
|
||||
* text: sourceCode,
|
||||
* contentExtractor: (text) => treeSitterParse(text)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export interface HighlightParams {
|
||||
|
|
@ -822,6 +868,21 @@ export interface HighlightParams {
|
|||
|
||||
/** Minimum semantic similarity score for semantic matches (default: 0.5) */
|
||||
threshold?: number
|
||||
|
||||
/**
|
||||
* Optional content type hint to skip auto-detection.
|
||||
* When omitted, the content type is detected from the text content.
|
||||
* @since v7.9.0
|
||||
*/
|
||||
contentType?: ContentType
|
||||
|
||||
/**
|
||||
* Optional custom content extractor function.
|
||||
* When provided, bypasses built-in detection and extraction entirely.
|
||||
* Use this to plug in custom parsers (tree-sitter, Monaco, proprietary formats).
|
||||
* @since v7.9.0
|
||||
*/
|
||||
contentExtractor?: (text: string) => ExtractedSegment[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -843,6 +904,13 @@ export interface Highlight {
|
|||
|
||||
/** Match type: 'text' (exact word match) or 'semantic' (concept match) */
|
||||
matchType: 'text' | 'semantic'
|
||||
|
||||
/**
|
||||
* Content category of the source segment (heading, code, prose, label).
|
||||
* Present when the input text was structured (JSON, HTML, Markdown).
|
||||
* @since v7.9.0
|
||||
*/
|
||||
contentCategory?: ContentCategory
|
||||
}
|
||||
|
||||
// ============= Export all types =============
|
||||
|
|
|
|||
403
src/utils/contentExtractor.ts
Normal file
403
src/utils/contentExtractor.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
/**
|
||||
* Content Extractor (v7.9.0)
|
||||
*
|
||||
* Detects content type (plaintext, rich-text JSON, HTML, Markdown) and extracts
|
||||
* text segments with content categories (prose, heading, code, label).
|
||||
*
|
||||
* Supports common rich-text editor formats:
|
||||
* - TipTap / ProseMirror: { type: 'doc', content: [...] }
|
||||
* - Slate.js: [{ type, children: [{ text }] }]
|
||||
* - Lexical: { root: { children: [...] } }
|
||||
* - Draft.js: { blocks: [{ text }] }
|
||||
* - Quill Delta: { ops: [{ insert }] }
|
||||
*
|
||||
* Falls back gracefully: structured text that doesn't match known patterns
|
||||
* is extracted as plain prose via recursive text collection.
|
||||
*/
|
||||
|
||||
import type { ContentType, ContentCategory, ExtractedSegment } from '../types/brainy.types.js'
|
||||
|
||||
/**
|
||||
* Detect content type from text content (no filename needed)
|
||||
*/
|
||||
export function detectContentType(text: string): ContentType {
|
||||
const trimmed = text.trimStart()
|
||||
|
||||
// JSON detection (covers TipTap, Slate, Lexical, Draft.js, Quill, etc.)
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return 'richtext-json'
|
||||
} catch {
|
||||
// Not valid JSON, fall through
|
||||
}
|
||||
}
|
||||
|
||||
// HTML detection (tag at start of content)
|
||||
if (/<[a-z!][a-z0-9]*[\s>/]/i.test(trimmed.substring(0, 200))) {
|
||||
return 'html'
|
||||
}
|
||||
|
||||
// Markdown detection (heading or code fence at start)
|
||||
if (/^#{1,6}\s/m.test(trimmed.substring(0, 500)) ||
|
||||
/^```/m.test(trimmed.substring(0, 500))) {
|
||||
return 'markdown'
|
||||
}
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text segments from content, auto-detecting type if not provided
|
||||
*/
|
||||
export function extractForHighlighting(
|
||||
text: string,
|
||||
contentType?: ContentType
|
||||
): ExtractedSegment[] {
|
||||
const type = contentType || detectContentType(text)
|
||||
|
||||
switch (type) {
|
||||
case 'richtext-json':
|
||||
return extractFromJson(text)
|
||||
case 'html':
|
||||
return extractFromHtml(text)
|
||||
case 'markdown':
|
||||
return extractFromMarkdown(text)
|
||||
case 'plaintext':
|
||||
default:
|
||||
return [{ text, contentCategory: 'prose' }]
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Rich-Text JSON Extraction =============
|
||||
|
||||
/**
|
||||
* Extract text from any JSON rich-text format.
|
||||
* Walks the node tree looking for text leaves across all common editors.
|
||||
*/
|
||||
function extractFromJson(text: string): ExtractedSegment[] {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(text.trim())
|
||||
} catch {
|
||||
// Invalid JSON — treat as plain text
|
||||
return [{ text, contentCategory: 'prose' }]
|
||||
}
|
||||
|
||||
const segments = walkRichTextNodes(parsed)
|
||||
|
||||
// If the walker found real text segments, return them
|
||||
if (segments.length > 0 && segments.some(s => s.text.trim().length > 0)) {
|
||||
return segments.filter(s => s.text.trim().length > 0)
|
||||
}
|
||||
|
||||
// Fallback: collect all string values from the JSON
|
||||
const fallbackText = extractTextFromJsonValue(parsed)
|
||||
if (fallbackText.trim()) {
|
||||
return [{ text: fallbackText, contentCategory: 'prose' }]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk rich-text nodes recursively.
|
||||
* Handles TipTap/ProseMirror, Slate, Lexical, Draft.js, and Quill Delta.
|
||||
*/
|
||||
function walkRichTextNodes(node: any): ExtractedSegment[] {
|
||||
if (node === null || node === undefined) return []
|
||||
|
||||
const segments: ExtractedSegment[] = []
|
||||
|
||||
// Handle arrays (Slate root is an array, Draft.js blocks, Quill ops)
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
segments.push(...walkRichTextNodes(child))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
if (typeof node !== 'object') return []
|
||||
|
||||
// Leaf: text content (TipTap/ProseMirror/Lexical/Slate)
|
||||
if (typeof node.text === 'string' && node.text.length > 0) {
|
||||
segments.push({ text: node.text, contentCategory: 'prose' })
|
||||
return segments
|
||||
}
|
||||
|
||||
// Leaf: Quill Delta insert
|
||||
if (typeof node.insert === 'string' && node.insert.length > 0) {
|
||||
segments.push({ text: node.insert, contentCategory: 'prose' })
|
||||
return segments
|
||||
}
|
||||
|
||||
// Draft.js block with text
|
||||
if (typeof node.text === 'string' && node.type !== undefined && node.text.length > 0) {
|
||||
const category = categorizeNodeType(node.type)
|
||||
segments.push({ text: node.text, contentCategory: category })
|
||||
return segments
|
||||
}
|
||||
|
||||
// Categorize by node type
|
||||
const category = categorizeNodeType(node.type)
|
||||
|
||||
// Walk children arrays: content (TipTap), children (Slate/Lexical), blocks (Draft.js), ops (Quill)
|
||||
const children = node.content || node.children || node.blocks || node.ops
|
||||
if (Array.isArray(children)) {
|
||||
for (const child of children) {
|
||||
const childSegments = walkRichTextNodes(child)
|
||||
// Apply parent's category if it's more specific than 'prose'
|
||||
if (category !== 'prose') {
|
||||
childSegments.forEach(s => { s.contentCategory = category })
|
||||
}
|
||||
segments.push(...childSegments)
|
||||
}
|
||||
}
|
||||
|
||||
// Lexical root wrapper
|
||||
if (node.root && typeof node.root === 'object') {
|
||||
segments.push(...walkRichTextNodes(node.root))
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize a node type string into a ContentCategory
|
||||
*/
|
||||
function categorizeNodeType(type?: string): ContentCategory {
|
||||
if (!type) return 'prose'
|
||||
const t = type.toLowerCase()
|
||||
if (t === 'heading' || /^h[1-6]$/.test(t)) return 'heading'
|
||||
if (t === 'code' || t === 'codeblock' || t === 'code_block') return 'code'
|
||||
return 'prose'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: recursively extract all string values from JSON
|
||||
*/
|
||||
function extractTextFromJsonValue(value: any): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(extractTextFromJsonValue).filter(Boolean).join(' ')
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const texts: string[] = []
|
||||
for (const v of Object.values(value)) {
|
||||
const text = extractTextFromJsonValue(v)
|
||||
if (text) texts.push(text)
|
||||
}
|
||||
return texts.join(' ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// ============= HTML Extraction =============
|
||||
|
||||
/**
|
||||
* Extract text from HTML using a simple state-machine tag parser.
|
||||
* No external dependencies.
|
||||
*/
|
||||
function extractFromHtml(html: string): ExtractedSegment[] {
|
||||
const segments: ExtractedSegment[] = []
|
||||
let current = ''
|
||||
let currentCategory: ContentCategory = 'prose'
|
||||
let i = 0
|
||||
let insideTag = false
|
||||
let tagName = ''
|
||||
let isClosingTag = false
|
||||
let skipContent = false
|
||||
|
||||
// Tag stack to track nesting
|
||||
const tagStack: string[] = []
|
||||
|
||||
while (i < html.length) {
|
||||
if (html[i] === '<') {
|
||||
// Flush current text if we have any
|
||||
if (current.trim() && !skipContent) {
|
||||
segments.push({ text: current.trim(), contentCategory: currentCategory })
|
||||
}
|
||||
current = ''
|
||||
|
||||
insideTag = true
|
||||
tagName = ''
|
||||
isClosingTag = false
|
||||
i++
|
||||
|
||||
// Check for closing tag
|
||||
if (i < html.length && html[i] === '/') {
|
||||
isClosingTag = true
|
||||
i++
|
||||
}
|
||||
|
||||
// Read tag name
|
||||
while (i < html.length && html[i] !== '>' && html[i] !== ' ' && html[i] !== '/') {
|
||||
tagName += html[i]
|
||||
i++
|
||||
}
|
||||
|
||||
// Skip to end of tag
|
||||
while (i < html.length && html[i] !== '>') {
|
||||
i++
|
||||
}
|
||||
if (i < html.length) i++ // skip '>'
|
||||
|
||||
const tagLower = tagName.toLowerCase()
|
||||
|
||||
if (isClosingTag) {
|
||||
// Pop tag stack
|
||||
if (tagStack.length > 0 && tagStack[tagStack.length - 1] === tagLower) {
|
||||
tagStack.pop()
|
||||
}
|
||||
if (tagLower === 'script' || tagLower === 'style') {
|
||||
skipContent = false
|
||||
}
|
||||
// Reset category based on remaining stack
|
||||
currentCategory = getCategoryFromTagStack(tagStack)
|
||||
} else {
|
||||
// Opening tag
|
||||
if (tagLower === 'script' || tagLower === 'style') {
|
||||
skipContent = true
|
||||
}
|
||||
|
||||
// Self-closing tags don't affect stack
|
||||
const selfClosing = html[i - 2] === '/' ||
|
||||
['br', 'hr', 'img', 'input', 'meta', 'link'].includes(tagLower)
|
||||
|
||||
if (!selfClosing) {
|
||||
tagStack.push(tagLower)
|
||||
currentCategory = getCategoryFromTagStack(tagStack)
|
||||
}
|
||||
}
|
||||
|
||||
insideTag = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (!insideTag) {
|
||||
// Decode common HTML entities
|
||||
if (html[i] === '&') {
|
||||
const entityEnd = html.indexOf(';', i)
|
||||
if (entityEnd !== -1 && entityEnd - i < 10) {
|
||||
const entity = html.substring(i, entityEnd + 1)
|
||||
current += decodeHtmlEntity(entity)
|
||||
i = entityEnd + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
current += html[i]
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
// Flush remaining text
|
||||
if (current.trim() && !skipContent) {
|
||||
segments.push({ text: current.trim(), contentCategory: currentCategory })
|
||||
}
|
||||
|
||||
return segments.filter(s => s.text.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine content category from the current tag stack
|
||||
*/
|
||||
function getCategoryFromTagStack(stack: string[]): ContentCategory {
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
const tag = stack[i]
|
||||
if (/^h[1-6]$/.test(tag)) return 'heading'
|
||||
if (tag === 'code' || tag === 'pre') return 'code'
|
||||
}
|
||||
return 'prose'
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode common HTML entities
|
||||
*/
|
||||
function decodeHtmlEntity(entity: string): string {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
}
|
||||
return entities[entity] || entity
|
||||
}
|
||||
|
||||
// ============= Markdown Extraction =============
|
||||
|
||||
/**
|
||||
* Extract text from Markdown with category detection
|
||||
*/
|
||||
function extractFromMarkdown(text: string): ExtractedSegment[] {
|
||||
const segments: ExtractedSegment[] = []
|
||||
const lines = text.split('\n')
|
||||
let inCodeBlock = false
|
||||
let codeContent = ''
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Fenced code block toggle
|
||||
if (/^```/.test(line.trimStart())) {
|
||||
if (inCodeBlock) {
|
||||
// End of code block
|
||||
if (codeContent.trim()) {
|
||||
segments.push({ text: codeContent.trim(), contentCategory: 'code' })
|
||||
}
|
||||
codeContent = ''
|
||||
inCodeBlock = false
|
||||
} else {
|
||||
// Start of code block
|
||||
inCodeBlock = true
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeContent += (codeContent ? '\n' : '') + line
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Indented code block (4 spaces or 1 tab)
|
||||
if (/^(?: |\t)/.test(line) && line.trim().length > 0) {
|
||||
let codeLines = line.replace(/^(?: |\t)/, '')
|
||||
i++
|
||||
while (i < lines.length && (/^(?: |\t)/.test(lines[i]) || lines[i].trim() === '')) {
|
||||
codeLines += '\n' + lines[i].replace(/^(?: |\t)/, '')
|
||||
i++
|
||||
}
|
||||
if (codeLines.trim()) {
|
||||
segments.push({ text: codeLines.trim(), contentCategory: 'code' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading (# style)
|
||||
const headingMatch = line.match(/^#{1,6}\s+(.+)$/)
|
||||
if (headingMatch) {
|
||||
segments.push({ text: headingMatch[1].trim(), contentCategory: 'heading' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular prose line
|
||||
if (line.trim().length > 0) {
|
||||
segments.push({ text: line.trim(), contentCategory: 'prose' })
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
// Flush unclosed code block
|
||||
if (inCodeBlock && codeContent.trim()) {
|
||||
segments.push({ text: codeContent.trim(), contentCategory: 'code' })
|
||||
}
|
||||
|
||||
return segments.filter(s => s.text.length > 0)
|
||||
}
|
||||
|
|
@ -1273,8 +1273,8 @@ export class MetadataIndexManager {
|
|||
if (typeof data === 'string') return data
|
||||
if (typeof data === 'number' || typeof data === 'boolean') return String(data)
|
||||
if (Array.isArray(data)) {
|
||||
// Skip large arrays (likely vectors)
|
||||
if (data.length > 10) return ''
|
||||
// Skip numeric arrays (vectors/embeddings), allow object/string arrays
|
||||
if (data.length > 0 && typeof data[0] === 'number') return ''
|
||||
return data.map(d => this.extractTextContent(d)).filter(Boolean).join(' ')
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue