feat: exact-match-first ranking in search and wikilink autocomplete
Add searchRank/bestSearchRank utilities that compute a tier (0=exact, 1=prefix, 2=fuzzy-only). Both useNoteSearch and WikilinkChatInput now sort by rank tier first, then by fuzzy score, ensuring notes with exact title or alias matches always surface above partial/fuzzy matches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fuzzyMatch } from './fuzzyMatch'
|
||||
import { fuzzyMatch, searchRank, bestSearchRank } from './fuzzyMatch'
|
||||
|
||||
describe('fuzzyMatch', () => {
|
||||
it('matches exact string', () => {
|
||||
@@ -44,3 +44,43 @@ describe('fuzzyMatch', () => {
|
||||
expect(fuzzyMatch('a', '').match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchRank', () => {
|
||||
it('returns 0 for exact match', () => {
|
||||
expect(searchRank('Refactoring', 'Refactoring')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for case-insensitive exact match', () => {
|
||||
expect(searchRank('refactoring', 'Refactoring')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 1 for prefix match', () => {
|
||||
expect(searchRank('Refactoring', 'Refactoring Ideas')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 1 for case-insensitive prefix match', () => {
|
||||
expect(searchRank('quarter', 'Quarter Review')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 2 for non-prefix fuzzy match', () => {
|
||||
expect(searchRank('Ideas', 'Refactoring Ideas')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bestSearchRank', () => {
|
||||
it('returns best rank across title and aliases', () => {
|
||||
expect(bestSearchRank('ref', 'Refactoring Notes', ['ref'])).toBe(0)
|
||||
})
|
||||
|
||||
it('returns title rank when no aliases match better', () => {
|
||||
expect(bestSearchRank('Refactoring', 'Refactoring', [])).toBe(0)
|
||||
})
|
||||
|
||||
it('prefers alias exact match over title prefix match', () => {
|
||||
expect(bestSearchRank('ref', 'Reference Manual', ['ref'])).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 2 when nothing matches as exact or prefix', () => {
|
||||
expect(bestSearchRank('ideas', 'Refactoring Ideas', ['thoughts'])).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/** Search rank tier: 0 = exact match, 1 = prefix match, 2 = fuzzy only. Lower is better. */
|
||||
export function searchRank(query: string, target: string): number {
|
||||
const q = query.toLowerCase()
|
||||
const t = target.toLowerCase()
|
||||
if (t === q) return 0
|
||||
if (t.startsWith(q)) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
/** Best rank across a title and its aliases. */
|
||||
export function bestSearchRank(query: string, title: string, aliases: string[]): number {
|
||||
let rank = searchRank(query, title)
|
||||
for (const alias of aliases) {
|
||||
rank = Math.min(rank, searchRank(query, alias))
|
||||
if (rank === 0) break
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
/** Fuzzy match: all query chars must appear in order in the target. */
|
||||
export function fuzzyMatch(query: string, target: string): { match: boolean; score: number } {
|
||||
const q = query.toLowerCase()
|
||||
|
||||
Reference in New Issue
Block a user