feat(species): auto-classify variety species from its label
Infer the catalog species a free-text variety label names ("Maiz de la
abuela" -> Zea mays) and prefill the category from the species family.
- Pure, testable matcher (domain/species_autoclassify.dart): whole-word,
accent/case-insensitive, Unicode-aware (any script), longest-name-wins,
ambiguous names left unclassified, light plural fold.
- Quick-add and draft naming auto-link the species when the field is empty
(non-destructive; an explicit category is kept).
- Edit sheet offers a one-tap suggestion from the typed name.
Tests: matcher unit, repository integration, SpeciesRepository.classifyLabel,
and an edit-sheet widget test.
This commit is contained in:
parent
0e6ef13d00
commit
b840b83c42
15 changed files with 510 additions and 3 deletions
128
apps/app_seeds/lib/domain/species_autoclassify.dart
Normal file
128
apps/app_seeds/lib/domain/species_autoclassify.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/// Auto-classification: infer the species a free-text variety label refers to,
|
||||
/// so typing "Maíz de la abuela" can link *Zea mays* (and prefill the category)
|
||||
/// without the grower opening the species picker.
|
||||
///
|
||||
/// Pure domain logic (no DB, no Flutter) so it is trivially unit-testable. The
|
||||
/// repository feeds it the bundled catalog names; matching is done here.
|
||||
library;
|
||||
|
||||
/// One catalog name that can identify a species: a common (vernacular) name or
|
||||
/// the scientific name, paired with the species it belongs to. The matcher is
|
||||
/// language-agnostic — it never assumes es/en — so the repository passes names
|
||||
/// from every bundled locale at once.
|
||||
class SpeciesNameEntry {
|
||||
const SpeciesNameEntry({required this.speciesId, required this.name});
|
||||
|
||||
final String speciesId;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// Detects the species a free-text variety [label] refers to by matching a
|
||||
/// bundled [names] entry as a whole word inside the label — accent- and
|
||||
/// case-insensitively. "Maíz de la abuela" → *Zea mays*; "Tomate cherry" →
|
||||
/// *Solanum lycopersicum*.
|
||||
///
|
||||
/// Returns the matched species id, or null when nothing matches or the best
|
||||
/// match is ambiguous. The rules keep it from mislabelling:
|
||||
/// * **Whole-word (token) match only**, so "pea" never fires on "peach" nor
|
||||
/// "haba" on "habanero" — a common name must appear as its own word(s).
|
||||
/// * **Longest name wins** (most specific): "broad bean" beats "bean".
|
||||
/// * **Ambiguous → null**: if the winning name maps to more than one species
|
||||
/// (e.g. "calabaza" spanning several squashes), it is left for the grower
|
||||
/// to disambiguate rather than guessed.
|
||||
///
|
||||
/// A light trailing-`s` fold lets a plural label word ("Tomates", "Habas")
|
||||
/// still match a singular catalog name. It is an additive nudge for the many
|
||||
/// languages that pluralise with `-s`, never an override of an exact match, and
|
||||
/// it cannot introduce a match that a different word did not already carry.
|
||||
String? matchSpeciesInLabel(String label, List<SpeciesNameEntry> names) {
|
||||
final haystack = _tokenize(label);
|
||||
if (haystack.isEmpty) return null;
|
||||
|
||||
({int tokens, int chars})? best;
|
||||
final winners = <String>{};
|
||||
for (final entry in names) {
|
||||
final needle = _tokenize(entry.name);
|
||||
if (!_containsSequence(haystack, needle)) continue;
|
||||
final chars = needle.fold<int>(0, (sum, t) => sum + t.length);
|
||||
final score = (tokens: needle.length, chars: chars);
|
||||
if (best == null || _isBetter(score, best!)) {
|
||||
best = score;
|
||||
winners
|
||||
..clear()
|
||||
..add(entry.speciesId);
|
||||
} else if (score.tokens == best!.tokens && score.chars == best!.chars) {
|
||||
winners.add(entry.speciesId);
|
||||
}
|
||||
}
|
||||
|
||||
// A single species behind the best-scoring name(s) → confident; otherwise the
|
||||
// name is shared across species, so leave it unclassified.
|
||||
return winners.length == 1 ? winners.first : null;
|
||||
}
|
||||
|
||||
bool _isBetter(({int tokens, int chars}) a, ({int tokens, int chars}) b) =>
|
||||
a.tokens > b.tokens || (a.tokens == b.tokens && a.chars > b.chars);
|
||||
|
||||
/// Lowercases, splits on any non-letter/non-digit boundary (Unicode-aware, so
|
||||
/// non-Latin scripts survive), and folds Latin diacritics on each token.
|
||||
List<String> _tokenize(String s) => s
|
||||
.toLowerCase()
|
||||
.split(_wordBoundary)
|
||||
.where((t) => t.isNotEmpty)
|
||||
.map(_foldDiacritics)
|
||||
.toList(growable: false);
|
||||
|
||||
final RegExp _wordBoundary = RegExp(r'[^\p{L}\p{N}]+', unicode: true);
|
||||
|
||||
/// True when [needle] appears as a contiguous run of words inside [haystack].
|
||||
bool _containsSequence(List<String> haystack, List<String> needle) {
|
||||
if (needle.isEmpty || needle.length > haystack.length) return false;
|
||||
for (var i = 0; i + needle.length <= haystack.length; i++) {
|
||||
var matched = true;
|
||||
for (var j = 0; j < needle.length; j++) {
|
||||
if (!_wordsEqual(haystack[i + j], needle[j])) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _wordsEqual(String a, String b) => a == b || _singular(a) == _singular(b);
|
||||
|
||||
/// Drops a single trailing `s` from words longer than three letters — a
|
||||
/// deliberately crude plural fold (see [matchSpeciesInLabel]).
|
||||
String _singular(String t) =>
|
||||
(t.length > 3 && t.endsWith('s')) ? t.substring(0, t.length - 1) : t;
|
||||
|
||||
/// Folds common Latin diacritics to their base letter so "Maiz" matches "Maíz".
|
||||
/// Only Latin marks are folded; other scripts pass through untouched, keeping
|
||||
/// the matcher usable for any language.
|
||||
String _foldDiacritics(String token) {
|
||||
if (token.codeUnits.every((c) => c < 0x80)) return token; // fast path: ASCII
|
||||
final buffer = StringBuffer();
|
||||
for (final ch in token.split('')) {
|
||||
buffer.write(_diacriticFolds[ch] ?? ch);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
const Map<String, String> _diacriticFolds = {
|
||||
'á': 'a', 'à': 'a', 'ä': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a',
|
||||
'ă': 'a', 'ą': 'a',
|
||||
'é': 'e', 'è': 'e', 'ë': 'e', 'ê': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e',
|
||||
'ě': 'e',
|
||||
'í': 'i', 'ì': 'i', 'ï': 'i', 'î': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i',
|
||||
'ó': 'o', 'ò': 'o', 'ö': 'o', 'ô': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o',
|
||||
'ő': 'o',
|
||||
'ú': 'u', 'ù': 'u', 'ü': 'u', 'û': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u',
|
||||
'ñ': 'n', 'ń': 'n', 'ň': 'n',
|
||||
'ç': 'c', 'ć': 'c', 'č': 'c',
|
||||
'ş': 's', 'š': 's', 'ș': 's', 'ś': 's',
|
||||
'ž': 'z', 'ź': 'z', 'ż': 'z',
|
||||
'ý': 'y', 'ÿ': 'y',
|
||||
'ğ': 'g', 'ł': 'l', 'ð': 'd', 'þ': 'th', 'ß': 'ss', 'œ': 'oe', 'æ': 'ae',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue