Add AI search query expansion
This commit is contained in:
@@ -0,0 +1,707 @@
|
||||
"""Tests for AI search (Step 3).
|
||||
|
||||
All LLM calls are mocked — CI never touches the network.
|
||||
|
||||
Coverage areas:
|
||||
- expand_query JSON output parsing (valid, fenced, prose, bad JSON, timeout)
|
||||
- Output contract enforcement (strict JSON array only)
|
||||
- Expansion term count cap and length cap
|
||||
- ai_search seam function
|
||||
- GET /search with ai=1 trigger
|
||||
- AI button visibility on search page
|
||||
- Graceful degradation on failure
|
||||
- ai_search_extra_hints appended to prompt
|
||||
- ai_search_enabled toggle
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.llm import (
|
||||
_MAX_EXPANSION_TERMS,
|
||||
_MAX_TERM_LENGTH,
|
||||
ExpansionResult,
|
||||
LLMResult,
|
||||
_parse_json_string_array,
|
||||
expand_query,
|
||||
is_configured,
|
||||
)
|
||||
from app.main import _ai_search, _build_search_results
|
||||
from app.models import AppSetting, Box, Item, SubItem
|
||||
from app.settings_store import LLMConfig, get_app_settings, save_app_settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: configure AI search for route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AI_CFG = LLMConfig(
|
||||
enabled=True,
|
||||
base_url="https://api.example.com/v1",
|
||||
model="gpt-4o-mini",
|
||||
api_key="sk-test-key",
|
||||
ai_search_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def _enable_ai_search(client, db_session):
|
||||
"""Persist a fully-configured AI search setup via the settings route."""
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "sk-test-key",
|
||||
"ai_search_enabled": "on",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_json_string_array: strict JSON contract enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseJsonStringArray:
|
||||
def test_valid_json_array(self):
|
||||
result = _parse_json_string_array('["炒锅","平底锅","汤锅"]')
|
||||
assert result == ["炒锅", "平底锅", "汤锅"]
|
||||
|
||||
def test_json_array_with_code_fence(self):
|
||||
result = _parse_json_string_array('```json\n["锅","铲子"]\n```')
|
||||
assert result == ["锅", "铲子"]
|
||||
|
||||
def test_json_array_with_code_fence_no_lang(self):
|
||||
result = _parse_json_string_array('```\n["锅","铲子"]\n```')
|
||||
assert result == ["锅", "铲子"]
|
||||
|
||||
def test_empty_string_returns_empty(self):
|
||||
assert _parse_json_string_array("") == []
|
||||
assert _parse_json_string_array(" ") == []
|
||||
|
||||
def test_prose_returns_empty(self):
|
||||
"""Prose text does NOT become expansion terms — strict contract."""
|
||||
assert _parse_json_string_array("I cannot help with that.") == []
|
||||
|
||||
def test_prose_newlines_returns_empty(self):
|
||||
"""Line-separated prose does NOT become expansion terms."""
|
||||
assert _parse_json_string_array("炒锅\n平底锅\n汤锅") == []
|
||||
|
||||
def test_prose_commas_returns_empty(self):
|
||||
"""Comma-separated prose does NOT become expansion terms."""
|
||||
assert _parse_json_string_array("炒锅, 平底锅, 汤锅") == []
|
||||
|
||||
def test_bad_json_returns_empty(self):
|
||||
"""Invalid JSON returns empty — no fallback."""
|
||||
assert _parse_json_string_array("{invalid json") == []
|
||||
|
||||
def test_json_object_returns_empty(self):
|
||||
"""JSON object (non-array) returns empty."""
|
||||
assert _parse_json_string_array('{"terms":["锅","厨具"]}') == []
|
||||
|
||||
def test_json_array_with_numbers_returns_empty(self):
|
||||
"""Non-string items in array cause rejection — strict contract."""
|
||||
assert _parse_json_string_array('[1, 2, 3]') == []
|
||||
|
||||
def test_json_array_with_mixed_types_returns_empty(self):
|
||||
"""Mixed string/number array is rejected."""
|
||||
assert _parse_json_string_array('["锅", 1]') == []
|
||||
|
||||
def test_empty_json_array(self):
|
||||
result = _parse_json_string_array('[]')
|
||||
assert result == []
|
||||
|
||||
def test_capped_at_max_terms(self):
|
||||
"""More than _MAX_EXPANSION_TERMS items are truncated."""
|
||||
terms = [f"词{i}" for i in range(20)]
|
||||
json_str = "[" + ",".join(f'"{t}"' for t in terms) + "]"
|
||||
result = _parse_json_string_array(json_str)
|
||||
assert len(result) == _MAX_EXPANSION_TERMS
|
||||
|
||||
def test_long_terms_filtered_out(self):
|
||||
"""Terms exceeding _MAX_TERM_LENGTH are silently dropped."""
|
||||
short = "锅"
|
||||
long_term = "A" * (_MAX_TERM_LENGTH + 1)
|
||||
json_str = f'["{short}", "{long_term}"]'
|
||||
result = _parse_json_string_array(json_str)
|
||||
assert result == ["锅"]
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
result = _parse_json_string_array('[" 锅 ", " 平底锅 "]')
|
||||
assert result == ["锅", "平底锅"]
|
||||
|
||||
def test_empty_strings_filtered(self):
|
||||
result = _parse_json_string_array('["锅", "", " ", "平底锅"]')
|
||||
assert result == ["锅", "平底锅"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# expand_query: prompt, hints, graceful degradation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExpandQueryNew:
|
||||
def test_returns_empty_when_not_configured(self):
|
||||
cfg = LLMConfig(enabled=False)
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_parses_valid_json_response(self, mock_call):
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["炒锅","平底锅","汤锅","厨具"]'}}]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert "炒锅" in result.terms
|
||||
assert "平底锅" in result.terms
|
||||
assert "厨具" in result.terms
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_handles_json_with_code_fence(self, mock_call):
|
||||
mock_call.return_value = {
|
||||
"choices": [
|
||||
{"message": {"content": '```json\n["炒锅","平底锅"]\n```'}}
|
||||
]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert "炒锅" in result.terms
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_prose_response_returns_empty_no_error(self, mock_call):
|
||||
"""Prose from model → empty terms, no error (successful call, unparseable output)."""
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": "I cannot help with that."}}]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_json_object_response_returns_empty_no_error(self, mock_call):
|
||||
"""JSON object (non-array) → empty terms, no error."""
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '{"terms":["锅","厨具"]}'}}]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_timeout_returns_error(self, mock_call):
|
||||
"""Timeout → empty terms + error message."""
|
||||
mock_call.side_effect = httpx.TimeoutException("timeout")
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is not None
|
||||
assert "超时" in result.error
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_network_error_returns_error(self, mock_call):
|
||||
"""Network error → empty terms + error message."""
|
||||
mock_call.side_effect = httpx.ConnectError("refused")
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is not None
|
||||
assert "无法连接" in result.error
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_http_error_returns_error(self, mock_call):
|
||||
"""HTTP error → empty terms + error message."""
|
||||
mock_call.side_effect = httpx.HTTPStatusError(
|
||||
"401",
|
||||
request=httpx.Request("POST", "http://x"),
|
||||
response=httpx.Response(401),
|
||||
)
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is not None
|
||||
assert "错误" in result.error
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_returns_empty_on_empty_choices(self, mock_call):
|
||||
mock_call.return_value = {"choices": []}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result.terms == []
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_extra_hints_appended_to_system_prompt(self, mock_call):
|
||||
"""When extra_hints is non-empty, it should be appended to the system prompt."""
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["扩展词"]'}}]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
expand_query(cfg, "锅", extra_hints="用户物品主要涉及厨房用品")
|
||||
|
||||
# Verify the system prompt includes the extra hints
|
||||
call_args = mock_call.call_args
|
||||
messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][1]
|
||||
system_content = messages[0]["content"]
|
||||
assert "用户物品主要涉及厨房用品" in system_content
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_extra_hints_ignored_when_empty(self, mock_call):
|
||||
"""When extra_hints is empty, system prompt should not change."""
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["扩展词"]'}}]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
expand_query(cfg, "锅", extra_hints="")
|
||||
|
||||
call_args = mock_call.call_args
|
||||
messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][1]
|
||||
system_content = messages[0]["content"]
|
||||
# Should be the base prompt only
|
||||
assert "搬家物品搜索助手" in system_content
|
||||
assert "JSON 字符串数组" in system_content
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_temperature_zero_passed(self, mock_call):
|
||||
"""expand_query should pass temperature=0 for deterministic output."""
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["扩展词"]'}}]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
expand_query(cfg, "锅")
|
||||
|
||||
call_args = mock_call.call_args
|
||||
assert call_args[1]["temperature"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ai_search: seam function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAiSearchSeam:
|
||||
@patch("app.main.expand_query")
|
||||
def test_returns_expanded_terms_and_results(self, mock_expand, client, db_session):
|
||||
"""AI search returns expanded terms and broader results."""
|
||||
box = Box(name="厨房箱", note="装了炒锅和铲子")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
mock_expand.return_value = ExpansionResult(terms=["炒锅", "平底锅", "汤锅"])
|
||||
|
||||
cfg = get_app_settings(db_session)
|
||||
expanded, results, error = _ai_search(db_session, cfg, "平底锅")
|
||||
|
||||
assert "炒锅" in expanded
|
||||
assert error is None
|
||||
assert len(results) >= 1
|
||||
assert any("厨房箱" in r["name"] or "炒锅" in (r.get("note") or "") for r in results)
|
||||
|
||||
@patch("app.main.expand_query")
|
||||
def test_includes_original_query_in_search(self, mock_expand, client, db_session):
|
||||
"""AI search includes the original query term in the search."""
|
||||
box = Box(name="冬季衣物箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
mock_expand.return_value = ExpansionResult(terms=["羽绒服"])
|
||||
|
||||
cfg = get_app_settings(db_session)
|
||||
expanded, results, error = _ai_search(db_session, cfg, "衣物")
|
||||
|
||||
assert error is None
|
||||
assert any("冬季衣物箱" in r["name"] for r in results)
|
||||
|
||||
@patch("app.main.expand_query")
|
||||
def test_empty_expansion_returns_normal_results_no_error(self, mock_expand, client, db_session):
|
||||
"""Legitimate empty expansion (no synonyms found) → normal results, no error."""
|
||||
box = Box(name="书房箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
mock_expand.return_value = ExpansionResult(terms=[])
|
||||
|
||||
cfg = get_app_settings(db_session)
|
||||
expanded, results, error = _ai_search(db_session, cfg, "书房")
|
||||
|
||||
assert expanded == []
|
||||
assert error is None
|
||||
assert any("书房箱" in r["name"] for r in results)
|
||||
|
||||
@patch("app.main.expand_query")
|
||||
def test_llm_failure_returns_normal_results_with_error(self, mock_expand, client, db_session):
|
||||
"""When expand_query signals failure, seam returns normal results + error message."""
|
||||
box = Box(name="厨房箱", note="装了炒锅")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
mock_expand.return_value = ExpansionResult(terms=[], error="AI 搜索请求超时,请稍后再试。")
|
||||
|
||||
cfg = get_app_settings(db_session)
|
||||
expanded, results, error = _ai_search(db_session, cfg, "厨房")
|
||||
|
||||
assert expanded == []
|
||||
assert error is not None
|
||||
assert "超时" in error
|
||||
assert len(results) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_search_results: multi-keyword support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildSearchResultsMultiKeyword:
|
||||
def test_single_keyword_works_as_before(self, db_session):
|
||||
box = Box(name="厨房箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
results = _build_search_results(db_session, "厨房")
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "厨房箱"
|
||||
|
||||
def test_multiple_keywords_match_any(self, db_session):
|
||||
box1 = Box(name="厨房箱")
|
||||
box2 = Box(name="卧室箱")
|
||||
db_session.add_all([box1, box2])
|
||||
db_session.commit()
|
||||
|
||||
results = _build_search_results(db_session, ["厨房", "卧室"])
|
||||
assert len(results) == 2
|
||||
|
||||
def test_multiple_keywords_dedupes_results(self, db_session):
|
||||
"""A box matching multiple keywords appears only once."""
|
||||
box = Box(name="厨房箱", note="装了厨房用品")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
results = _build_search_results(db_session, ["厨房", "用品"])
|
||||
assert len(results) == 1
|
||||
|
||||
def test_empty_keywords_returns_empty(self, db_session):
|
||||
results = _build_search_results(db_session, [])
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes: GET /search with ai=1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchRouteAI:
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_ai_search_finds_more_results(self, mock_call, client, db_session):
|
||||
"""Original query misses, but expanded term finds items."""
|
||||
box = Box(name="杂物箱")
|
||||
item = Item(name="炒锅", box=box, is_container=False)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["炒锅","平底锅","汤锅"]'}}]
|
||||
}
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
# Normal search for "平底锅" — no results
|
||||
response = client.get("/search?q=平底锅")
|
||||
assert "没有找到匹配结果" in response.text
|
||||
|
||||
# AI search for "平底锅" — finds "炒锅" via expansion
|
||||
response = client.get("/search?q=平底锅&ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "炒锅" in response.text
|
||||
assert "AI 帮你扩展了" in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_ai_search_includes_original_results(self, mock_call, client, db_session):
|
||||
"""AI search should also include results from original query."""
|
||||
box = Box(name="厨房箱")
|
||||
item1 = Item(name="锅铲", box=box, is_container=False)
|
||||
item2 = Item(name="平底锅", box=box, is_container=False)
|
||||
db_session.add_all([box, item1, item2])
|
||||
db_session.commit()
|
||||
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["炒锅","汤锅"]'}}]
|
||||
}
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=锅&ai=1")
|
||||
assert response.status_code == 200
|
||||
# Original result "平底锅" should still be there
|
||||
assert "平底锅" in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_ai_search_shows_expansion_banner(self, mock_call, client, db_session):
|
||||
"""When AI search is activated, a banner shows expanded terms."""
|
||||
box = Box(name="厨房箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["炒锅","平底锅"]'}}]
|
||||
}
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=锅&ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "AI 帮你扩展了" in response.text
|
||||
assert "炒锅" in response.text
|
||||
|
||||
def test_ai_search_without_flag_does_normal_search(self, client, db_session):
|
||||
"""Without ai=1, search behaves normally even when AI is configured."""
|
||||
box = Box(name="厨房箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=厨房")
|
||||
assert response.status_code == 200
|
||||
assert "厨房箱" in response.text
|
||||
assert "AI 帮你扩展了" not in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_ai_search_without_configuration_ignores_flag(self, mock_call, client, db_session):
|
||||
"""ai=1 is ignored when AI is not configured."""
|
||||
box = Box(name="厨房箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get("/search?q=厨房&ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "厨房箱" in response.text
|
||||
assert "AI 帮你扩展了" not in response.text
|
||||
mock_call.assert_not_called()
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_ai_search_graceful_degradation_on_llm_failure(self, mock_call, client, db_session):
|
||||
"""LLM failure (timeout) → normal results + friendly error banner."""
|
||||
box = Box(name="厨房箱", note="装了炒锅")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
# expand_query catches timeout and returns ExpansionResult with error
|
||||
mock_call.side_effect = httpx.TimeoutException("timeout")
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=厨房&ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "厨房箱" in response.text
|
||||
# Should show error banner — timeout is a real failure
|
||||
assert "超时" in response.text or "不可用" in response.text
|
||||
|
||||
def test_ai_search_empty_query_does_nothing(self, client, db_session):
|
||||
"""ai=1 with empty query does not trigger AI."""
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "AI 帮你扩展了" not in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_ai_search_disabled_ignores_flag(self, mock_call, client, db_session):
|
||||
"""ai=1 is ignored when ai_search_enabled is False."""
|
||||
box = Box(name="厨房箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
# Enable LLM but NOT ai_search_enabled
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "sk-test-key",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/search?q=厨房&ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "厨房箱" in response.text
|
||||
assert "AI 帮你扩展了" not in response.text
|
||||
mock_call.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Button visibility on search page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAIButtonVisibility:
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_button_visible_when_configured_and_enabled(self, mock_call, client, db_session):
|
||||
"""AI search button is visible when ai_search_enabled and configured."""
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=测试")
|
||||
assert response.status_code == 200
|
||||
assert "AI 智能搜索" in response.text
|
||||
|
||||
def test_button_hidden_when_not_configured(self, client, db_session):
|
||||
"""AI search button is hidden when LLM is not configured."""
|
||||
response = client.get("/search?q=测试")
|
||||
assert response.status_code == 200
|
||||
assert "AI 智能搜索" not in response.text
|
||||
|
||||
def test_button_hidden_when_ai_search_disabled(self, client, db_session):
|
||||
"""AI search button is hidden when ai_search_enabled is False."""
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "sk-test-key",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
response = client.get("/search?q=测试")
|
||||
assert "AI 智能搜索" not in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_button_hidden_on_empty_query(self, mock_call, client, db_session):
|
||||
"""AI search button is not shown when there's no query."""
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search")
|
||||
assert "AI 智能搜索" not in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_button_link_includes_current_query(self, mock_call, client, db_session):
|
||||
"""AI button link includes the current query parameter."""
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=锅")
|
||||
assert response.status_code == 200
|
||||
assert "ai=1" in response.text
|
||||
from urllib.parse import quote
|
||||
assert f"q={quote('锅')}" in response.text or "q=锅" in response.text
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_no_button_when_ai_already_activated(self, mock_call, client, db_session):
|
||||
"""When AI is already activated, show status text instead of button."""
|
||||
mock_call.return_value = {
|
||||
"choices": [{"message": {"content": '["炒锅"]'}}]
|
||||
}
|
||||
_enable_ai_search(client, db_session)
|
||||
|
||||
response = client.get("/search?q=锅&ai=1")
|
||||
assert response.status_code == 200
|
||||
assert "AI 搜索已启用" in response.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings: ai_search_extra_hints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtraHintsSettings:
|
||||
def test_extra_hints_defaults_to_empty(self, db_session):
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_extra_hints == ""
|
||||
|
||||
def test_save_extra_hints(self, db_session):
|
||||
save_app_settings(db_session, ai_search_extra_hints="用户物品主要涉及厨房")
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_extra_hints == "用户物品主要涉及厨房"
|
||||
|
||||
def test_save_extra_hints_empty_string(self, db_session):
|
||||
save_app_settings(db_session, ai_search_extra_hints="厨房用品")
|
||||
save_app_settings(db_session, ai_search_extra_hints="")
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_extra_hints == ""
|
||||
|
||||
def test_settings_page_has_extra_hints_textarea(self, client):
|
||||
response = client.get("/settings")
|
||||
assert response.status_code == 200
|
||||
assert 'name="ai_search_extra_hints"' in response.text
|
||||
assert "额外领域提示" in response.text
|
||||
|
||||
def test_settings_page_has_ai_search_checkbox(self, client):
|
||||
response = client.get("/settings")
|
||||
assert response.status_code == 200
|
||||
assert 'name="ai_search_enabled"' in response.text
|
||||
assert "启用 AI 智能搜索" in response.text
|
||||
|
||||
def test_save_ai_search_settings_via_route(self, client, db_session):
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "sk-key",
|
||||
"ai_search_enabled": "on",
|
||||
"ai_search_extra_hints": "用户物品主要涉及厨房用品",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_enabled is True
|
||||
assert cfg.ai_search_extra_hints == "用户物品主要涉及厨房用品"
|
||||
|
||||
def test_save_preserves_extra_hints_on_other_changes(self, client, db_session):
|
||||
"""Changing LLM settings should not clear extra hints."""
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "sk-key",
|
||||
"ai_search_enabled": "on",
|
||||
"ai_search_extra_hints": "厨房用品和电子产品",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "",
|
||||
"ai_search_enabled": "on",
|
||||
"ai_search_extra_hints": "厨房用品和电子产品",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_extra_hints == "厨房用品和电子产品"
|
||||
assert cfg.model == "gpt-4o"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: existing features still work without AI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegressionWithoutAI:
|
||||
def test_normal_search_still_works(self, client, db_session):
|
||||
box = Box(name="测试箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get("/search?q=测试")
|
||||
assert response.status_code == 200
|
||||
assert "测试箱" in response.text
|
||||
|
||||
def test_search_page_no_results(self, client):
|
||||
response = client.get("/search?q=不存在")
|
||||
assert "没有找到匹配结果" in response.text
|
||||
|
||||
def test_search_empty_query(self, client):
|
||||
response = client.get("/search")
|
||||
assert "输入关键词后" in response.text
|
||||
+32
-10
@@ -9,6 +9,7 @@ import pytest
|
||||
|
||||
import app.llm as llm_module
|
||||
from app.llm import LLMResult, expand_query, is_configured
|
||||
from app.llm import ExpansionResult
|
||||
from app.models import AppSetting
|
||||
from app.settings_store import LLMConfig, get_app_settings, save_app_settings
|
||||
|
||||
@@ -29,6 +30,7 @@ class TestLLMConfigDefaults:
|
||||
assert cfg.model == ""
|
||||
assert cfg.api_key == ""
|
||||
assert cfg.ai_search_enabled is False
|
||||
assert cfg.ai_search_extra_hints == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -208,37 +210,40 @@ class TestTestConnection:
|
||||
|
||||
|
||||
class TestExpandQuery:
|
||||
def test_returns_original_when_not_configured(self):
|
||||
def test_returns_empty_when_not_configured(self):
|
||||
cfg = LLMConfig(enabled=False)
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result == ["锅"]
|
||||
assert result.terms == []
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_expands_query_successfully(self, mock_call):
|
||||
mock_call.return_value = {
|
||||
"choices": [
|
||||
{"message": {"content": "平底锅\n炒锅\n锅具\n厨房锅"}}
|
||||
{"message": {"content": '["平底锅","炒锅","锅具","厨房锅"]'}}
|
||||
]
|
||||
}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert "锅" in result
|
||||
assert "平底锅" in result
|
||||
assert len(result) >= 4
|
||||
assert "平底锅" in result.terms
|
||||
assert "炒锅" in result.terms
|
||||
assert result.error is None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_fallback_on_api_failure(self, mock_call):
|
||||
mock_call.side_effect = Exception("network down")
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result == ["锅"]
|
||||
assert result.terms == []
|
||||
assert result.error is not None
|
||||
|
||||
@patch("app.llm._call_chat_completion")
|
||||
def test_fallback_on_empty_response(self, mock_call):
|
||||
mock_call.return_value = {"choices": [{"message": {"content": ""}}]}
|
||||
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
|
||||
result = expand_query(cfg, "锅")
|
||||
assert result == ["锅"]
|
||||
assert result.terms == []
|
||||
assert result.error is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -441,16 +446,33 @@ class TestSaveSettingsRoute:
|
||||
assert cfg.model == "gpt-4o-mini"
|
||||
assert cfg.api_key == "sk-original"
|
||||
|
||||
def test_save_does_not_touch_ai_search_enabled(self, client, db_session):
|
||||
"""P2 fix: saving LLM settings must not reset ai_search_enabled."""
|
||||
def test_save_includes_ai_search_enabled_checkbox(self, client, db_session):
|
||||
"""Saving settings now also persists the ai_search_enabled checkbox."""
|
||||
# Set ai_search_enabled to true first
|
||||
db_session.add(AppSetting(key="ai_search_enabled", value="true"))
|
||||
db_session.commit()
|
||||
# Save without the checkbox → ai_search_enabled is set to False
|
||||
client.post(
|
||||
"/settings",
|
||||
data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-key"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_enabled is False
|
||||
|
||||
def test_save_preserves_ai_search_enabled_when_checked(self, client, db_session):
|
||||
"""Saving settings with ai_search_enabled checked persists it."""
|
||||
client.post(
|
||||
"/settings",
|
||||
data={
|
||||
"enabled": "on",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "sk-key",
|
||||
"ai_search_enabled": "on",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cfg = get_app_settings(db_session)
|
||||
assert cfg.ai_search_enabled is True
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user