ローカルプロンプトのフィルタリングを単語境界マッチ+先頭位置順ソートに刷新
Lexxyのローカルプロンプト(メンション補完など)のフィルタリングロジックを変更し、単語の途中にマッチするノイズを排除しつつ、マッチ位置が先頭に近い候補を優先して表示するようになりました。
背景
これまでの実装では、フィルタ文字列が検索テキストのどこに出現してもマッチとして扱われていました。例えば「em」で検索すると、名前の途中に「em」を含む「David Bemmer」のような候補もリストに現れ、関連性の低い結果が混入していました。また、「Emma Smith」と「Anna Emilio」が並んでいる場合、入力した「em」がどちらの名前のより早い位置にマッチしているかは考慮されず、元の並び順のまま表示されていました。
これらの問題により、候補リストの直感的な順序付けが損なわれ、ユーザーが目的の候補を素早く選択しにくい状況が生じていました。
技術的な変更
string_helper.js の関数シグネチャと、それを呼び出す local_filter_source.js の処理フローが同時に変更され、「マッチするかどうか」から「どの位置でマッチするか」を返す設計に切り替わりました。
src/helpers/string_helper.js では、filterMatches 関数が filterMatchPosition 関数に置き換えられました。新関数はマッチした位置のインデックスを返し、マッチしない場合は -1 を返します。内部では String.prototype.includes の代わりに正規表現を使用し、(?:^|\b) の前置パターンによって単語の先頭またはテキスト先頭でのマッチのみを許容します。また、フィルタ文字列に正規表現の特殊文字が含まれる場合に備えて escapeForRegExp ヘルパー関数も追加されました。
変更前:
export function filterMatches(text, potentialMatch) {
return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
}
変更後:
export function filterMatchPosition(text, potentialMatch) {
const normalizedText = normalizeFilteredText(text)
const normalizedMatch = normalizeFilteredText(potentialMatch)
if (!normalizedMatch) return 0
const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`))
return match ? match.index : -1
}
function escapeForRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
src/editor/prompt/local_filter_source.js では、#buildListItemsFromPromptItems メソッドの処理フローが再構成されました。変更前はループ内で即時にリストアイテムを構築していましたが、変更後はまず全候補に対して filterMatchPosition を呼び出してマッチ結果を { promptItem, position } の配列に収集し、position で昇順ソートしてからリストアイテムを構築します。またフィルタ文字列が空の場合は新設の #buildAllListItems メソッドに処理を委譲し、無駄な照合処理を回避しています。
変更後のフロー:
テストは Playwright を使ったブラウザテストとして追加されました。test/browser/tests/prompts/mention_filtering.test.js では「ja」で検索したとき Jack Franklin・Jason Clack・Clara Jackson・Thomas Jaiden の順(マッチ位置の昇順)で4件が返ること、および「mid」で検索したとき Sam Smidjam がヒットしないこと(単語途中のマッチを除外)の2ケースが検証されています。
設計判断
filterMatches(boolean返却)をfilterMatchPosition(インデックス返却)に置き換える設計が採用されました。マッチ有無の判定とソートキーの取得を1回の処理で済ませられるため、全候補を2度走査する必要がありません。
単語境界の実現に \b アンカーを用いた正規表現が選ばれています。normalizeFilteredText によってダイアクリティカルマークを除去した正規化済み文字列に対してマッチングを行う設計になっており、フィルタ文字列の特殊文字エスケープも escapeForRegExp で明示的に処理しています。
また、フィルタが空の場合の早期リターンを #buildAllListItems として切り出しているのも注目点です。フィルタなし時はソート処理が不要であるため、パスを分離することで不要なオーバーヘッドを避けています。
まとめ
本PRは「単純な文字列包含チェック」から「単語境界を考慮した位置返却」へとフィルタリングの基礎関数を再設計し、その戻り値をソートキーとして活用するアーキテクチャへと移行しています。マッチ判定とランキングを単一の関数呼び出しで完結させた設計は、将来的にスコアリングロジックを拡張する際の基盤にもなり得ます。