共找到 {{ results|length }} 条结果。
diff --git a/app/templates/settings/form.html b/app/templates/settings/form.html
index 31f69da..4eae15c 100644
--- a/app/templates/settings/form.html
+++ b/app/templates/settings/form.html
@@ -47,6 +47,20 @@
{% endif %}
+
+
+
+ 开启后,搜索页将显示「AI 智能搜索」按钮,通过查询词扩展增强搜索结果。
+
+
+ 追加到 AI 搜索提示词末尾,帮助模型理解你的物品领域。留空则使用默认提示词。
+
diff --git a/docs/design/llm-integration-design.md b/docs/design/llm-integration-design.md
index 4137268..f564169 100644
--- a/docs/design/llm-integration-design.md
+++ b/docs/design/llm-integration-design.md
@@ -210,7 +210,7 @@ OpenAI 兼容的薄客户端,基于 `httpx`,**无新依赖** / A thin OpenAI
- `is_configured(cfg) -> bool`:开关开启且 `model`/`api_key` 齐全。
- `test_connection(cfg) -> Result`:发一个最小请求验证 `base_url`/`model`/`api_key`,供配置页"测试连接"用。
-- `expand_query(cfg, query) -> list[str]`:把查询词扩成一批近义/相关词(提示词与输出契约见 §5.2)。
+- `expand_query(cfg, query, extra_hints="") -> ExpansionResult`:把查询词扩成一批近义/相关词;`terms` 为扩展词列表(不含原词),`error` 用于区分超时/网络/HTTP 等真实调用失败(提示词与输出契约见 §5.2)。
- `analyze_image(...)`:**本轮不实现**,仅在文档中预留为未来接口(图片分析轮次)。Reserved for a future round, not implemented now.
要点 / Notes:
@@ -247,8 +247,8 @@ When disabled/unconfigured: the settings page still works; the AI-search button
- **常驻动作 / Persistent action:** 搜索页**始终**提供「AI 智能搜索」,**不以"零结果"为前提**——即便普通搜索已出结果,用户不满意时也能点。
The "AI search" action is **always** present on the search page, **not gated on zero results** — usable even when normal results exist.
-- **流程 / Flow:** 普通 `LIKE` 照常先出结果 → 用户触发 AI → `expand_query` 把查询词扩成近义/相关词 → 用「原词 + 扩展词」对 `name`/`note` 做 OR `LIKE` 重搜 → 展示,并用横幅标注「AI 帮你扩展了:…」。
- Normal `LIKE` first → user triggers AI → `expand_query` → OR-`LIKE` over name/note with the original + expanded terms → render with a banner listing the expansion.
+- **流程 / Flow:** 普通 `LIKE` 照常先出结果 → 用户触发 AI → `expand_query` 返回 `ExpansionResult`(扩展词 `terms` 不含原词;调用失败写入 `error`)→ `ai_search` 用「原词 + 扩展词」对 `name`/`note` 做 OR `LIKE` 重搜 → 展示,并用横幅标注「AI 帮你扩展了:…」。
+ Normal `LIKE` first → user triggers AI → `expand_query` returns an `ExpansionResult` (`terms` exclude the original query; failures are represented by `error`) → `ai_search` OR-`LIKE`s over name/note with the original + expanded terms → render with a banner listing the expansion.
- **只把查询词发出去 / Only the query leaves**,不外泄物品清单;token 恒定、不随上千件物品增长。
Only the query is sent; the inventory is not. Token cost is constant and does not grow with thousands of items.
@@ -261,8 +261,8 @@ Quality hinges on the prompt; integration stability hinges on the output contrac
Frames the moving/household domain, asks for related naming terms, follows the query's language, caps the count, no prose.
- **可选「额外领域提示」/ Optional extra hints:** KV `ai_search_extra_hints`(设置页一个多行输入,默认空)。非空时**追加**到基础提示词之后,供业主微调倾向(如"厨房用品多,偏向厨具类")。**它只能补充,不能改写输出格式。**
An optional free-text setting appended to the base prompt; it can only add guidance, never alter the output format.
-- **输出契约(代码强制,与提示词解耦)/ Output contract (code-enforced):** 要求模型只返回 **JSON 字符串数组**;解析时去掉 ` ```json ` 围栏 → `json.loads` → 失败按行/逗号兜底 → 再不行返回 `[]`。`expand_query` 只返回扩展词;**原词由 `ai_search` 并入并去重**,数量在代码侧再封顶一次。
- Require a JSON string array; tolerant parse with fallbacks to `[]`. `ai_search` adds the original term and dedupes; the count is capped in code.
+- **输出契约(代码强制,与提示词解耦)/ Output contract (code-enforced):** 要求模型只返回 **JSON 字符串数组**;解析时去掉 ` ```json ` 围栏 → `json.loads` → 只接受字符串数组 → 过滤空串/过长词 → 最多 8 个。散文、坏 JSON、JSON object、非字符串数组都视为**合法空扩展**(`terms=[]`, `error=None`);网络错误、HTTP 错误、超时等真实调用失败写入 `ExpansionResult.error`。`expand_query` 的 `terms` 只包含扩展词;**原词由 `ai_search` 并入并去重**。
+ Require a JSON string array; strip code fences, `json.loads`, accept only string arrays, filter empty/overlong terms, and cap to 8 terms. Prose, bad JSON, JSON objects, and non-string arrays are successful empty expansions (`terms=[]`, `error=None`); network/HTTP/timeout failures are represented by `ExpansionResult.error`. `expand_query.terms` contains only expanded terms; `ai_search` adds the original term and dedupes.
- **客户端参数 / Client params:** 低 temperature、较小 max_tokens、设超时。Low temperature, small max_tokens, a timeout.
- **措辞留松 / Wording left loose:** 默认提示词的具体字句可在 step-3 实测中迭代,不在文档里冻死。
Exact default wording can be iterated during step-3 testing.
@@ -271,8 +271,8 @@ Quality hinges on the prompt; integration stability hinges on the output contrac
- 路由层扩展现有 `GET /search`:增加 `ai=1` 触发位(如 `GET /search?q=锅&ai=1`),保持单页、可收藏、SSR 友好。
Extend the existing `GET /search` with an `ai=1` trigger (e.g. `/search?q=…&ai=1`), staying single-page and bookmarkable.
-- 内部定义可替换的检索 seam,例如 `ai_search(db, query) -> (expanded_terms, results)`:
- Define a replaceable retrieval seam, e.g. `ai_search(db, query) -> (expanded_terms, results)`:
+- 内部定义可替换的检索 seam,例如 `ai_search(db, query) -> (expanded_terms, results, error_message)`:
+ Define a replaceable retrieval seam, e.g. `ai_search(db, query) -> (expanded_terms, results, error_message)`:
- **本轮 / now:** 内部=查询词扩展 + 本地 `LIKE`。
- **未来 / later:** 换成向量嵌入 + 相似度检索,**路由与模板不变**。
Swap to embeddings + similarity later **without changing the route or template**.
@@ -284,6 +284,9 @@ Quality hinges on the prompt; integration stability hinges on the output contrac
AI 关闭/未配置 → 不显示按钮(或提示去 `/settings`);调用失败 → 友好提示并回退到普通结果。
AI off/unconfigured → no button (or a hint to `/settings`); on failure → a friendly message, fall back to normal results.
+合法空扩展(模型返回 `[]` 或输出无法通过严格 JSON 字符串数组契约)不视为调用失败:回退普通结果,不显示故障提示。
+A legitimate empty expansion (model returns `[]` or output fails the strict JSON-string-array contract) is not treated as a call failure: fall back to normal results without an error banner.
+
---
## 6. 数据模型与路由变更 / Data Model & Route Changes
diff --git a/docs/design/step-2-llm-integration.md b/docs/design/step-2-llm-integration.md
index cc063f7..589581c 100644
--- a/docs/design/step-2-llm-integration.md
+++ b/docs/design/step-2-llm-integration.md
@@ -55,7 +55,7 @@ A settings page to enter & test the LLM config, persisted to `app_settings`, plu
- [ ] 新增 `app/llm.py`(基于 `httpx`):
- [ ] `is_configured(cfg) -> bool`
- [ ] `test_connection(cfg) -> Result`(发最小请求验证 `base_url`/`model`/`api_key`)。
- - [ ] `expand_query(cfg, query) -> list[str]`(查询词扩展;**步骤 3 会用**,本步先落地+单测)。
+ - [ ] `expand_query(cfg, query) -> ExpansionResult`(查询词扩展;**步骤 3 会校准提示词与输出契约**;`terms` 为扩展词列表,`error` 用于区分超时/网络/HTTP 等真实调用失败)。
- [ ] 统一超时 + 错误处理;失败优雅降级。
- [ ] **(预留,不实现)** `analyze_image(...)`:仅留 TODO/签名占位 + 注释指向"未来图片分析轮次"。Reserved, not implemented.
- [ ] 把所有网络调用收敛到**单一函数边界**,便于测试整体 mock。
diff --git a/docs/design/step-3-ai-search.md b/docs/design/step-3-ai-search.md
index f1e6a9f..c9b03be 100644
--- a/docs/design/step-3-ai-search.md
+++ b/docs/design/step-3-ai-search.md
@@ -17,7 +17,7 @@ A **persistent** "AI search" action on the search page that broadens results via
- 现有搜索:`app/main.py::_build_search_results(db, query)` 对 `Box`/`Item`/`SubItem` 的 `name` 与 `note` 做大小写不敏感 `LIKE`,返回结果列表;路由 `GET /search`(函数 `search_page`,参数 `q`)渲染 `app/templates/search/index.html`。
Existing search: `_build_search_results(db, query)` does case-insensitive `LIKE` over name/note; route `GET /search` renders `search/index.html`.
-- 步骤 2 已提供:`app/llm.py::expand_query(cfg, query) -> list[str]`、配置读取 `get_app_settings(db)`、开关 `ai_search_enabled` 与 `is_configured(cfg)`、设置页 `app/templates/settings/form.html`。
+- 步骤 2 已提供:`app/llm.py::expand_query` 的基础能力、配置读取 `get_app_settings(db)`、开关 `ai_search_enabled` 与 `is_configured(cfg)`、设置页 `app/templates/settings/form.html`;本步将 `expand_query` 校准为返回结构化 `ExpansionResult(terms, error)`。
- 本步**新增**配置项 `ai_search_extra_hints`(可选「额外领域提示」)并在设置页加一个多行输入——这是本步**唯一**触及设置页之处。
This step adds the `ai_search_extra_hints` setting + a textarea on the settings page (the only settings-page change here).
- 本轮检索范围=`name` + `note`(`image_description` 本轮不存在,属未来图片分析轮次)。
@@ -27,12 +27,12 @@ A **persistent** "AI search" action on the search page that broadens results via
- **常驻、不依赖零结果。** 普通 `LIKE` 照常先出结果;AI 动作始终可用(开启且已配置时)。
Persistent and not gated on zero results.
-- **流程:** 触发 AI → `expand_query` 得到"原词 + 一批近义/相关词" → 用这组词对 `name`/`note` 做 OR `LIKE` 重搜 → 展示,并用横幅标注「AI 帮你扩展了:…」。**只把查询词发出去**,不外泄物品清单。
- Trigger → expand → OR-`LIKE` over the original + expanded terms → render with a banner of the expansion. Only the query leaves.
-- **可替换的检索 seam。** 把 AI 检索抽成一个函数(如 `ai_search(db, query) -> (expanded_terms, results)`),本轮内部=查询词扩展 + 本地 `LIKE`;**未来换成向量嵌入 + 相似度时,路由与模板不变**。
+- **流程:** 触发 AI → `expand_query` 返回 `ExpansionResult`(扩展词 `terms` 不含原词,调用失败写入 `error`)→ `ai_search` 合并「原词 + 扩展词」并对 `name`/`note` 做 OR `LIKE` 重搜 → 展示,并用横幅标注「AI 帮你扩展了:…」。**只把查询词发出去**,不外泄物品清单。
+ Trigger → `expand_query` returns an `ExpansionResult` (`terms` exclude the original query; failures are represented by `error`) → `ai_search` OR-`LIKE`s over the original + expanded terms → render with a banner of the expansion. Only the query leaves.
+- **可替换的检索 seam。** 把 AI 检索抽成一个函数(如 `ai_search(db, query) -> (expanded_terms, results, error_message)`),本轮内部=查询词扩展 + 本地 `LIKE`;**未来换成向量嵌入 + 相似度时,路由与模板不变**。
Wrap AI retrieval behind a swappable seam so embeddings can replace it later without touching route/template.
-- **提示词(决策 C,详见设计 §5.2)。** 基础系统提示词**写死在 `app/llm.py`**;设置页可选的 `ai_search_extra_hints` 非空时**追加**到其后;**输出契约由代码强制**(要求 JSON 字符串数组 → 容忍性解析 → 失败返回 `[]`),用户改 hints 也改不坏解析。
- Base prompt hardcoded; optional extra hints appended; output contract (JSON array → tolerant parse → `[]`) enforced in code.
+- **提示词(决策 C,详见设计 §5.2)。** 基础系统提示词**写死在 `app/llm.py`**;设置页可选的 `ai_search_extra_hints` 非空时**追加**到其后;**输出契约由代码强制**(只接受 JSON 字符串数组;散文/坏 JSON/非字符串数组解析为合法空扩展;网络/超时/HTTP 失败写入 `ExpansionResult.error`),用户改 hints 也改不坏解析。
+ Base prompt hardcoded; optional extra hints appended; output contract enforced in code: only a JSON string array is accepted; prose/bad JSON/non-string arrays become a successful empty expansion; network/timeout/HTTP failures are represented by `ExpansionResult.error`.
- **优雅降级。** AI 关闭/未配置 → 不显示按钮(或提示去 `/settings`);调用失败 → 友好提示 + 回退普通结果。
---
@@ -43,10 +43,13 @@ A **persistent** "AI search" action on the search page that broadens results via
- 基础系统提示词写死在 `app/llm.py`(搬家/家居场景、列相关命名词、跟随查询语言、≤ ~8 个、不解释、不造无关词)。默认提示词起点(**可迭代** / a starting point, tune during testing):
> 你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。给定一个搜索词,列出用户可能用来命名同一类物品的相关词:近义词、常见别称、上位类别、具体品类。规则:用与查询相同的语言;只给与该物品紧密相关、有助于在清单里找到它的词;不要解释、不要造无关词;最多 8 个;只输出一个 JSON 字符串数组,例如 `["炒锅","平底锅","汤锅","厨具"]`。
- 读取 `ai_search_extra_hints`,非空则**追加**到基础提示词之后(只补充,不改格式)。
- - **输出契约**:要求模型只回 JSON 字符串数组;解析去 ` ```json ` 围栏 → `json.loads` → 失败按行/逗号兜底 → 再不行返回 `[]`;任何异常/超时都返回 `[]`(不抛错)。
+ - **返回契约**:`expand_query(cfg, query, extra_hints="") -> ExpansionResult`,其中 `terms` 是扩展词列表(**不含原词**),`error` 在成功时为 `None`。
+ - **输出契约**:要求模型只回 JSON 字符串数组;解析去 ` ```json ` 围栏 → `json.loads` → 只接受字符串数组 → 过滤空串/过长词 → 最多 8 个;散文、坏 JSON、JSON object、非字符串数组都返回 `terms=[]` 且 `error=None`(合法空扩展);网络错误、HTTP 错误、超时等调用失败返回 `terms=[]` 且 `error=<友好错误>`;不向上抛 500。
- [ ] **新增配置项 `ai_search_extra_hints`**:KV 默认空;纳入 `get_app_settings` / `save_app_settings`;设置页 `app/templates/settings/form.html` 加一个多行输入(沿用 step 2 风格)。
-- [ ] 实现检索 seam:在 `app/main.py`(或抽一个小搜索模块 `app/search.py`)加 `ai_search(db, query) -> (expanded_terms, results)`:
- - 调 `expand_query(cfg, query)` 得到扩展词;
+- [ ] 实现检索 seam:在 `app/main.py`(或抽一个小搜索模块 `app/search.py`)加 `ai_search(db, query) -> (expanded_terms, results, error_message)`:
+ - 调 `expand_query(cfg, query)` 得到 `ExpansionResult`;
+ - 若 `result.error` 非空:回退普通搜索,并把友好错误传给模板;
+ - 若 `result.terms` 为空且无错误:视为合法空扩展,回退普通搜索,不显示故障提示;
- 用「原词 + 扩展词」对 `name`/`note` 做 OR `LIKE`(**复用现有 `_build_search_results` 的匹配逻辑**,避免重复实现),去重。
- 注意:现有 `_build_search_results(db, query)` 只接收单个查询词;建议把它泛化为接收一组关键词(对多个词做 OR),让 AI 搜索与普通搜索共用同一套匹配逻辑,避免分叉。
Note: `_build_search_results` currently takes a single query — generalize it to accept multiple keywords so AI and normal search share one matching path.
@@ -62,9 +65,9 @@ A **persistent** "AI search" action on the search page that broadens results via
- [ ] 扩展词驱动命中:原词 `LIKE` 搜不到、扩展后能搜到。
- [ ] 已有结果时点 AI 仍可用,且结果集被扩大(含原结果)。
- [ ] 按钮可见性随 `ai_search_enabled` + `is_configured()` 门控。
- - [ ] 调用失败 → 回退普通结果、页面不报错。
- - [ ] `expand_query` 输出解析:模型回合法 JSON 数组 → 正确解析;回散文/坏 JSON/超时 → 返回 `[]`、不抛错。
- Output parsing: valid JSON array → parsed; prose/bad JSON/timeout → `[]`, no raise.
+ - [ ] 调用失败(超时/网络/HTTP)→ 回退普通结果、显示友好提示、页面不报错。
+ - [ ] `expand_query` 输出解析:模型回合法 JSON 数组 → 正确解析;回散文/坏 JSON/非字符串数组 → `terms=[]` 且 `error=None`;超时/网络/HTTP 失败 → `terms=[]` 且 `error` 非空;均不抛错。
+ Output parsing: valid JSON array → parsed; prose/bad JSON/non-string arrays → `terms=[]`, `error=None`; timeout/network/HTTP failures → `terms=[]`, non-empty `error`; no raise.
- [ ] `ai_search_extra_hints` 非空时确被追加进请求(可对构造的请求体断言)。
Extra hints, when set, are appended to the request.
diff --git a/tests/test_ai_search.py b/tests/test_ai_search.py
new file mode 100644
index 0000000..a3af58f
--- /dev/null
+++ b/tests/test_ai_search.py
@@ -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
diff --git a/tests/test_settings.py b/tests/test_settings.py
index b67735b..f17aa4e 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -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