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.
27 KiB
设计文档 · LLM 接入与迁移地基 / Design · LLM Integration & Migration Foundation
中英双语。这是「下一轮改动」的总体设计(high-level design),实施步骤见
implementation-plan.md。 Bilingual. High-level design for the next round of changes; step-by-step plan inimplementation-plan.md.状态 / Status:已定稿,待实现 / Agreed, pending implementation 基线 / Base:
main@b9b6583
0. 本轮范围 / Scope of This Round
本轮只做三件事 / This round delivers exactly three things:
- 引入 Alembic 数据库迁移系统(含一层封装,让应用不直接接触 Alembic 细节)。 Introduce Alembic as the migration system (with a thin wrapper so the app never touches Alembic directly).
- LLM 接入:一个配置页 + 配置落库 + 一个可复用的 LLM 客户端。 LLM integration: a config page + DB-persisted config + a reusable LLM client.
- 最基础的 AI 搜索:搜索页常驻一个「AI 智能搜索」动作,用查询词扩展增强结果。 Basic AI search: a persistent "AI search" action on the search page, powered by query-term expansion.
本轮明确不做(留作未来)/ Explicitly out of scope this round(future):
- 图片内容分析(
image_description列、视觉模型调用、手动/批量/夜间生成)。 Image content analysis (image_descriptioncolumns, vision calls, manual/batch/nightly generation). - 向量嵌入 + 相似度语义搜索(AI 搜索的"高阶版")。 Vector embeddings + similarity semantic search (the "advanced" AI search).
- 多图、OCR、鉴权、标签系统等(见仓库简报 §15 / see brief §15)。
架构会为上述未来项预留接口(§9),但本轮不实现。 The architecture leaves seams for the above (§9) without implementing them now.
1. 设计原则 / Guiding Principles
- AI 是加分项,不是依赖 / AI is additive, never required. 未配置或调用失败时,整站行为与今天完全一致。AI 只在"能用且开启"时才介入。 When unconfigured or on failure, the app behaves exactly as today. AI engages only when configured and enabled.
- 单一 schema 事实来源 / One source of truth for schema.
Alembic 接管建表与变更;退休手写的
_sync_sqlite_image_columns()。 Alembic owns schema creation and changes; retire the hand-rolled_sync_sqlite_image_columns(). - 依赖最小化 / Minimal dependencies.
复用已在
requirements.txt的httpx调 OpenAI 兼容接口;本轮唯一新增依赖是alembic。 Reuse the existinghttpxfor OpenAI-compatible calls; the only new dependency isalembic. - 保持现有形态 / Keep the current shape. 仍是 FastAPI + Jinja2 SSR + SQLite,无前端构建链;新页面沿用现有模板风格。 Still FastAPI + Jinja2 SSR + SQLite, no frontend build; new pages follow existing template style.
- 测试不联网、数据隔离 / Tests stay offline and isolated. LLM 客户端做成单一可 mock 边界;迁移在测试中真实执行(临时 SQLite)。 The LLM client is a single mockable boundary; migrations actually run in tests (throwaway SQLite).
- 可信内网安全姿态 / Trusted-LAN posture. 无鉴权(仅内网/VPN 访问);API Key 明文落库为业主在其威胁模型下的明确选择(§7)。 No auth (LAN/VPN only); plaintext API key in DB is the owner's explicit choice under their threat model (§7).
2. 总体架构 / Architecture Overview
┌─────────────────────────────────────────────┐
HTTP (SSR) │ app/main.py │
───────────────────► │ 路由 / routes + 请求编排 / orchestration │
└───┬───────────────┬───────────────┬─────────┘
│ │ │
┌────────▼──────┐ ┌──────▼───────┐ ┌──────▼────────┐
│ app/llm.py │ │ app_settings │ │ 搜索逻辑 │
│ LLM 客户端 │ │ 读写 helper │ │ AI 检索 seam │
│ (httpx) │ │ (KV in DB) │ │ (可替换) │
└───────────────┘ └──────┬───────┘ └───────────────┘
│
┌───────────────────▼─────────────────────────┐
│ app/migrate.py │
│ 启动 / boot: verify_schema_is_current() 只读 │
│ └─ 与 head 不一致 → fail-close,拒绝启动 │
│ 命令 / CLI `python -m app.migrate`(幂等): │
│ └─ 空库建库 / 认领老库 / upgrade(见 §3) │
└───────────────────┬─────────────────────────┘
│ command.upgrade / stamp(仅迁移命令 / migration command only)
┌───────────────────▼─────────────────────────┐
│ Alembic (alembic.ini + migrations/) │
│ V1 baseline → V2(app_settings) → … │
└───────────────────┬─────────────────────────┘
│
┌─────▼─────┐
│ SQLite │
└───────────┘
新增模块 / New modules:app/migrate.py(Alembic 封装)、app/llm.py(LLM 客户端)、migrations/(Alembic 工程)、app/templates/settings/(配置页)。
改动模块 / Touched:app/db.py、app/main.py、app/models.py、app/templates/base.html、Dockerfile、requirements.txt、tests/。
3. 迁移子系统 / Migration Subsystem (Alembic)
3.1 为什么 / Why
配置表与未来的新列(如 tag、image_description)都需要可重复、可审阅的迁移;现有手写列同步只能补图片列,无法长期支撑。
A config table and future columns need repeatable, reviewable migrations; the hand-rolled column sync only patches image columns and won't scale.
3.2 收敛不变量 / The Convergence Invariant
所有数据库最终都收敛到同一个 head。V1 baseline 必须严格等于"今天的真实 schema"(三张表 + 现有图片列),不多一列。
All databases converge to the same head. The V1 baseline must equal today's actual schema exactly (the three tables + existing image columns) — nothing more.
迁移链 / chain: V1(baseline = 现状) ──► V2(app_settings) ──► …未来… ──► head
老的生产库 / existing prod DB: stamp 到 V1(只写版本号,不建表,不碰数据) ──► upgrade ──► head
全新/空库 / fresh DB: 跑 V1(真正建三张表) ───────────────────────► upgrade ──► head
↑ 终点一致 / same end state
stamp只向alembic_version写一条版本记录,不执行任何 DDL、不修改数据。这是安全认领已有库的关键。stamponly writes a row intoalembic_version; it runs no DDL and touches no data. This is the key to safely adopting an existing DB.
3.3 运行时机:校验与迁移分离 / Migrations Run Separately from Startup
关键决策:迁移不在应用启动时发生。 启动只做只读校验,迁移由一个独立、显式的命令/步骤执行。 Key decision: migrations do not happen at app startup. Startup only verifies (read-only); migrating is an explicit, separate step.
- 启动校验(fail-close)/ Startup check (fail-closed):
app/db.py::init_db()调app/migrate.py::verify_schema_is_current(url),比较 DB 当前 revision 与head:- 一致 → 正常启动 / match → start normally。
- 不一致(含空库、未认领的老库)→ fail-close:输出清晰日志、拒绝提供服务、提示先跑迁移步骤;不执行任何 DDL、不碰数据。 Mismatch (incl. empty or un-adopted DBs) → fail closed: clear log, refuse to serve, no DDL, no data change.
- 迁移命令 / The migration command: 独立、显式、幂等的
python -m app.migrate(逻辑在app/migrate.py)。已在head则空操作并退出 0,便于每次部署都安全重跑。 A separate, explicit, idempotentpython -m app.migrate. No-op (exit 0) when already athead, so it is safe to re-run on every deploy. - 退休手写列同步 / Retire the hand-rolled sync:
_sync_sqlite_image_columns()删除,schema 由 Alembic 单一接管。_sync_sqlite_image_columns()is removed; Alembic is the sole owner of schema.
为什么 / Why:避免"启动副作用式迁移"、避免多实例并发迁移竞态;当 code 与 DB 不一致时,宁可不启动也不带病运行。 Avoids surprise startup migrations and concurrent-migration races; on a code/DB mismatch it refuses to run rather than run wrong.
3.4 迁移命令的三种情况 / The Migration Command's Three Cases
python -m app.migrate 用 SQLAlchemy inspector 判定,分三种:
python -m app.migrate inspects the DB and branches three ways:
| 库的状态 / DB state | 动作 / Action |
|---|---|
| 空库 / empty | upgrade head(建库并升到最新 / create & upgrade to head) |
| 老库且与 baseline 一致 / existing, matches baseline(2a) | stamp V1 → upgrade head(认领后升级 / adopt then upgrade) |
| 老库但与 baseline 不一致 / existing, mismatched(2b) | fail-close,不做任何改动 / fail closed, no changes |
一致性比对的基准是 baseline(V1),不是 head。 未认领的老库结构停在 V1(不含
app_settings等后续内容),若拿 head 去比会把合法老库误判为不一致。 The match is compared against the baseline (V1), nothead— an un-adopted DB sits at V1 and would wrongly look "mismatched" if compared against head.⚠️ SQLite 的 autogenerate 比对存在假阳性(类型亲和、索引命名等),可能让 2b 误 fail。实现上需用容忍性比对或允许人工确认覆盖(见 §3.6 验证)。 SQLite autogenerate has false positives; 2b should use a tolerant comparison or allow a documented manual override (see §3.6).
3.5 部署形态:Compose db-migration 闸门 / Deployment Shape: a Compose Gate(未来 / future)
意图:用一个一次性 db-migration 服务跑迁移命令,成功才放行 App。本轮可先只交付命令本身,Compose 接线随后。
Intent: a one-shot db-migration service runs the command and the app starts only on its success. The command ships this round; the Compose wiring can follow.
services:
db-migration:
image: <same image>
command: python -m app.migrate # 成功 exit 0;2b/失败 exit ≠0
web:
depends_on:
db-migration:
condition: service_completed_successfully
迁移失败(含 2b 不一致)→ App 永不启动。 A failed migration (incl. a 2b mismatch) → the app never starts.
3.6 Alembic 配置要点 / Alembic config notes
migrations/env.py:target_metadata = Base.metadata;DB URL 从get_settings().database_url动态读取(不写死在alembic.ini);对 SQLite 设render_as_batch=True(便于未来改列/删列走 batch 模式)。target_metadata = Base.metadata; URL read dynamically from settings;render_as_batch=Truefor SQLite.- V1 baseline 的生成与验证 / Authoring & verifying V1: 用当前 models 对空库 autogenerate 得到完整建表脚本;再对生产库副本跑
alembic check,应显示无差异——即印证"schema 符合预期、可安全盖章"。 Autogenerate against an empty DB for the full create script; then runalembic checkagainst a copy of the prod DB — it should report no diff, confirming it's safe to stamp. - 镜像 / Image:
Dockerfile需COPYalembic.ini与migrations/,否则容器内无迁移脚本。 - CI(可选 / optional):加一步
alembic check,防止改了 model 却忘记生成迁移。 Add analembic checkstep to catch model/migration drift.
4. LLM 接入 / LLM Integration
4.1 配置存储:键值表 / Config storage: a KV table
新增表 app_settings(key TEXT PRIMARY KEY, value TEXT)(由 V2 迁移创建)。
New table app_settings(key TEXT PRIMARY KEY, value TEXT) (created by the V2 migration).
为什么用 KV 而非定型列 / Why KV instead of typed columns: 后续还会陆续加配置项;给已有表加列有迁移成本,而 KV 加配置项=加一行,永不迁移。类型与校验在 Python 侧处理。 More settings are coming; adding columns to an existing table costs a migration, whereas a KV row never does. Typing/validation live in Python.
本轮使用的 key / Keys used this round:
| key | 含义 / Meaning | 默认 / Default |
|---|---|---|
llm_enabled |
LLM 总开关 / master toggle | false |
llm_base_url |
OpenAI 兼容端点 / endpoint | https://api.openai.com/v1 |
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复用。 Helpersget_app_settings(db) -> LLMConfigandsave_app_settings(db, ...), reused by routes andapp/llm.py.
4.2 LLM 客户端 / The client (app/llm.py)
OpenAI 兼容的薄客户端,基于 httpx,无新依赖 / A thin OpenAI-compatible client over httpx, no new dependency:
is_configured(cfg) -> bool:开关开启且model/api_key齐全。test_connection(cfg) -> Result:发一个最小请求验证base_url/model/api_key,供配置页"测试连接"用。expand_query(cfg, query) -> list[str]:把查询词扩成一批近义/相关词(提示词与输出契约见 §5.2)。analyze_image(...):本轮不实现,仅在文档中预留为未来接口(图片分析轮次)。Reserved for a future round, not implemented now.
要点 / Notes:
- 统一超时与错误处理;失败不抛到用户面前,按"优雅降级"返回可识别的失败信号。 Unified timeout + error handling; failures degrade gracefully rather than surfacing as 500s.
- 同步实现即可——FastAPI 把同步
def路由丢线程池执行,阻塞式 httpx 调用可接受。 A synchronous implementation is fine — FastAPI runs sync handlers in a threadpool. - 唯一对外/网络边界,测试中整体 mock,CI 保持无网络。 The single network boundary, fully mocked in tests.
4.3 配置页 / Config page
| 路由 / Route | 作用 / Purpose |
|---|---|
GET /settings |
渲染配置表单(Key 脱敏显示)/ render form (key masked) |
POST /settings |
保存配置到 app_settings / persist to app_settings |
POST /settings/test |
用当前/待保存配置测试连接 / test connection |
- 模板
app/templates/settings/form.html,沿用现有卡片/表单样式;base.html顶部导航加一个「设置」入口。 Template undersettings/, reusing existing styles; add a "设置/Settings" link inbase.htmlnav. - Key 脱敏 / Key masking:页面不回显明文,显示「已配置,留空=不修改」,提交留空则保留原值。 Never echo the plaintext key; show "configured, leave blank to keep", and keep the old value if left blank.
4.4 降级 / Degradation
llm_enabled 关或未配置时:配置页照常可用;AI 搜索按钮隐藏或提示去配置;其余功能与现状一致。
When disabled/unconfigured: the settings page still works; the AI-search button is hidden or hints to configure; everything else is unchanged.
5. AI 搜索 / AI Search
5.1 行为 / Behavior
- 常驻动作 / Persistent action: 搜索页始终提供「AI 智能搜索」,不以"零结果"为前提——即便普通搜索已出结果,用户不满意时也能点。 The "AI search" action is always present on the search page, not gated on zero results — usable even when normal results exist.
- 流程 / Flow: 普通
LIKE照常先出结果 → 用户触发 AI →expand_query把查询词扩成近义/相关词 → 用「原词 + 扩展词」对name/note做 ORLIKE重搜 → 展示,并用横幅标注「AI 帮你扩展了:…」。 NormalLIKEfirst → user triggers AI →expand_query→ OR-LIKEover name/note with the original + expanded terms → render with a banner listing the expansion. - 只把查询词发出去 / Only the query leaves,不外泄物品清单;token 恒定、不随上千件物品增长。 Only the query is sent; the inventory is not. Token cost is constant and does not grow with thousands of items.
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_searchadds 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 existingGET /searchwith anai=1trigger (e.g./search?q=…&ai=1), staying single-page and bookmarkable. - 内部定义可替换的检索 seam,例如
ai_search(db, query) -> (expanded_terms, results): Define a replaceable retrieval seam, e.g.ai_search(db, query) -> (expanded_terms, results):- 本轮 / now: 内部=查询词扩展 + 本地
LIKE。 - 未来 / later: 换成向量嵌入 + 相似度检索,路由与模板不变。 Swap to embeddings + similarity later without changing the route or template.
- 本轮 / now: 内部=查询词扩展 + 本地
- 本轮检索范围=
name+note(image_description本轮不存在)。 Search scope this round =name+note(noimage_descriptionyet).
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.
6. 数据模型与路由变更 / Data Model & Route Changes
数据模型 / Data model(本轮):
- 新增
AppSetting(表app_settings,KV)。由 V2 迁移建表。 AddAppSetting(app_settings, KV), created by the V2 migration. boxes/items/subitems本轮不变。Unchanged this round.
新增/改动路由 / Routes added/changed:
GET /settings、POST /settings、POST /settings/test(新)。GET /search?q=&ai=1(扩展现有)。base.html导航新增「设置」。
7. 安全姿态 / Security Posture
- 无鉴权 / No auth:仅经可信内网 / VPN + nginx HTTPS 访问,业主已确认风险可接受。 LAN/VPN + nginx HTTPS only; owner accepts the risk.
- API Key 明文落库 / Plaintext API key in DB:业主明确选择。理由:备份经
rclone至业主自有 OneDrive,链路可信;若攻击者已能读到服务器文件,则任何落盘位置都不安全。 Owner's explicit choice; backups go viarcloneto the owner's own OneDrive, and a server-file-read attacker defeats any at-rest location anyway. - UI 不回显明文 Key / UI never echoes the key(§4.3)——这是表单卫生,不是加密。
- 外发数据 / Data egress:AI 搜索只发送查询词;图片分析(未来)才会外发图片。 AI search sends only the query; image egress only arrives with the future image-analysis feature.
8. 测试策略 / Testing Strategy
- 迁移在测试中真实执行 / Migrations run in tests: fixture 先在临时 SQLite 上跑迁移命令(建库 →
upgrade head),再create_app()(启动校验随之通过)。schema 来自迁移本身——单一事实来源 + 迁移覆盖。 The fixture runs the migration command on a tmp SQLite first, thencreate_app()(whose startup check then passes). - 认领逻辑测试 / Adoption test(2a): 构造"有
boxes数据但无alembic_version"的库 → 跑迁移命令 → 断言数据保留、版本到达 head。 Build a "hasboxesdata, noalembic_version" DB → run the migration command → assert data preserved and version at head. - fail-close 测试 / Fail-closed tests: ① DB 未到 head 时
create_app()启动应 fail-close;② 2b 不一致时迁移命令应 fail-close 且不改动。 ①create_app()fails closed when the DB is not at head; ② the migration command fails closed (and changes nothing) on a 2b mismatch. - LLM 全程 mock / Mock the LLM: 打桩
expand_query/test_connection(或底层 httpx),CI 不联网。 - 新增用例 / New cases: 配置增删改 + Key 脱敏;测试连接(mock);AI 搜索扩展命中;各降级路径(未配置/失败)。
9. 未来扩展(本轮不做,但已预留)/ Future Extensions (seams reserved)
| 未来项 / Future item | 预留点 / Seam already in place |
|---|---|
| 图片内容分析 / Image analysis | app/llm.py 预留 analyze_image;迁移系统可加 image_description 列;搜索范围可纳入该列。analyze_image reserved; migrations can add image_description; search can include it. |
| 向量语义搜索 / Vector semantic search | ai_search(...) seam 可整体替换;批处理可与图片描述补算共用。The ai_search seam is swappable; batch jobs can be shared. |
| 夜间批处理 / Nightly batch | 分析逻辑写成批量友好函数,cron 仅是薄包装(仿 backup cron)。 Batch-friendly functions; cron is a thin wrapper like the backup cron. |
| 文本/视觉模型分离 / Split models | app_settings 加一个 key 即可,无需迁移。Add one KV key, no migration. |
10. 决策记录 / Decisions Log
| # | 决策 / Decision | 理由 / Rationale |
|---|---|---|
| D1 | 先引入 Alembic 再做功能 / Alembic before features | 配置表与未来列都依赖可靠迁移;退休手写列同步。 |
| D2 | V1 baseline 严格等于现状,新东西放 V2+ / baseline = current schema only | 使 stamp 认领老库为真、安全。 |
| D3 | 迁移与启动分离:启动只校验 + fail-close,迁移走独立幂等命令(python -m app.migrate)/ 未来 Compose db-migration 闸门 / migrations separated from startup |
避免启动副作用式迁移与并发竞态;schema 不一致宁可不启动也不带病运行;迁移成功才放行 App。 |
| D4 | 配置用 KV 表 / KV settings table | 后续配置项多,避免反复给已有表加列。 |
| D5 | API Key 明文落库 / plaintext key | 业主威胁模型下可接受;备份至自有 OneDrive。 |
| D6 | 复用 httpx,手搓 OpenAI 调用 / reuse httpx | 不引入 openai SDK,依赖最小。 |
| D7 | AI 搜索常驻、不依赖零结果 / persistent AI search | 用户对已有结果不满意时也能用。 |
| 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 | 保证解析稳定(用户改不坏),又给业主一点不改代码即可微调的空间。 |