mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Fix cmd+j search ranking to require coverage of meaningful query words
Extract tokenization logic into a shared module to ensure consistent ranking across settings and project search bands. Enhance ranking with: - Coverage requirement: candidates must match most meaningful words, not just one (fixes "linear triage" matching all projects on "linear" alone) - Filler words: ignore navigation words like "open", "go", "the" when measuring coverage - Unicode support: split on Unicode word boundaries, not ASCII only
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { isCmdJPaletteQueryTooLarge } from './palette-results'
|
||||
import {
|
||||
cmdJPaletteTokenScore,
|
||||
normalizeCmdJPaletteQuery,
|
||||
uniqueNormalizedCmdJPaletteKeywords
|
||||
} from './palette-query-tokens'
|
||||
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
@@ -39,73 +44,6 @@ type RankedProjectResult = {
|
||||
const PROJECT_GROUP_ALIASES = ['group', 'repo group']
|
||||
const PROJECT_ALIASES = ['project', 'repo']
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
let normalized = ''
|
||||
let pendingWhitespace = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (isCmdJPaletteWhitespace(code)) {
|
||||
pendingWhitespace = normalized.length > 0
|
||||
continue
|
||||
}
|
||||
if (pendingWhitespace) {
|
||||
normalized += ' '
|
||||
pendingWhitespace = false
|
||||
}
|
||||
normalized += value.charAt(index).toLowerCase()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isCmdJPaletteWhitespace(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 160 ||
|
||||
code === 5760 ||
|
||||
(code >= 8192 && code <= 8202) ||
|
||||
code === 8232 ||
|
||||
code === 8233 ||
|
||||
code === 8239 ||
|
||||
code === 8287 ||
|
||||
code === 12288 ||
|
||||
code === 65279
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueNormalized(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return normalizeQuery(value)
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function tokenScore(query: string, values: readonly string[]): number {
|
||||
const candidateTokens = values.flatMap(tokenize)
|
||||
if (candidateTokens.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let score = 0
|
||||
for (const queryToken of tokenize(query)) {
|
||||
let best = 0
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken === queryToken) {
|
||||
best = Math.max(best, 3)
|
||||
} else if (candidateToken.startsWith(queryToken)) {
|
||||
best = Math.max(best, 2)
|
||||
} else if (candidateToken.includes(queryToken)) {
|
||||
best = Math.max(best, 1)
|
||||
}
|
||||
}
|
||||
score += best
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
@@ -134,7 +72,7 @@ function buildCmdJProjectSearchCandidates({
|
||||
),
|
||||
rowKey: getProjectGroupHeaderKey(group.id),
|
||||
order,
|
||||
keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES])
|
||||
keywords: uniqueNormalizedCmdJPaletteKeywords([group.name, ...PROJECT_GROUP_ALIASES])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,7 +94,11 @@ function buildCmdJProjectSearchCandidates({
|
||||
rowKey: target.key,
|
||||
repo: target.repo,
|
||||
order: projectGroups.length + repoIndex,
|
||||
keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES])
|
||||
keywords: uniqueNormalizedCmdJPaletteKeywords([
|
||||
target.label,
|
||||
repo.displayName,
|
||||
...PROJECT_ALIASES
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,7 +133,7 @@ function projectRankingForCandidate(
|
||||
query: string,
|
||||
candidate: CmdJProjectSearchResult
|
||||
): RankedProjectResult | null {
|
||||
const title = normalizeQuery(candidate.title)
|
||||
const title = normalizeCmdJPaletteQuery(candidate.title)
|
||||
if (query === title) {
|
||||
return { result: candidate, rule: 1, score: 0 }
|
||||
}
|
||||
@@ -199,13 +141,13 @@ function projectRankingForCandidate(
|
||||
return { result: candidate, rule: 2, score: 0 }
|
||||
}
|
||||
const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES
|
||||
if (aliasKeywords.map(normalizeQuery).includes(query)) {
|
||||
if (aliasKeywords.map(normalizeCmdJPaletteQuery).includes(query)) {
|
||||
return { result: candidate, rule: 3, score: 0 }
|
||||
}
|
||||
if (candidate.keywords.some((keyword) => keyword.startsWith(query))) {
|
||||
return { result: candidate, rule: 4, score: 0 }
|
||||
}
|
||||
const score = tokenScore(query, [candidate.title, ...candidate.keywords])
|
||||
const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords])
|
||||
return score > 0 ? { result: candidate, rule: 5, score } : null
|
||||
}
|
||||
|
||||
@@ -242,7 +184,7 @@ export function searchCmdJProjectResults({
|
||||
if (isCmdJPaletteQueryTooLarge(query)) {
|
||||
return []
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
const normalizedQuery = normalizeCmdJPaletteQuery(query)
|
||||
// Why: project/group rows sit after worktree matches, so one-character
|
||||
// searches would add broad noisy navigation targets before intent is clear.
|
||||
if (normalizedQuery.length < 2) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Why: the middle band and the project band scored queries with byte-identical copies of
|
||||
// this logic, so ranking fixes only ever landed in one of the two.
|
||||
|
||||
function isCmdJPaletteWhitespace(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 160 ||
|
||||
code === 5760 ||
|
||||
(code >= 8192 && code <= 8202) ||
|
||||
code === 8232 ||
|
||||
code === 8233 ||
|
||||
code === 8239 ||
|
||||
code === 8287 ||
|
||||
code === 12288 ||
|
||||
code === 65279
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeCmdJPaletteQuery(value: string): string {
|
||||
let normalized = ''
|
||||
let pendingWhitespace = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (isCmdJPaletteWhitespace(code)) {
|
||||
pendingWhitespace = normalized.length > 0
|
||||
continue
|
||||
}
|
||||
if (pendingWhitespace) {
|
||||
normalized += ' '
|
||||
pendingWhitespace = false
|
||||
}
|
||||
normalized += value.charAt(index).toLowerCase()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map(normalizeCmdJPaletteQuery).filter(Boolean))]
|
||||
}
|
||||
|
||||
// Why: splitting on non-ASCII would drop CJK and accented words entirely, so a localized
|
||||
// query would silently skip the coverage rule below instead of failing it.
|
||||
export function tokenizeCmdJPaletteQuery(value: string): string[] {
|
||||
return normalizeCmdJPaletteQuery(value)
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an
|
||||
// unmatched filler word must not count against a candidate's coverage.
|
||||
const CMD_J_QUERY_FILLER_TOKENS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'and',
|
||||
'change',
|
||||
'edit',
|
||||
'for',
|
||||
'from',
|
||||
'go',
|
||||
'goto',
|
||||
'in',
|
||||
'into',
|
||||
'jump',
|
||||
'me',
|
||||
'my',
|
||||
'of',
|
||||
'on',
|
||||
'open',
|
||||
'please',
|
||||
'set',
|
||||
'show',
|
||||
'the',
|
||||
'to',
|
||||
'view',
|
||||
'with'
|
||||
])
|
||||
|
||||
export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number {
|
||||
const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery)
|
||||
if (candidateTokens.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let score = 0
|
||||
let meaningful = 0
|
||||
let covered = 0
|
||||
for (const queryToken of tokenizeCmdJPaletteQuery(query)) {
|
||||
let best = 0
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken === queryToken) {
|
||||
best = Math.max(best, 3)
|
||||
} else if (candidateToken.startsWith(queryToken)) {
|
||||
best = Math.max(best, 2)
|
||||
} else if (candidateToken.includes(queryToken)) {
|
||||
best = Math.max(best, 1)
|
||||
}
|
||||
}
|
||||
score += best
|
||||
if (CMD_J_QUERY_FILLER_TOKENS.has(queryToken)) {
|
||||
continue
|
||||
}
|
||||
meaningful += 1
|
||||
if (best > 0) {
|
||||
covered += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Why: "linear triage" matched the Linear pane as strongly as "linear" did, so a candidate
|
||||
// now has to cover most of what was typed, not just one word of it.
|
||||
if (meaningful > 0 && covered * 2 <= meaningful) {
|
||||
return 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
@@ -208,6 +208,59 @@ describe('Cmd+J palette middle-band ranking', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('drops candidates that cover a minority of the words typed', () => {
|
||||
// Why: "linear triage" used to surface the Linear and Integrations panes on the
|
||||
// "linear" token alone, with the unmatched word costing nothing. See screenshot report.
|
||||
const integrationSections: SettingsNavSection[] = [
|
||||
{
|
||||
id: 'linear',
|
||||
title: 'Linear',
|
||||
description: 'How Linear works in Orca.',
|
||||
icon: Settings,
|
||||
searchEntries: [],
|
||||
group: 'capabilities'
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Integrations',
|
||||
description: 'Connect GitHub, GitLab, and Linear.',
|
||||
icon: Settings,
|
||||
searchEntries: [],
|
||||
group: 'setup'
|
||||
}
|
||||
]
|
||||
const rank = (query: string): string[] =>
|
||||
rankCmdJMiddleResults({
|
||||
query,
|
||||
settingsResults: buildCmdJSettingsResults(integrationSections),
|
||||
actionResults: []
|
||||
}).map((result) => result.id)
|
||||
|
||||
expect(rank('linear triage')).toEqual([])
|
||||
expect(rank('linear')).toEqual(['settings:linear', 'settings:integrations'])
|
||||
// Why: a majority still counts, so one stray word cannot blank an otherwise good match.
|
||||
expect(rank('linear integrations triage')).toEqual(['settings:integrations'])
|
||||
})
|
||||
|
||||
it('keeps out-of-order partial-word matches when every query word lands somewhere', () => {
|
||||
expect(top('font term')).toBe('settings:terminal')
|
||||
expect(top('font sparkles')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores navigation filler words when measuring coverage', () => {
|
||||
// Why: "open"/"go to" carry no intent, so they must not read as unmatched words
|
||||
// and blank the band mid-query.
|
||||
expect(top('open terminal settings')).toBe('settings:terminal')
|
||||
expect(top('go to ssh settings')).toBe('settings:ssh')
|
||||
expect(top('change the terminal font')).toBe('settings:terminal')
|
||||
})
|
||||
|
||||
it('counts non-latin query words instead of discarding them', () => {
|
||||
// Why: tokenizing on ASCII only let a localized query skip the coverage rule entirely.
|
||||
expect(top('ssh 主机 设置')).toBeUndefined()
|
||||
expect(top('ssh 设置')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match settings on one-character or description-only queries', () => {
|
||||
expect(top('t')).toBeUndefined()
|
||||
expect(top('cookie import')).toBeUndefined()
|
||||
@@ -316,6 +369,23 @@ function projectGroup(id: string, name: string, parentGroupId: string | null = n
|
||||
}
|
||||
|
||||
describe('Cmd+J project and repo-group search', () => {
|
||||
it('drops projects that only match the query through a generic alias word', () => {
|
||||
// Why: every project carries the 'repo'/'project' aliases, so "repo triage" used to
|
||||
// match all of them on that word alone — the same bug the middle band had.
|
||||
const search = (query: string): string[] =>
|
||||
searchCmdJProjectResults({
|
||||
query,
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'linear-sync'), repo('repo-2', 'billing')],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
}).map((result) => result.title)
|
||||
|
||||
expect(search('repo triage')).toEqual([])
|
||||
expect(search('linear triage')).toEqual([])
|
||||
expect(search('linear repo')).toEqual(['linear-sync'])
|
||||
})
|
||||
|
||||
it('finds a Project Group by name', () => {
|
||||
const [result] = searchCmdJProjectResults({
|
||||
query: 'infra',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
import type { CmdJQuickAction } from './quick-actions'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
|
||||
import {
|
||||
cmdJPaletteTokenScore,
|
||||
normalizeCmdJPaletteQuery,
|
||||
uniqueNormalizedCmdJPaletteKeywords
|
||||
} from './palette-query-tokens'
|
||||
|
||||
export type CmdJSettingsResult = {
|
||||
id: string
|
||||
@@ -53,40 +58,6 @@ export function isCmdJPaletteQueryTooLarge(
|
||||
return isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
}
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
let normalized = ''
|
||||
let pendingWhitespace = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (isCmdJPaletteWhitespace(code)) {
|
||||
pendingWhitespace = normalized.length > 0
|
||||
continue
|
||||
}
|
||||
if (pendingWhitespace) {
|
||||
normalized += ' '
|
||||
pendingWhitespace = false
|
||||
}
|
||||
normalized += value.charAt(index).toLowerCase()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isCmdJPaletteWhitespace(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 160 ||
|
||||
code === 5760 ||
|
||||
(code >= 8192 && code <= 8202) ||
|
||||
code === 8232 ||
|
||||
code === 8233 ||
|
||||
code === 8239 ||
|
||||
code === 8287 ||
|
||||
code === 12288 ||
|
||||
code === 65279
|
||||
)
|
||||
}
|
||||
|
||||
function keywordParts(section: SettingsNavSection): string[] {
|
||||
const baseId = section.id.startsWith('repo-') ? 'repo' : section.id
|
||||
const idWords = baseId.replace(/-/g, ' ')
|
||||
@@ -107,10 +78,6 @@ function targetEntryKeywordParts(entryTitle: string): string[] {
|
||||
return [entryTitle, `${entryTitle} settings`]
|
||||
}
|
||||
|
||||
function uniqueNormalized(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
|
||||
}
|
||||
|
||||
export function buildCmdJSettingsResults(
|
||||
sections: readonly SettingsNavSection[]
|
||||
): CmdJSettingsResult[] {
|
||||
@@ -123,7 +90,7 @@ export function buildCmdJSettingsResults(
|
||||
icon: section.icon,
|
||||
sectionId: section.id,
|
||||
order,
|
||||
configKeywords: uniqueNormalized(keywordParts(section))
|
||||
configKeywords: uniqueNormalizedCmdJPaletteKeywords(keywordParts(section))
|
||||
}
|
||||
const targetedResults = section.searchEntries
|
||||
.filter((entry) => entry.targetSectionId)
|
||||
@@ -136,7 +103,7 @@ export function buildCmdJSettingsResults(
|
||||
sectionId: section.id,
|
||||
targetSectionId: entry.targetSectionId,
|
||||
order: order + (entryIndex + 1) / 100,
|
||||
configKeywords: uniqueNormalized([
|
||||
configKeywords: uniqueNormalizedCmdJPaletteKeywords([
|
||||
...targetEntryKeywordParts(entry.title),
|
||||
...(entry.cmdJKeywords ?? entry.keywords ?? [])
|
||||
])
|
||||
@@ -154,35 +121,6 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean {
|
||||
return keyword.startsWith(query) || query.startsWith(keyword)
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return normalizeQuery(value)
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function tokenScore(query: string, values: readonly string[]): number {
|
||||
const candidateTokens = values.flatMap(tokenize)
|
||||
if (candidateTokens.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let score = 0
|
||||
for (const queryToken of tokenize(query)) {
|
||||
let best = 0
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken === queryToken) {
|
||||
best = Math.max(best, 3)
|
||||
} else if (candidateToken.startsWith(queryToken)) {
|
||||
best = Math.max(best, 2)
|
||||
} else if (candidateToken.includes(queryToken)) {
|
||||
best = Math.max(best, 1)
|
||||
}
|
||||
}
|
||||
score += best
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function rankingForCandidate(
|
||||
query: string,
|
||||
candidate: CmdJMiddleResult,
|
||||
@@ -231,7 +169,7 @@ function rankingForCandidate(
|
||||
candidate.kind === 'settings'
|
||||
? [candidate.title, ...candidate.configKeywords]
|
||||
: [candidate.title, ...candidate.verbKeywords]
|
||||
const score = tokenScore(query, values)
|
||||
const score = cmdJPaletteTokenScore(query, values)
|
||||
return score > 0 ? { result: candidate, rule: 6, score } : null
|
||||
}
|
||||
|
||||
@@ -263,7 +201,7 @@ export function rankCmdJMiddleResults({
|
||||
if (isCmdJPaletteQueryTooLarge(query)) {
|
||||
return []
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
const normalizedQuery = normalizeCmdJPaletteQuery(query)
|
||||
if (normalizedQuery.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user