Files

107 lines
3.9 KiB
Python

"""Tests for the smart skill-list search matcher.
``GET /api/skills?search=`` used to require the whole query as a contiguous
substring of the English name / Chinese name only — searching by what a skill
*does* (description, detail, tags) or with several keywords always came back
empty. These tests pin the intended behaviour:
- whitespace-separated keywords, AND across tokens but each token may hit a
different field;
- fields: name, name_zh, description, detail, tag names;
- case-insensitive, and still works as a plain single-keyword substring.
"""
from __future__ import annotations
import pytest
from app.gateway.routers.skills import (
SkillResponse,
_attach_and_filter_tags,
_skill_matches_tokens,
_skill_search_tokens,
)
from deerflow.persistence.tags import MemoryTagStore
from deerflow.skills.types import SkillCategory
def _resp(
name: str,
*,
name_zh: str | None = None,
description: str = "",
detail: str | None = None,
) -> SkillResponse:
return SkillResponse(
name=name,
name_zh=name_zh,
description=description,
detail=detail,
category=SkillCategory.CUSTOM,
)
def test_tokens_split_and_lowercase() -> None:
assert _skill_search_tokens(None) == []
assert _skill_search_tokens(" ") == []
assert _skill_search_tokens(" Report 生成 ") == ["report", "生成"]
def test_match_requires_every_token_somewhere() -> None:
skill = _resp("web-search", name_zh="网页搜索", description="Search the public web for reports")
# One token hits the name, the other the description — old contiguous
# substring semantics would have rejected this.
assert _skill_matches_tokens(skill, ["web", "report"]) is True
# A token nowhere present rejects the skill even though the other matches.
assert _skill_matches_tokens(skill, ["web", "missing"]) is False
def test_match_hits_name_zh_description_and_detail() -> None:
assert _skill_matches_tokens(_resp("ws", name_zh="舆情分析"), ["舆情"]) is True
assert _skill_matches_tokens(_resp("ws", description="生成任务研判报告"), ["研判"]) is True
assert _skill_matches_tokens(_resp("ws", detail="本技能用于军事情报整编"), ["整编"]) is True
def test_match_is_case_insensitive_substring() -> None:
skill = _resp("Task-Report-Builder", description="builds reports")
# Tokens reaching the matcher are pre-lowercased by _skill_search_tokens.
tokens = _skill_search_tokens("Task-Report BUILDER")
assert tokens == ["task-report", "builder"]
assert _skill_matches_tokens(skill, tokens) is True
def test_empty_tokens_match_everything() -> None:
assert _skill_matches_tokens(_resp("anything"), []) is True
@pytest.mark.asyncio
async def test_attach_and_filter_searches_tags() -> None:
store = MemoryTagStore()
await store.create_tag({"id": "t1", "name": "情报整编", "tag_type": "skill"})
await store.set_target_tags("skill", "alpha", ["t1"])
responses = [
_resp("alpha", description="unrelated text"),
_resp("beta", description="unrelated text"),
]
kept = await _attach_and_filter_tags(store, responses, "整编", None)
assert [r.name for r in kept] == ["alpha"]
# The attached tags ride along on the response.
assert [t["name"] for t in kept[0].tags] == ["情报整编"]
@pytest.mark.asyncio
async def test_attach_and_filter_keeps_legacy_single_keyword_behavior() -> None:
store = MemoryTagStore()
responses = [
_resp("web-search", name_zh="网页搜索", description="search the web"),
_resp("code-lint", name_zh="代码检查", description="lint python files"),
]
kept = await _attach_and_filter_tags(store, responses, "web", None)
assert [r.name for r in kept] == ["web-search"]
kept = await _attach_and_filter_tags(store, responses, "代码", None)
assert [r.name for r in kept] == ["code-lint"]