2026-01-26 17:16:18 -08:00
|
|
|
/**
|
|
|
|
|
* Hybrid Search Tests (v7.7.0)
|
|
|
|
|
*
|
|
|
|
|
* Tests for zero-config hybrid search combining semantic (vector) + text (keyword) matching
|
|
|
|
|
* using Reciprocal Rank Fusion (RRF).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { Brainy } from '../../../src/brainy'
|
|
|
|
|
import { createAddParams } from '../../helpers/test-factory'
|
|
|
|
|
import { NounType } from '../../../src/types/graphTypes'
|
|
|
|
|
|
|
|
|
|
describe('Hybrid Search (v7.7.0)', () => {
|
|
|
|
|
let brain: Brainy<any>
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
2026-06-09 14:58:25 -07:00
|
|
|
brain = new Brainy({ requireSubtype: false,
|
2026-01-26 17:16:18 -08:00
|
|
|
storage: { type: 'memory' }
|
|
|
|
|
})
|
|
|
|
|
await brain.init()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Zero-Config Hybrid (default mode)', () => {
|
|
|
|
|
it('should return results combining text and semantic matches', async () => {
|
|
|
|
|
// Add entities with specific text content
|
|
|
|
|
const davidId = await brain.add(createAddParams({
|
|
|
|
|
data: 'David Smith is a software engineer at Google',
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
metadata: { name: 'David Smith', role: 'engineer' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const johnId = await brain.add(createAddParams({
|
|
|
|
|
data: 'John Doe works as a data scientist',
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
metadata: { name: 'John Doe', role: 'scientist' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const janeId = await brain.add(createAddParams({
|
|
|
|
|
data: 'Jane Miller is a product manager',
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
metadata: { name: 'Jane Miller', role: 'manager' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Search for "David Smith" - should find exact text match
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'David Smith'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
expect(results[0].id).toBe(davidId)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should favor text matches for short queries', async () => {
|
|
|
|
|
// Add entities with similar semantic meaning but different exact text
|
|
|
|
|
const exactMatch = await brain.add(createAddParams({
|
|
|
|
|
data: 'Python programming language tutorial',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { title: 'Python Tutorial' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'JavaScript coding guide for beginners',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { title: 'JS Guide' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Short query "Python" should favor text match
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'Python',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// The exact text match for "Python" should rank high
|
|
|
|
|
const pythonResult = results.find(r => r.id === exactMatch)
|
|
|
|
|
expect(pythonResult).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should favor semantic matches for long queries', async () => {
|
|
|
|
|
// Add entities
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'Machine learning algorithms and neural networks',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { category: 'AI' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'Deep learning frameworks and artificial intelligence research',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { category: 'AI' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Long query should use semantic understanding
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'advanced artificial intelligence and machine learning techniques for data analysis',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// Both AI-related documents should appear in results
|
|
|
|
|
expect(results.some(r => r.metadata?.category === 'AI')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Search Mode Override', () => {
|
|
|
|
|
it('should use text-only search when searchMode is "text"', async () => {
|
|
|
|
|
const exactId = await brain.add(createAddParams({
|
|
|
|
|
data: 'JavaScript programming',
|
|
|
|
|
metadata: { exact: true }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'coding and software development',
|
|
|
|
|
metadata: { exact: false }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Force text-only search
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'JavaScript',
|
|
|
|
|
searchMode: 'text',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// Should find exact text match
|
|
|
|
|
expect(results[0].id).toBe(exactId)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should use semantic-only search when searchMode is "semantic"', async () => {
|
|
|
|
|
const aiId = await brain.add(createAddParams({
|
|
|
|
|
data: 'machine learning algorithms neural networks deep learning',
|
|
|
|
|
metadata: { category: 'AI' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'cooking recipes and food preparation kitchen',
|
|
|
|
|
metadata: { category: 'food' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Force semantic-only search
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'machine learning neural networks',
|
|
|
|
|
searchMode: 'semantic',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// Should return results using semantic search path
|
|
|
|
|
// (Note: exact ranking depends on embedding model)
|
|
|
|
|
expect(results.some(r => r.id === aiId)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should accept "vector" as alias for semantic', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'machine learning algorithms',
|
|
|
|
|
metadata: { category: 'AI' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'artificial intelligence',
|
|
|
|
|
searchMode: 'vector' as any, // Testing alias
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Hybrid Alpha Configuration', () => {
|
|
|
|
|
it('should use custom hybridAlpha when provided', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'exact text match test',
|
|
|
|
|
metadata: { type: 'text' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'similar semantic content',
|
|
|
|
|
metadata: { type: 'semantic' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Force more weight on text (alpha = 0.1)
|
|
|
|
|
const textWeightedResults = await brain.find({
|
|
|
|
|
query: 'exact text match test',
|
|
|
|
|
hybridAlpha: 0.1, // 90% text, 10% semantic
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(textWeightedResults.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should auto-detect alpha based on query length', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'test content for verification',
|
|
|
|
|
metadata: { test: true }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Short query (1-2 words) - should auto-detect alpha ~0.3
|
|
|
|
|
const shortResults = await brain.find({
|
|
|
|
|
query: 'test',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Long query (5+ words) - should auto-detect alpha ~0.7
|
|
|
|
|
const longResults = await brain.find({
|
|
|
|
|
query: 'test content for verification and analysis purposes',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Both should return results
|
|
|
|
|
expect(shortResults.length).toBeGreaterThan(0)
|
|
|
|
|
expect(longResults.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Word Tokenization', () => {
|
|
|
|
|
it('should find matches regardless of case', async () => {
|
|
|
|
|
const id = await brain.add(createAddParams({
|
|
|
|
|
data: 'UPPERCASE TEXT and lowercase text',
|
|
|
|
|
metadata: { mixed: true }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'uppercase',
|
|
|
|
|
searchMode: 'text',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
expect(results[0].id).toBe(id)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should ignore punctuation in text search', async () => {
|
|
|
|
|
const id = await brain.add(createAddParams({
|
|
|
|
|
data: 'Hello, World! How are you?',
|
|
|
|
|
metadata: { greeting: true }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'hello world',
|
|
|
|
|
searchMode: 'text',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
expect(results[0].id).toBe(id)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle multi-word searches', async () => {
|
|
|
|
|
const id = await brain.add(createAddParams({
|
|
|
|
|
data: 'software engineering best practices for web development',
|
|
|
|
|
metadata: { topic: 'engineering' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'cooking recipes for healthy meals',
|
|
|
|
|
metadata: { topic: 'food' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Multi-word search should match entities with more matching words
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'software engineering web development',
|
|
|
|
|
searchMode: 'text',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
expect(results[0].id).toBe(id)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Integration with Filters', () => {
|
|
|
|
|
it('should combine hybrid search with metadata filters', async () => {
|
|
|
|
|
const pythonId = await brain.add(createAddParams({
|
|
|
|
|
data: 'Python programming tutorial',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { language: 'python' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'JavaScript programming tutorial',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { language: 'javascript' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'Cooking recipes and kitchen tips',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { language: 'english' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Hybrid search + metadata filter - only find python documents
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'programming tutorial',
|
|
|
|
|
where: { language: 'python' },
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Should find only the Python document
|
|
|
|
|
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
|
|
|
expect(results.some(r => r.id === pythonId)).toBe(true)
|
|
|
|
|
expect(results.every(r => r.metadata?.language === 'python')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should combine hybrid search with type filters', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'John Smith person profile',
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
metadata: { name: 'John Smith' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const docId = await brain.add(createAddParams({
|
|
|
|
|
data: 'John Smith document reference',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { author: 'John Smith' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Search for "John Smith" but only documents
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'John Smith',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBe(1)
|
|
|
|
|
expect(results[0].id).toBe(docId)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('RRF Fusion Algorithm', () => {
|
|
|
|
|
it('should rank entities appearing in both text and semantic results higher', async () => {
|
|
|
|
|
// Entity that should match both text and semantic
|
|
|
|
|
const bothMatchId = await brain.add(createAddParams({
|
|
|
|
|
data: 'machine learning artificial intelligence neural networks',
|
|
|
|
|
metadata: { relevance: 'high' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Entity that matches semantically but not exact text
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'deep learning and AI algorithms',
|
|
|
|
|
metadata: { relevance: 'medium' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Entity that matches text but less semantically
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'machine learning is a buzzword',
|
|
|
|
|
metadata: { relevance: 'low' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'machine learning',
|
|
|
|
|
limit: 10
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// The entity that matches both should rank high
|
|
|
|
|
const topResult = results.find(r => r.id === bothMatchId)
|
|
|
|
|
expect(topResult).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Edge Cases', () => {
|
|
|
|
|
it('should handle empty query gracefully', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'test entity',
|
|
|
|
|
metadata: {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: '',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Empty query should return results (metadata-only search)
|
|
|
|
|
expect(results.length).toBeGreaterThanOrEqual(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle query with no text matches', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'completely unrelated content',
|
|
|
|
|
metadata: {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Query with words not in any entity
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'xyzabc123',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Should still return semantic results even if no text matches
|
|
|
|
|
expect(results).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle special characters in query', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'C++ programming guide',
|
|
|
|
|
metadata: {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'C++ programming',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle very short single-character queries', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'a b c d e f',
|
|
|
|
|
metadata: {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'a',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Single char queries may not match (min word length is 2)
|
|
|
|
|
expect(results).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Performance', () => {
|
|
|
|
|
it('should complete hybrid search in reasonable time', async () => {
|
|
|
|
|
// Add multiple entities
|
|
|
|
|
const promises = Array.from({ length: 20 }, (_, i) =>
|
|
|
|
|
brain.add(createAddParams({
|
|
|
|
|
data: `Test document number ${i} with various content about software engineering`,
|
|
|
|
|
metadata: { index: i }
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
await Promise.all(promises)
|
|
|
|
|
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'software engineering document',
|
|
|
|
|
limit: 10
|
|
|
|
|
})
|
|
|
|
|
const duration = Date.now() - startTime
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// Should complete within reasonable time (allowing for embedding generation)
|
|
|
|
|
expect(duration).toBeLessThan(5000)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Large Document Support (v7.8.0)', () => {
|
|
|
|
|
it('should index documents with 1000+ words without arbitrary limits', async () => {
|
|
|
|
|
// Create a document with 1000+ unique words
|
|
|
|
|
const words: string[] = []
|
|
|
|
|
for (let i = 0; i < 1100; i++) {
|
|
|
|
|
words.push(`word${i}`)
|
|
|
|
|
}
|
|
|
|
|
const largeText = words.join(' ')
|
|
|
|
|
|
|
|
|
|
const id = await brain.add(createAddParams({
|
|
|
|
|
data: largeText,
|
|
|
|
|
metadata: { wordCount: words.length }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Search for a word in the middle of the document (beyond old 50-word limit)
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'word500 word999',
|
|
|
|
|
searchMode: 'text',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
expect(results[0].id).toBe(id)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should not trigger sanity warnings for word-heavy entities', async () => {
|
|
|
|
|
// Create an entity with many words but simple metadata
|
|
|
|
|
const manyWords = Array.from({ length: 200 }, (_, i) => `word${i}`).join(' ')
|
|
|
|
|
|
|
|
|
|
// This should not trigger any warnings
|
|
|
|
|
const id = await brain.add(createAddParams({
|
|
|
|
|
data: manyWords,
|
|
|
|
|
metadata: { simple: 'metadata' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
expect(id).toBeTruthy()
|
|
|
|
|
|
|
|
|
|
// Verify we can find it
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'word150',
|
|
|
|
|
searchMode: 'text',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Match Visibility (v7.8.0)', () => {
|
|
|
|
|
it('should return textMatches showing which query words matched', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'David Smith is a brave warrior who battles dragons',
|
|
|
|
|
type: NounType.Person,
|
|
|
|
|
metadata: { name: 'David Smith' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'david warrior',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// Should show which words from the query matched
|
|
|
|
|
expect(results[0].textMatches).toBeDefined()
|
|
|
|
|
expect(results[0].textMatches).toContain('david')
|
|
|
|
|
expect(results[0].textMatches).toContain('warrior')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should return textScore and semanticScore separately', async () => {
|
|
|
|
|
await brain.add(createAddParams({
|
|
|
|
|
data: 'machine learning artificial intelligence neural networks',
|
|
|
|
|
metadata: { topic: 'AI' }
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'machine learning',
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
// Should have separate scores
|
|
|
|
|
expect(results[0].textScore).toBeDefined()
|
|
|
|
|
expect(typeof results[0].textScore).toBe('number')
|
|
|
|
|
expect(results[0].semanticScore).toBeDefined()
|
|
|
|
|
expect(typeof results[0].semanticScore).toBe('number')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should return matchSource indicating where result came from', async () => {
|
|
|
|
|
// Entity that matches both text and semantic
|
|
|
|
|
const bothId = await brain.add(createAddParams({
|
|
|
|
|
data: 'Python programming language tutorial',
|
|
|
|
|
metadata: {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'Python programming',
|
|
|
|
|
limit: 10
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(results.length).toBeGreaterThan(0)
|
|
|
|
|
const matchingResult = results.find(r => r.id === bothId)
|
|
|
|
|
expect(matchingResult).toBeDefined()
|
|
|
|
|
expect(matchingResult?.matchSource).toBeDefined()
|
|
|
|
|
expect(['text', 'semantic', 'both']).toContain(matchingResult?.matchSource)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should show matchSource as "both" for entities matching text AND semantic', async () => {
|
|
|
|
|
const id = await brain.add(createAddParams({
|
|
|
|
|
data: 'JavaScript Node.js programming backend development',
|
|
|
|
|
metadata: {}
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
query: 'JavaScript programming', // Exact words + semantic similarity
|
|
|
|
|
limit: 5
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = results.find(r => r.id === id)
|
|
|
|
|
expect(result).toBeDefined()
|
|
|
|
|
// This should match both text (exact words) and semantic
|
|
|
|
|
expect(result?.matchSource).toBe('both')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Hybrid Highlighting (v7.8.0)', () => {
|
|
|
|
|
it('should return both text and semantic matches with matchType', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'warrior fighter', // "warrior" and "fighter" are query words
|
|
|
|
|
text: 'A brave warrior and a skilled soldier fight battles',
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.3
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(highlights.length).toBeGreaterThan(0)
|
|
|
|
|
|
|
|
|
|
// Should have text matches (exact query words)
|
|
|
|
|
const textMatches = highlights.filter(h => h.matchType === 'text')
|
|
|
|
|
const semanticMatches = highlights.filter(h => h.matchType === 'semantic')
|
|
|
|
|
|
|
|
|
|
// "warrior" and "fighter" should be text matches
|
|
|
|
|
const textWords = textMatches.map(h => h.text.toLowerCase())
|
|
|
|
|
expect(textWords).toContain('warrior')
|
|
|
|
|
|
|
|
|
|
// Text matches should have score = 1.0
|
|
|
|
|
for (const h of textMatches) {
|
|
|
|
|
expect(h.score).toBe(1.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Semantic matches should have score < 1.0 but >= threshold
|
|
|
|
|
for (const h of semanticMatches) {
|
|
|
|
|
expect(h.score).toBeGreaterThanOrEqual(0.3)
|
|
|
|
|
expect(h.score).toBeLessThan(1.0)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should highlight semantically similar words', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'warrior',
|
|
|
|
|
text: 'David is a brave soldier who battles enemies',
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.3
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(highlights.length).toBeGreaterThan(0)
|
|
|
|
|
// Should find semantically similar words like "soldier", "battles"
|
|
|
|
|
const highlightedWords = highlights.map(h => h.text.toLowerCase())
|
|
|
|
|
expect(highlightedWords.some(w => ['soldier', 'battles', 'brave', 'enemies', 'david'].includes(w))).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should return scores for each highlight', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'programming',
|
|
|
|
|
text: 'Software engineering code development Python JavaScript',
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.3
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(highlights.length).toBeGreaterThan(0)
|
|
|
|
|
for (const h of highlights) {
|
|
|
|
|
expect(typeof h.score).toBe('number')
|
|
|
|
|
expect(h.score).toBeGreaterThanOrEqual(0.3)
|
|
|
|
|
expect(h.score).toBeLessThanOrEqual(1)
|
|
|
|
|
expect(['text', 'semantic']).toContain(h.matchType)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should return positions for each highlight', async () => {
|
|
|
|
|
const text = 'Apple banana cherry date'
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'fruit',
|
|
|
|
|
text,
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.2
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const h of highlights) {
|
|
|
|
|
expect(h.position).toBeDefined()
|
|
|
|
|
expect(h.position.length).toBe(2)
|
|
|
|
|
expect(h.position[0]).toBeLessThan(h.position[1])
|
|
|
|
|
// Verify the position matches the text
|
|
|
|
|
expect(text.substring(h.position[0], h.position[1])).toBe(h.text)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should filter semantic matches by threshold', async () => {
|
|
|
|
|
const highlightsLow = await brain.highlight({
|
|
|
|
|
query: 'food',
|
|
|
|
|
text: 'Apple banana cherry date elephant forest guitar',
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.2
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const highlightsHigh = await brain.highlight({
|
|
|
|
|
query: 'food',
|
|
|
|
|
text: 'Apple banana cherry date elephant forest guitar',
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.6
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Higher threshold should return fewer or equal results
|
|
|
|
|
expect(highlightsHigh.length).toBeLessThanOrEqual(highlightsLow.length)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should support sentence granularity', async () => {
|
|
|
|
|
const text = 'The cat sat on the mat. Dogs love to play. Birds fly south in winter.'
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'animals pets',
|
|
|
|
|
text,
|
|
|
|
|
granularity: 'sentence',
|
|
|
|
|
threshold: 0.3
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const h of highlights) {
|
|
|
|
|
// Each highlight should be a full sentence
|
|
|
|
|
expect(h.text).toMatch(/[.!?]$/)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should skip stopwords when highlighting words', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'test',
|
|
|
|
|
text: 'the a an is are test important',
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.1
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const highlightedWords = highlights.map(h => h.text.toLowerCase())
|
|
|
|
|
// Should not include common stopwords
|
|
|
|
|
expect(highlightedWords).not.toContain('the')
|
|
|
|
|
expect(highlightedWords).not.toContain('a')
|
|
|
|
|
expect(highlightedWords).not.toContain('an')
|
|
|
|
|
expect(highlightedWords).not.toContain('is')
|
|
|
|
|
expect(highlightedWords).not.toContain('are')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle empty text gracefully', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'test',
|
|
|
|
|
text: '',
|
|
|
|
|
granularity: 'word'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(highlights).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle empty query gracefully', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: '',
|
|
|
|
|
text: 'some text content',
|
|
|
|
|
granularity: 'word'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(highlights).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should prioritize text matches over semantic for same word', async () => {
|
|
|
|
|
const highlights = await brain.highlight({
|
|
|
|
|
query: 'programming',
|
|
|
|
|
text: 'programming is fun and coding is great', // "programming" is in text
|
|
|
|
|
granularity: 'word',
|
|
|
|
|
threshold: 0.3
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// The exact word "programming" should be a text match with score 1.0
|
|
|
|
|
const programmingMatch = highlights.find(h => h.text.toLowerCase() === 'programming')
|
|
|
|
|
expect(programmingMatch).toBeDefined()
|
|
|
|
|
expect(programmingMatch?.matchType).toBe('text')
|
|
|
|
|
expect(programmingMatch?.score).toBe(1.0)
|
|
|
|
|
})
|
|
|
|
|
})
|
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
2026-01-27 10:27:22 -08:00
|
|
|
|
|
|
|
|
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
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
expect(warriorMatch?.contentCategory).toBe('title')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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')
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
expect(introMatch?.contentCategory).toBe('title')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 = '<h1>Warrior Guide</h1><p>A brave fighter battles enemies.</p><code>const warrior = new Fighter()</code>'
|
|
|
|
|
|
|
|
|
|
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()
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
expect(warriorMatch?.contentCategory).toBe('title')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
|
|
|
|
|
// Should find "fighter" from paragraph
|
|
|
|
|
const fighterMatch = highlights.find(h => h.text.toLowerCase() === 'fighter')
|
|
|
|
|
expect(fighterMatch).toBeDefined()
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
expect(fighterMatch?.contentCategory).toBe('content')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
const headingMatch = highlights.find(h => h.text === 'Guide' && h.contentCategory === 'title')
|
|
|
|
|
|| highlights.find(h => h.text === 'Warrior' && h.contentCategory === 'title')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
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)
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
// Plain text gets 'content' category
|
|
|
|
|
expect(warriorMatch?.contentCategory).toBe('content')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should use contentType hint to skip auto-detection', async () => {
|
|
|
|
|
// Pass HTML as plain text via content type hint
|
|
|
|
|
const htmlAsPlain = '<h1>test</h1>'
|
|
|
|
|
|
|
|
|
|
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 [
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
{ text: 'custom extracted warrior content', contentCategory: 'content' as const },
|
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
2026-01-27 10:27:22 -08:00
|
|
|
{ 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')
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
expect(warriorMatch?.contentCategory).toBe('content')
|
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
2026-01-27 10:27:22 -08:00
|
|
|
})
|
feat: expand ContentCategory to universal 6-category set for highlight()
Replace document-centric categories (prose/heading/code/label) with a
universal set that works across documents, code, and UI:
- title: headings, identifiers, labels, JSON keys
- annotation: comments, docstrings, captions, alt text
- content: paragraphs, list items, flowing text
- value: string literals, numbers, form values
- code: unparsed code blocks
- structural: keywords, operators, punctuation
Built-in extractors now produce title/content/code. All 6 categories
are available for custom parsers (e.g. tree-sitter).
Also adds inline code detection in Markdown: backtick spans within
prose lines are split into separate code/content segments.
2026-01-27 13:44:58 -08:00
|
|
|
|
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
2026-01-27 10:27:22 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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([])
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-01-26 17:16:18 -08:00
|
|
|
})
|