Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,12 @@ class LuceneSearchEngine(
val sources = withContext(Dispatchers.IO) { snippetSources(hits.map { LineSnippetInfo(it.lineId, it.bookId, it.lineIndex) }) }
return hits.map { hit ->
val source = sources[hit.lineId] ?: SnippetSource(hit.rawText, 0, hit.rawText.length)
hit.copy(snippet = SnippetBuilder.build(source.text, context.highlightPlan, source.lineStart, source.lineEnd), rawText = source.text)
hit.copy(
snippet = SnippetBuilder.build(source.text, context.highlightPlan, source.lineStart, source.lineEnd),
rawText = source.text,
lineStart = source.lineStart,
lineEnd = source.lineEnd
)
}
}

Expand Down Expand Up @@ -316,8 +321,13 @@ class LuceneSearchEngine(
override fun highlightRanges(text: String, query: String): List<IntRange> =
mergedRanges(text, planFor(query, DEFAULT_NEAR).highlights(text))

override fun rangeSnippet(text: String, ranges: List<IntRange>): String =
SnippetBuilder.build(text, ranges.map { Occurrence(it.first, it.last + 1, 0, EvidenceLevel.LITERAL) })
override fun rangeSnippet(text: String, ranges: List<IntRange>, lineStart: Int, lineEnd: Int): String =
SnippetBuilder.build(
text,
ranges.map { Occurrence(it.first, it.last + 1, 0, EvidenceLevel.LITERAL) },
lineStart,
lineEnd
)

// The highlight plans of the latest queries: a results page and its preview ask for the same one many times
private val planCache = object : LinkedHashMap<String, HighlightPlan>(PLAN_CACHE_SIZE, 0.75f, true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,35 +86,36 @@ interface SearchEngine : Closeable {
fun buildSnippet(rawText: String, query: String, near: Int): String

/**
* The words that stand for [query] in [text] (plain, diacritics allowed), as ranges of [text]: the one rule every
* view highlights a search with (results' snippets, their preview, the reader's smart find). A query word counts
* as itself (prefixed, or inside a longer word from 4 letters) or as one of its inflections; consecutive matched
* words form one range.
* The words that stand for [query] in [text] (plain, diacritics allowed), as ranges of [text]: what a search
* highlights when it doesn't highlight by meaning (see [highlightsByMeaning]). A query word counts as itself
* (prefixed, or inside a longer word from 4 letters) or as one of its inflections; consecutive matched words form
* one range.
*/
fun highlightRanges(text: String, query: String): List<IntRange> = emptyList()

/**
* What to highlight of [query] in found lines [texts] (plain): [highlightRanges] where the query's words show,
* else, for engines that search by meaning, the passage closest in meaning to the query (the whole line when it
* is a single clause). With [alwaysByMeaning] (a find by meaning), that passage shows beside the words in every
* line. One batch, as the meaning takes a model run.
* Whether a search for [query] highlights by meaning: the passage closest in meaning in the found line alone,
* none of the query's words. False for engines without a meaning search, and for an exact (quoted) query.
*/
fun highlightsByMeaning(query: String): Boolean = false

/**
* What to highlight of [query] in found lines [texts] (plain): the one rule every view highlights a found line with
* (results' snippets, their preview, the reader's smart find). By meaning (see [highlightsByMeaning]), the passage
* closest in meaning (the whole line when it is a single clause), in one batch as it takes a model run; else, or
* when the meaning search fails, [highlightRanges].
*/
suspend fun highlights(
texts: List<String>,
query: String,
alwaysByMeaning: Boolean = false
query: String
): List<TextHighlights> {
val words = texts.map { highlightRanges(it, query) }
val wanted = texts.indices.filter { (alwaysByMeaning || words[it].isEmpty()) && texts[it].isNotBlank() }
val passages = if (wanted.isEmpty()) null else passagesByMeaning(query, wanted.map { texts[it] })
if (passages == null) return words.map { TextHighlights(it) }
val passageOf = wanted.indices.associate { wanted[it] to passages[it] }
val passages = if (highlightsByMeaning(query)) passagesByMeaning(query, texts) else null
if (passages == null) return texts.map { TextHighlights(highlightRanges(it, query)) }
return texts.mapIndexed { i, text ->
if (i !in passageOf) return@mapIndexed TextHighlights(words[i])
// A single clause is the passage itself
val passage = passageOf[i] ?: text.trim()
val at = text.indexOf(passage)
if (at < 0) TextHighlights(words[i]) else TextHighlights(unionRanges(words[i] + listOf(at until at + passage.length)), byMeaning = true)
val passage = passages[i] ?: text.trim()
val at = if (passage.isEmpty()) -1 else text.indexOf(passage)
TextHighlights(if (at < 0) emptyList() else listOf(at until at + passage.length), byMeaning = true)
}
}

Expand All @@ -124,8 +125,11 @@ interface SearchEngine : Closeable {
*/
suspend fun passagesByMeaning(query: String, texts: List<String>): List<String?>? = null

/** A snippet of [text] (snippet text, see [SnippetSources.clean]) laid out around [ranges], bold. */
fun rangeSnippet(text: String, ranges: List<IntRange>): String = text
/**
* A snippet of [text] (snippet text, see [SnippetSources.clean]) laid out around [ranges] (sorted), bold;
* [lineStart] until [lineEnd] is the hit line when [text] holds its neighbors (see [LineHit]).
*/
fun rangeSnippet(text: String, ranges: List<IntRange>, lineStart: Int = 0, lineEnd: Int = text.length): String = text

/**
* Fills `snippet` and `rawText` of [hits] fetched without snippets (`nextPage(limit, snippets = false)`), so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ data class SearchPage(
* @property rawText Original unprocessed text content of the line
* @property isBaseBook True if the line belongs to a base book (vs a commentary). Lets callers
* correlate results: anchor each hit on its base line and nest matching commentaries under it.
* @property lineStart start of the hit line in [rawText], which may hold its neighbors as context (snippet source)
* @property lineEnd end (exclusive) of the hit line in [rawText]
*/
data class LineHit(
val bookId: Long,
Expand All @@ -85,7 +87,9 @@ data class LineHit(
val snippet: String,
val score: Float,
val rawText: String,
val isBaseBook: Boolean = false
val isBaseBook: Boolean = false,
val lineStart: Int = 0,
val lineEnd: Int = rawText.length
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals

class HighlightsTest {
// Words: "שבת" wherever it is; meaning: the clause after the comma
// Words: the query wherever it is; meaning (unless quoted): the clause after the comma
private val engine =
object : SearchEngine {
override fun openSession(
Expand Down Expand Up @@ -33,7 +33,9 @@ class HighlightsTest {
): SearchFacets? = null

override fun highlightRanges(text: String, query: String): List<IntRange> =
Regex(query).findAll(text).map { it.range }.toList()
Regex(query.trim('"')).findAll(text).map { it.range }.toList()

override fun highlightsByMeaning(query: String): Boolean = !query.startsWith("\"")

override suspend fun passagesByMeaning(query: String, texts: List<String>): List<String?> =
texts.map { text -> text.substringAfter(", ", "").ifEmpty { null } }
Expand All @@ -45,17 +47,17 @@ class HighlightsTest {
mapIndexed { i, h -> h.byMeaning to h.ranges.map { lines[i].substring(it.first, it.last + 1) } }

@Test
fun `the words where they show, else the passage closest in meaning`() = runBlocking {
val lines = listOf("שמור שבת, וזכור", "יום מנוחה, לנפש", "יום אחד")
fun `by meaning, the passage alone, the whole line when a single clause`() = runBlocking {
val lines = listOf("שמור שבת, וזכור", "יום מנוחה, לנפש", "יום אחד", "")
assertEquals(
listOf(false to listOf("שבת"), true to listOf("לנפש"), true to listOf("יום אחד")),
listOf(true to listOf("וזכור"), true to listOf("לנפש"), true to listOf("יום אחד"), true to emptyList()),
engine.highlights(lines, "שבת").texts(lines),
)
}

@Test
fun `a find by meaning shows the passage beside the words`() = runBlocking {
fun `a quoted query highlights its words`() = runBlocking {
val lines = listOf("שמור שבת, וזכור שבת")
assertEquals(listOf(true to listOf("שבת", "וזכור שבת")), engine.highlights(lines, "שבת", alwaysByMeaning = true).texts(lines))
assertEquals(listOf(false to listOf("שבת", "שבת")), engine.highlights(lines, "\"שבת\"").texts(lines))
}
}
Loading