diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ca0c80..097eafa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,80 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [7.8.0](https://github.com/soulcraftlabs/brainy/compare/v7.7.0...v7.8.0) (2026-01-27) + +### Bug Fixes + +**highlight() hangs on structured text input** + +Three root causes fixed: + +1. **embedBatch() now uses native WASM batch API** — Previously called `embed()` individually N times via `Promise.all`, each creating a separate forward pass. Now delegates to `engine.embedBatch()` for a single WASM forward pass. Applies globally to all `embedBatch()` callers. + +2. **Smart content extraction for structured text** — `highlight()` now auto-detects content type (plain text, rich-text JSON, HTML, Markdown) and extracts meaningful text segments instead of splitting raw JSON/HTML into garbage chunks like `{"type":`. Supports TipTap, Slate.js, Lexical, Draft.js, and Quill Delta formats out of the box. + +3. **Timeout protection** — Semantic matching phase now has a 10-second timeout. On timeout or error, `highlight()` returns Phase 1 text-only matches (always fast) instead of hanging indefinitely. + +**extractTextContent() skips arrays of objects** — Changed from length-based skip (`data.length > 10`) to type-based check (`typeof data[0] === 'number'`). Arrays of objects (e.g., team members, items) are now properly indexed for text search instead of being silently skipped. + +### Features + +**Structured Content Highlighting** + +`highlight()` now handles structured text formats automatically: + +```typescript +// Rich-text JSON (TipTap, Slate, Lexical, Draft.js, Quill) +const highlights = await brain.highlight({ + query: "warrior", + text: JSON.stringify(tiptapDocument) +}) +// Each highlight includes contentCategory: 'heading' | 'prose' | 'code' | 'label' + +// HTML +await brain.highlight({ query: "warrior", text: "

Warriors

Brave fighters.

" }) + +// Markdown +await brain.highlight({ query: "warrior", text: "# Warriors\n\nBrave fighters." }) +``` + +**Content Category Annotations** + +Each `Highlight` now includes `contentCategory` when input is structured: +- `'heading'` — from `

`-`

`, `# Heading`, or heading nodes +- `'code'` — from ``/`
`, fenced/indented code blocks, or code nodes
+- `'prose'` — regular paragraph text
+- `'label'` — labels, captions, metadata-like text
+
+**Custom Content Extractors**
+
+New `contentExtractor` parameter lets developers plug in custom parsers:
+
+```typescript
+const highlights = await brain.highlight({
+  query: "function",
+  text: sourceCode,
+  contentExtractor: (text) => treeSitterParse(text)  // Custom parser
+})
+```
+
+**Content Type Hints**
+
+New `contentType` parameter to skip auto-detection:
+
+```typescript
+await brain.highlight({ query: "test", text: input, contentType: 'html' })
+```
+
+### New Types
+
+- `ContentType`: `'plaintext' | 'richtext-json' | 'html' | 'markdown'`
+- `ContentCategory`: `'prose' | 'heading' | 'code' | 'label'`
+- `ExtractedSegment`: `{ text: string, contentCategory: ContentCategory }`
+- `HighlightParams.contentType?` — optional content type hint
+- `HighlightParams.contentExtractor?` — optional custom parser callback
+- `Highlight.contentCategory?` — content role annotation
+
 ## [7.7.0](https://github.com/soulcraftlabs/brainy/compare/v7.6.1...v7.7.0) (2026-01-26)
 
 ### Features
diff --git a/README.md b/README.md
index edfc0846..1c85d92f 100644
--- a/README.md
+++ b/README.md
@@ -412,6 +412,13 @@ const results = await brain.find({ query: 'David Smith' })
 await brain.find({ query: 'exact match', searchMode: 'text' })     // Text only
 await brain.find({ query: 'AI concepts', searchMode: 'semantic' }) // Semantic only
 await brain.find({ query: 'hybrid', hybridAlpha: 0.3 })            // Custom weighting
+
+// Highlight structured content (v7.8.0) — plain text, JSON, HTML, Markdown
+const highlights = await brain.highlight({
+  query: 'warrior',
+  text: entity.data  // Auto-detects TipTap, Slate, Lexical, HTML, Markdown
+})
+// Each highlight has: text, score, position, matchType, contentCategory
 ```
 
 **→ [Hybrid Search Documentation](docs/api/README.md#hybrid-search-v770)**
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index e9b6711b..2ca57833 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -507,14 +507,16 @@ console.log(vector.length) // 384
 
 ---
 
-### `async embedBatch(texts: string[]): Promise` ✨ *v7.1.0*
-Batch embed multiple texts efficiently.
+### `async embedBatch(texts: string[]): Promise` ✨ *v7.1.0, Optimized v7.9.0*
+Batch embed multiple texts using native WASM batch API (single forward pass).
 
 **Parameters:**
 - `texts` - Array of strings to embed
 
 **Returns:** Array of 384-dimensional vectors
 
+> **v7.9.0**: Uses the engine's native `embed_batch()` for a single model forward pass instead of N individual `embed()` calls.
+
 **Example:**
 ```typescript
 const embeddings = await brain.embedBatch([
diff --git a/docs/api/README.md b/docs/api/README.md
index e62f4f49..95da2014 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -270,41 +270,85 @@ results[0].matchSource     // 'both' | 'text' | 'semantic'
 
 ---
 
-### `highlight(params)` → `Promise` ✨ *New v7.8.0*
+### `highlight(params)` → `Promise` ✨ *New v7.8.0, Enhanced v7.9.0*
 
 Zero-config highlighting for both exact matches AND semantic concepts.
+Handles plain text, rich-text JSON (TipTap, Slate, Lexical, Draft.js, Quill), HTML, and Markdown automatically.
 
 ```typescript
+// Plain text (works as before)
 const highlights = await brain.highlight({
   query: "david the warrior",
   text: "David Smith is a brave fighter who battles dragons"
 })
-
-// Returns both text matches AND semantic matches:
 // [
 //   { text: "David", score: 1.0, position: [0, 5], matchType: 'text' },
 //   { text: "fighter", score: 0.78, position: [25, 32], matchType: 'semantic' },
 //   { text: "battles", score: 0.72, position: [37, 44], matchType: 'semantic' }
 // ]
+
+// Rich-text JSON (auto-detected, v7.9.0)
+const highlights = await brain.highlight({
+  query: "david the warrior",
+  text: JSON.stringify(tiptapDocument)  // TipTap, Slate, Lexical, Draft.js, Quill
+})
+// Extracts text from nodes, annotates with contentCategory:
+// [
+//   { text: "David", score: 1.0, matchType: 'text', contentCategory: 'heading' },
+//   { text: "fighter", score: 0.78, matchType: 'semantic', contentCategory: 'prose' }
+// ]
+
+// HTML input (auto-detected, v7.9.0)
+const highlights = await brain.highlight({
+  query: "warrior",
+  text: "

David the Warrior

A brave fighter.

" +}) + +// Custom extractor for proprietary formats (v7.9.0) +const highlights = await brain.highlight({ + query: "function", + text: sourceCode, + contentExtractor: (text) => treeSitterParse(text) // Your custom parser +}) ``` **Parameters:** - `query`: `string` - The search query -- `text`: `string` - Text to highlight (e.g., entity.data) +- `text`: `string` - Text to highlight (plain text, JSON, HTML, or Markdown) - `granularity?`: `'word' | 'phrase' | 'sentence'` - Highlight unit (default: 'word') - `threshold?`: `number` - Min similarity for semantic matches (default: 0.5) +- `contentType?`: `ContentType` - Optional hint: `'plaintext' | 'richtext-json' | 'html' | 'markdown'`. Skips auto-detection when provided. *(v7.9.0)* +- `contentExtractor?`: `(text: string) => ExtractedSegment[]` - Custom parser. Bypasses built-in detection entirely. *(v7.9.0)* **Returns:** `Promise` - `text` - The matched text - `score` - Match score (1.0 for text matches, varies for semantic) -- `position` - [start, end] indices in original text +- `position` - [start, end] indices in extracted text - `matchType` - `'text'` (exact) or `'semantic'` (concept) +- `contentCategory?` - `'prose' | 'heading' | 'code' | 'label'` — Role of the source text in the document. Present when input is structured. *(v7.9.0)* + +**Supported Rich-Text Formats (v7.9.0):** + +| Format | Detection | Text nodes | +|--------|-----------|------------| +| TipTap / ProseMirror | `{ type: 'doc', content: [...] }` | `{ type: 'text', text }` | +| Slate.js | `[{ type, children }]` | `{ text }` | +| Lexical | `{ root: { children } }` | `{ type: 'text', text }` | +| Draft.js | `{ blocks: [{ text }] }` | `{ text }` in block | +| Quill Delta | `{ ops: [{ insert }] }` | `{ insert }` | +| HTML | Tags like `

`, `

`, `` | Visible text content | +| Markdown | `#` headings, ` ``` ` code blocks | Stripped markup | + +**Timeout Protection (v7.9.0):** +Semantic matching has a 10-second timeout. If embedding takes too long (e.g., WASM stall), `highlight()` returns text-only matches instead of hanging. **UI Pattern:** ```typescript -// Style differently based on match type +// Style differently based on match type and content category highlights.forEach(h => { const style = h.matchType === 'text' ? 'font-weight: bold' : 'background: yellow' + if (h.contentCategory === 'heading') { /* render as heading highlight */ } + if (h.contentCategory === 'code') { /* render with code styling */ } // Apply style from h.position[0] to h.position[1] }) ``` @@ -1677,9 +1721,9 @@ console.log(vector.length) // 384 --- -### `embedBatch(texts)` → `Promise` ✨ *New v7.1.0* +### `embedBatch(texts)` → `Promise` ✨ *New v7.1.0, Optimized v7.9.0* -Batch embed multiple texts efficiently. +Batch embed multiple texts using native WASM batch API (single forward pass). ```typescript const embeddings = await brain.embedBatch([ @@ -1691,6 +1735,8 @@ console.log(embeddings.length) // 3 console.log(embeddings[0].length) // 384 ``` +> **v7.9.0**: Now uses the WASM engine's native `embed_batch()` for a single model forward pass instead of N individual calls. This is the same batch API used internally by `highlight()`. + --- ### `similarity(textA, textB)` → `Promise` ✨ *New v7.1.0* diff --git a/src/brainy.ts b/src/brainy.ts index d7bfec7a..e7372ac0 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -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 implements BrainyInterface { 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 implements BrainyInterface { async highlight(params: import('./types/brainy.types.js').HighlightParams): Promise { 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 implements BrainyInterface { // 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 implements BrainyInterface { 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 + ): Promise { // 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 implements BrainyInterface { 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) } /** diff --git a/src/embeddings/EmbeddingManager.ts b/src/embeddings/EmbeddingManager.ts index ea88ffd7..1f9bfe6b 100644 --- a/src/embeddings/EmbeddingManager.ts +++ b/src/embeddings/EmbeddingManager.ts @@ -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 { + 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 */ diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index 53ac4fd4..bb754946 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -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 ============= diff --git a/src/utils/contentExtractor.ts b/src/utils/contentExtractor.ts new file mode 100644 index 00000000..f0d19b08 --- /dev/null +++ b/src/utils/contentExtractor.ts @@ -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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', + } + 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) +} diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index 2942f8dc..fa0c3903 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -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') { diff --git a/tests/unit/brainy/hybrid-search.test.ts b/tests/unit/brainy/hybrid-search.test.ts index 96fdbd2c..2aa9cc57 100644 --- a/tests/unit/brainy/hybrid-search.test.ts +++ b/tests/unit/brainy/hybrid-search.test.ts @@ -738,4 +738,242 @@ describe('Hybrid Search (v7.7.0)', () => { expect(programmingMatch?.score).toBe(1.0) }) }) + + describe('Structured Content Extraction (v7.9.0)', () => { + it('should extract text from TipTap/ProseMirror JSON', async () => { + const tiptapDoc = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'David the Warrior' }] + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'He is a brave fighter who battles dragons' }] + } + ] + }) + + const highlights = await brain.highlight({ + query: 'warrior fighter', + text: tiptapDoc, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + // Should find "Warrior" as text match from heading + const warriorMatch = highlights.find(h => h.text.toLowerCase() === 'warrior') + expect(warriorMatch).toBeDefined() + expect(warriorMatch?.matchType).toBe('text') + // Should annotate heading content + expect(warriorMatch?.contentCategory).toBe('heading') + }) + + it('should extract text from Slate.js JSON', async () => { + const slateDoc = JSON.stringify([ + { + type: 'heading', + children: [{ text: 'Introduction' }] + }, + { + type: 'paragraph', + children: [ + { text: 'This is ' }, + { text: 'bold text', bold: true }, + { text: ' in a paragraph' } + ] + } + ]) + + const highlights = await brain.highlight({ + query: 'Introduction bold', + text: slateDoc, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + const introMatch = highlights.find(h => h.text === 'Introduction') + expect(introMatch).toBeDefined() + expect(introMatch?.matchType).toBe('text') + expect(introMatch?.contentCategory).toBe('heading') + }) + + it('should extract text from Quill Delta JSON', async () => { + const quilDoc = JSON.stringify({ + ops: [ + { insert: 'Hello World\n', attributes: { header: 1 } }, + { insert: 'This is a paragraph with warrior content\n' } + ] + }) + + const highlights = await brain.highlight({ + query: 'warrior', + text: quilDoc, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + const warriorMatch = highlights.find(h => h.text.toLowerCase() === 'warrior') + expect(warriorMatch).toBeDefined() + expect(warriorMatch?.matchType).toBe('text') + }) + + it('should fall back to text extraction for generic JSON', async () => { + const apiResponse = JSON.stringify({ + name: 'warrior', + description: 'A brave fighter', + stats: { strength: 10, agility: 8 } + }) + + const highlights = await brain.highlight({ + query: 'warrior', + text: apiResponse, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + const warriorMatch = highlights.find(h => h.text.toLowerCase() === 'warrior') + expect(warriorMatch).toBeDefined() + }) + + it('should extract text from HTML with headings and code', async () => { + const html = '

Warrior Guide

A brave fighter battles enemies.

const warrior = new Fighter()' + + const highlights = await brain.highlight({ + query: 'warrior fighter', + text: html, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + // Should find "Warrior" from heading + const warriorMatch = highlights.find(h => h.text.toLowerCase() === 'warrior') + expect(warriorMatch).toBeDefined() + expect(warriorMatch?.contentCategory).toBe('heading') + + // Should find "fighter" from paragraph + const fighterMatch = highlights.find(h => h.text.toLowerCase() === 'fighter') + expect(fighterMatch).toBeDefined() + expect(fighterMatch?.contentCategory).toBe('prose') + }) + + it('should extract text from Markdown with headings and code', async () => { + const markdown = '# Warrior Guide\n\nA brave fighter battles enemies.\n\n```\nconst warrior = new Fighter()\n```' + + const highlights = await brain.highlight({ + query: 'warrior fighter', + text: markdown, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + // Should find text from heading + const headingMatch = highlights.find(h => h.text === 'Guide' && h.contentCategory === 'heading') + || highlights.find(h => h.text === 'Warrior' && h.contentCategory === 'heading') + expect(headingMatch).toBeDefined() + }) + + it('should preserve plain text behavior (regression)', async () => { + const plainText = 'David Smith is a brave warrior who battles dragons' + + const highlights = await brain.highlight({ + query: 'warrior', + text: plainText, + granularity: 'word', + threshold: 0.3 + }) + + expect(highlights.length).toBeGreaterThan(0) + const warriorMatch = highlights.find(h => h.text.toLowerCase() === 'warrior') + expect(warriorMatch).toBeDefined() + expect(warriorMatch?.matchType).toBe('text') + expect(warriorMatch?.score).toBe(1.0) + // Plain text gets 'prose' category + expect(warriorMatch?.contentCategory).toBe('prose') + }) + + it('should use contentType hint to skip auto-detection', async () => { + // Pass HTML as plain text via content type hint + const htmlAsPlain = '

test

' + + const highlights = await brain.highlight({ + query: 'test', + text: htmlAsPlain, + granularity: 'word', + threshold: 0.3, + contentType: 'plaintext' // Force plain text treatment + }) + + // When treated as plain text, the raw HTML tags become part of chunks + // The content should not be parsed as HTML + expect(highlights).toBeDefined() + }) + + it('should use custom contentExtractor when provided', async () => { + const customExtractor = (text: string) => { + return [ + { text: 'custom extracted warrior content', contentCategory: 'prose' as const }, + { text: 'Code Section', contentCategory: 'code' as const } + ] + } + + const highlights = await brain.highlight({ + query: 'warrior', + text: 'ignored because custom extractor is used', + granularity: 'word', + threshold: 0.3, + contentExtractor: customExtractor + }) + + expect(highlights.length).toBeGreaterThan(0) + const warriorMatch = highlights.find(h => h.text.toLowerCase() === 'warrior') + expect(warriorMatch).toBeDefined() + expect(warriorMatch?.matchType).toBe('text') + expect(warriorMatch?.contentCategory).toBe('prose') + }) + }) + + describe('Timeout Protection (v7.9.0)', () => { + it('should return text-only results if semantic phase times out', async () => { + // We test the timeout path by ensuring the highlight method doesn't hang. + // The mock embeddings are fast so timeout won't trigger in normal test, + // but we verify the method completes successfully. + const highlights = await brain.highlight({ + query: 'warrior', + text: 'David is a brave warrior who battles dragons', + granularity: 'word', + threshold: 0.3 + }) + + // Should complete without hanging + expect(highlights).toBeDefined() + expect(highlights.length).toBeGreaterThan(0) + }) + }) + + describe('embedBatch uses native batch API (v7.9.0)', () => { + it('should embed multiple texts in a single call', async () => { + const texts = ['hello world', 'foo bar', 'test content'] + const embeddings = await brain.embedBatch(texts) + + expect(embeddings.length).toBe(3) + // Each embedding should be 384 dimensions + for (const emb of embeddings) { + expect(emb.length).toBe(384) + } + }) + + it('should return empty array for empty input', async () => { + const embeddings = await brain.embedBatch([]) + expect(embeddings).toEqual([]) + }) + }) }) diff --git a/tests/unit/utils/contentExtractor.test.ts b/tests/unit/utils/contentExtractor.test.ts new file mode 100644 index 00000000..e1222129 --- /dev/null +++ b/tests/unit/utils/contentExtractor.test.ts @@ -0,0 +1,321 @@ +/** + * Content Extractor Tests (v7.9.0) + * + * Tests for content type detection and text extraction from + * structured formats (rich-text JSON, HTML, Markdown). + */ + +import { describe, it, expect } from 'vitest' +import { + detectContentType, + extractForHighlighting +} from '../../../src/utils/contentExtractor' + +describe('Content Extractor (v7.9.0)', () => { + describe('detectContentType()', () => { + it('should detect plaintext', () => { + expect(detectContentType('Hello world')).toBe('plaintext') + expect(detectContentType('Just some regular text.')).toBe('plaintext') + }) + + it('should detect rich-text JSON (object)', () => { + const tiptap = JSON.stringify({ type: 'doc', content: [] }) + expect(detectContentType(tiptap)).toBe('richtext-json') + }) + + it('should detect rich-text JSON (array)', () => { + const slate = JSON.stringify([{ type: 'paragraph', children: [{ text: 'hi' }] }]) + expect(detectContentType(slate)).toBe('richtext-json') + }) + + it('should detect HTML', () => { + expect(detectContentType('

Title

')).toBe('html') + expect(detectContentType('
content
')).toBe('html') + expect(detectContentType('')).toBe('html') + }) + + it('should detect Markdown', () => { + expect(detectContentType('# Heading\n\nSome text')).toBe('markdown') + expect(detectContentType('```\ncode\n```')).toBe('markdown') + }) + + it('should handle invalid JSON as plaintext', () => { + expect(detectContentType('{ broken json')).toBe('plaintext') + expect(detectContentType('[not valid')).toBe('plaintext') + }) + + it('should handle whitespace-prefixed JSON', () => { + const json = ' { "type": "doc" }' + expect(detectContentType(json)).toBe('richtext-json') + }) + }) + + describe('extractForHighlighting() — TipTap/ProseMirror', () => { + it('should extract text nodes with content categories', () => { + const doc = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'My Heading' }] + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Regular paragraph text' }] + }, + { + type: 'codeBlock', + content: [{ type: 'text', text: 'const x = 1' }] + } + ] + }) + + const segments = extractForHighlighting(doc) + expect(segments.length).toBe(3) + + expect(segments[0].text).toBe('My Heading') + expect(segments[0].contentCategory).toBe('heading') + + expect(segments[1].text).toBe('Regular paragraph text') + expect(segments[1].contentCategory).toBe('prose') + + expect(segments[2].text).toBe('const x = 1') + expect(segments[2].contentCategory).toBe('code') + }) + + it('should handle nested content with marks', () => { + const doc = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'bold world', marks: [{ type: 'bold' }] } + ] + } + ] + }) + + const segments = extractForHighlighting(doc) + expect(segments.length).toBe(2) + expect(segments[0].text).toBe('Hello ') + expect(segments[1].text).toBe('bold world') + }) + }) + + describe('extractForHighlighting() — Slate.js', () => { + it('should extract text from Slate children nodes', () => { + const doc = JSON.stringify([ + { + type: 'heading', + children: [{ text: 'Slate Heading' }] + }, + { + type: 'paragraph', + children: [ + { text: 'First part ' }, + { text: 'second part', bold: true } + ] + } + ]) + + const segments = extractForHighlighting(doc) + expect(segments.length).toBe(3) + expect(segments[0].text).toBe('Slate Heading') + expect(segments[0].contentCategory).toBe('heading') + expect(segments[1].text).toBe('First part ') + expect(segments[1].contentCategory).toBe('prose') + }) + }) + + describe('extractForHighlighting() — Lexical', () => { + it('should extract text from Lexical root structure', () => { + const doc = JSON.stringify({ + root: { + children: [ + { + type: 'heading', + children: [{ type: 'text', text: 'Lexical Heading' }] + }, + { + type: 'paragraph', + children: [{ type: 'text', text: 'Lexical body text' }] + } + ] + } + }) + + const segments = extractForHighlighting(doc) + expect(segments.length).toBe(2) + expect(segments[0].text).toBe('Lexical Heading') + expect(segments[0].contentCategory).toBe('heading') + expect(segments[1].text).toBe('Lexical body text') + expect(segments[1].contentCategory).toBe('prose') + }) + }) + + describe('extractForHighlighting() — Draft.js', () => { + it('should extract text from Draft.js blocks', () => { + const doc = JSON.stringify({ + blocks: [ + { type: 'header-one', text: 'Draft Heading' }, + { type: 'unstyled', text: 'Draft body text' } + ] + }) + + const segments = extractForHighlighting(doc) + expect(segments.length).toBeGreaterThanOrEqual(2) + // Draft.js blocks have text property directly with type + const headingSegment = segments.find(s => s.text === 'Draft Heading') + const bodySegment = segments.find(s => s.text === 'Draft body text') + expect(headingSegment).toBeDefined() + expect(bodySegment).toBeDefined() + }) + }) + + describe('extractForHighlighting() — Quill Delta', () => { + it('should extract text from Quill Delta ops', () => { + const doc = JSON.stringify({ + ops: [ + { insert: 'Hello World\n' }, + { insert: 'Second line\n' } + ] + }) + + const segments = extractForHighlighting(doc) + expect(segments.length).toBe(2) + expect(segments[0].text).toContain('Hello World') + expect(segments[1].text).toContain('Second line') + }) + }) + + describe('extractForHighlighting() — Generic JSON fallback', () => { + it('should extract all string values from unrecognized JSON', () => { + const generic = JSON.stringify({ + name: 'David', + description: 'A brave warrior', + stats: { strength: 10 } + }) + + const segments = extractForHighlighting(generic) + expect(segments.length).toBeGreaterThan(0) + const allText = segments.map(s => s.text).join(' ') + expect(allText).toContain('David') + expect(allText).toContain('brave warrior') + }) + + it('should return empty for JSON with no text', () => { + const noText = JSON.stringify({ count: 42, active: true }) + const segments = extractForHighlighting(noText) + // No string values to extract + expect(segments).toBeDefined() + }) + }) + + describe('extractForHighlighting() — HTML', () => { + it('should extract headings, paragraphs, and code', () => { + const html = '

Title

Body text here.

let x = 1
' + + const segments = extractForHighlighting(html) + expect(segments.length).toBeGreaterThanOrEqual(3) + + const headingSegment = segments.find(s => s.text === 'Title') + expect(headingSegment).toBeDefined() + expect(headingSegment?.contentCategory).toBe('heading') + + const bodySegment = segments.find(s => s.text === 'Body text here.') + expect(bodySegment).toBeDefined() + expect(bodySegment?.contentCategory).toBe('prose') + + const codeSegment = segments.find(s => s.text === 'let x = 1') + expect(codeSegment).toBeDefined() + expect(codeSegment?.contentCategory).toBe('code') + }) + + it('should skip script and style content', () => { + const html = '

Visible

Also visible

' + + const segments = extractForHighlighting(html) + const allText = segments.map(s => s.text).join(' ') + expect(allText).toContain('Visible') + expect(allText).toContain('Also visible') + expect(allText).not.toContain('alert') + expect(allText).not.toContain('.foo') + }) + + it('should decode common HTML entities', () => { + const html = '

Tom & Jerry <3

' + const segments = extractForHighlighting(html) + expect(segments.length).toBeGreaterThan(0) + expect(segments[0].text).toContain('Tom & Jerry <3') + }) + + it('should handle nested heading tags', () => { + const html = '

Nested Heading

' + const segments = extractForHighlighting(html) + const heading = segments.find(s => s.text === 'Nested Heading') + expect(heading).toBeDefined() + expect(heading?.contentCategory).toBe('heading') + }) + }) + + describe('extractForHighlighting() — Markdown', () => { + it('should extract headings', () => { + const md = '# Main Title\n\nSome body text\n\n## Subtitle' + + const segments = extractForHighlighting(md) + const heading1 = segments.find(s => s.text === 'Main Title') + const heading2 = segments.find(s => s.text === 'Subtitle') + const body = segments.find(s => s.text === 'Some body text') + + expect(heading1).toBeDefined() + expect(heading1?.contentCategory).toBe('heading') + expect(heading2).toBeDefined() + expect(heading2?.contentCategory).toBe('heading') + expect(body).toBeDefined() + expect(body?.contentCategory).toBe('prose') + }) + + it('should extract fenced code blocks', () => { + const md = '# Title\n\n```typescript\nconst x = 1\nconst y = 2\n```\n\nMore text' + + const segments = extractForHighlighting(md) + const code = segments.find(s => s.contentCategory === 'code') + expect(code).toBeDefined() + expect(code?.text).toContain('const x = 1') + }) + + it('should extract indented code blocks', () => { + // Indented code alone doesn't trigger markdown detection, so pass explicit type + const md = 'Normal text\n\n code line 1\n code line 2\n\nMore text' + + const segments = extractForHighlighting(md, 'markdown') + const code = segments.find(s => s.contentCategory === 'code') + expect(code).toBeDefined() + expect(code?.text).toContain('code line 1') + }) + }) + + describe('extractForHighlighting() — Plaintext', () => { + it('should return single prose segment for plain text', () => { + const text = 'Just some plain text content' + const segments = extractForHighlighting(text) + + expect(segments.length).toBe(1) + expect(segments[0].text).toBe(text) + expect(segments[0].contentCategory).toBe('prose') + }) + + it('should respect contentType override', () => { + // Even if content looks like HTML, plaintext hint should skip detection + const html = '

Title

' + const segments = extractForHighlighting(html, 'plaintext') + + expect(segments.length).toBe(1) + expect(segments[0].text).toBe(html) // Raw HTML, not extracted + expect(segments[0].contentCategory).toBe('prose') + }) + }) +}) diff --git a/tests/unit/utils/metadataIndex-text-indexing.test.ts b/tests/unit/utils/metadataIndex-text-indexing.test.ts index d3b34244..47c158d4 100644 --- a/tests/unit/utils/metadataIndex-text-indexing.test.ts +++ b/tests/unit/utils/metadataIndex-text-indexing.test.ts @@ -215,4 +215,70 @@ describe('MetadataIndexManager Text Indexing (v7.7.0)', () => { }) }) }) + + describe('extractTextContent() array handling (v7.9.0 fix)', () => { + let manager: MetadataIndexManager + + beforeEach(() => { + const mockStorage = { + getMetadata: async () => null, + saveMetadata: async () => {}, + getNoun: async () => null, + getNouns: async () => ({ items: [], total: 0, hasMore: false }), + getVerbsFromSource: async () => ({ items: [], total: 0, hasMore: false }), + } as any + + manager = new MetadataIndexManager(mockStorage, {}) + }) + + it('should skip numeric arrays (vectors/embeddings)', () => { + const result = manager.extractTextContent([0.1, 0.2, 0.3, 0.4, 0.5]) + expect(result).toBe('') + }) + + it('should extract text from string arrays', () => { + const result = manager.extractTextContent(['hello', 'world', 'test']) + expect(result).toContain('hello') + expect(result).toContain('world') + expect(result).toContain('test') + }) + + it('should extract text from arrays of objects', () => { + const result = manager.extractTextContent([ + { name: 'Alice', role: 'engineer' }, + { name: 'Bob', role: 'designer' }, + { name: 'Carol', role: 'manager' }, + { name: 'Dave', role: 'analyst' }, + { name: 'Eve', role: 'scientist' }, + { name: 'Frank', role: 'developer' }, + { name: 'Grace', role: 'architect' }, + { name: 'Heidi', role: 'lead' }, + { name: 'Ivan', role: 'intern' }, + { name: 'Judy', role: 'director' }, + { name: 'Karl', role: 'founder' }, + ]) + // Should NOT skip arrays of objects even with >10 elements + expect(result).toContain('Alice') + expect(result).toContain('Karl') + expect(result).toContain('engineer') + expect(result).toContain('founder') + }) + + it('should handle large numeric arrays (vectors)', () => { + const vector = Array.from({ length: 384 }, (_, i) => Math.sin(i)) + const result = manager.extractTextContent(vector) + expect(result).toBe('') + }) + + it('should handle empty arrays', () => { + const result = manager.extractTextContent([]) + expect(result).toBe('') + }) + + it('should handle mixed arrays starting with string', () => { + const result = manager.extractTextContent(['text', 42, true]) + expect(result).toContain('text') + expect(result).toContain('42') + }) + }) })