mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
feat(agent): install CJK history tokenizer for Docker and CLI
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""独立 state.db 模式与 FTS5 投影,采用 external-content 布局。"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BUNDLED_CJK_EXTENSION = Path(sys.prefix) / "lib/moviepilot/libfts5_cjk.so"
|
||||
SCHEMA_VERSION = 1
|
||||
TOOL_PREFIX_CHARS = 8192
|
||||
SCHEMA_SQL = """
|
||||
@@ -39,17 +41,21 @@ INDEXES = {"messages_fts": "unicode61", "messages_fts_trigram": "trigram", "mess
|
||||
|
||||
|
||||
def load_cjk(connection: sqlite3.Connection, extension: Path) -> bool:
|
||||
"""只加载宿主固定目录的 CJK tokenizer,加载结束立即关闭扩展权限。"""
|
||||
if not extension.is_file() or not hasattr(connection, "enable_load_extension"):
|
||||
"""优先使用运行环境自带扩展,兼容旧配置目录;每次加载后立即关闭权限。"""
|
||||
if not hasattr(connection, "enable_load_extension"):
|
||||
return False
|
||||
try:
|
||||
connection.enable_load_extension(True)
|
||||
connection.load_extension(str(extension))
|
||||
return True
|
||||
except (sqlite3.Error, OSError):
|
||||
return False
|
||||
finally:
|
||||
connection.enable_load_extension(False)
|
||||
for candidate in (BUNDLED_CJK_EXTENSION, extension):
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
try:
|
||||
connection.enable_load_extension(True)
|
||||
connection.load_extension(str(candidate))
|
||||
return True
|
||||
except (sqlite3.Error, OSError):
|
||||
continue
|
||||
finally:
|
||||
connection.enable_load_extension(False)
|
||||
return False
|
||||
|
||||
|
||||
def index_sql(table: str) -> str:
|
||||
|
||||
@@ -27,6 +27,16 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# 按镜像目标架构编译中文索引扩展,编译器不进入最终镜像。
|
||||
FROM base AS prepare_cjk
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY native/fts5_cjk/ /build/fts5_cjk/
|
||||
RUN python3 /build/fts5_cjk/install.py --output /native/libfts5_cjk.so
|
||||
|
||||
|
||||
# 准备软件包
|
||||
FROM base AS prepare_package
|
||||
|
||||
@@ -180,6 +190,11 @@ RUN "${VENV_PATH}/bin/python" /tmp/moviepilot-runtime-dependencies.py --full
|
||||
ARG MOVIEPILOT_PYTHON_VARIANT
|
||||
FROM verify_venv_${MOVIEPILOT_PYTHON_VARIANT} AS prepare_venv
|
||||
|
||||
COPY --from=prepare_cjk /native/ ${VENV_PATH}/lib/moviepilot/
|
||||
COPY --from=prepare_cjk /build/fts5_cjk/install.py /tmp/verify-cjk.py
|
||||
RUN "${VENV_PATH}/bin/python" /tmp/verify-cjk.py --check \
|
||||
&& rm /tmp/verify-cjk.py
|
||||
|
||||
# 准备后端源码
|
||||
FROM base AS prepare_backend
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| 外部内容 FTS5、触发器原子同步 | 正文只保存一次,FTS 读取固定视图;正文与索引在同一事务提交 |
|
||||
| 标准工具正文前缀 8192、工具 JSON 独立列 | 标准索引保持同一投影;显式 `role_filter="tool"` 搜原始完整工具正文 |
|
||||
| trigram 排除 tool、cron、subagent 及工具调用 JSON | 相同索引范围,减少大结果和重复自动任务的索引体积 |
|
||||
| `native/fts5_cjk` 的 `cjk_unicode61` | 原版 C 源码随仓库提供,附固定来源和 MIT 许可;可选安装,不用自行生成 n-gram 替代分词语义 |
|
||||
| `native/fts5_cjk` 的 `cjk_unicode61` | 原版 C 源码随仓库提供,附固定来源和 MIT 许可;Docker 构建与 CLI 安装接入,不用自行生成 n-gram 替代分词语义 |
|
||||
| `hermes_state_search.py`:BM25、短语/布尔/前缀、子串及 OR 重试 | `agent/history/query.py` / `search.py`:普通词首先 FTS5;无结果时子串索引重试,最后允许非 CJK 多词查询 OR 放宽;显式 OR/NOT 不放宽 |
|
||||
| CJK → bigram → trigram → LIKE | 两字中文在可选 tokenizer 可用时走 bigram,三字可走 trigram;单字、缺失索引和显式工具全文回退,响应报告实际路径 |
|
||||
| `tools/session_search_tool.py`:300 候选、默认3/最多10会话、谱系去重 | 相同预算;隐藏 subagent/tool/kanban,cron 排在交互会话后;排除已看谱系;标题优先尝试最新同名续篇 |
|
||||
@@ -36,10 +36,14 @@
|
||||
|
||||
## 中文分词器
|
||||
|
||||
标准 FTS5 和 trigram 来自 SQLite。可选 CJK tokenizer 源码在 `native/fts5_cjk/`,与 Hermes 的可选本地扩展机制一致。按运行环境架构编译并安装到固定目录 `config/agent/runtime/lib/libfts5_cjk.so`,具体命令见该目录 README;Agent 请求不会调用编译器。每次连接只临时开启扩展加载,加载后立即关闭。
|
||||
标准 FTS5 和 trigram 来自 SQLite。CJK tokenizer 源码在 `native/fts5_cjk/`。Docker 构建按目标架构编译,使用实际运行时 Python 验证后安装到 `/opt/venv/lib/moviepilot/libfts5_cjk.so`,不会被 `/config` 挂载遮住。CLI 的 `setup`、`install deps`、后端更新会使用所选 venv 编译和验证,同源码、架构且加载验收通过时复用已有文件。运行时优先加载 `<sys.prefix>/lib/moviepilot/libfts5_cjk.so`,失败或不存在时兼容旧位置 `config/agent/runtime/lib/libfts5_cjk.so`。Agent 请求不会调用编译器,每次连接只临时开启扩展加载,加载后立即关闭。
|
||||
|
||||
已有 CLI 安装可以运行 `moviepilot install cjk --venv /path/to/venv` 补装,追加 `--check` 只验证。缺少编译器或 SQLite 不支持扩展时,自动安装会明确告警;显式补装/检查返回非零退出码。Docker 旧镜像需要更换为包含本扩展的新构建,单纯更新后端源码不会补出本地库。编译器要求和 Docker 检查命令见 [安装说明](../native/fts5_cjk/README.md)。
|
||||
|
||||
没有 CJK 扩展时,两字中文会走 LIKE;这与 Hermes 的能力回退一致,但不等于索引速度。SQLite VM 查询期限限制扫描,超时返回 `query_timeout`/未完成,不能解读为“没有相关内容”。安装扩展后的旧消息在后台分批建索引,期间继续报告索引未就绪。不要跨平台复制已编译二进制。
|
||||
|
||||
扩展安装验收使用内存库实际索引“电影订阅成功”并查询“订阅”;这只证明运行环境能力。各用户历史索引完成后,`session_search` 返回 `index_status.messages_fts_cjk=true`,符合条件的两字查询返回 `search_path="cjk"`,才说明该次查询确实使用了中文索引。
|
||||
|
||||
## 能力边界和验证
|
||||
|
||||
FTS5 是词项检索,不保证任意语义改写都能命中。模型应依据结果用同义词、对象编号、OR、时间区间和已看会话排除继续查询,再用锚点上下文核实。用户明确提供的 URL、文件或实时系统仍然优先读取,不能仅凭历史无命中判断目标不存在。完整存储也不等于完整展示,不能用被截断的单条证据断言工具成功。
|
||||
|
||||
19
docs/cli.md
19
docs/cli.md
@@ -14,6 +14,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
|
||||
- 自动检查并尽量安装 `git`、`curl`、`uv 0.12.5+` 和 `Python 3.14+`
|
||||
- 克隆 `MoviePilot`
|
||||
- 安装后端依赖
|
||||
- 编译并验证 Agent 中文全文索引扩展;缺少编译器或 SQLite 扩展能力时明确告警并保留检索回退
|
||||
- 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
|
||||
- 下载 `MoviePilot-Resources` 主分支资源
|
||||
- 将 `resources.v3/*` 同步到后端 `app/application/site/`
|
||||
@@ -127,6 +128,7 @@ moviepilot tool run moviepilot_api operation_id=scheduler.run 'query={"job_id":"
|
||||
|
||||
```text
|
||||
moviepilot install deps
|
||||
moviepilot install cjk
|
||||
moviepilot install frontend
|
||||
moviepilot install resources
|
||||
moviepilot init
|
||||
@@ -167,6 +169,23 @@ moviepilot commands
|
||||
|
||||
## 安装命令
|
||||
|
||||
Agent 中文全文索引扩展会随 `install deps`、`setup` 和后端更新安装到所选 venv 的
|
||||
`lib/moviepilot/libfts5_cjk.so`。重复安装先校验源码、架构和实际加载能力,无变化时不重复编译。
|
||||
一键安装会尝试准备 Linux C 编译器;手动安装需要预先准备 `build-essential`(Debian/Ubuntu)、
|
||||
Xcode Command Line Tools(macOS)或 MSVC/MinGW(Windows),且 Python 的 SQLite 支持扩展加载。
|
||||
|
||||
已有环境可以单独补装或检查,不必重新安装所有依赖:
|
||||
|
||||
```shell
|
||||
moviepilot install cjk
|
||||
moviepilot install cjk --venv /path/to/venv
|
||||
moviepilot install cjk --venv /path/to/venv --check
|
||||
```
|
||||
|
||||
检查会在内存库中验证“订阅”两字搜索。失败返回非零退出码并显示原因;自动依赖安装中的
|
||||
扩展失败只告警,保留 trigram/LIKE 回退。扩展就绪后历史消息在后台分批建索引,
|
||||
实际 `session_search` 的 `search_path="cjk"` 表示该次查询已使用中文索引。
|
||||
|
||||
安装后端依赖:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -257,7 +257,7 @@ CI 上传 `complexity-report` 工件,门禁失败时也保留已生成的指
|
||||
|
||||
### Agent 历史消息索引验证
|
||||
|
||||
独立消息库位于 Agent 运行目录,不参与主库 Alembic。使用临时运行目录运行 `tests/test_agent_recall_persistence.py`,不得用真实用户的 `state.db` 做索引破坏或重建测试。可选 CJK tokenizer 保留 Hermes 原版源码及许可证于 `native/fts5_cjk/`;构建命令见该目录 README。测试有本地 C 编译器时在临时目录构建,无编译器时只跳过原生扩展用例,不能宣称该分词路径已验证。常规 FTS5 与 trigram、短中文回退无需该扩展,扩展缺失或失效时索引状态会明确降级。独立 schema 版本不等于产品版本;后续新增主库升级脚本从 3.1.0 命名。
|
||||
独立消息库位于 Agent 运行目录,不参与主库 Alembic。使用临时运行目录运行 `tests/test_agent_recall_persistence.py`,不得用真实用户的 `state.db` 做索引破坏或重建测试。CJK tokenizer 保留 Hermes 原版源码及许可证于 `native/fts5_cjk/`;Docker 构建与 CLI 依赖安装使用该目录的 `install.py`,按本机架构编译并用目标解释器实际验证两字中文查询,安装到 `<sys.prefix>/lib/moviepilot/libfts5_cjk.so`。旧配置目录内的手工扩展作为加载回退;编译器要求和补装/检查命令见该目录 README。测试有本地 C 编译器时在临时目录构建,无编译器时只跳过原生扩展用例,不能宣称该分词路径已验证。常规 FTS5 与 trigram、短中文回退无需该扩展,扩展缺失或失效时索引状态会明确降级。独立 schema 版本不等于产品版本;后续新增主库升级脚本从 3.1.0 命名。
|
||||
|
||||
### Agent 学习验证
|
||||
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
|
||||
本文基于 `v3` 分支 2026-09-07 的实现整理。实际行为以当前源码为准。
|
||||
|
||||
Agent 中文全文索引扩展在镜像的 `prepare_cjk` 阶段按目标架构编译,最终随 Python venv 放在
|
||||
`/opt/venv/lib/moviepilot/libfts5_cjk.so`。构建时由所选 standard/free-threaded Python 完成真实
|
||||
加载及两字中文查询验收;运行容器不需要编译器,挂载 `/config` 不影响扩展可见性。
|
||||
旧镜像需要重新构建或更换镜像,源码热更新不会自动安装该原生文件。
|
||||
|
||||
```shell
|
||||
docker exec <container> /opt/venv/bin/python /app/native/fts5_cjk/install.py --check
|
||||
```
|
||||
|
||||
该命令只验证环境能力,不访问用户库。旧历史的 CJK 索引随后分批回填,检索结果通过
|
||||
`index_status.messages_fts_cjk` 和 `search_path` 报告是否已经就绪及实际使用的路径。
|
||||
|
||||
## 1. 文件职责
|
||||
|
||||
| 文件 | 运行位置与职责 |
|
||||
|
||||
19
moviepilot
19
moviepilot
@@ -10,6 +10,7 @@ Usage: moviepilot [BOOTSTRAP COMMAND] | [RUNTIME COMMAND]
|
||||
|
||||
Bootstrap Commands:
|
||||
moviepilot install deps [--python PYTHON] [--venv PATH] [--recreate] [--config-dir PATH]
|
||||
moviepilot install cjk [--venv PATH] [--check]
|
||||
moviepilot install frontend [--version TAG] [--node-version 20.12.1] [--config-dir PATH]
|
||||
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH]
|
||||
moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
|
||||
@@ -56,6 +57,7 @@ show_commands() {
|
||||
cat <<'EOF'
|
||||
Bootstrap Commands
|
||||
install deps
|
||||
install cjk
|
||||
install frontend
|
||||
install resources
|
||||
init
|
||||
@@ -99,6 +101,7 @@ show_install_help() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
moviepilot install deps [OPTIONS]
|
||||
moviepilot install cjk [OPTIONS]
|
||||
moviepilot install frontend [OPTIONS]
|
||||
moviepilot install resources [OPTIONS]
|
||||
|
||||
@@ -109,6 +112,11 @@ Options:
|
||||
--recreate 删除并重建虚拟环境
|
||||
--config-dir PATH 指定配置目录
|
||||
说明 Rust 加速扩展通过 moviepilot-rust PyPI 依赖安装
|
||||
同时尝试编译并验证 Agent 中文全文索引扩展
|
||||
|
||||
cjk:
|
||||
--venv PATH 目标虚拟环境目录,默认 ./venv
|
||||
--check 只验证已安装扩展,不编译或修改文件
|
||||
|
||||
frontend:
|
||||
--version TAG 前端版本,默认使用 version.py 中的 FRONTEND_VERSION
|
||||
@@ -473,7 +481,7 @@ show_command_help() {
|
||||
install)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
""|deps|-h|--help)
|
||||
""|deps|cjk|-h|--help)
|
||||
show_install_help
|
||||
exit 0
|
||||
;;
|
||||
@@ -486,7 +494,7 @@ show_command_help() {
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "仅支持:moviepilot help install、moviepilot help install deps、moviepilot help install frontend、moviepilot help install resources" >&2
|
||||
echo "仅支持:moviepilot help install、moviepilot help install deps、moviepilot help install cjk、moviepilot help install frontend、moviepilot help install resources" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -602,13 +610,18 @@ case "${1:-}" in
|
||||
require_bootstrap_python
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-frontend "$@"
|
||||
;;
|
||||
cjk)
|
||||
shift
|
||||
require_bootstrap_python
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-cjk "$@"
|
||||
;;
|
||||
resources)
|
||||
shift
|
||||
require_bootstrap_python
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" install-resources "$@"
|
||||
;;
|
||||
*)
|
||||
echo "支持的命令:moviepilot install deps|frontend|resources" >&2
|
||||
echo "支持的命令:moviepilot install deps|cjk|frontend|resources" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -6,15 +6,52 @@ Author: Soju06 (upstream PR #65544). MIT license: LICENSE.hermes.
|
||||
SQLite headers in vendor/ are public domain, as stated in those files.
|
||||
Trailing whitespace in sqlite3ext.h is normalized; tokenizer code is unchanged.
|
||||
|
||||
The tokenizer wraps unicode61 and emits overlapping CJK bigrams. It is
|
||||
optional exactly as in Hermes; without it, queries use trigram or a timed
|
||||
LIKE fallback. Compile for the host architecture (never copy binaries
|
||||
across architectures) and install under the Agent runtime lib directory:
|
||||
The tokenizer wraps unicode61 and emits overlapping CJK bigrams. Without
|
||||
it, queries use trigram or a timed LIKE fallback. The C source is unchanged;
|
||||
`install.py` is MoviePilot's installation and verification entrypoint.
|
||||
|
||||
## Automatic installation
|
||||
|
||||
- Docker builds compile for the image's target architecture in `prepare_cjk`.
|
||||
The selected runtime Python (standard or free-threaded) verifies the extension
|
||||
before it is included at `/opt/venv/lib/moviepilot/libfts5_cjk.so`. No compiler
|
||||
is added to the final image, and `/config` mounts do not hide the library.
|
||||
- CLI `setup`, `install deps`, and backend updates compile and verify it using
|
||||
the selected virtual environment. An unchanged, working build is reused.
|
||||
Bootstrap installs the Linux C toolchain when necessary. Missing compilers or
|
||||
incompatible SQLite builds produce an explicit warning and retain search fallback.
|
||||
|
||||
To install or check an existing CLI environment:
|
||||
|
||||
```bash
|
||||
cc -shared -fPIC -O2 -Wall -Wextra -Inative/fts5_cjk/vendor \
|
||||
native/fts5_cjk/fts5_cjk.c -o /path/to/config/agent/runtime/lib/libfts5_cjk.so
|
||||
moviepilot install cjk --venv /path/to/venv
|
||||
moviepilot install cjk --venv /path/to/venv --check
|
||||
```
|
||||
|
||||
Only this fixed local path is loaded; tools cannot provide extension paths.
|
||||
No compiler is invoked by an Agent request.
|
||||
Debian/Ubuntu needs `build-essential`; macOS needs Xcode Command Line Tools
|
||||
(`xcode-select --install`); Windows needs a matching MSVC developer terminal or
|
||||
MinGW compiler. `CC` can select the compiler. Python's SQLite must support both
|
||||
FTS5 and extension loading. Successful verification actually indexes
|
||||
`电影订阅成功` and matches `订阅` in a temporary in-memory database.
|
||||
|
||||
To check a newly built Docker image/container:
|
||||
|
||||
```bash
|
||||
docker exec <container> /opt/venv/bin/python /app/native/fts5_cjk/install.py --check
|
||||
```
|
||||
|
||||
Existing images need to be rebuilt/replaced to receive the packaged extension;
|
||||
updating Python source alone does not install a native library in an old image.
|
||||
|
||||
## Runtime loading
|
||||
|
||||
The loader first tries `<sys.prefix>/lib/moviepilot/libfts5_cjk.so`, then the
|
||||
legacy `<config>/agent/runtime/lib/libfts5_cjk.so` for manual installations.
|
||||
Only these fixed local paths are loaded; tools cannot provide extension paths.
|
||||
No compiler is invoked by an Agent request. Never copy binaries across operating
|
||||
systems or architectures. Configuration backups do not need to contain the library.
|
||||
|
||||
Older messages are indexed in background batches after installation. A completed
|
||||
history query reports `index_status.messages_fts_cjk=true`; eligible two-character
|
||||
queries report `search_path="cjk"`. Installation verification does not claim that
|
||||
every user's historical index has already finished building.
|
||||
|
||||
113
native/fts5_cjk/install.py
Normal file
113
native/fts5_cjk/install.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""安装阶段编译并验收 CJK 扩展;不导入应用,也不访问用户数据库。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
SOURCE = Path(__file__).resolve().parent
|
||||
LIBRARY = "libfts5_cjk.so"
|
||||
|
||||
|
||||
def verify(extension: Path) -> None:
|
||||
"""用当前 Python 的 SQLite 验证动态加载和两字中文子串索引。"""
|
||||
with sqlite3.connect(":memory:") as connection:
|
||||
if not hasattr(connection, "enable_load_extension"):
|
||||
raise RuntimeError("当前 Python 的 SQLite 不支持加载扩展,请更换支持此能力的 Python")
|
||||
try:
|
||||
connection.enable_load_extension(True)
|
||||
connection.load_extension(str(extension))
|
||||
finally:
|
||||
connection.enable_load_extension(False)
|
||||
connection.execute("CREATE VIRTUAL TABLE probe USING fts5(content, tokenize='cjk_unicode61')")
|
||||
connection.execute("INSERT INTO probe VALUES ('电影订阅成功')")
|
||||
count = connection.execute("SELECT count(*) FROM probe WHERE probe MATCH '订阅'").fetchone()[0]
|
||||
if count != 1:
|
||||
raise RuntimeError("CJK 扩展已加载,但两字中文索引验收失败")
|
||||
|
||||
|
||||
def fingerprint() -> str:
|
||||
"""源码、构建逻辑或目标架构改变时重建,不因重复安装而重复编译。"""
|
||||
digest = hashlib.sha256(f"{sys.platform}:{platform.machine()}:{sys.maxsize}".encode())
|
||||
for name in ("install.py", "fts5_cjk.c", "vendor/sqlite3.h", "vendor/sqlite3ext.h"):
|
||||
digest.update((SOURCE / name).read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def compiler_command(output: Path) -> list[str]:
|
||||
"""选择本机 C 工具链;Windows 支持开发者终端中的 MSVC 或 MinGW。"""
|
||||
configured = os.environ.get("CC", "").strip()
|
||||
if configured:
|
||||
compiler = [configured] if Path(configured).is_file() else shlex.split(configured)
|
||||
else:
|
||||
available = next((path for name in ("cc", "gcc", "clang", "cl") if (path := shutil.which(name))), None)
|
||||
if not available:
|
||||
raise RuntimeError(
|
||||
"未找到 C 编译器:Debian/Ubuntu 安装 build-essential;macOS 执行 xcode-select --install;"
|
||||
"Windows 使用 MSVC 开发者终端或 MinGW,再运行 moviepilot install cjk"
|
||||
)
|
||||
compiler = [available]
|
||||
source = str(SOURCE / "fts5_cjk.c")
|
||||
headers = str(SOURCE / "vendor")
|
||||
if Path(compiler[0]).name.lower() in {"cl", "cl.exe"}:
|
||||
return [*compiler, "/nologo", "/LD", "/O2", f"/I{headers}", source, f"/Fe:{output}",
|
||||
f"/Fo:{output.with_suffix('.obj')}"]
|
||||
flags = ["-dynamiclib"] if sys.platform == "darwin" else ["-shared"]
|
||||
if sys.platform != "win32":
|
||||
flags.append("-fPIC")
|
||||
return [*compiler, *flags, "-O2", "-Wall", "-Wextra", f"-I{headers}", source, "-o", str(output)]
|
||||
|
||||
|
||||
def install(extension: Path) -> None:
|
||||
"""临时编译、真实加载验收后原子替换,失败时保留原有扩展。"""
|
||||
expected = fingerprint()
|
||||
stamp = extension.with_suffix(".sha256")
|
||||
if extension.is_file() and stamp.is_file() and stamp.read_text(encoding="utf-8").strip() == expected:
|
||||
try:
|
||||
verify(extension)
|
||||
return
|
||||
except (sqlite3.Error, OSError, RuntimeError):
|
||||
pass
|
||||
extension.parent.mkdir(parents=True, exist_ok=True)
|
||||
with TemporaryDirectory(prefix=".fts5-cjk-", dir=extension.parent) as temporary:
|
||||
candidate = Path(temporary) / LIBRARY
|
||||
subprocess.run(compiler_command(candidate), check=True, capture_output=True, text=True, timeout=120,
|
||||
cwd=temporary)
|
||||
verify(candidate)
|
||||
candidate.chmod(0o644)
|
||||
candidate.replace(extension)
|
||||
marker = Path(temporary) / stamp.name
|
||||
marker.write_text(expected + "\n", encoding="utf-8")
|
||||
marker.chmod(0o644)
|
||||
marker.replace(stamp)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""提供 Docker 和 CLI 共用的安装入口,以及无编译的能力检查。"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=Path(sys.prefix) / "lib/moviepilot" / LIBRARY)
|
||||
parser.add_argument("--check", action="store_true", help="只检查已安装扩展,不编译或修改文件")
|
||||
args = parser.parse_args()
|
||||
extension = args.output.expanduser().absolute()
|
||||
try:
|
||||
if args.check:
|
||||
verify(extension)
|
||||
else:
|
||||
install(extension)
|
||||
except (OSError, RuntimeError, sqlite3.Error, subprocess.SubprocessError) as error:
|
||||
detail = error.stderr.strip() if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error)
|
||||
print(f"CJK 扩展不可用:{detail}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"CJK 扩展已通过两字中文索引验证:{extension}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -457,6 +457,24 @@ ensure_python() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 一键安装准备本机编译器;扩展失败只降级检索,不阻断主程序安装。
|
||||
ensure_cjk_compiler() {
|
||||
if command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
case "$PACKAGE_MANAGER" in
|
||||
apt-get) install_system_packages build-essential ;;
|
||||
dnf|yum|zypper) install_system_packages gcc glibc-devel ;;
|
||||
pacman) install_system_packages base-devel ;;
|
||||
apk) install_system_packages build-base ;;
|
||||
brew)
|
||||
echo "中文索引扩展需要 Xcode Command Line Tools,请执行 xcode-select --install 后重试 moviepilot install cjk。" >&2
|
||||
return 1
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_prereqs() {
|
||||
if [[ "$OS_NAME" == "Windows" ]]; then
|
||||
echo "检测到当前环境为 Windows shell,建议改用 WSL、Linux 或 macOS 终端运行。" >&2
|
||||
@@ -467,6 +485,9 @@ ensure_prereqs() {
|
||||
python_install_hint
|
||||
exit 1
|
||||
fi
|
||||
if ! ensure_cjk_compiler; then
|
||||
echo "C 编译器尚未就绪,Agent 中文检索将暂用回退路径;安装后可用 moviepilot install cjk 补装。" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
|
||||
@@ -2969,12 +2969,29 @@ def install_deps(*, python_bin: str, venv_dir: Path, recreate: bool) -> Path:
|
||||
else:
|
||||
expose_uv_to_venv(uv_bin, venv_dir)
|
||||
install_browser_runtime(venv_python)
|
||||
install_cjk(venv_python, required=False)
|
||||
return venv_python
|
||||
finally:
|
||||
if temporary_uv_dir is not None:
|
||||
temporary_uv_dir.cleanup()
|
||||
|
||||
|
||||
def install_cjk(venv_python: Path, *, required: bool = True, check: bool = False) -> None:
|
||||
"""由目标解释器安装和验收扩展;自动安装失败明确告警,单独补装失败返回错误。"""
|
||||
command = [str(venv_python), str(ROOT / "native/fts5_cjk/install.py")]
|
||||
if check:
|
||||
command.append("--check")
|
||||
try:
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=180, cwd=ROOT)
|
||||
print_step(result.stdout.strip())
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
detail = error.stderr.strip() if isinstance(error, subprocess.CalledProcessError) and error.stderr else str(error)
|
||||
message = f"CJK 扩展安装/检查失败:{detail}"
|
||||
if required:
|
||||
raise RuntimeError(message) from error
|
||||
print_step(f"{message};历史检索暂用 trigram/LIKE 回退,可运行 moviepilot install cjk 重试")
|
||||
|
||||
|
||||
def install_browser_runtime(venv_python: Path) -> None:
|
||||
"""
|
||||
预下载 CloakBrowser 浏览器内核,避免首次仿真登录时才拉取大文件。
|
||||
@@ -3992,6 +4009,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
|
||||
)
|
||||
|
||||
cjk_parser = subparsers.add_parser("install-cjk", help="补装并验证 Agent 中文全文索引扩展")
|
||||
cjk_parser.add_argument("--venv", default=str(ROOT / "venv"), help="目标虚拟环境目录")
|
||||
cjk_parser.add_argument("--check", action="store_true", help="只验证已安装扩展,不编译或修改文件")
|
||||
|
||||
frontend_parser = subparsers.add_parser(
|
||||
"install-frontend", help="下载前端 release 并安装本地运行时"
|
||||
)
|
||||
@@ -4218,6 +4239,10 @@ def main() -> int:
|
||||
)
|
||||
|
||||
try:
|
||||
if args.command == "install-cjk":
|
||||
install_cjk(get_venv_python(Path(args.venv).expanduser().absolute()), check=args.check)
|
||||
return 0
|
||||
|
||||
if args.command == "install-deps":
|
||||
venv_python = install_deps(
|
||||
python_bin=args.python,
|
||||
|
||||
171
tests/test_cjk_install.py
Normal file
171
tests/test_cjk_install.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""验证原生扩展安装、目标解释器验收及部署入口,不访问用户数据。"""
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.history import schema
|
||||
from app.agent.history.storage import SqliteRecallRepository
|
||||
from app.application.messaging.recall import RecallMessage, RecallQuery, RecallSession
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALLER = ROOT / "native/fts5_cjk/install.py"
|
||||
|
||||
|
||||
def load_script(path: Path):
|
||||
"""独立加载纯标准库脚本,避免安装脚本的全局状态污染其他测试。"""
|
||||
spec = importlib.util.spec_from_file_location(f"test_{path.stem}", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def installed_extension(tmp_path_factory):
|
||||
"""在临时目录用生产安装入口真实编译一次,不写入测试环境或用户目录。"""
|
||||
if not any(shutil.which(name) for name in ("cc", "gcc", "clang", "cl")):
|
||||
pytest.skip("原生安装验收需要本机 C 编译器")
|
||||
extension = tmp_path_factory.mktemp("cjk-install") / "libfts5_cjk.so"
|
||||
subprocess.run([sys.executable, str(INSTALLER), "--output", str(extension)], check=True, capture_output=True)
|
||||
return extension
|
||||
|
||||
|
||||
def test_installed_extension_activates_history_and_backfills_old_messages(tmp_path, monkeypatch, installed_extension):
|
||||
"""补装后旧消息完成后台回填,两字搜索切换为 cjk,用户数据仍位于配置目录。"""
|
||||
bundled = tmp_path / "venv/lib/moviepilot/libfts5_cjk.so"
|
||||
monkeypatch.setattr(schema, "BUNDLED_CJK_EXTENSION", bundled)
|
||||
repository = SqliteRecallRepository(tmp_path / "config/agent/runtime")
|
||||
repository.append("alice", RecallSession("old"), (RecallMessage("m", "user", "电影订阅成功"),))
|
||||
assert repository.search("alice", RecallQuery(query="订阅"))["search_path"] == "like"
|
||||
bundled.parent.mkdir(parents=True)
|
||||
shutil.copy2(installed_extension, bundled)
|
||||
while repository.maintain("alice")["pending"]:
|
||||
pass
|
||||
result = repository.search("alice", RecallQuery(query="订阅"))
|
||||
assert result["search_path"] == "cjk" and result["count"] == 1
|
||||
assert result["index_status"]["messages_fts_cjk"]
|
||||
assert repository.database_path("alice").is_relative_to(tmp_path / "config")
|
||||
|
||||
|
||||
def test_repeated_install_validates_without_recompiling(monkeypatch, installed_extension):
|
||||
"""同源码和架构重复安装不需要编译器,检查模式同样只读。"""
|
||||
installer = load_script(INSTALLER)
|
||||
previous = installed_extension.stat().st_mtime_ns
|
||||
|
||||
def unexpected_compile(_output):
|
||||
"""有有效缓存时调用编译器即为回归。"""
|
||||
pytest.fail("有效扩展不应重新编译")
|
||||
|
||||
monkeypatch.setattr(installer, "compiler_command", unexpected_compile)
|
||||
installer.install(installed_extension)
|
||||
subprocess.run([sys.executable, str(INSTALLER), "--output", str(installed_extension), "--check"],
|
||||
check=True, capture_output=True)
|
||||
assert installed_extension.stat().st_mtime_ns == previous
|
||||
|
||||
|
||||
def test_cli_installs_into_selected_venv_and_check_is_readonly(tmp_path, installed_extension):
|
||||
"""真实 CLI 在独立 venv 中安装、检查,运行时可从该解释器前缀加载。"""
|
||||
assert installed_extension.is_file()
|
||||
venv = tmp_path / "selected-venv"
|
||||
subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(venv)], check=True, capture_output=True)
|
||||
setup = ROOT / "scripts/local_setup.py"
|
||||
command = [sys.executable, str(setup), "install-cjk", "--venv", str(venv)]
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
extension = venv / "lib/moviepilot/libfts5_cjk.so"
|
||||
assert extension.is_file() and "两字中文索引验证" in result.stdout
|
||||
previous = extension.stat().st_mtime_ns
|
||||
subprocess.run([*command, "--check"], check=True, capture_output=True)
|
||||
assert extension.stat().st_mtime_ns == previous
|
||||
python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
|
||||
probe = (
|
||||
"import sqlite3; from pathlib import Path; from app.agent.history.schema import load_cjk; "
|
||||
"assert load_cjk(sqlite3.connect(':memory:'), Path('missing-legacy-extension'))"
|
||||
)
|
||||
subprocess.run([str(python), "-c", probe], cwd=ROOT, check=True, capture_output=True)
|
||||
|
||||
|
||||
def test_failed_rebuild_keeps_working_extension(tmp_path, monkeypatch, installed_extension):
|
||||
"""编译失败不覆盖已安装文件,也不留下临时编译目录。"""
|
||||
installer = load_script(INSTALLER)
|
||||
extension = tmp_path / "libfts5_cjk.so"
|
||||
shutil.copy2(installed_extension, extension)
|
||||
original = extension.read_bytes()
|
||||
monkeypatch.setattr(installer, "compiler_command", lambda _output: [sys.executable, "-c", "raise SystemExit(1)"])
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
installer.install(extension)
|
||||
assert extension.read_bytes() == original
|
||||
assert not list(tmp_path.glob(".fts5-cjk-*"))
|
||||
installer.verify(extension)
|
||||
|
||||
|
||||
def test_invalid_bundled_extension_falls_back_to_legacy_and_disables_loading(tmp_path, monkeypatch, installed_extension):
|
||||
"""跨架构或损坏的自带文件不屏蔽旧手工安装,扩展加载权限始终关闭。"""
|
||||
invalid = tmp_path / "libfts5_cjk.so"
|
||||
invalid.write_bytes(b"invalid library")
|
||||
monkeypatch.setattr(schema, "BUNDLED_CJK_EXTENSION", invalid)
|
||||
with sqlite3.connect(":memory:") as connection:
|
||||
assert schema.load_cjk(connection, installed_extension)
|
||||
with pytest.raises(sqlite3.OperationalError, match="not authorized"):
|
||||
connection.load_extension(str(installed_extension))
|
||||
|
||||
|
||||
def test_missing_compiler_reports_repair_command(monkeypatch):
|
||||
"""缺少编译器时明确说明补装方式,不能宣称扩展已启用。"""
|
||||
installer = load_script(INSTALLER)
|
||||
monkeypatch.delenv("CC", raising=False)
|
||||
monkeypatch.setattr(installer.shutil, "which", lambda _name: None)
|
||||
with pytest.raises(RuntimeError, match="moviepilot install cjk"):
|
||||
installer.compiler_command(Path("unused"))
|
||||
|
||||
|
||||
def test_cli_uses_selected_interpreter_and_distinguishes_optional_failure(tmp_path, monkeypatch, capsys):
|
||||
"""自动安装告警降级,显式补装失败返回错误;两者都使用指定 venv。"""
|
||||
setup = load_script(ROOT / "scripts/local_setup.py")
|
||||
python = tmp_path / "custom-venv/bin/python"
|
||||
calls = []
|
||||
|
||||
def fail(command, **_kwargs):
|
||||
"""模拟目标解释器缺少扩展加载能力。"""
|
||||
calls.append(command)
|
||||
raise subprocess.CalledProcessError(1, command, stderr="SQLite extension loading unavailable")
|
||||
|
||||
monkeypatch.setattr(setup.subprocess, "run", fail)
|
||||
setup.install_cjk(python, required=False)
|
||||
assert "回退" in capsys.readouterr().out
|
||||
with pytest.raises(RuntimeError, match="SQLite extension loading unavailable"):
|
||||
setup.install_cjk(python, check=True)
|
||||
assert calls == [[str(python), str(INSTALLER)], [str(python), str(INSTALLER), "--check"]]
|
||||
|
||||
|
||||
def test_dependency_install_includes_cjk_and_parser_supports_repair(tmp_path, monkeypatch):
|
||||
"""deps 是 setup 和后端升级的共用入口,每次同步后都检查 CJK 扩展。"""
|
||||
setup = load_script(ROOT / "scripts/local_setup.py")
|
||||
calls = []
|
||||
monkeypatch.setattr(setup, "ensure_supported_python", lambda _python: None)
|
||||
monkeypatch.setattr(setup, "require_uv", lambda: tmp_path / "uv")
|
||||
monkeypatch.setattr(setup, "expose_uv_to_venv", lambda *_args: None)
|
||||
monkeypatch.setattr(setup, "run", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(setup, "install_browser_runtime", lambda _python: None)
|
||||
monkeypatch.setattr(setup, "install_cjk", lambda python, **kwargs: calls.append((python, kwargs)))
|
||||
python = setup.install_deps(python_bin=sys.executable, venv_dir=tmp_path / "venv", recreate=False)
|
||||
assert calls == [(python, {"required": False})]
|
||||
args = setup.build_parser().parse_args(["install-cjk", "--venv", str(tmp_path), "--check"])
|
||||
assert args.venv == str(tmp_path) and args.check
|
||||
|
||||
|
||||
def test_docker_packages_extension_outside_config_and_verifies_selected_python():
|
||||
"""镜像按目标架构构建并随 venv 交付,普通和 free-threaded 环境均需真实验收。"""
|
||||
dockerfile = (ROOT / "docker/Dockerfile").read_text(encoding="utf-8")
|
||||
stage = dockerfile.split("FROM base AS prepare_cjk", 1)[1].split("FROM base AS prepare_package", 1)[0]
|
||||
assert "build-essential" in stage
|
||||
assert "install.py --output /native/libfts5_cjk.so" in stage
|
||||
selected = dockerfile.split("FROM verify_venv_${MOVIEPILOT_PYTHON_VARIANT} AS prepare_venv", 1)[1]
|
||||
assert "COPY --from=prepare_cjk /native/ ${VENV_PATH}/lib/moviepilot/" in selected
|
||||
assert '"${VENV_PATH}/bin/python" /tmp/verify-cjk.py --check' in selected
|
||||
assert "/config/" not in stage
|
||||
Reference in New Issue
Block a user