From 70b0cf08ee400c924d274e98bad1ed3e5ce7f585 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 1 Jun 2026 21:28:29 +0200 Subject: [PATCH] Add AI search query expansion --- app/llm.py | 147 +++++- app/main.py | 202 +++++--- app/settings_store.py | 5 + app/templates/search/index.html | 24 + app/templates/settings/form.html | 14 + docs/design/llm-integration-design.md | 17 +- docs/design/step-2-llm-integration.md | 2 +- docs/design/step-3-ai-search.md | 27 +- tests/test_ai_search.py | 707 ++++++++++++++++++++++++++ tests/test_settings.py | 42 +- 10 files changed, 1064 insertions(+), 123 deletions(-) create mode 100644 tests/test_ai_search.py diff --git a/app/llm.py b/app/llm.py index c18a6db..8b7a6e0 100644 --- a/app/llm.py +++ b/app/llm.py @@ -8,6 +8,7 @@ Public API: - ``is_configured(cfg)`` — returns True when the client can make calls. - ``test_connection(cfg)`` — minimal request to verify credentials. - ``expand_query(cfg, query)`` — query-term expansion (step 3 consumer). + Returns ``ExpansionResult`` with ``terms`` and optional ``error``. - ``analyze_image(...)`` — **reserved stub, not implemented**. All calls go through ``_call_chat_completion()`` so tests can mock a single @@ -16,6 +17,8 @@ boundary. from __future__ import annotations +import json +import re from dataclasses import dataclass from typing import Any @@ -26,6 +29,18 @@ from app.settings_store import LLMConfig # Sensible defaults _TIMEOUT_SECONDS = 30 +# ── Prompt for query expansion (Step 3) ────────────────────────────────── +_EXPAND_QUERY_SYSTEM_PROMPT = ( + "你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。" + "给定一个搜索词,列出用户可能用来命名同一类物品的相关词:" + "近义词、常见别称、上位类别、具体品类。" + "规则:用与查询相同的语言;" + "只给与该物品紧密相关、有助于在清单里找到它的词;" + "不要解释、不要造无关词;最多 8 个;" + "只输出一个 JSON 字符串数组,例如 " + '`["炒锅","平底锅","汤锅","厨具"]`。' +) + @dataclass class LLMResult: @@ -36,6 +51,20 @@ class LLMResult: data: Any = None +@dataclass +class ExpansionResult: + """Structured result from ``expand_query``. + + ``terms`` is always a list (may be empty). + ``error`` is ``None`` on success (including legitimate empty results); + on failure (timeout, network error, HTTP error) it contains a + human-friendly error message. + """ + + terms: list[str] + error: str | None = None + + def is_configured(cfg: LLMConfig) -> bool: """Return True only when the LLM is enabled AND has required fields.""" return bool(cfg.enabled and cfg.model and cfg.api_key) @@ -87,44 +116,109 @@ def test_connection(cfg: LLMConfig) -> LLMResult: ) -def expand_query(cfg: LLMConfig, query: str) -> list[str]: +def expand_query( + cfg: LLMConfig, + query: str, + extra_hints: str = "", +) -> ExpansionResult: """Expand a search query into multiple synonymous terms via LLM. - **Step 3 will consume this.** Returns a list including the original query. - If the LLM call fails or is not configured, returns ``[query]`` as a - fallback (graceful degradation). + Returns an ``ExpansionResult``. On success ``terms`` contains the expanded + terms (possibly empty) and ``error`` is ``None``. On failure (network + error, timeout, HTTP error) ``terms`` is ``[]`` and ``error`` contains a + human-friendly message. """ if not is_configured(cfg): - return [query] + return ExpansionResult(terms=[]) + + system_prompt = _EXPAND_QUERY_SYSTEM_PROMPT + if extra_hints and extra_hints.strip(): + system_prompt += "\n" + extra_hints.strip() try: response = _call_chat_completion( cfg, messages=[ - { - "role": "system", - "content": ( - "你是一个搜索词扩展助手。用户给你一个搜索词," - "你返回 3-5 个同义词或相关词,每行一个。" - "不要编号、不要解释、不要标点。" - ), - }, + {"role": "system", "content": system_prompt}, {"role": "user", "content": query}, ], - max_tokens=100, + max_tokens=200, + temperature=0, + ) + except httpx.TimeoutException: + return ExpansionResult( + terms=[], + error="AI 搜索请求超时,请稍后再试。", + ) + except httpx.ConnectError: + return ExpansionResult( + terms=[], + error="无法连接到 AI 服务,请检查网络或设置。", + ) + except httpx.HTTPStatusError: + return ExpansionResult( + terms=[], + error="AI 服务返回错误,请检查配置。", ) - choices = response.get("choices", []) - if choices: - content = choices[0].get("message", {}).get("content", "") - expanded = [ - line.strip() for line in content.strip().splitlines() if line.strip() - ] - if expanded: - # Always include the original query - return [query] + [t for t in expanded if t != query] - return [query] except Exception: # noqa: BLE001 — graceful degradation - return [query] + return ExpansionResult( + terms=[], + error="AI 搜索暂时不可用,请稍后再试。", + ) + + choices = response.get("choices", []) + if not choices: + return ExpansionResult(terms=[]) + content = choices[0].get("message", {}).get("content", "") + return ExpansionResult(terms=_parse_json_string_array(content)) + + +# ── Constants for output contract enforcement ──────────────────────────── +_MAX_EXPANSION_TERMS = 8 +_MAX_TERM_LENGTH = 30 + + +def _parse_json_string_array(content: str) -> list[str]: + """Parse LLM output into a list of strings. + + Strict contract enforcement: + 1. Strip markdown code fences; + 2. Try ``json.loads`` — only accept a JSON **array of strings**; + 3. Anything else (prose, JSON objects, bad JSON) → return ``[]``. + + This ensures the output contract is enforced by code: no matter what + the model returns or what ``ai_search_extra_hints`` contains, only a + valid JSON string array is accepted. + """ + text = content.strip() + if not text: + return [] + + # Strip markdown code fences + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + text = text.strip() + + # Attempt JSON parse — strictly require a list + try: + parsed = json.loads(text) + except (json.JSONDecodeError, ValueError): + return [] + + if not isinstance(parsed, list): + return [] + + # Validate every element is a string; reject non-string items + terms: list[str] = [] + for item in parsed: + if not isinstance(item, str): + return [] + cleaned = item.strip() + if cleaned and len(cleaned) <= _MAX_TERM_LENGTH: + terms.append(cleaned) + + # Cap total count + return terms[:_MAX_EXPANSION_TERMS] def analyze_image(cfg: LLMConfig, image_data: bytes, prompt: str) -> LLMResult: @@ -151,6 +245,7 @@ def _call_chat_completion( *, messages: list[dict[str, str]], max_tokens: int = 1, + temperature: float | None = None, ) -> dict: """Call the OpenAI-compatible ``/chat/completions`` endpoint. @@ -164,6 +259,8 @@ def _call_chat_completion( "messages": messages, "max_tokens": max_tokens, } + if temperature is not None: + payload["temperature"] = temperature headers = { "Authorization": f"Bearer {cfg.api_key}", "Content-Type": "application/json", diff --git a/app/main.py b/app/main.py index ff40328..569d6ae 100644 --- a/app/main.py +++ b/app/main.py @@ -5,12 +5,12 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload from fastapi.responses import FileResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from sqlalchemy import func, or_ +from sqlalchemy import func, false, or_ from sqlalchemy.orm import Session from app.db import get_db, init_db from app.images import process_upload -from app.llm import test_connection +from app.llm import expand_query, is_configured, test_connection from app.llm import LLMResult from app.models import Box, Item, SubItem from app.settings_store import LLMConfig, get_app_settings, save_app_settings @@ -160,100 +160,130 @@ def _build_boxes_overview_summary(db: Session) -> dict[str, int | str]: } -def _build_search_results(db: Session, query: str) -> list[dict]: - keyword = f"%{query.lower()}%" +def _build_search_results(db: Session, query: str | list[str]) -> list[dict]: + """Search Box / Item / SubItem by name and note using case-insensitive LIKE. + + Accepts either a single query string or a list of keywords. + When multiple keywords are given, they are combined with OR — a match on + *any* keyword is sufficient. + """ + keywords = [query] if isinstance(query, str) else query + patterns = [f"%{kw.lower()}%" for kw in keywords] + + def _or_like(column, note_column): + """Build an OR filter that matches any pattern on either column.""" + conditions = [] + for pat in patterns: + conditions.append(func.lower(column).like(pat)) + conditions.append(func.lower(func.coalesce(note_column, "")).like(pat)) + return or_(false(), *conditions) if conditions else false() + results: list[dict] = [] + seen_ids: set[tuple[str, int]] = set() + + def _add(result_type: str, obj_id: int, entry: dict) -> None: + key = (result_type, obj_id) + if key not in seen_ids: + seen_ids.add(key) + results.append(entry) box_matches = ( db.query(Box) - .filter( - or_( - func.lower(Box.name).like(keyword), - func.lower(func.coalesce(Box.note, "")).like(keyword), - ) - ) + .filter(_or_like(Box.name, Box.note)) .order_by(Box.id.desc()) .all() ) for box in box_matches: - results.append( - { - "type": "Box", - "name": box.name, - "note": box.note, - "detail_url": f"/boxes/{box.id}", - "detail_label": "查看箱子", - "secondary_url": None, - "secondary_label": None, - "path": "顶层箱子", - "is_container": None, - "image_url": f"/boxes/{box.id}/image" if box.image_blob else None, - } - ) + _add("Box", box.id, { + "type": "Box", + "name": box.name, + "note": box.note, + "detail_url": f"/boxes/{box.id}", + "detail_label": "查看箱子", + "secondary_url": None, + "secondary_label": None, + "path": "顶层箱子", + "is_container": None, + "image_url": f"/boxes/{box.id}/image" if box.image_blob else None, + }) item_matches = ( db.query(Item) .join(Item.box) - .filter( - or_( - func.lower(Item.name).like(keyword), - func.lower(func.coalesce(Item.note, "")).like(keyword), - ) - ) + .filter(_or_like(Item.name, Item.note)) .order_by(Item.id.desc()) .all() ) for item in item_matches: - results.append( - { - "type": "Item", - "name": item.name, - "note": item.note, - "detail_url": f"/items/{item.id}", - "detail_label": "查看物品", - "secondary_url": f"/boxes/{item.box.id}", - "secondary_label": "查看所属箱子", - "path": f"位于箱子:{item.box.name}", - "is_container": item.is_container, - "image_url": f"/items/{item.id}/image" if item.image_blob else None, - } - ) + _add("Item", item.id, { + "type": "Item", + "name": item.name, + "note": item.note, + "detail_url": f"/items/{item.id}", + "detail_label": "查看物品", + "secondary_url": f"/boxes/{item.box.id}", + "secondary_label": "查看所属箱子", + "path": f"位于箱子:{item.box.name}", + "is_container": item.is_container, + "image_url": f"/items/{item.id}/image" if item.image_blob else None, + }) subitem_matches = ( db.query(SubItem) .join(SubItem.parent_item) .join(Item.box) - .filter( - or_( - func.lower(SubItem.name).like(keyword), - func.lower(func.coalesce(SubItem.note, "")).like(keyword), - ) - ) + .filter(_or_like(SubItem.name, SubItem.note)) .order_by(SubItem.id.desc()) .all() ) for subitem in subitem_matches: - results.append( - { - "type": "SubItem", - "name": subitem.name, - "note": subitem.note, - "detail_url": f"/items/{subitem.parent_item.id}", - "detail_label": "查看所属物品", - "secondary_url": f"/boxes/{subitem.parent_item.box.id}", - "secondary_label": "查看所属箱子", - "path": ( - f"位于物品:{subitem.parent_item.name} / " - f"箱子:{subitem.parent_item.box.name}" - ), - "is_container": None, - "image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None, - } - ) + _add("SubItem", subitem.id, { + "type": "SubItem", + "name": subitem.name, + "note": subitem.note, + "detail_url": f"/items/{subitem.parent_item.id}", + "detail_label": "查看所属物品", + "secondary_url": f"/boxes/{subitem.parent_item.box.id}", + "secondary_label": "查看所属箱子", + "path": ( + f"位于物品:{subitem.parent_item.name} / " + f"箱子:{subitem.parent_item.box.name}" + ), + "is_container": None, + "image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None, + }) return results +def _ai_search(db: Session, cfg: "LLMConfig", query: str) -> tuple[list[str], list[dict], str | None]: + """Swappable AI search seam. + + Returns ``(expanded_terms, results, error_message)``. + - On success: expanded terms + broadened results, ``error_message`` is ``None``. + - On failure (timeout, network error, HTTP error): empty terms + normal LIKE + results + friendly error message. + - On empty expansion (model returned ``[]`` legitimately): empty terms + normal + results, ``error_message`` is ``None``. + """ + result = expand_query(cfg, query, extra_hints=cfg.ai_search_extra_hints) + + if result.error: + # Real failure (timeout / network / HTTP) → show error + fallback + results = _build_search_results(db, query) + return [], results, result.error + + if not result.terms: + # Legitimate empty expansion → normal results, no error + results = _build_search_results(db, query) + return [], results, None + + # Deduplicate: original query + expanded terms + all_terms = list(dict.fromkeys([query] + result.terms)) + results = _build_search_results(db, all_terms) + return result.terms, results, None + + def create_app() -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): @@ -285,10 +315,28 @@ def create_app() -> FastAPI: def search_page( request: Request, q: str | None = None, + ai: str | None = None, db: Session = Depends(get_db), ): query = (q or "").strip() - results = _build_search_results(db, query) if query else [] + cfg = get_app_settings(db) + ai_requested = ai == "1" + ai_available = cfg.ai_search_enabled and is_configured(cfg) + expanded_terms: list[str] = [] + ai_error: str | None = None + + if query: + if ai_requested and ai_available: + try: + expanded_terms, results, ai_error = _ai_search(db, cfg, query) + except Exception: # noqa: BLE001 — graceful degradation + ai_error = "AI 搜索暂时不可用,已回退到普通搜索。" + results = _build_search_results(db, query) + else: + results = _build_search_results(db, query) + else: + results = [] + return templates.TemplateResponse( request=request, name="search/index.html", @@ -297,6 +345,10 @@ def create_app() -> FastAPI: "query": query, "results": results, "searched": bool(query), + "ai_activated": ai_requested and ai_available and bool(query), + "expanded_terms": expanded_terms, + "ai_error": ai_error, + "ai_available": ai_available, }, ) @@ -335,6 +387,8 @@ def create_app() -> FastAPI: base_url: str | None = Form(default=None), model: str | None = Form(default=None), api_key: str | None = Form(default=None), + ai_search_enabled: str | None = Form(default=None), + ai_search_extra_hints: str | None = Form(default=None), db: Session = Depends(get_db), ) -> Response: # Origin/Referer check for browser requests @@ -370,6 +424,8 @@ def create_app() -> FastAPI: base_url=resolved_base_url, model=resolved_model, api_key=existing_cfg.api_key, + ai_search_enabled=ai_search_enabled == "on", + ai_search_extra_hints=_clean_text(ai_search_extra_hints) or "", ), "api_key_configured": bool(existing_cfg.api_key), "test_result": LLMResult( @@ -382,12 +438,16 @@ def create_app() -> FastAPI: # submitted_key is None → keep old key; str (including "") → use new value resolved_api_key = submitted_key + resolved_extra_hints = _clean_text(ai_search_extra_hints) or "" + save_app_settings( db, enabled=enabled == "on", base_url=resolved_base_url, model=resolved_model, api_key=resolved_api_key, + ai_search_enabled=ai_search_enabled == "on", + ai_search_extra_hints=resolved_extra_hints, ) return RedirectResponse(url="/settings", status_code=status.HTTP_303_SEE_OTHER) @@ -398,6 +458,8 @@ def create_app() -> FastAPI: base_url: str | None = Form(default=None), model: str | None = Form(default=None), api_key: str | None = Form(default=None), + ai_search_enabled: str | None = Form(default=None), + ai_search_extra_hints: str | None = Form(default=None), db: Session = Depends(get_db), ): # Origin/Referer check for browser requests @@ -436,6 +498,8 @@ def create_app() -> FastAPI: base_url=resolved_base_url, model=resolved_model, api_key="", + ai_search_enabled=ai_search_enabled == "on", + ai_search_extra_hints=_clean_text(ai_search_extra_hints) or "", ), "api_key_configured": bool(existing_cfg.api_key), "test_result": LLMResult( @@ -450,6 +514,8 @@ def create_app() -> FastAPI: base_url=resolved_base_url, model=resolved_model, api_key=resolved_api_key or "", + ai_search_enabled=ai_search_enabled == "on", + ai_search_extra_hints=_clean_text(ai_search_extra_hints) or "", ) result = test_connection(test_cfg) diff --git a/app/settings_store.py b/app/settings_store.py index 8ee84a4..d91d63a 100644 --- a/app/settings_store.py +++ b/app/settings_store.py @@ -25,6 +25,7 @@ class LLMConfig: model: str = "" api_key: str = "" ai_search_enabled: bool = False + ai_search_extra_hints: str = "" def _get_value(rows: dict[str, str], key: str, default: str) -> str: @@ -48,6 +49,7 @@ def get_app_settings(db: Session) -> LLMConfig: model=_get_value(rows, "llm_model", ""), api_key=_get_value(rows, "llm_api_key", ""), ai_search_enabled=_get_bool(rows, "ai_search_enabled", False), + ai_search_extra_hints=_get_value(rows, "ai_search_extra_hints", ""), ) @@ -59,6 +61,7 @@ def save_app_settings( model: str | None = None, api_key: str | None = None, ai_search_enabled: bool | None = None, + ai_search_extra_hints: str | None = None, ) -> None: """Write settings to ``app_settings``. @@ -77,6 +80,8 @@ def save_app_settings( updates["llm_api_key"] = api_key if ai_search_enabled is not None: updates["ai_search_enabled"] = str(ai_search_enabled).lower() + if ai_search_extra_hints is not None: + updates["ai_search_extra_hints"] = ai_search_extra_hints for key, value in updates.items(): existing = db.get(AppSetting, key) diff --git a/app/templates/search/index.html b/app/templates/search/index.html index 873d6b2..42e87e2 100644 --- a/app/templates/search/index.html +++ b/app/templates/search/index.html @@ -20,7 +20,31 @@ +{% if query and ai_available %} +
+ {% if ai_activated %} + AI 搜索已启用 + {% else %} + + AI 智能搜索 + + {% endif %} +
+{% endif %} + {% if searched %} + {% if ai_error %} +
+

{{ ai_error }}

+
+ {% endif %} + + {% if ai_activated and expanded_terms %} +
+

AI 帮你扩展了:{{ expanded_terms | join('、') }}

+
+ {% endif %} + {% if results %}

共找到 {{ 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