cleanpy 0.1.3

Python 3.12+ 最佳实践知识库 - MCP 服务器
Documentation
// Topic management and search functionality

use anyhow::{Context, Result};
use std::collections::HashMap;

use crate::types::{DocumentMatch, TopicSummary};

/// List all available topics
pub(crate) fn list_topics() -> Result<serde_json::Value> {
    eprintln!("📋 请求主题列表");

    let summaries: Vec<TopicSummary> = crate::TOPICS
        .iter()
        .map(|t| TopicSummary {
            topic: t.topic,
            title: t.title,
            when_to_use: t.when_to_use,
            keywords: t.keywords,
        })
        .collect();

    eprintln!("✓ 返回 {} 个主题", summaries.len());

    serde_json::to_value(summaries).context("Failed to serialize topics")
}

/// Parse YAML front matter from markdown file
///
/// Extracts metadata (topic, title, when_to_use), keywords list, and body content
/// from markdown files with YAML front matter delimited by `---`.
///
/// # Returns
///
/// `Some((front_matter, keywords, body))` if valid front matter is found, `None` otherwise.
fn parse_front_matter(content: &str) -> Option<(HashMap<String, String>, Vec<String>, String)> {
    let lines: Vec<&str> = content.lines().collect();

    if lines.is_empty() || lines[0].trim() != "---" {
        return None;
    }

    let mut front_matter = HashMap::new();
    let mut keywords = Vec::new();
    let mut in_keywords = false;
    let mut body_start = 0;

    for (idx, line) in lines.iter().enumerate().skip(1) {
        let trimmed = line.trim();

        if trimmed == "---" {
            body_start = idx + 1;
            break;
        }

        if trimmed == "keywords:" {
            in_keywords = true;
            continue;
        }

        if in_keywords {
            if let Some(stripped) = trimmed.strip_prefix("- ") {
                keywords.push(stripped.trim().to_string());
            } else if trimmed.contains(':') {
                in_keywords = false;
            }
        }

        if !in_keywords && trimmed.contains(':') {
            let parts: Vec<&str> = trimmed.splitn(2, ':').collect();
            if parts.len() == 2 {
                front_matter.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
            }
        }
    }

    let body = lines[body_start..].join("\n");
    Some((front_matter, keywords, body))
}

