From d36b94098168bcfaff5ddaaec30d462a926fe1c4 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 1 Jun 2026 20:06:22 +0200 Subject: [PATCH] Add LLM settings integration Add app_settings migration, settings UI, and OpenAI-compatible httpx LLM client with mocked tests. Preserve API keys on blank form submissions, require a fresh key when base_url changes, and keep AI search settings untouched for step 3. Update docs/design LLM integration and step 3 AI search notes, including prompt contract and extra-hints planning. --- app/llm.py | 175 +++++ app/main.py | 198 ++++++ app/models.py | 7 + app/settings_store.py | 88 +++ app/templates/base.html | 1 + app/templates/settings/form.html | 55 ++ docs/design/llm-integration-design.md | 23 +- docs/design/step-3-ai-search.md | 18 +- .../versions/a1b2c3d4e5f6_v2_app_settings.py | 32 + pytest.ini | 2 + tests/test_migrate.py | 36 +- tests/test_settings.py | 634 ++++++++++++++++++ 12 files changed, 1254 insertions(+), 15 deletions(-) create mode 100644 app/llm.py create mode 100644 app/settings_store.py create mode 100644 app/templates/settings/form.html create mode 100644 migrations/versions/a1b2c3d4e5f6_v2_app_settings.py create mode 100644 tests/test_settings.py diff --git a/app/llm.py b/app/llm.py new file mode 100644 index 0000000..c18a6db --- /dev/null +++ b/app/llm.py @@ -0,0 +1,175 @@ +"""LLM client module — all network egress is concentrated here. + +Uses ``httpx`` (already in requirements) to call OpenAI-compatible endpoints. +No ``openai`` SDK dependency. Sync functions are fine: FastAPI runs sync +handlers in a threadpool. + +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). +- ``analyze_image(...)`` — **reserved stub, not implemented**. + +All calls go through ``_call_chat_completion()`` so tests can mock a single +boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from app.settings_store import LLMConfig + +# Sensible defaults +_TIMEOUT_SECONDS = 30 + + +@dataclass +class LLMResult: + """Uniform result wrapper for LLM calls.""" + + success: bool + message: str + data: Any = 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) + + +def test_connection(cfg: LLMConfig) -> LLMResult: + """Send a minimal chat-completion request to verify the config. + + Uses a tiny prompt to minimise cost. Returns an ``LLMResult`` indicating + success or failure with a human-readable message. + """ + if not is_configured(cfg): + return LLMResult( + success=False, + message="LLM 未配置或未启用(缺少 model 或 api_key)。", + ) + + try: + response = _call_chat_completion( + cfg, + messages=[{"role": "user", "content": "Hi"}], + max_tokens=1, + ) + return LLMResult( + success=True, + message=f"连接成功(模型:{cfg.model})。", + data=response, + ) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + return LLMResult( + success=False, + message=f"连接失败(HTTP {status})。请检查 base_url、model 和 api_key。", + ) + except httpx.ConnectError: + return LLMResult( + success=False, + message="无法连接到服务器。请检查 base_url 是否正确。", + ) + except httpx.TimeoutException: + return LLMResult( + success=False, + message="连接超时。请检查网络或 base_url 是否可达。", + ) + except Exception as exc: # noqa: BLE001 — graceful degradation + return LLMResult( + success=False, + message=f"未知错误:{exc}", + ) + + +def expand_query(cfg: LLMConfig, query: str) -> list[str]: + """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). + """ + if not is_configured(cfg): + return [query] + + try: + response = _call_chat_completion( + cfg, + messages=[ + { + "role": "system", + "content": ( + "你是一个搜索词扩展助手。用户给你一个搜索词," + "你返回 3-5 个同义词或相关词,每行一个。" + "不要编号、不要解释、不要标点。" + ), + }, + {"role": "user", "content": query}, + ], + max_tokens=100, + ) + 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] + + +def analyze_image(cfg: LLMConfig, image_data: bytes, prompt: str) -> LLMResult: + """Analyze an image via LLM vision API. + + .. note:: **Reserved stub — not implemented.** Will be filled in a future + round for image analysis. The signature is fixed so callers can + depend on it. + """ + # TODO: Implement in future round for image analysis. + return LLMResult( + success=False, + message="图片分析功能尚未实现。", + ) + + +# ------------------------------------------------------------------ +# Internal boundary — all network calls go through this single function +# ------------------------------------------------------------------ + + +def _call_chat_completion( + cfg: LLMConfig, + *, + messages: list[dict[str, str]], + max_tokens: int = 1, +) -> dict: + """Call the OpenAI-compatible ``/chat/completions`` endpoint. + + Returns the parsed JSON response body on success (status 2xx). + Raises ``httpx.HTTPStatusError`` on non-2xx, or other ``httpx`` exceptions + on network failures — callers handle these for graceful degradation. + """ + url = cfg.base_url.rstrip("/") + "/chat/completions" + payload: dict[str, Any] = { + "model": cfg.model, + "messages": messages, + "max_tokens": max_tokens, + } + headers = { + "Authorization": f"Bearer {cfg.api_key}", + "Content-Type": "application/json", + } + + with httpx.Client(timeout=_TIMEOUT_SECONDS) as client: + response = client.post(url, json=payload, headers=headers) + response.raise_for_status() + return response.json() diff --git a/app/main.py b/app/main.py index 0226481..ff40328 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,10 @@ 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 LLMResult from app.models import Box, Item, SubItem +from app.settings_store import LLMConfig, get_app_settings, save_app_settings templates = Jinja2Templates(directory="app/templates") STATIC_DIR = Path("app/static") @@ -88,6 +91,46 @@ def _wants_add_next(submit_action: str | None) -> bool: return submit_action == "save_and_add_next" +def _validate_settings_origin(request: Request) -> str | None: + """Check Origin/Referer for same-host browser requests. + + Returns an error message if validation fails, or None if OK. + Missing both headers (e.g. curl, API call) is allowed for now. + """ + origin = request.headers.get("origin") + referer = request.headers.get("referer") + + if origin: + host = request.headers.get("host", "") + # origin includes scheme, host only has host:port + from urllib.parse import urlparse + + parsed = urlparse(origin) + origin_host = parsed.netloc + if origin_host != host: + return "请求来源与当前站点不一致,操作被拒绝。" + elif referer: + host = request.headers.get("host", "") + from urllib.parse import urlparse + + parsed = urlparse(referer) + referer_host = parsed.netloc + if referer_host != host: + return "请求来源与当前站点不一致,操作被拒绝。" + + return None + + +def _validate_base_url_scheme(base_url: str) -> str | None: + """Return an error message if base_url scheme is not allowed, else None.""" + from urllib.parse import urlparse + + parsed = urlparse(base_url) + if parsed.scheme not in ("https", "http"): + return "Base URL 必须以 http:// 或 https:// 开头。" + return None + + def _format_average(total: int, divisor: int) -> str: if divisor == 0: return "0.0" @@ -267,6 +310,161 @@ def create_app() -> FastAPI: context={"page_title": "箱子", "boxes": boxes, "summary": summary}, ) + # ------------------------------------------------------------------ + # Settings + # ------------------------------------------------------------------ + + @app.get("/settings") + def settings_page(request: Request, db: Session = Depends(get_db)): + cfg = get_app_settings(db) + return templates.TemplateResponse( + request=request, + name="settings/form.html", + context={ + "page_title": "设置", + "config": cfg, + "api_key_configured": bool(cfg.api_key), + "test_result": None, + }, + ) + + @app.post("/settings") + def save_settings( + request: Request, + enabled: str | None = Form(default=None), + base_url: str | None = Form(default=None), + model: str | None = Form(default=None), + api_key: str | None = Form(default=None), + db: Session = Depends(get_db), + ) -> Response: + # Origin/Referer check for browser requests + origin_error = _validate_settings_origin(request) + if origin_error: + raise HTTPException(status_code=403, detail=origin_error) + + resolved_base_url = _clean_text(base_url) or "https://api.openai.com/v1" + + # Validate base_url scheme + scheme_error = _validate_base_url_scheme(resolved_base_url) + if scheme_error: + raise HTTPException(status_code=400, detail=scheme_error) + + resolved_model = _clean_text(model) or "" + + # Only base_url change counts as an endpoint change — model switches + # under the same base_url do not require a new key. + existing_cfg = get_app_settings(db) + submitted_key = _clean_text(api_key) + base_url_changed = resolved_base_url != existing_cfg.base_url + + if base_url_changed and submitted_key is None: + # base_url changed but no new key provided — refuse to save, + # return to settings page with a clear error message. + return templates.TemplateResponse( + request=request, + name="settings/form.html", + context={ + "page_title": "设置", + "config": LLMConfig( + enabled=enabled == "on", + base_url=resolved_base_url, + model=resolved_model, + api_key=existing_cfg.api_key, + ), + "api_key_configured": bool(existing_cfg.api_key), + "test_result": LLMResult( + success=False, + message="Base URL 已变更,请重新输入 API Key 后保存。", + ), + }, + ) + + # submitted_key is None → keep old key; str (including "") → use new value + resolved_api_key = submitted_key + + save_app_settings( + db, + enabled=enabled == "on", + base_url=resolved_base_url, + model=resolved_model, + api_key=resolved_api_key, + ) + return RedirectResponse(url="/settings", status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/settings/test") + def test_settings_connection( + request: Request, + enabled: str | None = Form(default=None), + base_url: str | None = Form(default=None), + model: str | None = Form(default=None), + api_key: str | None = Form(default=None), + db: Session = Depends(get_db), + ): + # Origin/Referer check for browser requests + origin_error = _validate_settings_origin(request) + if origin_error: + raise HTTPException(status_code=403, detail=origin_error) + + resolved_base_url = _clean_text(base_url) or "https://api.openai.com/v1" + + # Validate base_url scheme + scheme_error = _validate_base_url_scheme(resolved_base_url) + if scheme_error: + raise HTTPException(status_code=400, detail=scheme_error) + + resolved_model = _clean_text(model) or "" + + # Only reuse stored key if base_url matches saved config. Model switches + # under the same base_url can use the same key; a base_url change cannot. + existing_cfg = get_app_settings(db) + submitted_key = _clean_text(api_key) + base_url_matches = resolved_base_url == existing_cfg.base_url + if base_url_matches and submitted_key is None: + resolved_api_key = existing_cfg.api_key + elif submitted_key is not None: + resolved_api_key = submitted_key + else: + # base_url changed but no key provided — refuse to test + + return templates.TemplateResponse( + request=request, + name="settings/form.html", + context={ + "page_title": "设置", + "config": LLMConfig( + enabled=enabled == "on", + base_url=resolved_base_url, + model=resolved_model, + api_key="", + ), + "api_key_configured": bool(existing_cfg.api_key), + "test_result": LLMResult( + success=False, + message="Base URL 已变更,请重新输入 API Key 后再测试。", + ), + }, + ) + + test_cfg = LLMConfig( + enabled=enabled == "on", + base_url=resolved_base_url, + model=resolved_model, + api_key=resolved_api_key or "", + ) + + result = test_connection(test_cfg) + + return templates.TemplateResponse( + request=request, + name="settings/form.html", + context={ + "page_title": "设置", + "config": test_cfg, + "api_key_configured": bool(test_cfg.api_key), + "test_result": result, + }, + ) + @app.get("/boxes/new") def new_box_page(request: Request): return templates.TemplateResponse( diff --git a/app/models.py b/app/models.py index 2e76460..d034011 100644 --- a/app/models.py +++ b/app/models.py @@ -90,3 +90,10 @@ class SubItem(Base): ) parent_item: Mapped[Item] = relationship(back_populates="subitems") + + +class AppSetting(Base): + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(Text, primary_key=True) + value: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/settings_store.py b/app/settings_store.py new file mode 100644 index 0000000..8ee84a4 --- /dev/null +++ b/app/settings_store.py @@ -0,0 +1,88 @@ +"""Settings read/write helpers for the ``app_settings`` KV table. + +Provides a typed ``LLMConfig`` dataclass and two helpers: + +- ``get_app_settings(db) -> LLMConfig`` — reads KV rows (or returns defaults). +- ``save_app_settings(db, ...) -> None`` — writes KV rows; API key left blank + means "keep the old value". +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models import AppSetting + + +@dataclass +class LLMConfig: + """All settings consumed by the LLM client and settings UI.""" + + enabled: bool = False + base_url: str = "https://api.openai.com/v1" + model: str = "" + api_key: str = "" + ai_search_enabled: bool = False + + +def _get_value(rows: dict[str, str], key: str, default: str) -> str: + return rows.get(key, default) + + +def _get_bool(rows: dict[str, str], key: str, default: bool) -> bool: + return rows.get(key, str(default).lower()) == "true" + + +def get_app_settings(db: Session) -> LLMConfig: + """Read all settings from ``app_settings`` and return an ``LLMConfig``.""" + rows: dict[str, str] = {} + for row in db.query(AppSetting).all(): + if row.value is not None: + rows[row.key] = row.value + + return LLMConfig( + enabled=_get_bool(rows, "llm_enabled", False), + base_url=_get_value(rows, "llm_base_url", "https://api.openai.com/v1"), + model=_get_value(rows, "llm_model", ""), + api_key=_get_value(rows, "llm_api_key", ""), + ai_search_enabled=_get_bool(rows, "ai_search_enabled", False), + ) + + +def save_app_settings( + db: Session, + *, + enabled: bool | None = None, + base_url: str | None = None, + model: str | None = None, + api_key: str | None = None, + ai_search_enabled: bool | None = None, +) -> None: + """Write settings to ``app_settings``. + + If ``api_key`` is ``None`` (form field left blank), the existing key is + preserved. All other fields are written as-is. + """ + updates: dict[str, str | None] = {} + + if enabled is not None: + updates["llm_enabled"] = str(enabled).lower() + if base_url is not None: + updates["llm_base_url"] = base_url + if model is not None: + updates["llm_model"] = model + if api_key is not None: + updates["llm_api_key"] = api_key + if ai_search_enabled is not None: + updates["ai_search_enabled"] = str(ai_search_enabled).lower() + + for key, value in updates.items(): + existing = db.get(AppSetting, key) + if existing is not None: + existing.value = value + else: + db.add(AppSetting(key=key, value=value)) + + db.commit() diff --git a/app/templates/base.html b/app/templates/base.html index 72325e8..4a379ba 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -19,6 +19,7 @@ {% block content %}{% endblock %} diff --git a/app/templates/settings/form.html b/app/templates/settings/form.html new file mode 100644 index 0000000..31f69da --- /dev/null +++ b/app/templates/settings/form.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block content %} + + + +{% if test_result %} +
+

