deer-flow/backend/tests/test_skill_catalog.py
PeaceMaker-best e831720304
feat(skills): rank deferred discovery by agent intent (#5369)
* feat(skills): rank deferred discovery by intent

* fix(skills): preserve exact selections and cache search metadata

---------

Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-17 07:59:58 +08:00

323 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for SkillCatalog — deferred skill discovery search engine."""
from pathlib import Path
from unittest.mock import patch
import pytest
from deerflow.skills.catalog import MAX_QUERY_CHARS, MAX_RESULTS, SkillCatalog, _normalize_search_text
from deerflow.skills.types import Skill, SkillCategory
# ── Fixtures ──────────────────────────────────────────────────────────────────
def _make_skill(
name: str,
description: str = "A skill",
category: SkillCategory = SkillCategory.PUBLIC,
allowed_tools: tuple[str, ...] | None = None,
) -> Skill:
"""Create a minimal Skill for testing."""
base = Path("/mnt/skills") / category.value / name
return Skill(
name=name,
description=description,
license=None,
skill_dir=base,
skill_file=base / "SKILL.md",
relative_path=Path(name),
category=category,
allowed_tools=allowed_tools,
enabled=True,
)
@pytest.fixture
def sample_skills() -> list[Skill]:
return [
_make_skill("data-analysis", "Analyze data with Python, pandas, jupyter"),
_make_skill("deep-research", "Conduct multi-source research with fact-checking"),
_make_skill("chart-visualization", "Visualize data with interactive charts"),
_make_skill("podcast-generation", "Generate podcast scripts and audio"),
_make_skill("music-generation", "Generate music compositions"),
_make_skill("video-generation", "Generate video from text prompts"),
_make_skill("image-generation", "Generate images from descriptions"),
_make_skill("ppt-generation", "Generate PowerPoint presentations"),
_make_skill("custom-analyzer", "Custom data analyzer", category=SkillCategory.CUSTOM),
]
@pytest.fixture
def catalog(sample_skills: list[Skill]) -> SkillCatalog:
return SkillCatalog(tuple(sample_skills))
# ── Name property ─────────────────────────────────────────────────────────────
def test_names_returns_frozenset(catalog: SkillCatalog):
assert isinstance(catalog.names, frozenset)
def test_names_contains_all_skills(catalog: SkillCatalog, sample_skills: list[Skill]):
expected = {s.name for s in sample_skills}
assert catalog.names == expected
def test_empty_catalog_names():
catalog = SkillCatalog(())
assert catalog.names == frozenset()
# ── Exact selection (select:) ─────────────────────────────────────────────────
def test_select_single(catalog: SkillCatalog):
result = catalog.search("select:data-analysis")
assert len(result) == 1
assert result[0].name == "data-analysis"
def test_select_multiple(catalog: SkillCatalog):
result = catalog.search("select:data-analysis,deep-research")
names = {s.name for s in result}
assert names == {"data-analysis", "deep-research"}
def test_select_nonexistent(catalog: SkillCatalog):
result = catalog.search("select:nonexistent-skill")
assert result == []
def test_select_partial_match(catalog: SkillCatalog):
"""select: with one valid and one invalid name returns only the valid one."""
result = catalog.search("select:data-analysis,nonexistent")
assert len(result) == 1
assert result[0].name == "data-analysis"
def test_select_returns_all_requested(catalog: SkillCatalog, sample_skills: list[Skill]):
"""select: returns all requested names without capping — exact selection, not ranked search."""
all_names = ",".join(sorted(catalog.names))
result = catalog.search(f"select:{all_names}")
assert len(result) == len(sample_skills)
def test_long_select_preserves_exact_names_and_catalog_order():
skills = tuple(_make_skill(f"skill-number-{i:02d}-with-a-longish-name") for i in range(30))
catalog = SkillCatalog(skills)
requested = [s.name for s in reversed(skills)] + [skills[0].name, "missing", "SKILL-NUMBER-00-WITH-A-LONGISH-NAME"]
query = " select:" + ", ".join(requested) + " "
assert len(query) > MAX_QUERY_CHARS
assert catalog.search(query) == list(skills)
def test_select_does_not_match_a_name_cut_at_search_limit():
skills = (_make_skill("data"), _make_skill("data-analysis"))
prefix = "select:" + "," * (MAX_QUERY_CHARS - len("select:data"))
assert SkillCatalog(skills).search(f"{prefix}data-analysis") == [skills[1]]
@pytest.mark.parametrize("prefix", ["", "+report "])
def test_ranked_search_still_ignores_terms_beyond_character_limit(prefix: str):
catalog = SkillCatalog((_make_skill("report", "needle"),))
query = prefix + "unknown " * MAX_QUERY_CHARS + "needle"
assert catalog.search(query) == catalog.search(query[:MAX_QUERY_CHARS])
def test_search_normalizes_catalog_metadata_once(catalog: SkillCatalog):
with patch("deerflow.skills.catalog._normalize_search_text", wraps=_normalize_search_text) as normalize:
assert catalog.search("select:data-analysis")
normalize.assert_not_called()
assert catalog.search("data")
assert catalog.search("+data Python")
assert catalog.search("+data")
assert catalog.search("research")
normalized_inputs = [call.args[0] for call in normalize.call_args_list]
for skill in catalog.skills:
assert normalized_inputs.count(skill.name) == 1
assert normalized_inputs.count(skill.description) == 1
# ── Required-prefix search (+) ────────────────────────────────────────────────
def test_required_prefix_filters_by_name(catalog: SkillCatalog):
result = catalog.search("+podcast")
assert all("podcast" in s.name for s in result)
def test_required_prefix_with_ranking(catalog: SkillCatalog):
"""'+gen generation' should require 'gen' in name, rank by 'generation'."""
result = catalog.search("+gen generation")
assert all("gen" in s.name for s in result)
def test_required_prefix_bare_plus(catalog: SkillCatalog):
"""Bare '+' with no token returns empty."""
result = catalog.search("+")
assert result == []
def test_required_prefix_no_match(catalog: SkillCatalog):
result = catalog.search("+zzz_nonexistent")
assert result == []
def test_required_prefix_keeps_single_letter_semantics():
catalog = SkillCatalog((_make_skill("r-analysis"), _make_skill("python-analysis")))
assert [skill.name for skill in catalog.search("+r")] == ["r-analysis"]
# ── Free-text intent search ───────────────────────────────────────────────────
def test_keyword_matches_name(catalog: SkillCatalog):
result = catalog.search("podcast")
assert any(s.name == "podcast-generation" for s in result)
def test_keyword_matches_description(catalog: SkillCatalog):
"""Description match should also be returned."""
result = catalog.search("pandas")
assert any(s.name == "data-analysis" for s in result)
def test_name_match_scores_higher_than_description(catalog: SkillCatalog):
"""When both name and description match, name match should rank first."""
# 'data-analysis' name matches 'data', description also matches 'data'
# 'deep-research' description matches 'data' (no, it doesn't)
# Let's use 'chart' — matches chart-visualization by name
result = catalog.search("chart")
assert result[0].name == "chart-visualization"
def test_search_is_case_insensitive(catalog: SkillCatalog):
result_lower = catalog.search("data")
result_upper = catalog.search("DATA")
assert {s.name for s in result_lower} == {s.name for s in result_upper}
def test_regex_punctuation_is_treated_as_literal_input(catalog: SkillCatalog):
"""Model-generated punctuation must not be compiled or raise."""
result = catalog.search("(invalid")
assert isinstance(result, list)
def test_multi_term_query_matches_across_name_separators(catalog: SkillCatalog):
result = catalog.search("chart visualization")
assert result[0].name == "chart-visualization"
def test_multi_term_query_matches_noncontiguous_description(catalog: SkillCatalog):
result = catalog.search("analyze Python")
assert result[0].name == "data-analysis"
def test_more_intent_terms_outrank_incidental_match():
catalog = SkillCatalog(
(
_make_skill("python-style", "Format Python source code"),
_make_skill("spreadsheet-analysis", "Analyze spreadsheet data with Python"),
)
)
result = catalog.search("analyze spreadsheet python")
assert [skill.name for skill in result] == ["spreadsheet-analysis", "python-style"]
def test_name_match_outranks_description_only_at_equal_coverage():
catalog = SkillCatalog(
(
_make_skill("scripting", "Automate work with Python"),
_make_skill("python-workflow", "Automate developer work"),
)
)
result = catalog.search("python")
assert [skill.name for skill in result] == ["python-workflow", "scripting"]
def test_score_ties_preserve_catalog_order():
catalog = SkillCatalog(
(
_make_skill("first", "Generate reports"),
_make_skill("second", "Generate reports"),
)
)
assert [skill.name for skill in catalog.search("reports")] == ["first", "second"]
def test_unicode_compatibility_normalization(catalog: SkillCatalog):
result = catalog.search("")
assert result[0].name == "data-analysis"
def test_single_letter_language_term_remains_searchable():
catalog = SkillCatalog((_make_skill("cpp-analysis", "Analyze C++ code"),))
assert catalog.search("C++")[0].name == "cpp-analysis"
def test_cjk_terms_rank_by_coverage():
catalog = SkillCatalog(
(
_make_skill("generic-chart", "生成可视化图表"),
_make_skill("data-visualization", "执行数据分析和可视化"),
)
)
result = catalog.search("数据 可视化")
assert [skill.name for skill in result] == ["data-visualization", "generic-chart"]
def test_empty_query(catalog: SkillCatalog):
result = catalog.search("")
assert result == []
def test_whitespace_only_query(catalog: SkillCatalog):
result = catalog.search(" ")
assert result == []
def test_punctuation_only_query(catalog: SkillCatalog):
assert catalog.search("((...---___") == []
def test_long_query_is_bounded_and_does_not_raise(catalog: SkillCatalog):
result = catalog.search("data " * 100_000)
assert result[0].name == "data-analysis"
def test_max_results_cap(catalog: SkillCatalog):
"""Free-text search should cap results at MAX_RESULTS."""
# 'generation' matches many descriptions
result = catalog.search("generation")
assert len(result) <= MAX_RESULTS
# ── Edge cases ────────────────────────────────────────────────────────────────
def test_frozen_catalog_is_hashable(catalog: SkillCatalog):
"""SkillCatalog with real skills must be hashable (frozen=True on both Skill and SkillCatalog)."""
assert hash(catalog) is not None
def test_names_cached_property_stable(catalog: SkillCatalog):
"""Multiple accesses to .names should return the same frozenset."""
assert catalog.names is catalog.names