feat(f8): search ranking + multi-source aggregation
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
scoreText (more normalized occurrences rank higher; earlier first match breaks ties) and rankHits (score every source, drop non-matches, attach a display snippet of the original text, sort best-first with an explicit input-order tiebreak since Dart's sort isn't stable). The search_indexer's pure ranking core, making search_text + search_snippet load-bearing. flutter analyze lib/editor clean; 223/223 tests (+9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
80
lib/editor/search/search_ranking.dart
Normal file
80
lib/editor/search/search_ranking.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// lib/editor/search/search_ranking.dart
|
||||||
|
//
|
||||||
|
// Pure ranking + aggregation for full-text search (F8). Given many sources (PDF
|
||||||
|
// text pages, typed boxes, OCR'd handwriting) keyed by a ref string, score each
|
||||||
|
// for a query and return the best hits with display snippets. This is the
|
||||||
|
// search_indexer's ranking core; the FTS pre-filter/index lives in the DB.
|
||||||
|
//
|
||||||
|
// Pure (no storage/widgets); builds on search_text (normalize/match) +
|
||||||
|
// search_snippet (excerpt). Fully unit-tested.
|
||||||
|
|
||||||
|
import 'search_snippet.dart';
|
||||||
|
import 'search_text.dart';
|
||||||
|
|
||||||
|
/// A ranked search result: WHERE it is ([ref]), the display [snippet], and the
|
||||||
|
/// [score] (higher = better).
|
||||||
|
class SearchHit {
|
||||||
|
const SearchHit({required this.ref, required this.snippet, required this.score});
|
||||||
|
|
||||||
|
final String ref;
|
||||||
|
final Snippet snippet;
|
||||||
|
final double score;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SearchHit($ref, score=${score.toStringAsFixed(3)})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score [source] for [query]: more (normalized) occurrences rank higher, and an
|
||||||
|
/// earlier first match breaks ties. 0 when there is no match.
|
||||||
|
double scoreText(String source, String query) {
|
||||||
|
final q = normalizeForIndex(query);
|
||||||
|
if (q.isEmpty) return 0;
|
||||||
|
final s = normalizeForIndex(source);
|
||||||
|
if (s.isEmpty) return 0;
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
var from = 0;
|
||||||
|
var firstPos = -1;
|
||||||
|
while (true) {
|
||||||
|
final idx = s.indexOf(q, from);
|
||||||
|
if (idx < 0) break;
|
||||||
|
if (firstPos < 0) firstPos = idx;
|
||||||
|
count++;
|
||||||
|
from = idx + q.length;
|
||||||
|
}
|
||||||
|
if (count == 0) return 0;
|
||||||
|
|
||||||
|
// Earliness in (0,1]: a match at position 0 scores 1.0.
|
||||||
|
final earliness = 1.0 - (firstPos / s.length);
|
||||||
|
return count + earliness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build + rank hits across [sources] (ref → raw text) for [query]: drop
|
||||||
|
/// non-matches, attach a display snippet of the ORIGINAL text, sort best-first.
|
||||||
|
/// Ties (equal score) keep input order (stable).
|
||||||
|
List<SearchHit> rankHits(
|
||||||
|
Map<String, String> sources,
|
||||||
|
String query, {
|
||||||
|
int window = 80,
|
||||||
|
}) {
|
||||||
|
final indexed = <({int order, SearchHit hit})>[];
|
||||||
|
var order = 0;
|
||||||
|
for (final entry in sources.entries) {
|
||||||
|
final i = order++;
|
||||||
|
final score = scoreText(entry.value, query);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
final snippet = snippetFor(entry.value, query, window: window);
|
||||||
|
if (snippet == null) continue; // matched normalized but not raw (rare)
|
||||||
|
indexed.add((
|
||||||
|
order: i,
|
||||||
|
hit: SearchHit(ref: entry.key, snippet: snippet, score: score),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Descending score; ties keep input order (Dart's sort isn't stable, so the
|
||||||
|
// input index is an explicit tiebreaker).
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final byScore = b.hit.score.compareTo(a.hit.score);
|
||||||
|
return byScore != 0 ? byScore : a.order.compareTo(b.order);
|
||||||
|
});
|
||||||
|
return [for (final e in indexed) e.hit];
|
||||||
|
}
|
||||||
63
test/search_ranking_test.dart
Normal file
63
test/search_ranking_test.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Tests for search ranking + aggregation (F8).
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/search/search_ranking.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('scoreText', () {
|
||||||
|
test('0 for empty query or no match', () {
|
||||||
|
expect(scoreText('hello world', ''), 0);
|
||||||
|
expect(scoreText('hello world', 'zzz'), 0);
|
||||||
|
expect(scoreText('', 'x'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('more occurrences score higher', () {
|
||||||
|
final one = scoreText('cat dog', 'cat');
|
||||||
|
final three = scoreText('cat cat cat', 'cat');
|
||||||
|
expect(three, greaterThan(one));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('earlier first match breaks ties (same count)', () {
|
||||||
|
final early = scoreText('needle then padding padding', 'needle');
|
||||||
|
final late = scoreText('padding padding then needle', 'needle');
|
||||||
|
expect(early, greaterThan(late));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a match at position 0 gives earliness 1 (score = count + 1)', () {
|
||||||
|
expect(scoreText('cat', 'cat'), closeTo(2.0, 1e-9)); // 1 occ + 1.0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('rankHits', () {
|
||||||
|
test('drops non-matches and orders best-first', () {
|
||||||
|
final hits = rankHits({
|
||||||
|
'p1': 'one mention of fox',
|
||||||
|
'p2': 'fox fox fox everywhere', // 3 occ → highest
|
||||||
|
'p3': 'nothing relevant here',
|
||||||
|
}, 'fox');
|
||||||
|
expect(hits.map((h) => h.ref), ['p2', 'p1']);
|
||||||
|
expect(hits.first.score, greaterThan(hits.last.score));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each hit carries a display snippet of the original text', () {
|
||||||
|
final hits = rankHits({'p1': 'The quick brown fox jumps'}, 'fox');
|
||||||
|
expect(hits, hasLength(1));
|
||||||
|
expect(hits.first.snippet.match, 'fox');
|
||||||
|
expect(hits.first.ref, 'p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equal scores keep input order (explicit tiebreak)', () {
|
||||||
|
final hits = rankHits({
|
||||||
|
'a': 'match',
|
||||||
|
'b': 'match',
|
||||||
|
}, 'match');
|
||||||
|
expect(hits.map((h) => h.ref), ['a', 'b']);
|
||||||
|
expect(hits[0].score, hits[1].score);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty query → no hits', () {
|
||||||
|
expect(rankHits({'a': 'anything'}, ''), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user