d36b940981
test / pytest (push) Successful in 1m13s
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.
176 lines
5.6 KiB
Python
176 lines
5.6 KiB
Python
"""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()
|