FEST offline lookup: dataset contract, cached repository, ranked search

Milestone 8. docs/fest-dataset.md is the interface to the out-of-repo
server job that flattens the M30 Rekvirent extract (FEST, not
Felleskatalogen — decision #8) into slim JSON. The repository
downloads on demand, parses BEFORE overwriting the cache (a failed
refresh can never clobber a working dataset), and serves all searches
from memory. Ranking (prefix > word-prefix > substring) is a pure
object with tests. Every failure mode degrades to manual entry —
autocomplete is a convenience, never a gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-10 14:04:05 +02:00
commit c4d0ed9c3b
3 changed files with 212 additions and 0 deletions

View file

@ -0,0 +1,105 @@
package no.naiv.meddetsamme.fest
import android.content.Context
import java.io.File
import java.io.IOException
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Offline drug lookup against the slim FEST dataset (see docs/fest-dataset.md
* for the contract and the why). Download on demand cache file all
* searches hit the in-memory list. Autocomplete is a convenience: every
* failure mode here degrades to "type it yourself", never to a blocked flow.
*/
@Serializable
data class FestEntry(
val name: String,
val strength: String = "",
val unit: String,
val form: String = "",
val atc: String? = null,
val packageSize: Double? = null,
)
@Serializable
data class FestDataset(
val version: Int,
val updated: String = "",
val entries: List<FestEntry> = emptyList(),
)
class FestRepository(
private val context: Context,
private val client: OkHttpClient = OkHttpClient(),
) {
private val json = Json { ignoreUnknownKeys = true }
private val cacheFile get() = File(context.filesDir, "fest-slim.json")
@Volatile private var loaded: FestDataset? = null
/** Cached dataset, loading from disk on first use. Null until downloaded once. */
fun dataset(): FestDataset? {
loaded?.let { return it }
synchronized(this) {
loaded?.let { return it }
if (!cacheFile.exists()) return null
return try {
json.decodeFromString<FestDataset>(cacheFile.readText()).also { loaded = it }
} catch (e: Exception) {
null // corrupt cache → behave as not-downloaded; refresh overwrites
}
}
}
/** @throws IOException network/HTTP errors; IllegalArgumentException bad payload. */
fun refresh(url: String) {
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw IOException("FEST-nedlasting feilet: HTTP ${resp.code}")
val body = resp.body?.string() ?: throw IOException("Tomt svar")
val parsed = try {
json.decodeFromString<FestDataset>(body)
} catch (e: Exception) {
throw IllegalArgumentException("Uventet FEST-format: ${e.message}", e)
}
require(parsed.version == 1) { "Ustøttet FEST-versjon ${parsed.version}" }
// Parse-then-write: a failed download can never clobber a good cache.
cacheFile.writeText(body)
loaded = parsed
}
}
fun search(query: String, limit: Int = 20): List<FestEntry> =
FestSearch.search(dataset()?.entries.orEmpty(), query, limit)
}
/** Pure search/ranking — separated from the repository for unit testing. */
object FestSearch {
fun search(entries: List<FestEntry>, query: String, limit: Int = 20): List<FestEntry> {
val q = query.trim().lowercase()
if (q.length < 2) return emptyList() // single letters are noise, not search
// Rank: name prefix < word prefix < substring. Stable name order within ranks.
fun rank(e: FestEntry): Int {
val name = e.name.lowercase()
return when {
name.startsWith(q) -> 0
name.split(' ', '-').any { it.startsWith(q) } -> 1
q in name -> 2
else -> 3
}
}
return entries
.map { it to rank(it) }
.filter { it.second < 3 }
.sortedWith(compareBy({ it.second }, { it.first.name.lowercase() }))
.take(limit)
.map { it.first }
}
}