greentic-flow-builder 0.1.0

AI-powered Adaptive Card flow builder with visual graph editor and demo runner
Documentation
//! Keyword-based preset discovery.
//!
//! Searches across preset name, title, description, tags, and use-cases
//! using substring match + fuzzy scoring.

use super::{PresetMatch, TemplateDiscovery};
use crate::template::PresetInfo;
use std::collections::BTreeMap;
use strsim::normalized_damerau_levenshtein;

pub struct KeywordDiscovery {
    presets: BTreeMap<String, PresetInfo>,
}

impl KeywordDiscovery {
    pub fn new(presets: BTreeMap<String, PresetInfo>) -> Self {
        Self { presets }
    }

    fn score_preset(&self, preset: &PresetInfo, query_lower: &str) -> f64 {
        let mut best: f64 = 0.0;

        let name_score = normalized_damerau_levenshtein(&preset.name.to_lowercase(), query_lower);
        best = best.max(name_score * 1.5);

        let title_score = normalized_damerau_levenshtein(&preset.title.to_lowercase(), query_lower);
        best = best.max(title_score * 1.2);

        if preset.description.to_lowercase().contains(query_lower) {
            best = best.max(0.8);
        }

        for tag in &preset.tags {
            let tag_score = normalized_damerau_levenshtein(&tag.to_lowercase(), query_lower);
            best = best.max(tag_score);
        }

        for use_case in &preset.use_cases {
            if use_case.to_lowercase().contains(query_lower) {
                best = best.max(0.9);
            }
        }

        best
    }
}

impl TemplateDiscovery for KeywordDiscovery {
    fn find_presets(&self, query: &str) -> Vec<PresetMatch> {
        let query_lower = query.to_lowercase();
        let mut matches: Vec<PresetMatch> = self
            .presets
            .values()
            .map(|p| PresetMatch {
                name: p.name.clone(),
                score: self.score_preset(p, &query_lower),
            })
            .filter(|m| m.score > 0.3)
            .collect();
        matches.sort_by(|a, b| {
            b.score
                .partial_cmp(&a.score)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        matches.truncate(10);
        matches
    }

    fn get_preset(&self, name: &str) -> Option<&PresetInfo> {
        self.presets.get(name)
    }

    fn list_all(&self) -> Vec<&PresetInfo> {
        self.presets.values().collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;

    fn preset(name: &str, tags: &[&str]) -> PresetInfo {
        PresetInfo {
            name: name.to_string(),
            category: "test".to_string(),
            title: name.to_string(),
            description: format!("Test preset {}", name),
            tags: tags.iter().map(|s| s.to_string()).collect(),
            use_cases: vec![],
            example: Value::Null,
            schema: Value::Null,
            template_source: String::new(),
        }
    }

    #[test]
    fn test_find_by_name() {
        let mut presets = BTreeMap::new();
        presets.insert("menu-card".to_string(), preset("menu-card", &[]));
        presets.insert("leave-request".to_string(), preset("leave-request", &[]));
        let discovery = KeywordDiscovery::new(presets);
        let matches = discovery.find_presets("menu");
        assert!(!matches.is_empty());
        assert_eq!(matches[0].name, "menu-card");
    }

    #[test]
    fn test_find_by_tag() {
        let mut presets = BTreeMap::new();
        presets.insert(
            "leave-request".to_string(),
            preset("leave-request", &["cuti", "izin"]),
        );
        let discovery = KeywordDiscovery::new(presets);
        let matches = discovery.find_presets("cuti");
        assert!(!matches.is_empty());
        assert_eq!(matches[0].name, "leave-request");
    }

    #[test]
    fn test_get_preset() {
        let mut presets = BTreeMap::new();
        presets.insert("menu-card".to_string(), preset("menu-card", &[]));
        let discovery = KeywordDiscovery::new(presets);
        assert!(discovery.get_preset("menu-card").is_some());
        assert!(discovery.get_preset("nonexistent").is_none());
    }
}