Add AI search query expansion
test / pytest (push) Successful in 1m20s
docker-image / build-and-push (push) Successful in 5m6s

This commit is contained in:
2026-06-01 21:28:29 +02:00
parent d36b940981
commit 70b0cf08ee
10 changed files with 1064 additions and 123 deletions
+122 -25
View File
@@ -8,6 +8,7 @@ Public API:
- ``is_configured(cfg)`` — returns True when the client can make calls. - ``is_configured(cfg)`` — returns True when the client can make calls.
- ``test_connection(cfg)`` — minimal request to verify credentials. - ``test_connection(cfg)`` — minimal request to verify credentials.
- ``expand_query(cfg, query)`` — query-term expansion (step 3 consumer). - ``expand_query(cfg, query)`` — query-term expansion (step 3 consumer).
Returns ``ExpansionResult`` with ``terms`` and optional ``error``.
- ``analyze_image(...)`` — **reserved stub, not implemented**. - ``analyze_image(...)`` — **reserved stub, not implemented**.
All calls go through ``_call_chat_completion()`` so tests can mock a single All calls go through ``_call_chat_completion()`` so tests can mock a single
@@ -16,6 +17,8 @@ boundary.
from __future__ import annotations from __future__ import annotations
import json
import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -26,6 +29,18 @@ from app.settings_store import LLMConfig
# Sensible defaults # Sensible defaults
_TIMEOUT_SECONDS = 30 _TIMEOUT_SECONDS = 30
# ── Prompt for query expansion (Step 3) ──────────────────────────────────
_EXPAND_QUERY_SYSTEM_PROMPT = (
"你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。"
"给定一个搜索词,列出用户可能用来命名同一类物品的相关词:"
"近义词、常见别称、上位类别、具体品类。"
"规则:用与查询相同的语言;"
"只给与该物品紧密相关、有助于在清单里找到它的词;"
"不要解释、不要造无关词;最多 8 个;"
"只输出一个 JSON 字符串数组,例如 "
'`["炒锅","平底锅","汤锅","厨具"]`。'
)
@dataclass @dataclass
class LLMResult: class LLMResult:
@@ -36,6 +51,20 @@ class LLMResult:
data: Any = None 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: def is_configured(cfg: LLMConfig) -> bool:
"""Return True only when the LLM is enabled AND has required fields.""" """Return True only when the LLM is enabled AND has required fields."""
return bool(cfg.enabled and cfg.model and cfg.api_key) 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. """Expand a search query into multiple synonymous terms via LLM.
**Step 3 will consume this.** Returns a list including the original query. Returns an ``ExpansionResult``. On success ``terms`` contains the expanded
If the LLM call fails or is not configured, returns ``[query]`` as a terms (possibly empty) and ``error`` is ``None``. On failure (network
fallback (graceful degradation). error, timeout, HTTP error) ``terms`` is ``[]`` and ``error`` contains a
human-friendly message.
""" """
if not is_configured(cfg): 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: try:
response = _call_chat_completion( response = _call_chat_completion(
cfg, cfg,
messages=[ messages=[
{ {"role": "system", "content": system_prompt},
"role": "system",
"content": (
"你是一个搜索词扩展助手。用户给你一个搜索词,"
"你返回 3-5 个同义词或相关词,每行一个。"
"不要编号、不要解释、不要标点。"
),
},
{"role": "user", "content": query}, {"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 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: def analyze_image(cfg: LLMConfig, image_data: bytes, prompt: str) -> LLMResult:
@@ -151,6 +245,7 @@ def _call_chat_completion(
*, *,
messages: list[dict[str, str]], messages: list[dict[str, str]],
max_tokens: int = 1, max_tokens: int = 1,
temperature: float | None = None,
) -> dict: ) -> dict:
"""Call the OpenAI-compatible ``/chat/completions`` endpoint. """Call the OpenAI-compatible ``/chat/completions`` endpoint.
@@ -164,6 +259,8 @@ def _call_chat_completion(
"messages": messages, "messages": messages,
"max_tokens": max_tokens, "max_tokens": max_tokens,
} }
if temperature is not None:
payload["temperature"] = temperature
headers = { headers = {
"Authorization": f"Bearer {cfg.api_key}", "Authorization": f"Bearer {cfg.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
+101 -35
View File
@@ -5,12 +5,12 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
from fastapi.responses import FileResponse, RedirectResponse, Response from fastapi.responses import FileResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import func, or_ from sqlalchemy import func, false, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import get_db, init_db from app.db import get_db, init_db
from app.images import process_upload 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.llm import LLMResult
from app.models import Box, Item, SubItem from app.models import Box, Item, SubItem
from app.settings_store import LLMConfig, get_app_settings, save_app_settings from app.settings_store import LLMConfig, get_app_settings, save_app_settings
@@ -160,24 +160,41 @@ def _build_boxes_overview_summary(db: Session) -> dict[str, int | str]:
} }
def _build_search_results(db: Session, query: str) -> list[dict]: def _build_search_results(db: Session, query: str | list[str]) -> list[dict]:
keyword = f"%{query.lower()}%" """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] = [] 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 = ( box_matches = (
db.query(Box) db.query(Box)
.filter( .filter(_or_like(Box.name, Box.note))
or_(
func.lower(Box.name).like(keyword),
func.lower(func.coalesce(Box.note, "")).like(keyword),
)
)
.order_by(Box.id.desc()) .order_by(Box.id.desc())
.all() .all()
) )
for box in box_matches: for box in box_matches:
results.append( _add("Box", box.id, {
{
"type": "Box", "type": "Box",
"name": box.name, "name": box.name,
"note": box.note, "note": box.note,
@@ -188,24 +205,17 @@ def _build_search_results(db: Session, query: str) -> list[dict]:
"path": "顶层箱子", "path": "顶层箱子",
"is_container": None, "is_container": None,
"image_url": f"/boxes/{box.id}/image" if box.image_blob else None, "image_url": f"/boxes/{box.id}/image" if box.image_blob else None,
} })
)
item_matches = ( item_matches = (
db.query(Item) db.query(Item)
.join(Item.box) .join(Item.box)
.filter( .filter(_or_like(Item.name, Item.note))
or_(
func.lower(Item.name).like(keyword),
func.lower(func.coalesce(Item.note, "")).like(keyword),
)
)
.order_by(Item.id.desc()) .order_by(Item.id.desc())
.all() .all()
) )
for item in item_matches: for item in item_matches:
results.append( _add("Item", item.id, {
{
"type": "Item", "type": "Item",
"name": item.name, "name": item.name,
"note": item.note, "note": item.note,
@@ -216,25 +226,18 @@ def _build_search_results(db: Session, query: str) -> list[dict]:
"path": f"位于箱子:{item.box.name}", "path": f"位于箱子:{item.box.name}",
"is_container": item.is_container, "is_container": item.is_container,
"image_url": f"/items/{item.id}/image" if item.image_blob else None, "image_url": f"/items/{item.id}/image" if item.image_blob else None,
} })
)
subitem_matches = ( subitem_matches = (
db.query(SubItem) db.query(SubItem)
.join(SubItem.parent_item) .join(SubItem.parent_item)
.join(Item.box) .join(Item.box)
.filter( .filter(_or_like(SubItem.name, SubItem.note))
or_(
func.lower(SubItem.name).like(keyword),
func.lower(func.coalesce(SubItem.note, "")).like(keyword),
)
)
.order_by(SubItem.id.desc()) .order_by(SubItem.id.desc())
.all() .all()
) )
for subitem in subitem_matches: for subitem in subitem_matches:
results.append( _add("SubItem", subitem.id, {
{
"type": "SubItem", "type": "SubItem",
"name": subitem.name, "name": subitem.name,
"note": subitem.note, "note": subitem.note,
@@ -248,12 +251,39 @@ def _build_search_results(db: Session, query: str) -> list[dict]:
), ),
"is_container": None, "is_container": None,
"image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None, "image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None,
} })
)
return results 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: def create_app() -> FastAPI:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -285,10 +315,28 @@ def create_app() -> FastAPI:
def search_page( def search_page(
request: Request, request: Request,
q: str | None = None, q: str | None = None,
ai: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
query = (q or "").strip() 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( return templates.TemplateResponse(
request=request, request=request,
name="search/index.html", name="search/index.html",
@@ -297,6 +345,10 @@ def create_app() -> FastAPI:
"query": query, "query": query,
"results": results, "results": results,
"searched": bool(query), "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), base_url: str | None = Form(default=None),
model: str | None = Form(default=None), model: str | None = Form(default=None),
api_key: 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), db: Session = Depends(get_db),
) -> Response: ) -> Response:
# Origin/Referer check for browser requests # Origin/Referer check for browser requests
@@ -370,6 +424,8 @@ def create_app() -> FastAPI:
base_url=resolved_base_url, base_url=resolved_base_url,
model=resolved_model, model=resolved_model,
api_key=existing_cfg.api_key, 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), "api_key_configured": bool(existing_cfg.api_key),
"test_result": LLMResult( "test_result": LLMResult(
@@ -382,12 +438,16 @@ def create_app() -> FastAPI:
# submitted_key is None → keep old key; str (including "") → use new value # submitted_key is None → keep old key; str (including "") → use new value
resolved_api_key = submitted_key resolved_api_key = submitted_key
resolved_extra_hints = _clean_text(ai_search_extra_hints) or ""
save_app_settings( save_app_settings(
db, db,
enabled=enabled == "on", enabled=enabled == "on",
base_url=resolved_base_url, base_url=resolved_base_url,
model=resolved_model, model=resolved_model,
api_key=resolved_api_key, 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) 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), base_url: str | None = Form(default=None),
model: str | None = Form(default=None), model: str | None = Form(default=None),
api_key: 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), db: Session = Depends(get_db),
): ):
# Origin/Referer check for browser requests # Origin/Referer check for browser requests
@@ -436,6 +498,8 @@ def create_app() -> FastAPI:
base_url=resolved_base_url, base_url=resolved_base_url,
model=resolved_model, model=resolved_model,
api_key="", 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), "api_key_configured": bool(existing_cfg.api_key),
"test_result": LLMResult( "test_result": LLMResult(
@@ -450,6 +514,8 @@ def create_app() -> FastAPI:
base_url=resolved_base_url, base_url=resolved_base_url,
model=resolved_model, model=resolved_model,
api_key=resolved_api_key or "", 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) result = test_connection(test_cfg)
+5
View File
@@ -25,6 +25,7 @@ class LLMConfig:
model: str = "" model: str = ""
api_key: str = "" api_key: str = ""
ai_search_enabled: bool = False ai_search_enabled: bool = False
ai_search_extra_hints: str = ""
def _get_value(rows: dict[str, str], key: str, default: str) -> 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", ""), model=_get_value(rows, "llm_model", ""),
api_key=_get_value(rows, "llm_api_key", ""), api_key=_get_value(rows, "llm_api_key", ""),
ai_search_enabled=_get_bool(rows, "ai_search_enabled", False), 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, model: str | None = None,
api_key: str | None = None, api_key: str | None = None,
ai_search_enabled: bool | None = None, ai_search_enabled: bool | None = None,
ai_search_extra_hints: str | None = None,
) -> None: ) -> None:
"""Write settings to ``app_settings``. """Write settings to ``app_settings``.
@@ -77,6 +80,8 @@ def save_app_settings(
updates["llm_api_key"] = api_key updates["llm_api_key"] = api_key
if ai_search_enabled is not None: if ai_search_enabled is not None:
updates["ai_search_enabled"] = str(ai_search_enabled).lower() 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(): for key, value in updates.items():
existing = db.get(AppSetting, key) existing = db.get(AppSetting, key)
+24
View File
@@ -20,7 +20,31 @@
</form> </form>
</section> </section>
{% if query and ai_available %}
<section class="card" style="margin-top: 8px;">
{% if ai_activated %}
<span class="muted">AI 搜索已启用</span>
{% else %}
<a href="/search?q={{ query | urlencode }}&ai=1" class="button button-secondary" style="display:inline-block; text-decoration:none;">
AI 智能搜索
</a>
{% endif %}
</section>
{% endif %}
{% if searched %} {% if searched %}
{% if ai_error %}
<section class="card" style="margin-top: 8px; border-color: #b42318;">
<p style="margin:0; color: #b42318;"><strong>{{ ai_error }}</strong></p>
</section>
{% endif %}
{% if ai_activated and expanded_terms %}
<section class="card" style="margin-top: 8px; border-color: #0b57d0;">
<p style="margin:0; color: #0b57d0;"><strong>AI 帮你扩展了:</strong>{{ expanded_terms | join('、') }}</p>
</section>
{% endif %}
{% if results %} {% if results %}
<section class="stack"> <section class="stack">
<p class="muted">共找到 {{ results|length }} 条结果。</p> <p class="muted">共找到 {{ results|length }} 条结果。</p>
+14
View File
@@ -47,6 +47,20 @@
{% endif %} {% endif %}
</label> </label>
<hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">
<label class="form-field checkbox-row">
<input type="checkbox" name="ai_search_enabled" {% if config.ai_search_enabled %}checked{% endif %}>
启用 AI 智能搜索
</label>
<p class="checkbox-help">开启后,搜索页将显示「AI 智能搜索」按钮,通过查询词扩展增强搜索结果。</p>
<label class="form-field">
额外领域提示(可选)
<textarea name="ai_search_extra_hints" rows="3" placeholder="例如:用户物品主要涉及厨房用品和电子产品">{% if config.ai_search_extra_hints %}{{ config.ai_search_extra_hints }}{% endif %}</textarea>
</label>
<p class="checkbox-help">追加到 AI 搜索提示词末尾,帮助模型理解你的物品领域。留空则使用默认提示词。</p>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="button button-primary">保存设置</button> <button type="submit" class="button button-primary">保存设置</button>
<button type="submit" class="button button-secondary" formaction="/settings/test" formmethod="post">测试连接</button> <button type="submit" class="button button-secondary" formaction="/settings/test" formmethod="post">测试连接</button>
+10 -7
View File
@@ -210,7 +210,7 @@ OpenAI 兼容的薄客户端,基于 `httpx`**无新依赖** / A thin OpenAI
- `is_configured(cfg) -> bool`:开关开启且 `model`/`api_key` 齐全。 - `is_configured(cfg) -> bool`:开关开启且 `model`/`api_key` 齐全。
- `test_connection(cfg) -> Result`:发一个最小请求验证 `base_url`/`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. - `analyze_image(...)`:**本轮不实现**,仅在文档中预留为未来接口(图片分析轮次)。Reserved for a future round, not implemented now.
要点 / Notes 要点 / Notes
@@ -247,8 +247,8 @@ When disabled/unconfigured: the settings page still works; the AI-search button
- **常驻动作 / Persistent action** 搜索页**始终**提供「AI 智能搜索」,**不以"零结果"为前提**——即便普通搜索已出结果,用户不满意时也能点。 - **常驻动作 / 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. 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 帮你扩展了:…」。 - **流程 / Flow** 普通 `LIKE` 照常先出结果 → 用户触发 AI → `expand_query` 返回 `ExpansionResult`(扩展词 `terms` 不含原词;调用失败写入 `error`)→ `ai_search` 用「原词 + 扩展词」对 `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. 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 leaves**,不外泄物品清单;token 恒定、不随上千件物品增长。
Only the query is sent; the inventory is not. Token cost is constant and does not grow with thousands of items. 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. 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`(设置页一个多行输入,默认空)。非空时**追加**到基础提示词之后,供业主微调倾向(如"厨房用品多,偏向厨具类")。**它只能补充,不能改写输出格式。** - **可选「额外领域提示」/ 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. 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` 并入并去重**,数量在代码侧再封顶一次 - **输出契约(代码强制,与提示词解耦)/ 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; tolerant parse with fallbacks to `[]`. `ai_search` adds the original term and dedupes; the count is capped in code. 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. - **客户端参数 / Client params** 低 temperature、较小 max_tokens、设超时。Low temperature, small max_tokens, a timeout.
- **措辞留松 / Wording left loose** 默认提示词的具体字句可在 step-3 实测中迭代,不在文档里冻死。 - **措辞留松 / Wording left loose** 默认提示词的具体字句可在 step-3 实测中迭代,不在文档里冻死。
Exact default wording can be iterated during step-3 testing. 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 友好。 - 路由层扩展现有 `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. 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)` - 内部定义可替换的检索 seam,例如 `ai_search(db, query) -> (expanded_terms, results, error_message)`
Define a replaceable retrieval seam, e.g. `ai_search(db, query) -> (expanded_terms, results)`: Define a replaceable retrieval seam, e.g. `ai_search(db, query) -> (expanded_terms, results, error_message)`:
- **本轮 / now:** 内部=查询词扩展 + 本地 `LIKE` - **本轮 / now:** 内部=查询词扩展 + 本地 `LIKE`
- **未来 / later:** 换成向量嵌入 + 相似度检索,**路由与模板不变**。 - **未来 / later:** 换成向量嵌入 + 相似度检索,**路由与模板不变**。
Swap to embeddings + similarity later **without changing the route or template**. 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 关闭/未配置 → 不显示按钮(或提示去 `/settings`);调用失败 → 友好提示并回退到普通结果。
AI off/unconfigured → no button (or a hint to `/settings`); on failure → a friendly message, fall back to normal results. 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 ## 6. 数据模型与路由变更 / Data Model & Route Changes
+1 -1
View File
@@ -55,7 +55,7 @@ A settings page to enter & test the LLM config, persisted to `app_settings`, plu
- [ ] 新增 `app/llm.py`(基于 `httpx`): - [ ] 新增 `app/llm.py`(基于 `httpx`):
- [ ] `is_configured(cfg) -> bool` - [ ] `is_configured(cfg) -> bool`
- [ ] `test_connection(cfg) -> Result`(发最小请求验证 `base_url`/`model`/`api_key`)。 - [ ] `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. - [ ] **(预留,不实现)** `analyze_image(...)`:仅留 TODO/签名占位 + 注释指向"未来图片分析轮次"。Reserved, not implemented.
- [ ] 把所有网络调用收敛到**单一函数边界**,便于测试整体 mock。 - [ ] 把所有网络调用收敛到**单一函数边界**,便于测试整体 mock。
+15 -12
View File
@@ -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` - 现有搜索:`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`. 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`(可选「额外领域提示」)并在设置页加一个多行输入——这是本步**唯一**触及设置页之处。 - 本步**新增**配置项 `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). 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` 本轮不存在,属未来图片分析轮次)。 - 本轮检索范围=`name` + `note``image_description` 本轮不存在,属未来图片分析轮次)。
@@ -27,12 +27,12 @@ A **persistent** "AI search" action on the search page that broadens results via
- **常驻、不依赖零结果。** 普通 `LIKE` 照常先出结果;AI 动作始终可用(开启且已配置时)。 - **常驻、不依赖零结果。** 普通 `LIKE` 照常先出结果;AI 动作始终可用(开启且已配置时)。
Persistent and not gated on zero results. Persistent and not gated on zero results.
- **流程:** 触发 AI → `expand_query` 得到"原词 + 一批近义/相关词" → 用这组词`name`/`note` 做 OR `LIKE` 重搜 → 展示,并用横幅标注「AI 帮你扩展了:…」。**只把查询词发出去**,不外泄物品清单。 - **流程:** 触发 AI → `expand_query` 返回 `ExpansionResult`(扩展词 `terms` 不含原词,调用失败写入 `error`)→ `ai_search` 合并「原词 + 扩展词」并`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. 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)`),本轮内部=查询词扩展 + 本地 `LIKE`;**未来换成向量嵌入 + 相似度时,路由与模板不变**。 - **可替换的检索 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. 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 也改不坏解析。 - **提示词(决策 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 (JSON array → tolerant parse → `[]`) enforced in code. 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`);调用失败 → 友好提示 + 回退普通结果。 - **优雅降级。** 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): - 基础系统提示词写死在 `app/llm.py`(搬家/家居场景、列相关命名词、跟随查询语言、≤ ~8 个、不解释、不造无关词)。默认提示词起点(**可迭代** / a starting point, tune during testing):
> 你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。给定一个搜索词,列出用户可能用来命名同一类物品的相关词:近义词、常见别称、上位类别、具体品类。规则:用与查询相同的语言;只给与该物品紧密相关、有助于在清单里找到它的词;不要解释、不要造无关词;最多 8 个;只输出一个 JSON 字符串数组,例如 `["炒锅","平底锅","汤锅","厨具"]`。 > 你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。给定一个搜索词,列出用户可能用来命名同一类物品的相关词:近义词、常见别称、上位类别、具体品类。规则:用与查询相同的语言;只给与该物品紧密相关、有助于在清单里找到它的词;不要解释、不要造无关词;最多 8 个;只输出一个 JSON 字符串数组,例如 `["炒锅","平底锅","汤锅","厨具"]`。
- 读取 `ai_search_extra_hints`,非空则**追加**到基础提示词之后(只补充,不改格式)。 - 读取 `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 风格)。 - [ ] **新增配置项 `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)` - [ ] 实现检索 seam:在 `app/main.py`(或抽一个小搜索模块 `app/search.py`)加 `ai_search(db, query) -> (expanded_terms, results, error_message)`
-`expand_query(cfg, query)` 得到扩展词 -`expand_query(cfg, query)` 得到 `ExpansionResult`
-`result.error` 非空:回退普通搜索,并把友好错误传给模板;
-`result.terms` 为空且无错误:视为合法空扩展,回退普通搜索,不显示故障提示;
- 用「原词 + 扩展词」对 `name`/`note` 做 OR `LIKE`**复用现有 `_build_search_results` 的匹配逻辑**,避免重复实现),去重。 - 用「原词 + 扩展词」对 `name`/`note` 做 OR `LIKE`**复用现有 `_build_search_results` 的匹配逻辑**,避免重复实现),去重。
- 注意:现有 `_build_search_results(db, query)` 只接收单个查询词;建议把它泛化为接收一组关键词(对多个词做 OR),让 AI 搜索与普通搜索共用同一套匹配逻辑,避免分叉。 - 注意:现有 `_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. 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` 搜不到、扩展后能搜到。 - [ ] 扩展词驱动命中:原词 `LIKE` 搜不到、扩展后能搜到。
- [ ] 已有结果时点 AI 仍可用,且结果集被扩大(含原结果)。 - [ ] 已有结果时点 AI 仍可用,且结果集被扩大(含原结果)。
- [ ] 按钮可见性随 `ai_search_enabled` + `is_configured()` 门控。 - [ ] 按钮可见性随 `ai_search_enabled` + `is_configured()` 门控。
- [ ] 调用失败 → 回退普通结果、页面不报错。 - [ ] 调用失败(超时/网络/HTTP→ 回退普通结果、显示友好提示、页面不报错。
- [ ] `expand_query` 输出解析:模型回合法 JSON 数组 → 正确解析;回散文/坏 JSON/超时 → 返回 `[]`不抛错。 - [ ] `expand_query` 输出解析:模型回合法 JSON 数组 → 正确解析;回散文/坏 JSON/非字符串数组 → `terms=[]``error=None`;超时/网络/HTTP 失败 → `terms=[]``error` 非空;均不抛错。
Output parsing: valid JSON array → parsed; prose/bad JSON/timeout → `[]`, no raise. 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` 非空时确被追加进请求(可对构造的请求体断言)。 - [ ] `ai_search_extra_hints` 非空时确被追加进请求(可对构造的请求体断言)。
Extra hints, when set, are appended to the request. Extra hints, when set, are appended to the request.
+707
View File
@@ -0,0 +1,707 @@
"""Tests for AI search (Step 3).
All LLM calls are mocked — CI never touches the network.
Coverage areas:
- expand_query JSON output parsing (valid, fenced, prose, bad JSON, timeout)
- Output contract enforcement (strict JSON array only)
- Expansion term count cap and length cap
- ai_search seam function
- GET /search with ai=1 trigger
- AI button visibility on search page
- Graceful degradation on failure
- ai_search_extra_hints appended to prompt
- ai_search_enabled toggle
"""
from unittest.mock import patch
import httpx
import pytest
from app.llm import (
_MAX_EXPANSION_TERMS,
_MAX_TERM_LENGTH,
ExpansionResult,
LLMResult,
_parse_json_string_array,
expand_query,
is_configured,
)
from app.main import _ai_search, _build_search_results
from app.models import AppSetting, Box, Item, SubItem
from app.settings_store import LLMConfig, get_app_settings, save_app_settings
# ---------------------------------------------------------------------------
# Helper: configure AI search for route tests
# ---------------------------------------------------------------------------
_AI_CFG = LLMConfig(
enabled=True,
base_url="https://api.example.com/v1",
model="gpt-4o-mini",
api_key="sk-test-key",
ai_search_enabled=True,
)
def _enable_ai_search(client, db_session):
"""Persist a fully-configured AI search setup via the settings route."""
client.post(
"/settings",
data={
"enabled": "on",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o-mini",
"api_key": "sk-test-key",
"ai_search_enabled": "on",
},
follow_redirects=False,
)
# ---------------------------------------------------------------------------
# _parse_json_string_array: strict JSON contract enforcement
# ---------------------------------------------------------------------------
class TestParseJsonStringArray:
def test_valid_json_array(self):
result = _parse_json_string_array('["炒锅","平底锅","汤锅"]')
assert result == ["炒锅", "平底锅", "汤锅"]
def test_json_array_with_code_fence(self):
result = _parse_json_string_array('```json\n["","铲子"]\n```')
assert result == ["", "铲子"]
def test_json_array_with_code_fence_no_lang(self):
result = _parse_json_string_array('```\n["","铲子"]\n```')
assert result == ["", "铲子"]
def test_empty_string_returns_empty(self):
assert _parse_json_string_array("") == []
assert _parse_json_string_array(" ") == []
def test_prose_returns_empty(self):
"""Prose text does NOT become expansion terms — strict contract."""
assert _parse_json_string_array("I cannot help with that.") == []
def test_prose_newlines_returns_empty(self):
"""Line-separated prose does NOT become expansion terms."""
assert _parse_json_string_array("炒锅\n平底锅\n汤锅") == []
def test_prose_commas_returns_empty(self):
"""Comma-separated prose does NOT become expansion terms."""
assert _parse_json_string_array("炒锅, 平底锅, 汤锅") == []
def test_bad_json_returns_empty(self):
"""Invalid JSON returns empty — no fallback."""
assert _parse_json_string_array("{invalid json") == []
def test_json_object_returns_empty(self):
"""JSON object (non-array) returns empty."""
assert _parse_json_string_array('{"terms":["","厨具"]}') == []
def test_json_array_with_numbers_returns_empty(self):
"""Non-string items in array cause rejection — strict contract."""
assert _parse_json_string_array('[1, 2, 3]') == []
def test_json_array_with_mixed_types_returns_empty(self):
"""Mixed string/number array is rejected."""
assert _parse_json_string_array('["", 1]') == []
def test_empty_json_array(self):
result = _parse_json_string_array('[]')
assert result == []
def test_capped_at_max_terms(self):
"""More than _MAX_EXPANSION_TERMS items are truncated."""
terms = [f"{i}" for i in range(20)]
json_str = "[" + ",".join(f'"{t}"' for t in terms) + "]"
result = _parse_json_string_array(json_str)
assert len(result) == _MAX_EXPANSION_TERMS
def test_long_terms_filtered_out(self):
"""Terms exceeding _MAX_TERM_LENGTH are silently dropped."""
short = ""
long_term = "A" * (_MAX_TERM_LENGTH + 1)
json_str = f'["{short}", "{long_term}"]'
result = _parse_json_string_array(json_str)
assert result == [""]
def test_whitespace_stripped(self):
result = _parse_json_string_array('["", " 平底锅 "]')
assert result == ["", "平底锅"]
def test_empty_strings_filtered(self):
result = _parse_json_string_array('["", "", " ", "平底锅"]')
assert result == ["", "平底锅"]
# ---------------------------------------------------------------------------
# expand_query: prompt, hints, graceful degradation
# ---------------------------------------------------------------------------
class TestExpandQueryNew:
def test_returns_empty_when_not_configured(self):
cfg = LLMConfig(enabled=False)
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is None
@patch("app.llm._call_chat_completion")
def test_parses_valid_json_response(self, mock_call):
mock_call.return_value = {
"choices": [{"message": {"content": '["炒锅","平底锅","汤锅","厨具"]'}}]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert "炒锅" in result.terms
assert "平底锅" in result.terms
assert "厨具" in result.terms
assert result.error is None
@patch("app.llm._call_chat_completion")
def test_handles_json_with_code_fence(self, mock_call):
mock_call.return_value = {
"choices": [
{"message": {"content": '```json\n["炒锅","平底锅"]\n```'}}
]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert "炒锅" in result.terms
assert result.error is None
@patch("app.llm._call_chat_completion")
def test_prose_response_returns_empty_no_error(self, mock_call):
"""Prose from model → empty terms, no error (successful call, unparseable output)."""
mock_call.return_value = {
"choices": [{"message": {"content": "I cannot help with that."}}]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is None
@patch("app.llm._call_chat_completion")
def test_json_object_response_returns_empty_no_error(self, mock_call):
"""JSON object (non-array) → empty terms, no error."""
mock_call.return_value = {
"choices": [{"message": {"content": '{"terms":["","厨具"]}'}}]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is None
@patch("app.llm._call_chat_completion")
def test_timeout_returns_error(self, mock_call):
"""Timeout → empty terms + error message."""
mock_call.side_effect = httpx.TimeoutException("timeout")
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is not None
assert "超时" in result.error
@patch("app.llm._call_chat_completion")
def test_network_error_returns_error(self, mock_call):
"""Network error → empty terms + error message."""
mock_call.side_effect = httpx.ConnectError("refused")
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is not None
assert "无法连接" in result.error
@patch("app.llm._call_chat_completion")
def test_http_error_returns_error(self, mock_call):
"""HTTP error → empty terms + error message."""
mock_call.side_effect = httpx.HTTPStatusError(
"401",
request=httpx.Request("POST", "http://x"),
response=httpx.Response(401),
)
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is not None
assert "错误" in result.error
@patch("app.llm._call_chat_completion")
def test_returns_empty_on_empty_choices(self, mock_call):
mock_call.return_value = {"choices": []}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "")
assert result.terms == []
assert result.error is None
@patch("app.llm._call_chat_completion")
def test_extra_hints_appended_to_system_prompt(self, mock_call):
"""When extra_hints is non-empty, it should be appended to the system prompt."""
mock_call.return_value = {
"choices": [{"message": {"content": '["扩展词"]'}}]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
expand_query(cfg, "", extra_hints="用户物品主要涉及厨房用品")
# Verify the system prompt includes the extra hints
call_args = mock_call.call_args
messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][1]
system_content = messages[0]["content"]
assert "用户物品主要涉及厨房用品" in system_content
@patch("app.llm._call_chat_completion")
def test_extra_hints_ignored_when_empty(self, mock_call):
"""When extra_hints is empty, system prompt should not change."""
mock_call.return_value = {
"choices": [{"message": {"content": '["扩展词"]'}}]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
expand_query(cfg, "", extra_hints="")
call_args = mock_call.call_args
messages = call_args[1]["messages"] if "messages" in call_args[1] else call_args[0][1]
system_content = messages[0]["content"]
# Should be the base prompt only
assert "搬家物品搜索助手" in system_content
assert "JSON 字符串数组" in system_content
@patch("app.llm._call_chat_completion")
def test_temperature_zero_passed(self, mock_call):
"""expand_query should pass temperature=0 for deterministic output."""
mock_call.return_value = {
"choices": [{"message": {"content": '["扩展词"]'}}]
}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
expand_query(cfg, "")
call_args = mock_call.call_args
assert call_args[1]["temperature"] == 0
# ---------------------------------------------------------------------------
# _ai_search: seam function
# ---------------------------------------------------------------------------
class TestAiSearchSeam:
@patch("app.main.expand_query")
def test_returns_expanded_terms_and_results(self, mock_expand, client, db_session):
"""AI search returns expanded terms and broader results."""
box = Box(name="厨房箱", note="装了炒锅和铲子")
db_session.add(box)
db_session.commit()
mock_expand.return_value = ExpansionResult(terms=["炒锅", "平底锅", "汤锅"])
cfg = get_app_settings(db_session)
expanded, results, error = _ai_search(db_session, cfg, "平底锅")
assert "炒锅" in expanded
assert error is None
assert len(results) >= 1
assert any("厨房箱" in r["name"] or "炒锅" in (r.get("note") or "") for r in results)
@patch("app.main.expand_query")
def test_includes_original_query_in_search(self, mock_expand, client, db_session):
"""AI search includes the original query term in the search."""
box = Box(name="冬季衣物箱")
db_session.add(box)
db_session.commit()
mock_expand.return_value = ExpansionResult(terms=["羽绒服"])
cfg = get_app_settings(db_session)
expanded, results, error = _ai_search(db_session, cfg, "衣物")
assert error is None
assert any("冬季衣物箱" in r["name"] for r in results)
@patch("app.main.expand_query")
def test_empty_expansion_returns_normal_results_no_error(self, mock_expand, client, db_session):
"""Legitimate empty expansion (no synonyms found) → normal results, no error."""
box = Box(name="书房箱")
db_session.add(box)
db_session.commit()
mock_expand.return_value = ExpansionResult(terms=[])
cfg = get_app_settings(db_session)
expanded, results, error = _ai_search(db_session, cfg, "书房")
assert expanded == []
assert error is None
assert any("书房箱" in r["name"] for r in results)
@patch("app.main.expand_query")
def test_llm_failure_returns_normal_results_with_error(self, mock_expand, client, db_session):
"""When expand_query signals failure, seam returns normal results + error message."""
box = Box(name="厨房箱", note="装了炒锅")
db_session.add(box)
db_session.commit()
mock_expand.return_value = ExpansionResult(terms=[], error="AI 搜索请求超时,请稍后再试。")
cfg = get_app_settings(db_session)
expanded, results, error = _ai_search(db_session, cfg, "厨房")
assert expanded == []
assert error is not None
assert "超时" in error
assert len(results) >= 1
# ---------------------------------------------------------------------------
# _build_search_results: multi-keyword support
# ---------------------------------------------------------------------------
class TestBuildSearchResultsMultiKeyword:
def test_single_keyword_works_as_before(self, db_session):
box = Box(name="厨房箱")
db_session.add(box)
db_session.commit()
results = _build_search_results(db_session, "厨房")
assert len(results) == 1
assert results[0]["name"] == "厨房箱"
def test_multiple_keywords_match_any(self, db_session):
box1 = Box(name="厨房箱")
box2 = Box(name="卧室箱")
db_session.add_all([box1, box2])
db_session.commit()
results = _build_search_results(db_session, ["厨房", "卧室"])
assert len(results) == 2
def test_multiple_keywords_dedupes_results(self, db_session):
"""A box matching multiple keywords appears only once."""
box = Box(name="厨房箱", note="装了厨房用品")
db_session.add(box)
db_session.commit()
results = _build_search_results(db_session, ["厨房", "用品"])
assert len(results) == 1
def test_empty_keywords_returns_empty(self, db_session):
results = _build_search_results(db_session, [])
assert results == []
# ---------------------------------------------------------------------------
# Routes: GET /search with ai=1
# ---------------------------------------------------------------------------
class TestSearchRouteAI:
@patch("app.llm._call_chat_completion")
def test_ai_search_finds_more_results(self, mock_call, client, db_session):
"""Original query misses, but expanded term finds items."""
box = Box(name="杂物箱")
item = Item(name="炒锅", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
mock_call.return_value = {
"choices": [{"message": {"content": '["炒锅","平底锅","汤锅"]'}}]
}
_enable_ai_search(client, db_session)
# Normal search for "平底锅" — no results
response = client.get("/search?q=平底锅")
assert "没有找到匹配结果" in response.text
# AI search for "平底锅" — finds "炒锅" via expansion
response = client.get("/search?q=平底锅&ai=1")
assert response.status_code == 200
assert "炒锅" in response.text
assert "AI 帮你扩展了" in response.text
@patch("app.llm._call_chat_completion")
def test_ai_search_includes_original_results(self, mock_call, client, db_session):
"""AI search should also include results from original query."""
box = Box(name="厨房箱")
item1 = Item(name="锅铲", box=box, is_container=False)
item2 = Item(name="平底锅", box=box, is_container=False)
db_session.add_all([box, item1, item2])
db_session.commit()
mock_call.return_value = {
"choices": [{"message": {"content": '["炒锅","汤锅"]'}}]
}
_enable_ai_search(client, db_session)
response = client.get("/search?q=锅&ai=1")
assert response.status_code == 200
# Original result "平底锅" should still be there
assert "平底锅" in response.text
@patch("app.llm._call_chat_completion")
def test_ai_search_shows_expansion_banner(self, mock_call, client, db_session):
"""When AI search is activated, a banner shows expanded terms."""
box = Box(name="厨房箱")
db_session.add(box)
db_session.commit()
mock_call.return_value = {
"choices": [{"message": {"content": '["炒锅","平底锅"]'}}]
}
_enable_ai_search(client, db_session)
response = client.get("/search?q=锅&ai=1")
assert response.status_code == 200
assert "AI 帮你扩展了" in response.text
assert "炒锅" in response.text
def test_ai_search_without_flag_does_normal_search(self, client, db_session):
"""Without ai=1, search behaves normally even when AI is configured."""
box = Box(name="厨房箱")
db_session.add(box)
db_session.commit()
_enable_ai_search(client, db_session)
response = client.get("/search?q=厨房")
assert response.status_code == 200
assert "厨房箱" in response.text
assert "AI 帮你扩展了" not in response.text
@patch("app.llm._call_chat_completion")
def test_ai_search_without_configuration_ignores_flag(self, mock_call, client, db_session):
"""ai=1 is ignored when AI is not configured."""
box = Box(name="厨房箱")
db_session.add(box)
db_session.commit()
response = client.get("/search?q=厨房&ai=1")
assert response.status_code == 200
assert "厨房箱" in response.text
assert "AI 帮你扩展了" not in response.text
mock_call.assert_not_called()
@patch("app.llm._call_chat_completion")
def test_ai_search_graceful_degradation_on_llm_failure(self, mock_call, client, db_session):
"""LLM failure (timeout) → normal results + friendly error banner."""
box = Box(name="厨房箱", note="装了炒锅")
db_session.add(box)
db_session.commit()
# expand_query catches timeout and returns ExpansionResult with error
mock_call.side_effect = httpx.TimeoutException("timeout")
_enable_ai_search(client, db_session)
response = client.get("/search?q=厨房&ai=1")
assert response.status_code == 200
assert "厨房箱" in response.text
# Should show error banner — timeout is a real failure
assert "超时" in response.text or "不可用" in response.text
def test_ai_search_empty_query_does_nothing(self, client, db_session):
"""ai=1 with empty query does not trigger AI."""
_enable_ai_search(client, db_session)
response = client.get("/search?ai=1")
assert response.status_code == 200
assert "AI 帮你扩展了" not in response.text
@patch("app.llm._call_chat_completion")
def test_ai_search_disabled_ignores_flag(self, mock_call, client, db_session):
"""ai=1 is ignored when ai_search_enabled is False."""
box = Box(name="厨房箱")
db_session.add(box)
db_session.commit()
# Enable LLM but NOT ai_search_enabled
client.post(
"/settings",
data={
"enabled": "on",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o-mini",
"api_key": "sk-test-key",
},
follow_redirects=False,
)
response = client.get("/search?q=厨房&ai=1")
assert response.status_code == 200
assert "厨房箱" in response.text
assert "AI 帮你扩展了" not in response.text
mock_call.assert_not_called()
# ---------------------------------------------------------------------------
# Button visibility on search page
# ---------------------------------------------------------------------------
class TestAIButtonVisibility:
@patch("app.llm._call_chat_completion")
def test_button_visible_when_configured_and_enabled(self, mock_call, client, db_session):
"""AI search button is visible when ai_search_enabled and configured."""
_enable_ai_search(client, db_session)
response = client.get("/search?q=测试")
assert response.status_code == 200
assert "AI 智能搜索" in response.text
def test_button_hidden_when_not_configured(self, client, db_session):
"""AI search button is hidden when LLM is not configured."""
response = client.get("/search?q=测试")
assert response.status_code == 200
assert "AI 智能搜索" not in response.text
def test_button_hidden_when_ai_search_disabled(self, client, db_session):
"""AI search button is hidden when ai_search_enabled is False."""
client.post(
"/settings",
data={
"enabled": "on",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o-mini",
"api_key": "sk-test-key",
},
follow_redirects=False,
)
response = client.get("/search?q=测试")
assert "AI 智能搜索" not in response.text
@patch("app.llm._call_chat_completion")
def test_button_hidden_on_empty_query(self, mock_call, client, db_session):
"""AI search button is not shown when there's no query."""
_enable_ai_search(client, db_session)
response = client.get("/search")
assert "AI 智能搜索" not in response.text
@patch("app.llm._call_chat_completion")
def test_button_link_includes_current_query(self, mock_call, client, db_session):
"""AI button link includes the current query parameter."""
_enable_ai_search(client, db_session)
response = client.get("/search?q=锅")
assert response.status_code == 200
assert "ai=1" in response.text
from urllib.parse import quote
assert f"q={quote('')}" in response.text or "q=锅" in response.text
@patch("app.llm._call_chat_completion")
def test_no_button_when_ai_already_activated(self, mock_call, client, db_session):
"""When AI is already activated, show status text instead of button."""
mock_call.return_value = {
"choices": [{"message": {"content": '["炒锅"]'}}]
}
_enable_ai_search(client, db_session)
response = client.get("/search?q=锅&ai=1")
assert response.status_code == 200
assert "AI 搜索已启用" in response.text
# ---------------------------------------------------------------------------
# Settings: ai_search_extra_hints
# ---------------------------------------------------------------------------
class TestExtraHintsSettings:
def test_extra_hints_defaults_to_empty(self, db_session):
cfg = get_app_settings(db_session)
assert cfg.ai_search_extra_hints == ""
def test_save_extra_hints(self, db_session):
save_app_settings(db_session, ai_search_extra_hints="用户物品主要涉及厨房")
cfg = get_app_settings(db_session)
assert cfg.ai_search_extra_hints == "用户物品主要涉及厨房"
def test_save_extra_hints_empty_string(self, db_session):
save_app_settings(db_session, ai_search_extra_hints="厨房用品")
save_app_settings(db_session, ai_search_extra_hints="")
cfg = get_app_settings(db_session)
assert cfg.ai_search_extra_hints == ""
def test_settings_page_has_extra_hints_textarea(self, client):
response = client.get("/settings")
assert response.status_code == 200
assert 'name="ai_search_extra_hints"' in response.text
assert "额外领域提示" in response.text
def test_settings_page_has_ai_search_checkbox(self, client):
response = client.get("/settings")
assert response.status_code == 200
assert 'name="ai_search_enabled"' in response.text
assert "启用 AI 智能搜索" in response.text
def test_save_ai_search_settings_via_route(self, client, db_session):
client.post(
"/settings",
data={
"enabled": "on",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o-mini",
"api_key": "sk-key",
"ai_search_enabled": "on",
"ai_search_extra_hints": "用户物品主要涉及厨房用品",
},
follow_redirects=False,
)
cfg = get_app_settings(db_session)
assert cfg.ai_search_enabled is True
assert cfg.ai_search_extra_hints == "用户物品主要涉及厨房用品"
def test_save_preserves_extra_hints_on_other_changes(self, client, db_session):
"""Changing LLM settings should not clear extra hints."""
client.post(
"/settings",
data={
"enabled": "on",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o-mini",
"api_key": "sk-key",
"ai_search_enabled": "on",
"ai_search_extra_hints": "厨房用品和电子产品",
},
follow_redirects=False,
)
client.post(
"/settings",
data={
"enabled": "on",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o",
"api_key": "",
"ai_search_enabled": "on",
"ai_search_extra_hints": "厨房用品和电子产品",
},
follow_redirects=False,
)
cfg = get_app_settings(db_session)
assert cfg.ai_search_extra_hints == "厨房用品和电子产品"
assert cfg.model == "gpt-4o"
# ---------------------------------------------------------------------------
# Regression: existing features still work without AI
# ---------------------------------------------------------------------------
class TestRegressionWithoutAI:
def test_normal_search_still_works(self, client, db_session):
box = Box(name="测试箱")
db_session.add(box)
db_session.commit()
response = client.get("/search?q=测试")
assert response.status_code == 200
assert "测试箱" in response.text
def test_search_page_no_results(self, client):
response = client.get("/search?q=不存在")
assert "没有找到匹配结果" in response.text
def test_search_empty_query(self, client):
response = client.get("/search")
assert "输入关键词后" in response.text
+32 -10
View File
@@ -9,6 +9,7 @@ import pytest
import app.llm as llm_module import app.llm as llm_module
from app.llm import LLMResult, expand_query, is_configured from app.llm import LLMResult, expand_query, is_configured
from app.llm import ExpansionResult
from app.models import AppSetting from app.models import AppSetting
from app.settings_store import LLMConfig, get_app_settings, save_app_settings from app.settings_store import LLMConfig, get_app_settings, save_app_settings
@@ -29,6 +30,7 @@ class TestLLMConfigDefaults:
assert cfg.model == "" assert cfg.model == ""
assert cfg.api_key == "" assert cfg.api_key == ""
assert cfg.ai_search_enabled is False assert cfg.ai_search_enabled is False
assert cfg.ai_search_extra_hints == ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -208,37 +210,40 @@ class TestTestConnection:
class TestExpandQuery: class TestExpandQuery:
def test_returns_original_when_not_configured(self): def test_returns_empty_when_not_configured(self):
cfg = LLMConfig(enabled=False) cfg = LLMConfig(enabled=False)
result = expand_query(cfg, "") result = expand_query(cfg, "")
assert result == [""] assert result.terms == []
assert result.error is None
@patch("app.llm._call_chat_completion") @patch("app.llm._call_chat_completion")
def test_expands_query_successfully(self, mock_call): def test_expands_query_successfully(self, mock_call):
mock_call.return_value = { mock_call.return_value = {
"choices": [ "choices": [
{"message": {"content": "平底锅\n炒锅\n锅具\n厨房锅"}} {"message": {"content": '["平底锅","炒锅","锅具","厨房锅"]'}}
] ]
} }
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "") result = expand_query(cfg, "")
assert "" in result assert "平底" in result.terms
assert "平底" in result assert "" in result.terms
assert len(result) >= 4 assert result.error is None
@patch("app.llm._call_chat_completion") @patch("app.llm._call_chat_completion")
def test_fallback_on_api_failure(self, mock_call): def test_fallback_on_api_failure(self, mock_call):
mock_call.side_effect = Exception("network down") mock_call.side_effect = Exception("network down")
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "") result = expand_query(cfg, "")
assert result == [""] assert result.terms == []
assert result.error is not None
@patch("app.llm._call_chat_completion") @patch("app.llm._call_chat_completion")
def test_fallback_on_empty_response(self, mock_call): def test_fallback_on_empty_response(self, mock_call):
mock_call.return_value = {"choices": [{"message": {"content": ""}}]} mock_call.return_value = {"choices": [{"message": {"content": ""}}]}
cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key")
result = expand_query(cfg, "") 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.model == "gpt-4o-mini"
assert cfg.api_key == "sk-original" assert cfg.api_key == "sk-original"
def test_save_does_not_touch_ai_search_enabled(self, client, db_session): def test_save_includes_ai_search_enabled_checkbox(self, client, db_session):
"""P2 fix: saving LLM settings must not reset ai_search_enabled.""" """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.add(AppSetting(key="ai_search_enabled", value="true"))
db_session.commit() db_session.commit()
# Save without the checkbox → ai_search_enabled is set to False
client.post( client.post(
"/settings", "/settings",
data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-key"}, data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-key"},
follow_redirects=False, follow_redirects=False,
) )
cfg = get_app_settings(db_session) 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 assert cfg.ai_search_enabled is True