//! Tag-classifier actions (Layer A rules builder) on `BrowserState`. //! //! The UI (`ui/classifier.rs`) reads `state.classifier` and calls these methods; they //! wrap the backend rule operations and keep the cached rule list fresh. use audiofiles_core::rules::{ MatchMode, NewRule, Rule, RuleAction, RuleCondition, RuleField, RuleOp, }; use super::{BrowserState, RuleDraft}; impl BrowserState { /// Reload the cached rule list from the backend. pub fn refresh_rules(&mut self) { self.classifier.rules = self.backend.list_rules().unwrap_or_default(); self.classifier.loaded = true; } /// Ensure rules are loaded at least once (called when the section opens). pub fn ensure_rules_loaded(&mut self) { if !self.classifier.loaded { self.refresh_rules(); } } /// Begin authoring a new rule (one empty condition, one add-tag action). pub fn classifier_new_draft(&mut self) { self.classifier.editing = Some(RuleDraft { id: None, name: String::new(), enabled: true, match_mode: MatchMode::All, conditions: vec![RuleCondition { field: RuleField::Name, op: RuleOp::Contains, value: String::new(), }], actions: vec![RuleAction::AddTag(String::new())], priority: 0, created_at: 0, match_count: None, }); } /// Begin editing an existing rule. pub fn classifier_edit_rule(&mut self, rule: &Rule) { self.classifier.editing = Some(RuleDraft { id: Some(rule.id.clone()), name: rule.name.clone(), enabled: rule.enabled, match_mode: rule.match_mode, conditions: rule.conditions.clone(), actions: rule.actions.clone(), priority: rule.priority, created_at: rule.created_at, match_count: None, }); } /// Discard the in-progress draft. pub fn classifier_cancel_draft(&mut self) { self.classifier.editing = None; } /// Build a `Rule` from the current draft (placeholder id/priority for new rules, /// fine for dry-run, which only reads conditions + match_mode). fn draft_as_rule(draft: &RuleDraft) -> Rule { Rule { id: draft.id.clone().unwrap_or_default(), name: draft.name.clone(), enabled: draft.enabled, priority: draft.priority, match_mode: draft.match_mode, conditions: draft.conditions.clone(), actions: draft.actions.clone(), created_at: draft.created_at, } } /// Dry-run the current draft against the library, caching the match count. pub fn classifier_test_draft(&mut self) { let Some(draft) = self.classifier.editing.as_ref() else { return; }; let rule = Self::draft_as_rule(draft); match self.backend.preview_rule_matches(&rule) { Ok(n) => { if let Some(d) = self.classifier.editing.as_mut() { d.match_count = Some(n); } self.status = format!("Rule matches {n} sample{}", if n == 1 { "" } else { "s" }); } Err(e) => self.status = format!("Rule test failed: {e}"), } } /// Persist the current draft (create or update), then reconcile + refresh. pub fn classifier_save_draft(&mut self) { let Some(draft) = self.classifier.editing.clone() else { return; }; if draft.name.trim().is_empty() { self.status = "Name the rule before saving.".to_string(); return; } let result = match draft.id { None => self .backend .create_rule(NewRule { name: draft.name.trim().to_string(), enabled: draft.enabled, priority: None, match_mode: draft.match_mode, conditions: draft.conditions.clone(), actions: draft.actions.clone(), }) .map(|_| ()), Some(_) => self.backend.update_rule(&Self::draft_as_rule(&draft)), }; match result { Ok(()) => { self.classifier.editing = None; self.refresh_rules(); // Apply immediately so the rule's effect is visible. self.classifier_apply_all(); } Err(e) => self.status = format!("Could not save rule: {e}"), } } /// Delete a rule (reconciling its tags away) and refresh. pub fn classifier_delete_rule(&mut self, id: &str) { match self.backend.delete_rule(id) { Ok(()) => { self.refresh_rules(); self.refresh_selected_tags(); self.status = "Rule deleted.".to_string(); } Err(e) => self.status = format!("Could not delete rule: {e}"), } } /// Toggle a rule's enabled flag, then re-apply so the change takes effect. pub fn classifier_toggle_rule(&mut self, id: &str, enabled: bool) { if let Err(e) = self.backend.set_rule_enabled(id, enabled) { self.status = format!("Could not update rule: {e}"); return; } self.refresh_rules(); self.classifier_apply_all(); } /// Move a rule up or down in evaluation order by swapping priorities with its /// neighbor. `idx` is the position in the (priority-ordered) cached list. pub fn classifier_move_rule(&mut self, idx: usize, up: bool) { let rules = &self.classifier.rules; let other = if up { if idx == 0 { return; } idx - 1 } else { if idx + 1 >= rules.len() { return; } idx + 1 }; let mut a = rules[idx].clone(); let mut b = rules[other].clone(); std::mem::swap(&mut a.priority, &mut b.priority); if self.backend.update_rule(&a).is_ok() && self.backend.update_rule(&b).is_ok() { self.refresh_rules(); self.classifier_apply_all(); } } /// Re-apply all rules across the library; records how many samples changed. pub fn classifier_apply_all(&mut self) { match self.backend.apply_all_rules() { Ok(n) => { self.classifier.last_apply = Some(n); self.refresh_selected_tags(); self.status = format!( "Rules applied: {n} sample{} updated", if n == 1 { "" } else { "s" } ); } Err(e) => self.status = format!("Could not apply rules: {e}"), } } // ── Layer B: k-NN auto-tagging ── /// Auto-apply above-threshold suggestions across the whole library, runs on a worker /// thread (O(N) k-NN over the library is heavy), completing via `poll_workers`. pub fn classifier_auto_apply_ml(&mut self) { use audiofiles_core::analysis::exemplar::DEFAULT_K; self.start_classifier_job( crate::backend::ClassifierJob::AutoApply { k: DEFAULT_K }, "Auto-tagging library\u{2026}", ); } /// Apply a finished classifier job's result (called from `poll_workers`). The worker /// wrote to its own DB connection, so refresh the affected cached views here. pub fn on_classifier_job_done(&mut self, result: crate::backend::ClassifierJobResult) { use crate::backend::ClassifierJobResult as R; match result { R::Trained(info) => { self.classifier.head_info = info; self.refresh_head(); self.status = match info { Some(i) => format!( "Trained model on {} sample{} across {} tag{}.", i.exemplar_count, if i.exemplar_count == 1 { "" } else { "s" }, i.class_count, if i.class_count == 1 { "" } else { "s" }, ), None => "Not enough tagged samples to train a model yet.".to_string(), }; } R::AutoApplied(n) => { self.classifier.last_ml_apply = Some(n); self.refresh_selected_tags(); self.status = format!( "Auto-tagging applied {n} tag{}.", if n == 1 { "" } else { "s" } ); } R::Clustered(clusters) => { self.classifier.cluster_names = vec![String::new(); clusters.len()]; self.classifier.clusters = clusters; self.status = format!( "Found {} cluster{}.", self.classifier.clusters.len(), if self.classifier.clusters.len() == 1 { "" } else { "s" }, ); } R::Exported(path) => { let msg = format!("Exported classifier to {}", path.display()); self.classifier.last_export_msg = Some(msg.clone()); self.status = msg; } R::Suggested { hash, outcome } => { // Drop the result if the selection moved on while the O(library) // index built on the worker (the user clicked another sample). let current = self .selected_node() .and_then(|n| n.node.sample_hash.clone()); if current.as_deref() != Some(hash.as_str()) { return; } let mut all = outcome.auto_applied; all.extend(outcome.pending_review); let existing: std::collections::HashSet<&String> = self.selected_tags.iter().collect(); all.retain(|s| !existing.contains(&s.tag)); self.status = if all.is_empty() { "No tag suggestions from similar samples.".to_string() } else { format!( "{} tag suggestion{}.", all.len(), if all.len() == 1 { "" } else { "s" } ) }; self.selected_ml_suggestions = all; } } } /// Dispatch a heavy classifier job to the worker, marking the section busy. Buttons are /// disabled while `busy` is set; the result lands in `poll_workers`. fn start_classifier_job(&mut self, job: crate::backend::ClassifierJob, label: &str) { if self.classifier.busy.is_some() { return; // one at a time } self.classifier.last_error = None; match self.backend.start_classifier_job(job) { Ok(()) => { self.classifier.busy = Some(label.to_string()); self.status = label.to_string(); } Err(e) => { self.classifier.last_error = Some(format!("Could not start: {e}")); self.status = format!("Could not start: {e}"); } } } // ── .afcl file sharing (classifier layers) ── /// Load the layer list (and seed export-form defaults) when the section first opens. pub fn ensure_layers_loaded(&mut self) { if !self.classifier.layers_loaded { // One-time export-form defaults (derive(Default) leaves bools false). if self.classifier.export_name.is_empty() { self.classifier.export_name = "My classifier".to_string(); self.classifier.export_include_exemplars = true; self.classifier.export_include_rules = true; self.classifier.export_include_policy = true; } self.refresh_layers(); } } pub fn refresh_layers(&mut self) { self.classifier.layers = self.backend.list_classifier_layers().unwrap_or_default(); self.classifier.layers_loaded = true; } /// Export the user's classifier data to `path` using the export-form selections, /// runs on a worker thread (reads + serializes the whole library). pub fn classifier_export_afcl(&mut self, path: &std::path::Path) { use audiofiles_core::analysis::afcl::ExportOptions; let opts = ExportOptions { name: { let n = self.classifier.export_name.trim(); if n.is_empty() { "My classifier".to_string() } else { n.to_string() } }, description: String::new(), license_note: String::new(), include_exemplars: self.classifier.export_include_exemplars, include_rules: self.classifier.export_include_rules, include_policy: self.classifier.export_include_policy, }; self.classifier.last_export_msg = None; self.start_classifier_job( crate::backend::ClassifierJob::Export { path: path.to_path_buf(), opts, }, "Exporting\u{2026}", ); } /// Import a `.afcl` file as a new removable layer, refreshing dependent views. pub fn classifier_import_afcl(&mut self, path: &std::path::Path) { match self.backend.import_afcl(path) { Ok(s) => { let msg = format!( "Imported \"{}\": {} exemplar{}, {} rule{} (disabled), {} threshold{}.", s.name, s.exemplars, if s.exemplars == 1 { "" } else { "s" }, s.rules, if s.rules == 1 { "" } else { "s" }, s.policies, if s.policies == 1 { "" } else { "s" }, ); self.status.clone_from(&msg); self.classifier.last_import_msg = Some(msg); // Import adds disabled rules + policies; refresh those views too. self.refresh_layers(); self.refresh_rules(); self.refresh_policies(); } Err(e) => { let msg = format!("Import failed: {e}"); self.status.clone_from(&msg); self.classifier.last_import_msg = Some(msg); } } } /// Remove a layer and everything it brought in. pub fn classifier_remove_layer(&mut self, id: &str) { match self.backend.remove_classifier_layer(id) { Ok(()) => { self.status = "Removed classifier layer.".to_string(); self.refresh_layers(); self.refresh_rules(); self.refresh_selected_tags(); } Err(e) => self.status = format!("Could not remove layer: {e}"), } } pub fn classifier_set_layer_enabled(&mut self, id: &str, enabled: bool) { if let Err(e) = self.backend.set_classifier_layer_enabled(id, enabled) { self.status = format!("Could not update layer: {e}"); } self.refresh_layers(); } pub fn classifier_set_layer_weight(&mut self, id: &str, weight: f64) { if let Err(e) = self.backend.set_classifier_layer_weight(id, weight) { self.status = format!("Could not set layer weight: {e}"); } } // ── Optional trained head (Layer B speed-up) ── /// Load head summary + readiness once when the section opens. pub fn ensure_head_loaded(&mut self) { if !self.classifier.head_loaded { self.refresh_head(); } } pub fn refresh_head(&mut self) { self.classifier.head_info = self.backend.classifier_head_info().unwrap_or_default(); self.classifier.head_readiness = self.backend.classifier_head_readiness().ok(); self.classifier.head_loaded = true; } /// Distill the tagged library into a trained head and persist it, runs on a worker /// thread. Once present, the library-wide "Suggest tags" pass routes through it. pub fn classifier_train_head(&mut self) { self.start_classifier_job( crate::backend::ClassifierJob::TrainHead, "Training model\u{2026}", ); } /// Discard the trained head; auto-tagging reverts to k-NN. pub fn classifier_clear_head(&mut self) { match self.backend.clear_classifier_head() { Ok(()) => { self.status = "Trained model cleared. Using nearest-neighbor matching.".to_string(); } Err(e) => self.status = format!("Could not clear model: {e}"), } self.refresh_head(); } pub fn ensure_policies_loaded(&mut self) { if !self.classifier.policies_loaded { self.refresh_policies(); } } pub fn refresh_policies(&mut self) { self.classifier.policies = self.backend.list_tag_policies().unwrap_or_default(); self.classifier.policies_loaded = true; } /// Persist a tag's thresholds. pub fn classifier_set_policy(&mut self, tag: &str, review: f64, auto: f64) { if let Err(e) = self.backend.set_tag_policy(tag, review, auto) { self.status = format!("Could not set thresholds: {e}"); } } /// Add a per-tag policy at default thresholds, ready to tune. pub fn classifier_add_policy(&mut self) { use audiofiles_core::analysis::exemplar::{ DEFAULT_AUTO_THRESHOLD, DEFAULT_REVIEW_THRESHOLD, }; let tag = self.classifier.new_policy_tag.trim().to_string(); if tag.is_empty() { return; } if self .backend .set_tag_policy(&tag, DEFAULT_REVIEW_THRESHOLD, DEFAULT_AUTO_THRESHOLD) .is_ok() { self.classifier.new_policy_tag.clear(); self.refresh_policies(); } } // ── k-NN suggestions for the selected sample (detail panel) ── /// Populate `selected_ml_suggestions` with k-NN suggestions for the selected sample. pub fn suggest_ml_for_selected(&mut self) { use audiofiles_core::analysis::exemplar::DEFAULT_K; self.selected_ml_suggestions.clear(); let Some(hash) = self .selected_node() .and_then(|n| n.node.sample_hash.clone()) else { return; }; // Dispatch to the classifier worker: building the exemplar index is // O(library) and used to run under the DB lock on the GUI thread. The // result lands in `on_classifier_job_done`, which drops it if the // selection changed meanwhile (hash mismatch). self.start_classifier_job( crate::backend::ClassifierJob::SuggestSample { hash: hash.to_string(), k: DEFAULT_K, }, "Finding similar tags\u{2026}", ); } /// Accept one k-NN suggestion: apply the tag (`source = 'ml'`) and drop it from /// the pending list, refreshing the chips without clearing other suggestions. pub fn accept_ml_suggestion(&mut self, tag: &str) { let Some(hash) = self .selected_node() .and_then(|n| n.node.sample_hash.clone()) else { return; }; if self.backend.accept_ml_tag(&hash, tag).is_ok() { self.selected_ml_suggestions.retain(|s| s.tag != tag); self.status = format!("Added tag \"{tag}\""); self.selected_tags = std::sync::Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_default()); if let Ok(prov) = self.backend.sample_tag_provenance(&hash) { self.selected_tag_sources = prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect(); } } } // ── Clustering bootstrap ── /// Cluster the library on a worker thread; `cluster_k < 2` means use a default of 8. pub fn run_clustering(&mut self) { let k = if self.classifier.cluster_k < 2 { 8 } else { self.classifier.cluster_k as usize }; self.start_classifier_job( crate::backend::ClassifierJob::Cluster { k }, "Finding clusters\u{2026}", ); } /// Apply the named tag to every member of cluster `idx`. pub fn apply_cluster(&mut self, idx: usize) { let Some(cluster) = self.classifier.clusters.get(idx) else { return; }; let members = cluster.member_hashes.clone(); let tag = self .classifier .cluster_names .get(idx) .cloned() .unwrap_or_default(); let tag = tag.trim().to_string(); if tag.is_empty() { self.status = "Name the cluster before tagging it.".to_string(); return; } match self.backend.apply_cluster_tag(&members, &tag) { Ok(n) => { self.status = format!( "Tagged {n} sample{} as \"{tag}\"", if n == 1 { "" } else { "s" } ); self.refresh_selected_tags(); } Err(e) => self.status = format!("Could not tag cluster: {e}"), } } // ── Folder-label harvest ── pub fn ensure_folder_loaded(&mut self) { if !self.classifier.folder_loaded { self.refresh_folder_labels(); } } pub fn refresh_folder_labels(&mut self) { let labels = self.backend.harvest_folder_labels().unwrap_or_default(); self.classifier.folder_tags = labels.iter().map(|l| l.suggested_tag.clone()).collect(); self.classifier.folder_labels = labels; self.classifier.folder_loaded = true; } /// Apply the (possibly re-namespaced) tag to folder label `idx`. pub fn apply_folder_label(&mut self, idx: usize) { let Some(label) = self.classifier.folder_labels.get(idx) else { return; }; let hashes = label.sample_hashes.clone(); let tag = self .classifier .folder_tags .get(idx) .cloned() .unwrap_or_default(); let tag = tag.trim().to_string(); if tag.is_empty() { return; } match self.backend.apply_harvested_tag(&hashes, &tag) { Ok(n) => { self.status = format!( "Tagged {n} sample{} as \"{tag}\"", if n == 1 { "" } else { "s" } ); self.refresh_selected_tags(); } Err(e) => self.status = format!("Could not apply folder tag: {e}"), } } /// Undo a whole machine-source pass (e.g. remove all `cluster` tags). pub fn undo_tag_source(&mut self, source: &str) { match self.backend.remove_tags_by_source(source) { Ok(n) => { self.status = format!("Removed {n} {source} tag{}", if n == 1 { "" } else { "s" }); self.refresh_selected_tags(); } Err(e) => self.status = format!("Could not remove tags: {e}"), } } }