From c4d0ed9c3b26c3b798f6af168b9e208f47e95e4a Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 10 Jun 2026 14:04:05 +0200 Subject: [PATCH] FEST offline lookup: dataset contract, cached repository, ranked search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../naiv/meddetsamme/fest/FestRepository.kt | 105 ++++++++++++++++++ .../naiv/meddetsamme/fest/FestSearchTest.kt | 56 ++++++++++ docs/fest-dataset.md | 51 +++++++++ 3 files changed, 212 insertions(+) create mode 100644 app/src/main/java/no/naiv/meddetsamme/fest/FestRepository.kt create mode 100644 app/src/test/java/no/naiv/meddetsamme/fest/FestSearchTest.kt create mode 100644 docs/fest-dataset.md diff --git a/app/src/main/java/no/naiv/meddetsamme/fest/FestRepository.kt b/app/src/main/java/no/naiv/meddetsamme/fest/FestRepository.kt new file mode 100644 index 0000000..18020c6 --- /dev/null +++ b/app/src/main/java/no/naiv/meddetsamme/fest/FestRepository.kt @@ -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 = 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(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(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 = + FestSearch.search(dataset()?.entries.orEmpty(), query, limit) +} + +/** Pure search/ranking — separated from the repository for unit testing. */ +object FestSearch { + + fun search(entries: List, query: String, limit: Int = 20): List { + 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 } + } +} diff --git a/app/src/test/java/no/naiv/meddetsamme/fest/FestSearchTest.kt b/app/src/test/java/no/naiv/meddetsamme/fest/FestSearchTest.kt new file mode 100644 index 0000000..4b21878 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/fest/FestSearchTest.kt @@ -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(json) + assertEquals(1, ds.version) + assertEquals("Paracet", ds.entries.single().name) + assertEquals(20.0, ds.entries.single().packageSize!!, 0.0) + } +} diff --git a/docs/fest-dataset.md b/docs/fest-dataset.md new file mode 100644 index 0000000..d2b080c --- /dev/null +++ b/docs/fest-dataset.md @@ -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:///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.