fix: prioritize exact substring matches over fuzzy in model search
- Exact substring in label (earliest position wins) - Word-boundary prefix matches - Description substring matches - Fuzzy matching as fallback This ensures 'opus' shows claude-3-opus before openrouter models.
This commit is contained in:
committed by
Peter Steinberger
parent
de44e0ad33
commit
950f8a04ea
@@ -45,6 +45,41 @@ describe("SearchableSelectList", () => {
|
||||
expect(selected?.value).toBe("google/gemini-pro");
|
||||
});
|
||||
|
||||
it("prioritizes exact substring matches over fuzzy matches", () => {
|
||||
// Add items where one has early exact match, others are fuzzy or late matches
|
||||
const items = [
|
||||
{ value: "openrouter/auto", label: "openrouter/auto", description: "Routes to best" },
|
||||
{ value: "opus-direct", label: "opus-direct", description: "Direct opus model" },
|
||||
{ value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" },
|
||||
];
|
||||
const list = new SearchableSelectList(items, 5, mockTheme);
|
||||
|
||||
// Type "opus" - should match "opus-direct" first (earliest exact substring)
|
||||
for (const ch of "opus") {
|
||||
list.handleInput(ch);
|
||||
}
|
||||
|
||||
// First result should be "opus-direct" where "opus" appears at position 0
|
||||
const selected = list.getSelectedItem();
|
||||
expect(selected?.value).toBe("opus-direct");
|
||||
});
|
||||
|
||||
it("exact label match beats description match", () => {
|
||||
const items = [
|
||||
{ value: "provider/other", label: "provider/other", description: "This mentions opus in description" },
|
||||
{ value: "provider/opus-model", label: "provider/opus-model", description: "Something else" },
|
||||
];
|
||||
const list = new SearchableSelectList(items, 5, mockTheme);
|
||||
|
||||
for (const ch of "opus") {
|
||||
list.handleInput(ch);
|
||||
}
|
||||
|
||||
// Label match should win over description match
|
||||
const selected = list.getSelectedItem();
|
||||
expect(selected?.value).toBe("provider/opus-model");
|
||||
});
|
||||
|
||||
it("filters items with fuzzy matching", () => {
|
||||
const list = new SearchableSelectList(testItems, 5, mockTheme);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export class SearchableSelectList implements Component {
|
||||
if (!query) {
|
||||
this.filteredItems = this.items;
|
||||
} else {
|
||||
this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`);
|
||||
this.filteredItems = this.smartFilter(query);
|
||||
}
|
||||
|
||||
// Reset selection when filter changes
|
||||
@@ -54,6 +54,69 @@ export class SearchableSelectList implements Component {
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart filtering that prioritizes:
|
||||
* 1. Exact substring match in label (highest priority)
|
||||
* 2. Word-boundary prefix match in label
|
||||
* 3. Exact substring match in description
|
||||
* 4. Fuzzy match (lowest priority)
|
||||
*/
|
||||
private smartFilter(query: string): SelectItem[] {
|
||||
const q = query.toLowerCase();
|
||||
|
||||
type ScoredItem = { item: SelectItem; score: number };
|
||||
const scored: ScoredItem[] = [];
|
||||
|
||||
for (const item of this.items) {
|
||||
const label = item.label.toLowerCase();
|
||||
const desc = (item.description ?? "").toLowerCase();
|
||||
let score = Infinity;
|
||||
|
||||
// Tier 1: Exact substring in label (score 0-99)
|
||||
const labelIndex = label.indexOf(q);
|
||||
if (labelIndex !== -1) {
|
||||
// Earlier match = better score
|
||||
score = labelIndex;
|
||||
}
|
||||
// Tier 2: Word-boundary prefix in label (score 100-199)
|
||||
else if (this.matchesWordBoundary(label, q)) {
|
||||
score = 100;
|
||||
}
|
||||
// Tier 3: Exact substring in description (score 200-299)
|
||||
else if (desc.indexOf(q) !== -1) {
|
||||
score = 200;
|
||||
}
|
||||
// Tier 4: Fuzzy match (score 300+)
|
||||
else {
|
||||
const fuzzyResult = fuzzyFilter([item], query, (i) => `${i.label} ${i.description ?? ""}`);
|
||||
if (fuzzyResult.length > 0) {
|
||||
score = 300;
|
||||
}
|
||||
}
|
||||
|
||||
if (score !== Infinity) {
|
||||
scored.push({ item, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (lower = better)
|
||||
scored.sort((a, b) => a.score - b.score);
|
||||
return scored.map((s) => s.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query matches at a word boundary in text.
|
||||
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
|
||||
*/
|
||||
private matchesWordBoundary(text: string, query: string): boolean {
|
||||
const wordBoundaryRegex = new RegExp(`(?:^|[\\s\\-_./:])(${this.escapeRegex(query)})`, "i");
|
||||
return wordBoundaryRegex.test(text);
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
setSelectedIndex(index: number) {
|
||||
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user