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");
|
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", () => {
|
it("filters items with fuzzy matching", () => {
|
||||||
const list = new SearchableSelectList(testItems, 5, mockTheme);
|
const list = new SearchableSelectList(testItems, 5, mockTheme);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class SearchableSelectList implements Component {
|
|||||||
if (!query) {
|
if (!query) {
|
||||||
this.filteredItems = this.items;
|
this.filteredItems = this.items;
|
||||||
} else {
|
} else {
|
||||||
this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`);
|
this.filteredItems = this.smartFilter(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset selection when filter changes
|
// Reset selection when filter changes
|
||||||
@@ -54,6 +54,69 @@ export class SearchableSelectList implements Component {
|
|||||||
this.notifySelectionChange();
|
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) {
|
setSelectedIndex(index: number) {
|
||||||
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user