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
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue