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
+134 -68
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.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)