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:
lucaronin
2026-03-08 21:00:58 +01:00
parent dd7f0f978a
commit 4ce9167e09
5 changed files with 110 additions and 11 deletions

View File

@@ -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)
})
})

View File

@@ -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()