/// Search across all documentation files for keywords
///
/// Performs a comprehensive search across all embedded markdown documentation,
/// scoring matches based on where keywords appear:
/// - Topic match: 10 points (highest priority)
/// - Title match: 5 points
/// - Keywords match: 3 points
/// - When-to-use match: 2 points
/// - Body content match: 1 point (lowest priority)
///
/// Results are sorted by relevance score and formatted as a markdown document
/// with document metadata and matched keywords.
///
/// # Arguments
///
/// * `query_words` - Slice of search keywords (case-insensitive)
///
/// # Returns
///
/// Formatted markdown string with search results sorted by relevance
pub(crate) fn search_across_docs(query_words: &[String]) -> Result<String> {
    eprintln!("🔍 全局搜索关键词: {:?}", query_words);

    if query_words.is_empty() {
        return Ok("# ⚠️ 搜索错误\n\n搜索关键词不能为空。".to_string());
    }

    // Convert query words to lowercase for case-insensitive matching
    let query_lower: Vec<String> = query_words.iter().map(|w| w.to_lowercase()).collect();

    let mut matches: Vec<DocumentMatch> = Vec::new();

    // Search through all embedded topics
    for topic_info in crate::TOPICS.iter() {
        // Parse front matter from embedded content to extract body text
        let (_front_matter, _keywords_from_content, body) =
            match parse_front_matter(topic_info.content) {
                Some(parsed) => parsed,
                None => continue,
            };

        // Calculate relevance score
        let mut score = 0.0f32;
        let mut matched_keywords = Vec::new();

        // Get metadata (prioritize from TopicInfo structure, fallback to parsed front matter)
        let topic = topic_info.topic;
        let title = topic_info.title;
        let when_to_use = topic_info.when_to_use;

        let topic_lower = topic.to_lowercase();
        let title_lower = title.to_lowercase();
        let when_to_use_lower = when_to_use.to_lowercase();

        for query_word in &query_lower {
            // Topic match (highest priority)
            if topic_lower.contains(query_word) {
                score += 10.0;
                matched_keywords.push(query_word.clone());
            }

            // Title match
            if title_lower.contains(query_word) {
                score += 5.0;
                if !matched_keywords.contains(query_word) {
                    matched_keywords.push(query_word.clone());
                }
            }

            // Keywords match (check both static keywords and parsed keywords)
            for keyword in topic_info.keywords.iter() {
                if keyword.to_lowercase().contains(query_word) {
                    score += 3.0;
                    if !matched_keywords.contains(query_word) {
                        matched_keywords.push(query_word.clone());
                    }
                    break;
                }
            }

            // When to use match
            if when_to_use_lower.contains(query_word) {
                score += 2.0;
                if !matched_keywords.contains(query_word) {
                    matched_keywords.push(query_word.clone());
                }
            }

            // Body content match (lower priority)
            if body.to_lowercase().contains(query_word) {
                score += 1.0;
                if !matched_keywords.contains(query_word) {
                    matched_keywords.push(query_word.clone());
                }
            }
        }

        // Only include documents with at least one match
        if score > 0.0 {
            matches.push(DocumentMatch {
                topic: topic.to_string(),
                title: title.to_string(),
                when_to_use: when_to_use.to_string(),
                keywords: topic_info.keywords.iter().map(|k| k.to_string()).collect(),
                file_path: topic_info.file.to_string(),
                matched_keywords,
                relevance_score: score,
            });
        }
    }

    if matches.is_empty() {
        return Ok(format!(
            "# 未找到匹配结果\n\n搜索关键词: **{}**\n\n没有找到包含这些关键词的文档。\n\n提示:尝试使用不同的关键词或访问完整的主题列表。",
            query_words.join(", ")
        ));
    }

    // Sort by relevance score (descending)
    matches.sort_by(|a, b| b.relevance_score.partial_cmp(&a.relevance_score).unwrap());

    // Format the results
    let mut output = format!(
        "# 搜索结果\n\n搜索关键词: **{}**\n\n找到 **{}** 个相关文档:\n\n",
        query_words.join(", "),
        matches.len()
    );

    for (idx, doc_match) in matches.iter().enumerate() {
        let relevance_percentage =
            (doc_match.relevance_score / matches[0].relevance_score * 100.0).round() as u32;

        output.push_str(&format!(
            "## {}. {} (相关度: {}%)\n\n",
            idx + 1,
            doc_match.title,
            relevance_percentage
        ));

        output.push_str(&format!("- **主题**: `{}`\n", doc_match.topic));
        output.push_str(&format!("- **文件**: `{}`\n", doc_match.file_path));

        if !doc_match.when_to_use.is_empty() {
            output.push_str(&format!("- **使用场景**: {}\n", doc_match.when_to_use));
        }

        if !doc_match.keywords.is_empty() {
            output.push_str(&format!(
                "- **关键词**: {}\n",
                doc_match
                    .keywords
                    .iter()
                    .map(|k| format!("`{}`", k))
                    .collect::<Vec<_>>()
                    .join(", ")
            ));
        }

        output.push_str(&format!(
            "- **匹配关键词**: {}\n",
            doc_match
                .matched_keywords
                .iter()
                .map(|k| format!("`{}`", k))
                .collect::<Vec<_>>()
                .join(", ")
        ));

        output.push('\n');
    }

    output.push_str("---\n\n💡 使用 `get_tips` 工具并指定准确的 `topic` 值来获取完整文档内容。\n");

    eprintln!("✓ 找到 {} 个匹配文档", matches.len());

    Ok(output)
}

/// Get tips for a specific topic with automatic fallback to search
///
/// This function first attempts an exact match against known topics.
/// If no exact match is found, it automatically performs a fuzzy search
/// by splitting the topic string into keywords and searching across all
/// embedded documentation.
///
/// # Arguments
///
/// * `topic` - Either an exact topic identifier (e.g., "pep695_generics") or search
///   keywords (e.g., "async programming", "error-handling")
///
/// # Returns
///
/// - On exact match: Full documentation content from embedded data
/// - On search: Formatted list of matching documents with relevance scores
/// - On no results: Helpful message with suggestions
pub(crate) fn get_tips(topic: &str) -> Result<String> {
    eprintln!("📖 请求主题: {}", topic);

    // Try exact match first - use embedded content directly
    let topic_info = crate::TOPICS.iter().find(|t| t.topic == topic);

    if let Some(info) = topic_info {
        // Exact match found - return the embedded content
        eprintln!("✓ 返回文档: {} ({} 字符)", topic, info.content.len());
        Ok(info.content.to_string())
    } else {
        // No exact match - perform fuzzy search across embedded content
        eprintln!("⚠️ 未找到精确匹配,尝试全局搜索");

        // Split topic into words by various delimiters
        let query_words: Vec<String> = topic
            .split(|c: char| c.is_whitespace() || c == '_' || c == '-')
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string())
            .collect();

        if query_words.is_empty() {
            anyhow::bail!("主题未找到: {}", topic);
        }

        search_across_docs(&query_words)
    }
}