107 lines
3.9 KiB
Python
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"]
|