+ {{ "✓ " if test_result.success else "✗ " }}{{ test_result.message }} +

+
+{% endif %} + +
+ +

开启后,AI 相关功能将使用下方配置连接 LLM 服务。

+ + + + + + + +
+ + +
+
+{% endblock %} diff --git a/docs/design/llm-integration-design.md b/docs/design/llm-integration-design.md index d220b69..4137268 100644 --- a/docs/design/llm-integration-design.md +++ b/docs/design/llm-integration-design.md @@ -199,6 +199,7 @@ More settings are coming; adding columns to an *existing* table costs a migratio | `llm_model` | 模型名 / model name | (空 / empty) | | `llm_api_key` | API Key(明文 / plaintext,见 §7) | (空 / empty) | | `ai_search_enabled` | AI 搜索功能开关 / AI-search feature toggle | `false` | +| `ai_search_extra_hints` | AI 搜索:可选「额外领域提示」,追加到默认系统提示词(step 3 引入)/ optional extra domain hints appended to the default prompt | (空 / empty) | > 读写封装 / Access helpers:`get_app_settings(db) -> LLMConfig`(dataclass 视图)与 `save_app_settings(db, ...)`,供路由与 `app/llm.py` 复用。 > Helpers `get_app_settings(db) -> LLMConfig` and `save_app_settings(db, ...)`, reused by routes and `app/llm.py`. @@ -209,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]`:把查询词扩成一批近义/相关词(本轮 AI 搜索用,见 §5)。 +- `expand_query(cfg, query) -> list[str]`:把查询词扩成一批近义/相关词(提示词与输出契约见 §5.2)。 - `analyze_image(...)`:**本轮不实现**,仅在文档中预留为未来接口(图片分析轮次)。Reserved for a future round, not implemented now. 要点 / Notes: @@ -251,7 +252,22 @@ When disabled/unconfigured: the settings page still works; the AI-search button - **只把查询词发出去 / 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. -### 5.2 实现接口 / Implementation seam +### 5.2 提示词与输出契约 / Prompt & Output Contract + +`expand_query` 的**质量**取决于提示词,**集成稳定性**取决于输出契约——两者都在代码侧掌控(决策 C)。 +Quality hinges on the prompt; integration stability hinges on the output contract — both are code-controlled (decision C). + +- **基础系统提示词写死在 `app/llm.py`(用户改不坏)/ Base system prompt hardcoded:** 框定搬家/家居场景,要求"列出用户可能用来命名同一物品的相关词(近义、别称、上位类别、具体品类)";语言跟随查询;最多约 8 个;不解释、不造无关词。 + 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. +- **客户端参数 / 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. + +### 5.3 实现接口 / Implementation seam - 路由层扩展现有 `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. @@ -263,7 +279,7 @@ When disabled/unconfigured: the settings page still works; the AI-search button - 本轮检索范围=`name` + `note`(`image_description` 本轮不存在)。 Search scope this round = `name` + `note` (no `image_description` yet). -### 5.3 降级 / Degradation +### 5.4 降级 / Degradation AI 关闭/未配置 → 不显示按钮(或提示去 `/settings`);调用失败 → 友好提示并回退到普通结果。 AI off/unconfigured → no button (or a hint to `/settings`); on failure → a friendly message, fall back to normal results. @@ -334,3 +350,4 @@ AI off/unconfigured → no button (or a hint to `/settings`); on failure → a f | D8 | AI 搜索 v1=查询词扩展 / query-term expansion | 上千件物品下可扩展、不外泄清单、token 恒定。 | | D9 | 检索做成可替换 seam / pluggable retrieval | 未来换嵌入式语义搜索时上层不动。 | | D10 | 图片分析不在本轮 / image analysis deferred | 业主本轮三件事不含它;架构预留接口。 | +| D11 | AI 搜索提示词:默认写死 + 可选「额外领域提示」;输出契约由代码强制 / hardcoded default prompt + optional extra-hints, code-enforced JSON contract | 保证解析稳定(用户改不坏),又给业主一点不改代码即可微调的空间。 | diff --git a/docs/design/step-3-ai-search.md b/docs/design/step-3-ai-search.md index 1cbb75b..f1e6a9f 100644 --- a/docs/design/step-3-ai-search.md +++ b/docs/design/step-3-ai-search.md @@ -17,7 +17,9 @@ 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)`。 +- 步骤 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`。 +- 本步**新增**配置项 `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` 本轮不存在,属未来图片分析轮次)。 Search scope = `name` + `note` (no `image_description` this round). @@ -29,12 +31,20 @@ A **persistent** "AI search" action on the search page that broadens results via 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`;**未来换成向量嵌入 + 相似度时,路由与模板不变**。 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. - **优雅降级。** AI 关闭/未配置 → 不显示按钮(或提示去 `/settings`);调用失败 → 友好提示 + 回退普通结果。 --- ## 任务 / Tasks +- [ ] **落地/校准 `expand_query` 的提示词(按设计 §5.2)**: + - 基础系统提示词写死在 `app/llm.py`(搬家/家居场景、列相关命名词、跟随查询语言、≤ ~8 个、不解释、不造无关词)。默认提示词起点(**可迭代** / a starting point, tune during testing): + > 你是搬家物品搜索助手。用户在搜索自己打包的箱子与物品(家居/搬家场景)。给定一个搜索词,列出用户可能用来命名同一类物品的相关词:近义词、常见别称、上位类别、具体品类。规则:用与查询相同的语言;只给与该物品紧密相关、有助于在清单里找到它的词;不要解释、不要造无关词;最多 8 个;只输出一个 JSON 字符串数组,例如 `["炒锅","平底锅","汤锅","厨具"]`。 + - 读取 `ai_search_extra_hints`,非空则**追加**到基础提示词之后(只补充,不改格式)。 + - **输出契约**:要求模型只回 JSON 字符串数组;解析去 ` ```json ` 围栏 → `json.loads` → 失败按行/逗号兜底 → 再不行返回 `[]`;任何异常/超时都返回 `[]`(不抛错)。 +- [ ] **新增配置项 `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)` 得到扩展词; - 用「原词 + 扩展词」对 `name`/`note` 做 OR `LIKE`(**复用现有 `_build_search_results` 的匹配逻辑**,避免重复实现),去重。 @@ -53,12 +63,16 @@ A **persistent** "AI search" action on the search page that broadens results via - [ ] 已有结果时点 AI 仍可用,且结果集被扩大(含原结果)。 - [ ] 按钮可见性随 `ai_search_enabled` + `is_configured()` 门控。 - [ ] 调用失败 → 回退普通结果、页面不报错。 + - [ ] `expand_query` 输出解析:模型回合法 JSON 数组 → 正确解析;回散文/坏 JSON/超时 → 返回 `[]`、不抛错。 + Output parsing: valid JSON array → parsed; prose/bad JSON/timeout → `[]`, no raise. + - [ ] `ai_search_extra_hints` 非空时确被追加进请求(可对构造的请求体断言)。 + Extra hints, when set, are appended to the request. --- ## 涉及文件 / Files -`app/main.py`、(可选 `app/search.py`)、`app/templates/search/index.html`、`tests/`。 +`app/llm.py`、`app/main.py`、(可选 `app/search.py`)、`app/templates/search/index.html`、`app/templates/settings/form.html`、配置读写 helper(step 2 的 settings store)、`tests/`。 --- diff --git a/migrations/versions/a1b2c3d4e5f6_v2_app_settings.py b/migrations/versions/a1b2c3d4e5f6_v2_app_settings.py new file mode 100644 index 0000000..a70c793 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_v2_app_settings.py @@ -0,0 +1,32 @@ +"""V2 app_settings + +Revision ID: a1b2c3d4e5f6 +Revises: 57af90893f55 +Create Date: 2026-06-01 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, Sequence[str], None] = '57af90893f55' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table('app_settings', + sa.Column('key', sa.Text(), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('key') + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table('app_settings') diff --git a/pytest.ini b/pytest.ini index 0e7730c..1a5b62a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,6 @@ [pytest] pythonpath = . +testpaths = tests +norecursedirs = app .venv filterwarnings = ignore:'asyncio\.iscoroutinefunction' is deprecated and slated for removal in Python 3\.16; use inspect\.iscoroutinefunction\(\) instead:DeprecationWarning:fastapi\.routing diff --git a/tests/test_migrate.py b/tests/test_migrate.py index 4a60960..acc81cd 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -28,6 +28,7 @@ from app.db import Base, SessionLocal, configure_database from app.migrate import ( V1_REVISION, _detect_db_state, + _make_alembic_config, run_migrations, verify_schema_is_current, ) @@ -35,6 +36,18 @@ from app.main import create_app from fastapi.testclient import TestClient +def _get_head_revision() -> str: + """Resolve the current Alembic head revision from migration scripts.""" + from alembic.script import ScriptDirectory + + cfg = _make_alembic_config("sqlite:///") # URL is unused for script lookup + script = ScriptDirectory.from_config(cfg) + return script.get_current_head() + + +HEAD_REVISION = _get_head_revision() + + @pytest.fixture() def tmp_db_path(tmp_path): """Provide a temporary SQLite database path.""" @@ -55,7 +68,7 @@ def tmp_db_url(tmp_db_path): class TestFreshDBMigration: """Empty database gets all tables created by migration.""" - def test_creates_all_three_tables(self, tmp_db_url): + def test_creates_all_tables(self, tmp_db_url): run_migrations(tmp_db_url) eng = create_engine(tmp_db_url) tables = set(inspect(eng).get_table_names()) @@ -63,6 +76,7 @@ class TestFreshDBMigration: assert "boxes" in tables assert "items" in tables assert "subitems" in tables + assert "app_settings" in tables def test_creates_alembic_version_table(self, tmp_db_url): run_migrations(tmp_db_url) @@ -77,7 +91,7 @@ class TestFreshDBMigration: with eng.begin() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() eng.dispose() - assert version == V1_REVISION + assert version == HEAD_REVISION def test_boxes_table_has_all_columns(self, tmp_db_url): run_migrations(tmp_db_url) @@ -147,9 +161,11 @@ class TestUnmanagedDBAdoption2a: """Database with existing tables matching baseline gets adopted.""" def _create_old_db(self, db_url: str) -> None: - """Simulate a pre-Alembic DB: create_all + insert data.""" + """Simulate a pre-Alembic DB: create V1 tables only + insert data.""" eng = create_engine(db_url) - Base.metadata.create_all(bind=eng) + # Only create V1 tables (boxes, items, subitems) — not app_settings + for table_name in ("boxes", "items", "subitems"): + Base.metadata.tables[table_name].create(bind=eng) with eng.begin() as conn: conn.execute(text( "INSERT INTO boxes (name, room, status, created_at, updated_at) " @@ -170,7 +186,7 @@ class TestUnmanagedDBAdoption2a: with eng.begin() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() eng.dispose() - assert version == V1_REVISION + assert version == HEAD_REVISION def test_data_preserved_after_adoption(self, tmp_db_url): self._create_old_db(tmp_db_url) @@ -187,14 +203,14 @@ class TestUnmanagedDBAdoption2a: assert item_count == 1 assert box_name == "Kitchen Box" - def test_no_extra_tables_created(self, tmp_db_url): + def test_no_extra_tables_beyond_migrations(self, tmp_db_url): self._create_old_db(tmp_db_url) run_migrations(tmp_db_url) eng = create_engine(tmp_db_url) tables = set(inspect(eng).get_table_names()) eng.dispose() - assert tables == {"alembic_version", "boxes", "items", "subitems"} + assert tables == {"alembic_version", "boxes", "items", "subitems", "app_settings"} def test_adoption_is_idempotent(self, tmp_db_url): """Running run_migrations twice does not error or duplicate data.""" @@ -209,7 +225,7 @@ class TestUnmanagedDBAdoption2a: eng.dispose() assert box_count == 1 - assert version == V1_REVISION + assert version == HEAD_REVISION # --------------------------------------------------------------------------- @@ -396,7 +412,7 @@ class TestManagedDBMigration: with eng.begin() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() eng.dispose() - assert version == V1_REVISION + assert version == HEAD_REVISION # --------------------------------------------------------------------------- @@ -639,7 +655,7 @@ class TestProdDBCopyAdoption: subitems_after = conn.execute(text("SELECT COUNT(*) FROM subitems")).scalar() eng.dispose() - assert version == V1_REVISION + assert version == HEAD_REVISION assert boxes_after == boxes_before assert items_after == items_before assert subitems_after == subitems_before diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..b67735b --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,634 @@ +"""Tests for the settings store, LLM client, and settings routes. + +All LLM calls are mocked — CI never touches the network. +""" + +from unittest.mock import patch + +import pytest + +import app.llm as llm_module +from app.llm import LLMResult, expand_query, is_configured +from app.models import AppSetting +from app.settings_store import LLMConfig, get_app_settings, save_app_settings + +# Alias to avoid pytest collecting it as a test function +_test_connection = llm_module.test_connection + + +# --------------------------------------------------------------------------- +# LLMConfig dataclass defaults +# --------------------------------------------------------------------------- + + +class TestLLMConfigDefaults: + def test_default_values(self): + cfg = LLMConfig() + assert cfg.enabled is False + assert cfg.base_url == "https://api.openai.com/v1" + assert cfg.model == "" + assert cfg.api_key == "" + assert cfg.ai_search_enabled is False + + +# --------------------------------------------------------------------------- +# settings_store: get_app_settings +# --------------------------------------------------------------------------- + + +class TestGetAppSettings: + def test_returns_defaults_when_no_rows(self, db_session): + cfg = get_app_settings(db_session) + assert cfg.enabled is False + assert cfg.base_url == "https://api.openai.com/v1" + assert cfg.model == "" + assert cfg.api_key == "" + assert cfg.ai_search_enabled is False + + def test_reads_stored_values(self, db_session): + db_session.add(AppSetting(key="llm_enabled", value="true")) + db_session.add(AppSetting(key="llm_base_url", value="https://custom.api/v1")) + db_session.add(AppSetting(key="llm_model", value="gpt-4o")) + db_session.add(AppSetting(key="llm_api_key", value="sk-test-key")) + db_session.add(AppSetting(key="ai_search_enabled", value="true")) + db_session.commit() + + cfg = get_app_settings(db_session) + assert cfg.enabled is True + assert cfg.base_url == "https://custom.api/v1" + assert cfg.model == "gpt-4o" + assert cfg.api_key == "sk-test-key" + assert cfg.ai_search_enabled is True + + def test_handles_null_value_as_default(self, db_session): + db_session.add(AppSetting(key="llm_model", value=None)) + db_session.commit() + + cfg = get_app_settings(db_session) + assert cfg.model == "" + + +# --------------------------------------------------------------------------- +# settings_store: save_app_settings +# --------------------------------------------------------------------------- + + +class TestSaveAppSettings: + def test_saves_new_settings(self, db_session): + save_app_settings( + db_session, + enabled=True, + base_url="https://my-api.com/v1", + model="gpt-4o-mini", + api_key="sk-new-key", + ) + + cfg = get_app_settings(db_session) + assert cfg.enabled is True + assert cfg.base_url == "https://my-api.com/v1" + assert cfg.model == "gpt-4o-mini" + assert cfg.api_key == "sk-new-key" + + def test_updates_existing_settings(self, db_session): + save_app_settings(db_session, enabled=True, model="old-model", api_key="key1") + save_app_settings(db_session, model="new-model") + + cfg = get_app_settings(db_session) + assert cfg.model == "new-model" + # enabled was not passed in second save, so it stays unchanged + assert cfg.enabled is True + + def test_api_key_none_preserves_old_key(self, db_session): + save_app_settings(db_session, api_key="sk-original") + save_app_settings(db_session, model="gpt-4o", api_key=None) + + cfg = get_app_settings(db_session) + assert cfg.api_key == "sk-original" + assert cfg.model == "gpt-4o" + + def test_api_key_empty_string_overwrites(self, db_session): + save_app_settings(db_session, api_key="sk-original") + save_app_settings(db_session, api_key="") + + cfg = get_app_settings(db_session) + assert cfg.api_key == "" + + def test_partial_save_only_updates_specified_fields(self, db_session): + save_app_settings(db_session, enabled=True, model="gpt-4o") + save_app_settings(db_session, base_url="https://new.url/v1") + + cfg = get_app_settings(db_session) + assert cfg.enabled is True + assert cfg.model == "gpt-4o" + assert cfg.base_url == "https://new.url/v1" + + +# --------------------------------------------------------------------------- +# is_configured +# --------------------------------------------------------------------------- + + +class TestIsConfigured: + def test_false_when_disabled(self): + cfg = LLMConfig(enabled=False, model="gpt-4o", api_key="sk-key") + assert is_configured(cfg) is False + + def test_false_when_no_model(self): + cfg = LLMConfig(enabled=True, model="", api_key="sk-key") + assert is_configured(cfg) is False + + def test_false_when_no_api_key(self): + cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="") + assert is_configured(cfg) is False + + def test_true_when_all_set(self): + cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") + assert is_configured(cfg) is True + + +# --------------------------------------------------------------------------- +# test_connection (mocked) +# --------------------------------------------------------------------------- + + +class TestTestConnection: + def test_returns_failure_when_not_configured(self): + cfg = LLMConfig(enabled=False) + result = _test_connection(cfg) + assert result.success is False + assert "未配置" in result.message + + @patch("app.llm._call_chat_completion") + def test_success_when_configured(self, mock_call): + mock_call.return_value = {"choices": [{"message": {"content": "Hi"}}]} + cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") + result = _test_connection(cfg) + assert result.success is True + assert "连接成功" in result.message + assert "gpt-4o" in result.message + + @patch("app.llm._call_chat_completion") + def test_handles_http_error(self, mock_call): + import httpx + + 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-bad") + result = _test_connection(cfg) + assert result.success is False + assert "401" in result.message + + @patch("app.llm._call_chat_completion") + def test_handles_connect_error(self, mock_call): + import httpx + + mock_call.side_effect = httpx.ConnectError("refused") + cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") + result = _test_connection(cfg) + assert result.success is False + assert "无法连接" in result.message + + @patch("app.llm._call_chat_completion") + def test_handles_timeout(self, mock_call): + import httpx + + mock_call.side_effect = httpx.TimeoutException("timeout") + cfg = LLMConfig(enabled=True, model="gpt-4o", api_key="sk-key") + result = _test_connection(cfg) + assert result.success is False + assert "超时" in result.message + + +# --------------------------------------------------------------------------- +# expand_query (mocked) +# --------------------------------------------------------------------------- + + +class TestExpandQuery: + def test_returns_original_when_not_configured(self): + cfg = LLMConfig(enabled=False) + result = expand_query(cfg, "锅") + assert result == ["锅"] + + @patch("app.llm._call_chat_completion") + def test_expands_query_successfully(self, mock_call): + mock_call.return_value = { + "choices": [ + {"message": {"content": "平底锅\n炒锅\n锅具\n厨房锅"}} + ] + } + 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 + + @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 == ["锅"] + + @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 == ["锅"] + + +# --------------------------------------------------------------------------- +# Routes: GET /settings +# --------------------------------------------------------------------------- + + +class TestSettingsPage: + def test_settings_page_returns_200(self, client): + response = client.get("/settings") + assert response.status_code == 200 + + def test_settings_page_has_form_elements(self, client): + response = client.get("/settings") + assert "设置" in response.text + assert 'name="enabled"' in response.text + assert 'name="base_url"' in response.text + assert 'name="model"' in response.text + assert 'name="api_key"' in response.text + assert "保存设置" in response.text + assert "测试连接" in response.text + + def test_settings_page_shows_nav_link(self, client): + response = client.get("/boxes") + assert "设置" in response.text + assert 'href="/settings"' in response.text + + def test_settings_page_no_api_key_echoed(self, client, db_session): + save_app_settings(db_session, api_key="sk-super-secret-key-12345") + response = client.get("/settings") + assert "sk-super-secret-key-12345" not in response.text + assert "已配置" in response.text + + def test_settings_page_shows_placeholder_when_no_key(self, client): + response = client.get("/settings") + assert "输入 API Key" in response.text + + def test_settings_page_shows_default_base_url(self, client): + response = client.get("/settings") + assert "https://api.openai.com/v1" in response.text + + +# --------------------------------------------------------------------------- +# Routes: POST /settings +# --------------------------------------------------------------------------- + + +class TestSaveSettingsRoute: + def test_save_settings_redirects(self, client): + response = client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://my-api.com/v1", + "model": "gpt-4o-mini", + "api_key": "sk-test-key", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/settings" + + def test_saved_settings_persist(self, client, db_session): + client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://my-api.com/v1", + "model": "gpt-4o-mini", + "api_key": "sk-test-key", + }, + follow_redirects=False, + ) + cfg = get_app_settings(db_session) + assert cfg.enabled is True + assert cfg.base_url == "https://my-api.com/v1" + assert cfg.model == "gpt-4o-mini" + assert cfg.api_key == "sk-test-key" + + def test_save_with_blank_api_key_preserves_old(self, client, db_session): + # First save with a key + client.post( + "/settings", + data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-original"}, + follow_redirects=False, + ) + # Second save without key (blank) + client.post( + "/settings", + data={"enabled": "on", "model": "gpt-4o", "api_key": ""}, + follow_redirects=False, + ) + cfg = get_app_settings(db_session) + assert cfg.api_key == "sk-original" + + def test_save_disabled_state(self, client, db_session): + # First enable + client.post( + "/settings", + data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-key"}, + follow_redirects=False, + ) + # Then disable (no 'enabled' checkbox) + client.post( + "/settings", + data={"model": "gpt-4o", "api_key": ""}, + follow_redirects=False, + ) + cfg = get_app_settings(db_session) + assert cfg.enabled is False + + def test_save_settings_no_api_key_in_redirect_page(self, client): + client.post( + "/settings", + data={"enabled": "on", "model": "gpt-4o", "api_key": "sk-secret-key"}, + follow_redirects=False, + ) + response = client.get("/settings") + assert "sk-secret-key" not in response.text + + def test_save_refuses_when_base_url_changes_and_key_blank(self, client, db_session): + """P1 fix: if base_url changes and api_key is blank, refuse save with error.""" + client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://old-api.com/v1", + "model": "gpt-4o", + "api_key": "sk-old-key", + }, + follow_redirects=False, + ) + # Try saving with different base_url, no key + response = client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://new-api.com/v1", + "model": "gpt-4o", + "api_key": "", # blank + base_url changed → refuse + }, + ) + assert response.status_code == 200 + assert "请重新输入 API Key 后保存" in response.text + # Old config should be unchanged — nothing was saved + cfg = get_app_settings(db_session) + assert cfg.base_url == "https://old-api.com/v1" + assert cfg.api_key == "sk-old-key" + + def test_save_preserves_key_when_endpoint_unchanged_and_key_blank(self, client, db_session): + """P1 fix: if endpoint is unchanged and api_key is blank, keep old key.""" + client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o", + "api_key": "sk-original", + }, + follow_redirects=False, + ) + # Re-save same endpoint, blank key + client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o", + "api_key": "", + }, + follow_redirects=False, + ) + cfg = get_app_settings(db_session) + assert cfg.api_key == "sk-original" + + def test_save_preserves_key_when_only_model_changes_and_key_blank(self, client, db_session): + """Model change alone should not clear the key — same base_url, different model.""" + client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o", + "api_key": "sk-original", + }, + follow_redirects=False, + ) + # Change only model, leave api_key blank + client.post( + "/settings", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "api_key": "", + }, + follow_redirects=False, + ) + cfg = get_app_settings(db_session) + 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.""" + db_session.add(AppSetting(key="ai_search_enabled", value="true")) + db_session.commit() + 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 True + + +# --------------------------------------------------------------------------- +# Routes: POST /settings/test +# --------------------------------------------------------------------------- + + +class TestTestConnectionRoute: + @patch("app.llm._call_chat_completion") + def test_test_connection_success(self, mock_call, client): + mock_call.return_value = {"choices": [{"message": {"content": "Hi"}}]} + response = client.post( + "/settings/test", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o", + "api_key": "sk-test", + }, + ) + assert response.status_code == 200 + assert "连接成功" in response.text + assert "gpt-4o" in response.text + + @patch("app.llm._call_chat_completion") + def test_test_connection_failure(self, mock_call, client): + import httpx + + mock_call.side_effect = httpx.HTTPStatusError( + "401", + request=httpx.Request("POST", "http://x"), + response=httpx.Response(401), + ) + response = client.post( + "/settings/test", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o", + "api_key": "sk-bad", + }, + ) + assert response.status_code == 200 + assert "连接失败" in response.text + assert "401" in response.text + + def test_test_connection_not_configured(self, client): + response = client.post( + "/settings/test", + data={ + "enabled": "", # not checked + "base_url": "https://api.openai.com/v1", + "model": "", + "api_key": "", + }, + ) + assert response.status_code == 200 + assert "未配置" in response.text + + @patch("app.llm._call_chat_completion") + def test_test_connection_uses_stored_key_when_endpoint_matches(self, mock_call, client, db_session): + """When api_key is blank but base_url and model match saved config, the stored key should be used.""" + mock_call.return_value = {"choices": [{"message": {"content": "Hi"}}]} + # Store a config first + save_app_settings( + db_session, + enabled=True, + base_url="https://api.openai.com/v1", + model="gpt-4o", + api_key="sk-stored-key", + ) + response = client.post( + "/settings/test", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o", + "api_key": "", # blank → use stored key (endpoint matches) + }, + ) + assert response.status_code == 200 + assert "连接成功" in response.text + + @patch("app.llm._call_chat_completion") + def test_test_connection_uses_stored_key_when_only_model_changes(self, mock_call, client, db_session): + """Model changes under the same base_url can reuse the stored key.""" + captured = {} + + def fake_call(cfg, **kwargs): + captured["base_url"] = cfg.base_url + captured["model"] = cfg.model + captured["api_key"] = cfg.api_key + return {"choices": [{"message": {"content": "Hi"}}]} + + mock_call.side_effect = fake_call + save_app_settings( + db_session, + enabled=True, + base_url="https://api.openai.com/v1", + model="gpt-4o", + api_key="sk-stored-key", + ) + response = client.post( + "/settings/test", + data={ + "enabled": "on", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "api_key": "", + }, + ) + assert response.status_code == 200 + assert "连接成功" in response.text + assert captured == { + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "api_key": "sk-stored-key", + } + + def test_test_connection_refuses_stored_key_when_endpoint_changed(self, client, db_session): + """When base_url changed and api_key is blank, refuse to test.""" + save_app_settings( + db_session, + enabled=True, + base_url="https://api.openai.com/v1", + model="gpt-4o", + api_key="sk-stored-key", + ) + response = client.post( + "/settings/test", + data={ + "enabled": "on", + "base_url": "https://attacker.example/v1", # different endpoint + "model": "gpt-4o", + "api_key": "", # blank → refuse + }, + ) + assert response.status_code == 200 + assert "请重新输入 API Key" in response.text + + def test_test_connection_result_shows_on_settings_page(self, client): + """Test result is rendered on the same settings page.""" + response = client.post( + "/settings/test", + data={ + "enabled": "", + "base_url": "https://api.openai.com/v1", + "model": "", + "api_key": "", + }, + ) + assert response.status_code == 200 + assert "设置" in response.text + assert "保存设置" in response.text + + +# --------------------------------------------------------------------------- +# Graceful degradation: unconfigured LLM does not affect existing features +# --------------------------------------------------------------------------- + + +class TestGracefulDegradation: + def test_boxes_page_works_without_llm_config(self, client): + response = client.get("/boxes") + assert response.status_code == 200 + + def test_search_page_works_without_llm_config(self, client): + response = client.get("/search?q=test") + assert response.status_code == 200 + + def test_crud_works_without_llm_config(self, client, db_session): + from app.models import Box + + response = client.post( + "/boxes", + data={"name": "No LLM Box"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert db_session.query(Box).count() == 1