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:
parent
e9a6679242
commit
c4d0ed9c3b
3 changed files with 212 additions and 0 deletions
105
app/src/main/java/no/naiv/meddetsamme/fest/FestRepository.kt
Normal file
105
app/src/main/java/no/naiv/meddetsamme/fest/FestRepository.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
56
app/src/test/java/no/naiv/meddetsamme/fest/FestSearchTest.kt
Normal file
56
app/src/test/java/no/naiv/meddetsamme/fest/FestSearchTest.kt
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package no.naiv.meddetsamme.fest
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FestSearchTest {
|
||||
|
||||
private val entries = listOf(
|
||||
FestEntry(name = "Paracet", strength = "500 mg", unit = "tablett", atc = "N02BE01"),
|
||||
FestEntry(name = "Paracetamol Orifarm", strength = "1 g", unit = "tablett"),
|
||||
FestEntry(name = "Pinex", strength = "500 mg", unit = "tablett", atc = "N02BE01"),
|
||||
FestEntry(name = "Ibux", strength = "400 mg", unit = "tablett", atc = "M01AE01"),
|
||||
FestEntry(name = "Kodein/paracetamol SA", strength = "30/400 mg", unit = "tablett"),
|
||||
FestEntry(name = "Levaxin", strength = "50 µg", unit = "tablett", atc = "H03AA01"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `prefix beats word-prefix beats substring`() {
|
||||
val hits = FestSearch.search(entries, "para")
|
||||
assertEquals(
|
||||
listOf("Paracet", "Paracetamol Orifarm", "Kodein/paracetamol SA"),
|
||||
hits.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case and whitespace insensitive`() {
|
||||
assertEquals("Levaxin", FestSearch.search(entries, " LEVA ").single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single characters return nothing`() {
|
||||
assertTrue(FestSearch.search(entries, "p").isEmpty())
|
||||
assertTrue(FestSearch.search(entries, " ").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit caps results`() {
|
||||
assertEquals(2, FestSearch.search(entries, "para", limit = 2).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dataset json parses with unknown keys ignored`() {
|
||||
val json = """
|
||||
{"version":1,"updated":"2026-06-01","futureField":true,
|
||||
"entries":[{"name":"Paracet","strength":"500 mg","unit":"tablett",
|
||||
"form":"tablett","atc":"N02BE01","packageSize":20.0,"extra":1}]}
|
||||
""".trimIndent()
|
||||
val ds = Json { ignoreUnknownKeys = true }.decodeFromString<FestDataset>(json)
|
||||
assertEquals(1, ds.version)
|
||||
assertEquals("Paracet", ds.entries.single().name)
|
||||
assertEquals(20.0, ds.entries.single().packageSize!!, 0.0)
|
||||
}
|
||||
}
|
||||
51
docs/fest-dataset.md
Normal file
51
docs/fest-dataset.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# FEST slim dataset — contract
|
||||
|
||||
The app does **offline** autocomplete against a slim, pre-flattened JSON file.
|
||||
The job that produces it lives **outside this repo**: it pulls FEST's open
|
||||
Rekvirent extract (the M30 SOAP/WCF XML dump from DMP), flattens the
|
||||
human-use medication entries, and publishes the JSON behind Caddy or into the
|
||||
Garage bucket. FEST changes slowly; refresh the published file monthly-ish.
|
||||
|
||||
Why FEST and not Felleskatalogen: FEST is the open national dataset;
|
||||
Felleskatalogen is licensed editorial content with no open API. Why not live
|
||||
lookups: M30 is a bulk XML dump, not a per-keystroke API — and the phone must
|
||||
work offline.
|
||||
|
||||
## Where the app loads it from
|
||||
|
||||
`Innstillinger → FEST-URL` (e.g. `https://<host>/fest/slim.json`). The app
|
||||
downloads on demand, caches to `filesDir/fest-slim.json`, and reads only the
|
||||
cache afterwards. A staleness nudge appears after ~45 days (dataset `updated`
|
||||
field), but old data keeps working — autocomplete is a convenience, never a
|
||||
gate.
|
||||
|
||||
## JSON shape (version 1)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-06-01",
|
||||
"entries": [
|
||||
{
|
||||
"name": "Paracet",
|
||||
"strength": "500 mg",
|
||||
"unit": "tablett",
|
||||
"form": "tablett",
|
||||
"atc": "N02BE01",
|
||||
"packageSize": 20.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `name` — preparation name (varenavn), required.
|
||||
- `strength` — display string, may be empty.
|
||||
- `unit` — what one dose counts ("tablett", "ml", "dose"), required.
|
||||
- `form` — FEST's form text, free-form (not this app's MedForm enum; the
|
||||
editor maps it heuristically and lets the user override).
|
||||
- `atc` — ATC code, optional.
|
||||
- `packageSize` — units per package for the most common package, optional.
|
||||
|
||||
Unknown extra keys are ignored (`ignoreUnknownKeys`), so the producer can add
|
||||
fields without breaking older app versions. Bump `version` only for breaking
|
||||
changes.
|
||||
Loading…
Add table
Add a link
Reference in a new issue