Skip to main content

max / audiofiles

23.0 KB · 616 lines History Blame Raw
1 //! Tag-classifier actions (Layer A rules builder) on `BrowserState`.
2 //!
3 //! The UI (`ui/classifier.rs`) reads `state.classifier` and calls these methods; they
4 //! wrap the backend rule operations and keep the cached rule list fresh.
5
6 use audiofiles_core::rules::{
7 MatchMode, NewRule, Rule, RuleAction, RuleCondition, RuleField, RuleOp,
8 };
9
10 use super::{BrowserState, RuleDraft};
11
12 impl BrowserState {
13 /// Reload the cached rule list from the backend.
14 pub fn refresh_rules(&mut self) {
15 self.classifier.rules = self.backend.list_rules().unwrap_or_default();
16 self.classifier.loaded = true;
17 }
18
19 /// Ensure rules are loaded at least once (called when the section opens).
20 pub fn ensure_rules_loaded(&mut self) {
21 if !self.classifier.loaded {
22 self.refresh_rules();
23 }
24 }
25
26 /// Begin authoring a new rule (one empty condition, one add-tag action).
27 pub fn classifier_new_draft(&mut self) {
28 self.classifier.editing = Some(RuleDraft {
29 id: None,
30 name: String::new(),
31 enabled: true,
32 match_mode: MatchMode::All,
33 conditions: vec![RuleCondition {
34 field: RuleField::Name,
35 op: RuleOp::Contains,
36 value: String::new(),
37 }],
38 actions: vec![RuleAction::AddTag(String::new())],
39 priority: 0,
40 created_at: 0,
41 match_count: None,
42 });
43 }
44
45 /// Begin editing an existing rule.
46 pub fn classifier_edit_rule(&mut self, rule: &Rule) {
47 self.classifier.editing = Some(RuleDraft {
48 id: Some(rule.id.clone()),
49 name: rule.name.clone(),
50 enabled: rule.enabled,
51 match_mode: rule.match_mode,
52 conditions: rule.conditions.clone(),
53 actions: rule.actions.clone(),
54 priority: rule.priority,
55 created_at: rule.created_at,
56 match_count: None,
57 });
58 }
59
60 /// Discard the in-progress draft.
61 pub fn classifier_cancel_draft(&mut self) {
62 self.classifier.editing = None;
63 }
64
65 /// Build a `Rule` from the current draft (placeholder id/priority for new rules,
66 /// fine for dry-run, which only reads conditions + match_mode).
67 fn draft_as_rule(draft: &RuleDraft) -> Rule {
68 Rule {
69 id: draft.id.clone().unwrap_or_default(),
70 name: draft.name.clone(),
71 enabled: draft.enabled,
72 priority: draft.priority,
73 match_mode: draft.match_mode,
74 conditions: draft.conditions.clone(),
75 actions: draft.actions.clone(),
76 created_at: draft.created_at,
77 }
78 }
79
80 /// Dry-run the current draft against the library, caching the match count.
81 pub fn classifier_test_draft(&mut self) {
82 let Some(draft) = self.classifier.editing.as_ref() else {
83 return;
84 };
85 let rule = Self::draft_as_rule(draft);
86 match self.backend.preview_rule_matches(&rule) {
87 Ok(n) => {
88 if let Some(d) = self.classifier.editing.as_mut() {
89 d.match_count = Some(n);
90 }
91 self.status = format!("Rule matches {n} sample{}", if n == 1 { "" } else { "s" });
92 }
93 Err(e) => self.status = format!("Rule test failed: {e}"),
94 }
95 }
96
97 /// Persist the current draft (create or update), then reconcile + refresh.
98 pub fn classifier_save_draft(&mut self) {
99 let Some(draft) = self.classifier.editing.clone() else {
100 return;
101 };
102 if draft.name.trim().is_empty() {
103 self.status = "Name the rule before saving.".to_string();
104 return;
105 }
106 let result = match draft.id {
107 None => self
108 .backend
109 .create_rule(NewRule {
110 name: draft.name.trim().to_string(),
111 enabled: draft.enabled,
112 priority: None,
113 match_mode: draft.match_mode,
114 conditions: draft.conditions.clone(),
115 actions: draft.actions.clone(),
116 })
117 .map(|_| ()),
118 Some(_) => self.backend.update_rule(&Self::draft_as_rule(&draft)),
119 };
120 match result {
121 Ok(()) => {
122 self.classifier.editing = None;
123 self.refresh_rules();
124 // Apply immediately so the rule's effect is visible.
125 self.classifier_apply_all();
126 }
127 Err(e) => self.status = format!("Could not save rule: {e}"),
128 }
129 }
130
131 /// Delete a rule (reconciling its tags away) and refresh.
132 pub fn classifier_delete_rule(&mut self, id: &str) {
133 match self.backend.delete_rule(id) {
134 Ok(()) => {
135 self.refresh_rules();
136 self.refresh_selected_tags();
137 self.status = "Rule deleted.".to_string();
138 }
139 Err(e) => self.status = format!("Could not delete rule: {e}"),
140 }
141 }
142
143 /// Toggle a rule's enabled flag, then re-apply so the change takes effect.
144 pub fn classifier_toggle_rule(&mut self, id: &str, enabled: bool) {
145 if let Err(e) = self.backend.set_rule_enabled(id, enabled) {
146 self.status = format!("Could not update rule: {e}");
147 return;
148 }
149 self.refresh_rules();
150 self.classifier_apply_all();
151 }
152
153 /// Move a rule up or down in evaluation order by swapping priorities with its
154 /// neighbor. `idx` is the position in the (priority-ordered) cached list.
155 pub fn classifier_move_rule(&mut self, idx: usize, up: bool) {
156 let rules = &self.classifier.rules;
157 let other = if up {
158 if idx == 0 {
159 return;
160 }
161 idx - 1
162 } else {
163 if idx + 1 >= rules.len() {
164 return;
165 }
166 idx + 1
167 };
168 let mut a = rules[idx].clone();
169 let mut b = rules[other].clone();
170 std::mem::swap(&mut a.priority, &mut b.priority);
171 if self.backend.update_rule(&a).is_ok() && self.backend.update_rule(&b).is_ok() {
172 self.refresh_rules();
173 self.classifier_apply_all();
174 }
175 }
176
177 /// Re-apply all rules across the library; records how many samples changed.
178 pub fn classifier_apply_all(&mut self) {
179 match self.backend.apply_all_rules() {
180 Ok(n) => {
181 self.classifier.last_apply = Some(n);
182 self.refresh_selected_tags();
183 self.status = format!(
184 "Rules applied: {n} sample{} updated",
185 if n == 1 { "" } else { "s" }
186 );
187 }
188 Err(e) => self.status = format!("Could not apply rules: {e}"),
189 }
190 }
191
192 // ── Layer B: k-NN auto-tagging ──
193
194 /// Auto-apply above-threshold suggestions across the whole library, runs on a worker
195 /// thread (O(N) k-NN over the library is heavy), completing via `poll_workers`.
196 pub fn classifier_auto_apply_ml(&mut self) {
197 use audiofiles_core::analysis::exemplar::DEFAULT_K;
198 self.start_classifier_job(
199 crate::backend::ClassifierJob::AutoApply { k: DEFAULT_K },
200 "Auto-tagging library\u{2026}",
201 );
202 }
203
204 /// Apply a finished classifier job's result (called from `poll_workers`). The worker
205 /// wrote to its own DB connection, so refresh the affected cached views here.
206 pub fn on_classifier_job_done(&mut self, result: crate::backend::ClassifierJobResult) {
207 use crate::backend::ClassifierJobResult as R;
208 match result {
209 R::Trained(info) => {
210 self.classifier.head_info = info;
211 self.refresh_head();
212 self.status = match info {
213 Some(i) => format!(
214 "Trained model on {} sample{} across {} tag{}.",
215 i.exemplar_count,
216 if i.exemplar_count == 1 { "" } else { "s" },
217 i.class_count,
218 if i.class_count == 1 { "" } else { "s" },
219 ),
220 None => "Not enough tagged samples to train a model yet.".to_string(),
221 };
222 }
223 R::AutoApplied(n) => {
224 self.classifier.last_ml_apply = Some(n);
225 self.refresh_selected_tags();
226 self.status = format!(
227 "Auto-tagging applied {n} tag{}.",
228 if n == 1 { "" } else { "s" }
229 );
230 }
231 R::Clustered(clusters) => {
232 self.classifier.cluster_names = vec![String::new(); clusters.len()];
233 self.classifier.clusters = clusters;
234 self.status = format!(
235 "Found {} cluster{}.",
236 self.classifier.clusters.len(),
237 if self.classifier.clusters.len() == 1 {
238 ""
239 } else {
240 "s"
241 },
242 );
243 }
244 R::Exported(path) => {
245 let msg = format!("Exported classifier to {}", path.display());
246 self.classifier.last_export_msg = Some(msg.clone());
247 self.status = msg;
248 }
249 R::Suggested { hash, outcome } => {
250 // Drop the result if the selection moved on while the O(library)
251 // index built on the worker (the user clicked another sample).
252 let current = self
253 .selected_node()
254 .and_then(|n| n.node.sample_hash.clone());
255 if current.as_deref() != Some(hash.as_str()) {
256 return;
257 }
258 let mut all = outcome.auto_applied;
259 all.extend(outcome.pending_review);
260 let existing: std::collections::HashSet<&String> =
261 self.selected_tags.iter().collect();
262 all.retain(|s| !existing.contains(&s.tag));
263 self.status = if all.is_empty() {
264 "No tag suggestions from similar samples.".to_string()
265 } else {
266 format!(
267 "{} tag suggestion{}.",
268 all.len(),
269 if all.len() == 1 { "" } else { "s" }
270 )
271 };
272 self.selected_ml_suggestions = all;
273 }
274 }
275 }
276
277 /// Dispatch a heavy classifier job to the worker, marking the section busy. Buttons are
278 /// disabled while `busy` is set; the result lands in `poll_workers`.
279 fn start_classifier_job(&mut self, job: crate::backend::ClassifierJob, label: &str) {
280 if self.classifier.busy.is_some() {
281 return; // one at a time
282 }
283 self.classifier.last_error = None;
284 match self.backend.start_classifier_job(job) {
285 Ok(()) => {
286 self.classifier.busy = Some(label.to_string());
287 self.status = label.to_string();
288 }
289 Err(e) => {
290 self.classifier.last_error = Some(format!("Could not start: {e}"));
291 self.status = format!("Could not start: {e}");
292 }
293 }
294 }
295
296 // ── .afcl file sharing (classifier layers) ──
297
298 /// Load the layer list (and seed export-form defaults) when the section first opens.
299 pub fn ensure_layers_loaded(&mut self) {
300 if !self.classifier.layers_loaded {
301 // One-time export-form defaults (derive(Default) leaves bools false).
302 if self.classifier.export_name.is_empty() {
303 self.classifier.export_name = "My classifier".to_string();
304 self.classifier.export_include_exemplars = true;
305 self.classifier.export_include_rules = true;
306 self.classifier.export_include_policy = true;
307 }
308 self.refresh_layers();
309 }
310 }
311
312 pub fn refresh_layers(&mut self) {
313 self.classifier.layers = self.backend.list_classifier_layers().unwrap_or_default();
314 self.classifier.layers_loaded = true;
315 }
316
317 /// Export the user's classifier data to `path` using the export-form selections,
318 /// runs on a worker thread (reads + serializes the whole library).
319 pub fn classifier_export_afcl(&mut self, path: &std::path::Path) {
320 use audiofiles_core::analysis::afcl::ExportOptions;
321 let opts = ExportOptions {
322 name: {
323 let n = self.classifier.export_name.trim();
324 if n.is_empty() {
325 "My classifier".to_string()
326 } else {
327 n.to_string()
328 }
329 },
330 description: String::new(),
331 license_note: String::new(),
332 include_exemplars: self.classifier.export_include_exemplars,
333 include_rules: self.classifier.export_include_rules,
334 include_policy: self.classifier.export_include_policy,
335 };
336 self.classifier.last_export_msg = None;
337 self.start_classifier_job(
338 crate::backend::ClassifierJob::Export {
339 path: path.to_path_buf(),
340 opts,
341 },
342 "Exporting\u{2026}",
343 );
344 }
345
346 /// Import a `.afcl` file as a new removable layer, refreshing dependent views.
347 pub fn classifier_import_afcl(&mut self, path: &std::path::Path) {
348 match self.backend.import_afcl(path) {
349 Ok(s) => {
350 let msg = format!(
351 "Imported \"{}\": {} exemplar{}, {} rule{} (disabled), {} threshold{}.",
352 s.name,
353 s.exemplars,
354 if s.exemplars == 1 { "" } else { "s" },
355 s.rules,
356 if s.rules == 1 { "" } else { "s" },
357 s.policies,
358 if s.policies == 1 { "" } else { "s" },
359 );
360 self.status.clone_from(&msg);
361 self.classifier.last_import_msg = Some(msg);
362 // Import adds disabled rules + policies; refresh those views too.
363 self.refresh_layers();
364 self.refresh_rules();
365 self.refresh_policies();
366 }
367 Err(e) => {
368 let msg = format!("Import failed: {e}");
369 self.status.clone_from(&msg);
370 self.classifier.last_import_msg = Some(msg);
371 }
372 }
373 }
374
375 /// Remove a layer and everything it brought in.
376 pub fn classifier_remove_layer(&mut self, id: &str) {
377 match self.backend.remove_classifier_layer(id) {
378 Ok(()) => {
379 self.status = "Removed classifier layer.".to_string();
380 self.refresh_layers();
381 self.refresh_rules();
382 self.refresh_selected_tags();
383 }
384 Err(e) => self.status = format!("Could not remove layer: {e}"),
385 }
386 }
387
388 pub fn classifier_set_layer_enabled(&mut self, id: &str, enabled: bool) {
389 if let Err(e) = self.backend.set_classifier_layer_enabled(id, enabled) {
390 self.status = format!("Could not update layer: {e}");
391 }
392 self.refresh_layers();
393 }
394
395 pub fn classifier_set_layer_weight(&mut self, id: &str, weight: f64) {
396 if let Err(e) = self.backend.set_classifier_layer_weight(id, weight) {
397 self.status = format!("Could not set layer weight: {e}");
398 }
399 }
400
401 // ── Optional trained head (Layer B speed-up) ──
402
403 /// Load head summary + readiness once when the section opens.
404 pub fn ensure_head_loaded(&mut self) {
405 if !self.classifier.head_loaded {
406 self.refresh_head();
407 }
408 }
409
410 pub fn refresh_head(&mut self) {
411 self.classifier.head_info = self.backend.classifier_head_info().unwrap_or_default();
412 self.classifier.head_readiness = self.backend.classifier_head_readiness().ok();
413 self.classifier.head_loaded = true;
414 }
415
416 /// Distill the tagged library into a trained head and persist it, runs on a worker
417 /// thread. Once present, the library-wide "Suggest tags" pass routes through it.
418 pub fn classifier_train_head(&mut self) {
419 self.start_classifier_job(
420 crate::backend::ClassifierJob::TrainHead,
421 "Training model\u{2026}",
422 );
423 }
424
425 /// Discard the trained head; auto-tagging reverts to k-NN.
426 pub fn classifier_clear_head(&mut self) {
427 match self.backend.clear_classifier_head() {
428 Ok(()) => {
429 self.status = "Trained model cleared. Using nearest-neighbor matching.".to_string();
430 }
431 Err(e) => self.status = format!("Could not clear model: {e}"),
432 }
433 self.refresh_head();
434 }
435
436 pub fn ensure_policies_loaded(&mut self) {
437 if !self.classifier.policies_loaded {
438 self.refresh_policies();
439 }
440 }
441
442 pub fn refresh_policies(&mut self) {
443 self.classifier.policies = self.backend.list_tag_policies().unwrap_or_default();
444 self.classifier.policies_loaded = true;
445 }
446
447 /// Persist a tag's thresholds.
448 pub fn classifier_set_policy(&mut self, tag: &str, review: f64, auto: f64) {
449 if let Err(e) = self.backend.set_tag_policy(tag, review, auto) {
450 self.status = format!("Could not set thresholds: {e}");
451 }
452 }
453
454 /// Add a per-tag policy at default thresholds, ready to tune.
455 pub fn classifier_add_policy(&mut self) {
456 use audiofiles_core::analysis::exemplar::{
457 DEFAULT_AUTO_THRESHOLD, DEFAULT_REVIEW_THRESHOLD,
458 };
459 let tag = self.classifier.new_policy_tag.trim().to_string();
460 if tag.is_empty() {
461 return;
462 }
463 if self
464 .backend
465 .set_tag_policy(&tag, DEFAULT_REVIEW_THRESHOLD, DEFAULT_AUTO_THRESHOLD)
466 .is_ok()
467 {
468 self.classifier.new_policy_tag.clear();
469 self.refresh_policies();
470 }
471 }
472
473 // ── k-NN suggestions for the selected sample (detail panel) ──
474
475 /// Populate `selected_ml_suggestions` with k-NN suggestions for the selected sample.
476 pub fn suggest_ml_for_selected(&mut self) {
477 use audiofiles_core::analysis::exemplar::DEFAULT_K;
478 self.selected_ml_suggestions.clear();
479 let Some(hash) = self
480 .selected_node()
481 .and_then(|n| n.node.sample_hash.clone())
482 else {
483 return;
484 };
485 // Dispatch to the classifier worker: building the exemplar index is
486 // O(library) and used to run under the DB lock on the GUI thread. The
487 // result lands in `on_classifier_job_done`, which drops it if the
488 // selection changed meanwhile (hash mismatch).
489 self.start_classifier_job(
490 crate::backend::ClassifierJob::SuggestSample {
491 hash: hash.to_string(),
492 k: DEFAULT_K,
493 },
494 "Finding similar tags\u{2026}",
495 );
496 }
497
498 /// Accept one k-NN suggestion: apply the tag (`source = 'ml'`) and drop it from
499 /// the pending list, refreshing the chips without clearing other suggestions.
500 pub fn accept_ml_suggestion(&mut self, tag: &str) {
501 let Some(hash) = self
502 .selected_node()
503 .and_then(|n| n.node.sample_hash.clone())
504 else {
505 return;
506 };
507 if self.backend.accept_ml_tag(&hash, tag).is_ok() {
508 self.selected_ml_suggestions.retain(|s| s.tag != tag);
509 self.status = format!("Added tag \"{tag}\"");
510 self.selected_tags =
511 std::sync::Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_default());
512 if let Ok(prov) = self.backend.sample_tag_provenance(&hash) {
513 self.selected_tag_sources = prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect();
514 }
515 }
516 }
517
518 // ── Clustering bootstrap ──
519
520 /// Cluster the library on a worker thread; `cluster_k < 2` means use a default of 8.
521 pub fn run_clustering(&mut self) {
522 let k = if self.classifier.cluster_k < 2 {
523 8
524 } else {
525 self.classifier.cluster_k as usize
526 };
527 self.start_classifier_job(
528 crate::backend::ClassifierJob::Cluster { k },
529 "Finding clusters\u{2026}",
530 );
531 }
532
533 /// Apply the named tag to every member of cluster `idx`.
534 pub fn apply_cluster(&mut self, idx: usize) {
535 let Some(cluster) = self.classifier.clusters.get(idx) else {
536 return;
537 };
538 let members = cluster.member_hashes.clone();
539 let tag = self
540 .classifier
541 .cluster_names
542 .get(idx)
543 .cloned()
544 .unwrap_or_default();
545 let tag = tag.trim().to_string();
546 if tag.is_empty() {
547 self.status = "Name the cluster before tagging it.".to_string();
548 return;
549 }
550 match self.backend.apply_cluster_tag(&members, &tag) {
551 Ok(n) => {
552 self.status = format!(
553 "Tagged {n} sample{} as \"{tag}\"",
554 if n == 1 { "" } else { "s" }
555 );
556 self.refresh_selected_tags();
557 }
558 Err(e) => self.status = format!("Could not tag cluster: {e}"),
559 }
560 }
561
562 // ── Folder-label harvest ──
563
564 pub fn ensure_folder_loaded(&mut self) {
565 if !self.classifier.folder_loaded {
566 self.refresh_folder_labels();
567 }
568 }
569
570 pub fn refresh_folder_labels(&mut self) {
571 let labels = self.backend.harvest_folder_labels().unwrap_or_default();
572 self.classifier.folder_tags = labels.iter().map(|l| l.suggested_tag.clone()).collect();
573 self.classifier.folder_labels = labels;
574 self.classifier.folder_loaded = true;
575 }
576
577 /// Apply the (possibly re-namespaced) tag to folder label `idx`.
578 pub fn apply_folder_label(&mut self, idx: usize) {
579 let Some(label) = self.classifier.folder_labels.get(idx) else {
580 return;
581 };
582 let hashes = label.sample_hashes.clone();
583 let tag = self
584 .classifier
585 .folder_tags
586 .get(idx)
587 .cloned()
588 .unwrap_or_default();
589 let tag = tag.trim().to_string();
590 if tag.is_empty() {
591 return;
592 }
593 match self.backend.apply_harvested_tag(&hashes, &tag) {
594 Ok(n) => {
595 self.status = format!(
596 "Tagged {n} sample{} as \"{tag}\"",
597 if n == 1 { "" } else { "s" }
598 );
599 self.refresh_selected_tags();
600 }
601 Err(e) => self.status = format!("Could not apply folder tag: {e}"),
602 }
603 }
604
605 /// Undo a whole machine-source pass (e.g. remove all `cluster` tags).
606 pub fn undo_tag_source(&mut self, source: &str) {
607 match self.backend.remove_tags_by_source(source) {
608 Ok(n) => {
609 self.status = format!("Removed {n} {source} tag{}", if n == 1 { "" } else { "s" });
610 self.refresh_selected_tags();
611 }
612 Err(e) => self.status = format!("Could not remove tags: {e}"),
613 }
614 }
615 }
616