Contracts for 14 default categories and the category suggestion (UX-3, UX-8)
- contracts/schema/migrations/0002_categories_v2.sql: server migration 0002 rebuilds categories with the 14-key default_key CHECK, inserts housing, insurance, children and fees with server_versions taken from the global sequence, sets schema_version 2. server-v1.sql unchanged. - contracts/category-keywords.csv: the keyword table of the suggestion (148 entries, from docs/ux). - contracts/test-vectors/suggest_category.json: 65 Kotlin-only cases of CS-4 to CS-6. - contracts/examples/E-1-health: schema_version 2. - tools/contracts-check.py: reference suggestCategory, keyword-table checks, 0002 load checks on a fresh and an in-use database, plain icon/color check, new icon and colour tokens. - android/domain: suggestCategory (CS-4 to CS-6, code points, platform NFC and categories through UnicodeText), vector runner for suggest_category, eight vector files. - server/crates/kvitt-domain/tests/vectors.rs: suggest_category is Kotlin-only. The OpenAPI DefaultCategoryKey extension and the two new category examples are held back until the server's wire types know the new keys (M12b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -9,7 +9,7 @@ dependencies {
|
||||
}
|
||||
|
||||
// The shared test vectors (SPEC §14.5.5) are copied from the repository's contracts/ folder into the test resources.
|
||||
// A missing file is not tolerated: VectorFilesTest fails unless exactly the seven files of §14.5.5 are present.
|
||||
// A missing file is not tolerated: VectorFilesTest fails unless exactly the eight files of §14.5.5 are present.
|
||||
val testVectorsRoot = layout.buildDirectory.dir("generated/test-vectors")
|
||||
val copyTestVectors = tasks.register<Sync>("copyTestVectors") {
|
||||
description = "Copies contracts/test-vectors/*.json into the :domain test resources."
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
|
||||
// Category suggestion from the description (UX-3, category-suggestion.md CS-4 to CS-6). Pure; the vectors are
|
||||
// contracts/test-vectors/suggest_category.json (Kotlin only).
|
||||
//
|
||||
// The input records live here and not in :core:model: default keys are open strings because the
|
||||
// :core:model DefaultCategoryKey enum does not yet carry the four keys of schema v2 (housing, insurance,
|
||||
// children, fees), and the keyword table (contracts/category-keywords.csv) is data, not a UI contract.
|
||||
|
||||
/** One non-deleted expense on the phone (every account, either user). */
|
||||
data class SuggestionHistoryEntry(
|
||||
val description: String,
|
||||
val categoryId: CategoryId,
|
||||
val expenseDate: LocalDate,
|
||||
/** Fixed RFC 3339 form (`YYYY-MM-DDTHH:MM:SS.mmmZ`), so string order is time order. */
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
enum class KeywordMatch { TOKEN, PREFIX, PHRASE }
|
||||
|
||||
/** One row of the keyword table, `keyword` already normalised (CS-6). */
|
||||
data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String)
|
||||
|
||||
/** A category that is neither archived nor deleted; `defaultKey` null for a user-created one. */
|
||||
data class ActiveCategory(val id: CategoryId, val defaultKey: String?)
|
||||
|
||||
/**
|
||||
* The Unicode services CS-4 needs that kotlin.* does not offer in a pure module: NFC and the general category of a
|
||||
* code point. The app binds the platform's (java.text.Normalizer, Character); tests bind the same.
|
||||
*/
|
||||
interface UnicodeText {
|
||||
fun nfc(text: String): String
|
||||
|
||||
/** General category Lu, Ll, Lt, Lm or Lo. */
|
||||
fun isLetter(codePoint: Int): Boolean
|
||||
|
||||
/** General category Nd. */
|
||||
fun isDigit(codePoint: Int): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CS-4: NFC, lowercase without locale rules, delete `'`, `’` and `:`, every other code point that is not a letter,
|
||||
* a decimal digit or `&` becomes a space, then whitespace is collapsed and trimmed. Iterates code points.
|
||||
*/
|
||||
fun normalizeDescription(text: String, unicode: UnicodeText): String {
|
||||
val lowered = unicode.nfc(text).lowercase()
|
||||
val out = StringBuilder(lowered.length)
|
||||
var i = 0
|
||||
while (i < lowered.length) {
|
||||
val c = lowered[i]
|
||||
val pair = c.isHighSurrogate() && i + 1 < lowered.length && lowered[i + 1].isLowSurrogate()
|
||||
val width = if (pair) 2 else 1
|
||||
val codePoint = if (pair) 0x10000 + ((c.code - 0xD800) shl 10) + (lowered[i + 1].code - 0xDC00) else c.code
|
||||
when {
|
||||
codePoint == APOSTROPHE || codePoint == RIGHT_SINGLE_QUOTE || codePoint == COLON -> Unit
|
||||
codePoint == AMPERSAND || unicode.isLetter(codePoint) || unicode.isDigit(codePoint) -> out.append(lowered, i, i + width)
|
||||
else -> out.append(' ')
|
||||
}
|
||||
i += width
|
||||
}
|
||||
return out.split(' ').filter { it.isNotEmpty() }.joinToString(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* CS-5: history exact, then history first token, then the keyword table (CS-6), then [fallback] (the AC-8.6 default).
|
||||
* An empty normalised description gives [fallback]. History counts only categories in [activeCategories]; the most
|
||||
* often used wins, a tie goes to the category whose latest expense is latest (`expenseDate`, then `createdAt`), and a
|
||||
* remaining tie to the lower category id (TODO(spec): not stated in category-suggestion.md).
|
||||
*/
|
||||
fun suggestCategory(
|
||||
description: String,
|
||||
history: List<SuggestionHistoryEntry>,
|
||||
keywords: List<CategoryKeyword>,
|
||||
activeCategories: List<ActiveCategory>,
|
||||
fallback: CategoryId?,
|
||||
unicode: UnicodeText,
|
||||
): CategoryId? {
|
||||
val normalised = normalizeDescription(description, unicode)
|
||||
if (normalised.isEmpty()) return fallback
|
||||
val tokens = normalised.split(' ')
|
||||
val active = activeCategories.map { it.id }.toSet()
|
||||
val stored = history.map { normalizeDescription(it.description, unicode) to it }
|
||||
pickFromHistory(stored.filter { it.first == normalised }.map { it.second }, active)?.let { return it }
|
||||
pickFromHistory(stored.filter { it.first.isNotEmpty() && it.first.substringBefore(' ') == tokens[0] }.map { it.second }, active)
|
||||
?.let { return it }
|
||||
val byKey = activeCategories.mapNotNull { c -> c.defaultKey?.let { it to c.id } }.toMap()
|
||||
return keywords.withIndex()
|
||||
.filter { matches(it.value, tokens) }
|
||||
.sortedWith(compareByDescending<IndexedValue<CategoryKeyword>> { codePointLength(it.value.keyword) }.thenBy { it.index })
|
||||
.firstNotNullOfOrNull { byKey[it.value.defaultKey] }
|
||||
?: fallback
|
||||
}
|
||||
|
||||
private class Tally(val id: CategoryId, val count: Int, val latest: SuggestionHistoryEntry)
|
||||
|
||||
private val RECENCY: Comparator<SuggestionHistoryEntry> = compareBy({ it.expenseDate }, { it.createdAt })
|
||||
|
||||
private fun pickFromHistory(rows: List<SuggestionHistoryEntry>, active: Set<CategoryId>): CategoryId? = rows
|
||||
.filter { it.categoryId in active }
|
||||
.groupBy { it.categoryId }
|
||||
.map { (id, group) -> Tally(id, group.size, group.maxWith(RECENCY)) }
|
||||
.sortedWith(
|
||||
compareByDescending<Tally> { it.count }
|
||||
.thenComparator { a, b -> RECENCY.compare(b.latest, a.latest) }
|
||||
.thenBy { it.id.value },
|
||||
)
|
||||
.firstOrNull()
|
||||
?.id
|
||||
|
||||
private fun matches(entry: CategoryKeyword, tokens: List<String>): Boolean = when (entry.match) {
|
||||
KeywordMatch.TOKEN -> entry.keyword in tokens
|
||||
KeywordMatch.PREFIX -> tokens.any { it.startsWith(entry.keyword) }
|
||||
KeywordMatch.PHRASE -> {
|
||||
val phrase = entry.keyword.split(' ')
|
||||
(0..tokens.size - phrase.size).any { start -> tokens.subList(start, start + phrase.size) == phrase }
|
||||
}
|
||||
}
|
||||
|
||||
private fun codePointLength(text: String): Int = text.count { !it.isLowSurrogate() }
|
||||
|
||||
private const val APOSTROPHE = 0x27
|
||||
private const val RIGHT_SINGLE_QUOTE = 0x2019
|
||||
private const val COLON = 0x3A
|
||||
private const val AMPERSAND = 0x26
|
||||
@@ -0,0 +1,12 @@
|
||||
package se.hagstedt.kvitt.domain
|
||||
|
||||
import java.text.Normalizer
|
||||
|
||||
/** The platform's Unicode services for the tests (the app binds the same in :data). */
|
||||
object JvmUnicodeText : UnicodeText {
|
||||
override fun nfc(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC)
|
||||
|
||||
override fun isLetter(codePoint: Int): Boolean = Character.isLetter(codePoint)
|
||||
|
||||
override fun isDigit(codePoint: Int): Boolean = Character.getType(codePoint) == Character.DECIMAL_DIGIT_NUMBER.toInt()
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The vector directory holds exactly the seven files of SPEC §14.5.5; a missing or untested file fails. */
|
||||
/** The vector directory holds exactly the eight files of SPEC §14.5.5; a missing or untested file fails. */
|
||||
class VectorFilesTest {
|
||||
@Test
|
||||
fun everyVectorFileIsPresentAndTested() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import se.hagstedt.kvitt.model.analytics.AnalyticsExpense
|
||||
import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity
|
||||
@@ -48,6 +49,7 @@ class VectorTest(private val case: VectorCase, private val label: String) {
|
||||
"over_settled_accounts" -> ok(strings(overSettledAccounts(allocations(input), positions(input))))
|
||||
"spend_series" -> series(input)
|
||||
"resolve_range" -> range(input)
|
||||
"suggest_category" -> suggest(input)
|
||||
else -> error("no runner for $function")
|
||||
}
|
||||
|
||||
@@ -149,6 +151,19 @@ class VectorTest(private val case: VectorCase, private val label: String) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun suggest(input: JsonObject): JsonElement {
|
||||
val history = input.objects("history").map {
|
||||
SuggestionHistoryEntry(it.str("description"), CategoryId(it.str("category_id")), LocalDate.parse(it.str("expense_date")), it.str("created_at"))
|
||||
}
|
||||
val keywords = input.objects("keywords").map {
|
||||
CategoryKeyword(it.str("keyword"), KeywordMatch.valueOf(it.str("match").uppercase()), it.str("default_key"))
|
||||
}
|
||||
val active = input.objects("active_categories").map { ActiveCategory(CategoryId(it.str("id")), it.strOrNull("default_key")) }
|
||||
val fallback = input.strOrNull("fallback")?.let { CategoryId(it) }
|
||||
val r = suggestCategory(input.str("description"), history, keywords, active, fallback, JvmUnicodeText)
|
||||
return ok(r?.let { JsonPrimitive(it.value) } ?: JsonNull)
|
||||
}
|
||||
|
||||
private fun range(input: JsonObject): JsonElement {
|
||||
val preset = RangePreset.valueOf(input.str("preset").uppercase())
|
||||
fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) }
|
||||
|
||||
@@ -13,10 +13,10 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
|
||||
/** The seven vector files of SPEC §14.5.5, keyed by function name. */
|
||||
/** The eight vector files of SPEC §14.5.5 (with suggest_category, UX-3), keyed by function name. */
|
||||
val VECTOR_FUNCTIONS: List<String> = listOf(
|
||||
"compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts",
|
||||
"spend_series", "resolve_range",
|
||||
"spend_series", "resolve_range", "suggest_category",
|
||||
)
|
||||
|
||||
data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement)
|
||||
|
||||
Reference in New Issue
Block a user