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:
vjrj 2026-07-10 02:05:00 +02:00
parent 0e6ef13d00
commit b840b83c42
15 changed files with 510 additions and 3 deletions

View file

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import '../domain/species_autoclassify.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';
@ -657,6 +658,11 @@ class VarietyRepository {
/// The 20-second quick-add: a [label] (required) plus an optional [category],
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
///
/// If the label names a bundled species ("Maíz de la abuela" *Zea mays*),
/// the variety is linked to it automatically and its category prefilled from
/// the species' family — the grower never has to open the species picker for
/// the common case. An explicit [category] is always kept as given.
Future<String> addQuickVariety({
required String label,
String? category,
@ -665,6 +671,8 @@ class VarietyRepository {
Uint8List? photoBytes,
}) async {
final varietyId = idGen.newId();
final auto = await _autoClassifyFromLabel(label);
final resolvedCategory = _hasText(category) ? category : auto?.family;
await _db.transaction(() async {
final (created, updated) = await _stamp();
await _db
@ -676,7 +684,8 @@ class VarietyRepository {
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
category: Value(category),
category: Value(resolvedCategory),
speciesId: Value(auto?.id),
),
);
@ -762,12 +771,26 @@ class VarietyRepository {
/// Names a draft and promotes it out of the "to catalogue" tray: sets its
/// [label] and clears [isDraft] in one LWW write.
///
/// Like [addQuickVariety], if the new [label] names a bundled species the
/// draft is auto-linked to it and its category prefilled from the family
/// but only when the draft has neither yet, so it never overrides a species
/// or category the grower already set on the draft.
Future<void> nameDraft(String id, String label) async {
final (_, updated) = await _stamp();
final draft = await (_db.select(
_db.varieties,
)..where((v) => v.id.equals(id))).getSingleOrNull();
final auto = draft?.speciesId == null
? await _autoClassifyFromLabel(label)
: null;
final prefillCategory = auto != null && !_hasText(draft?.category);
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
label: Value(label),
isDraft: const Value(false),
speciesId: auto == null ? const Value.absent() : Value(auto.id),
category: prefillCategory ? Value(auto.family) : const Value.absent(),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
@ -949,6 +972,40 @@ class VarietyRepository {
);
}
/// Infers a catalog species from a free-text [label] (see
/// [matchSpeciesInLabel]). Returns the matched species' id and family, or null
/// when the label names no known species or the match is ambiguous.
Future<({String id, String? family})?> _autoClassifyFromLabel(
String label,
) async {
final speciesId = matchSpeciesInLabel(label, await _speciesNamesForMatching());
if (speciesId == null) return null;
final species = await (_db.select(
_db.species,
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
return species == null ? null : (id: species.id, family: species.family);
}
/// Every catalog name that can identify a species its scientific name and
/// all non-deleted common names, across every bundled locale flattened for
/// the language-agnostic matcher. The catalog is small, so this is cheap.
Future<List<SpeciesNameEntry>> _speciesNamesForMatching() async {
final species = await (_db.select(
_db.species,
)..where((s) => s.isDeleted.equals(false))).get();
final commons = await (_db.select(
_db.speciesCommonNames,
)..where((n) => n.isDeleted.equals(false))).get();
return [
for (final s in species)
SpeciesNameEntry(speciesId: s.id, name: s.scientificName),
for (final c in commons)
SpeciesNameEntry(speciesId: c.speciesId, name: c.name),
];
}
bool _hasText(String? value) => value != null && value.trim().isNotEmpty;
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category
/// yet, prefill it from the species' botanical family (data-model §6).
Future<void> linkSpecies(String varietyId, String speciesId) async {