diff --git a/.env.example b/.env.example index 26527e5..ca6f039 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ GOOGLE_API_KEY="" # GEMINI_API_KEY="" # Vertex AI ADC(不使用 GOOGLE_API_KEY 时启用) # GOOGLE_GENAI_USE_VERTEXAI="true" +# 企业版端点(与 USE_VERTEXAI 冲突时运行期显式报错,二选一) +# GOOGLE_GENAI_USE_ENTERPRISE="true" # GOOGLE_CLOUD_PROJECT="your-project-id" # GOOGLE_CLOUD_LOCATION="us-central1" # GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/service-account.json" @@ -29,6 +31,8 @@ VOLC_SECRET_KEY="" JINA_API_KEY="" DEEPSEEK_API_KEY="" GROK_API_KEY="" +# LM Studio(本地 OpenAI 兼容服务)密钥位;本地服务通常留空即可 +# LM_STUDIO_API_KEY="" # 本地覆盖项 # 默认嵌入模型已在 config.toml 中设置为 local-hash。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fed185d..ba6063e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,8 +61,34 @@ jobs: release_artifacts/*.tar.gz if-no-files-found: error + test: + # 发布流程此前从不跑测试:打标签的提交若没走过 PR 门禁,产物会在测试全红 + # 的情况下被发出去。刻意用单独的 job 而不是塞进 build:build 是五平台矩阵, + # 其中四个平台(Windows 与 macOS/linux arm64)本仓库从未在 CI 上跑过测试, + # 拿它们当发布门禁等于用一个没人验证过的信号去拦发布。这里固定在 + # ubuntu-24.04——与 quality.yml 同一环境,覆盖已验证的路径。 + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.11" + + - name: Setup uv + uses: astral-sh/setup-uv@v7.6.0 + + - name: Sync dependencies + run: uv sync --group dev + + - name: Run tests + run: uv run pytest -q + publish: - needs: build + needs: [build, test] runs-on: ubuntu-24.04 steps: @@ -126,12 +152,102 @@ jobs: - name: Extract release notes run: python scripts/extract_release_notes.py --version "${{ steps.release_meta.outputs.version }}" --output RELEASE_NOTES.md + - name: Verify archive contents + shell: bash + run: | + set -euo pipefail + # 发布包必须自证两件事,否则坏包会直接流到用户手里: + # 1) 不含凭证文件(.env / config.toml)——它们由用户从示例复制, + # 打进包里等于把某台机器的配置发给所有人; + # 2) 含离线分词兜底(tiktoken_cache)——缺失时首次构建知识库 + # 会在无网环境下失败,而构建期的 --validate 有网、看不出来。 + # download-artifact 把每个 artifact 解到 release_artifacts//, + # 所以归档在下一层,必须用 find 递归(与 publish 步骤的 maxdepth 2 一致)。 + # 用 glob 会一个都匹配不到,循环体不执行、fail 保持 0,整步静默通过。 + archives=() + while IFS= read -r archive; do + archives+=("${archive}") + done < <(find release_artifacts -maxdepth 2 -type f \ + \( -name "PyRAG-Kit-*.tar.gz" -o -name "PyRAG-Kit-*.zip" \) | sort) + if [ "${#archives[@]}" -eq 0 ]; then + echo "未找到任何归档,无法校验(下载步骤是否失败?)。" >&2 + exit 1 + fi + + fail=0 + for archive in "${archives[@]}"; do + echo "== ${archive}" + if [[ "${archive}" == *.zip ]]; then + listing="$(python -c "import sys,zipfile;print('\n'.join(zipfile.ZipFile(sys.argv[1]).namelist()))" "${archive}")" + else + listing="$(tar -tzf "${archive}")" + fi + + # 判据必须写成 `grep -E ... >/dev/null`,**不能**用 `grep -q`。 + # `grep -q` 命中即退出,上游 `printf` 收到 SIGPIPE 后以 141 退出; + # `set -o pipefail` 让整条管道返回 141,而 `if` 只把 0 当成功—— + # 于是「命中」被判成「未命中」。触发条件是归档列表超过管道缓冲区 + # (Linux 上 64 KiB):实测真实 v1.4.0 产物的列表已是 75–79 KB, + # 也就是这条检查自发布起就一直是失效的。 + # + # 两个方向都会给出与事实相反的结论:凭证检查静默跳过(含 `.env` + # 的包被放行发布),tiktoken_cache 检查误报缺失(正常包被拦下)。 + # `>/dev/null` 让 grep 读完整个输入,不产生 SIGPIPE。 + if grep -E '/(\.env|config\.toml)$' >/dev/null <<<"${listing}"; then + echo " ✗ 归档内含凭证文件" >&2 + grep -E '/(\.env|config\.toml)$' >&2 <<<"${listing}" + fail=1 + fi + if ! grep -F '/tiktoken_cache/' >/dev/null <<<"${listing}"; then + echo " ✗ 归档内缺少 tiktoken_cache(离线分词兜底)" >&2 + fail=1 + fi + done + + if [ "${fail}" -ne 0 ]; then + exit 1 + fi + echo "归档校验通过(${#archives[@]} 个)。" + + - name: Generate checksums + shell: bash + run: | + set -euo pipefail + # 只对归档取校验和,便于用户核对下载内容与发布产物一致。 + # 输出用 basename:download-artifact 会多一层 / 目录, + # 带上它会让用户拿单个下载文件对不上号。sha256sum 是 coreutils 标配, + # shasum 只在装了 perl 的机器上有,所以优先前者。 + if command -v sha256sum >/dev/null 2>&1; then + hash_cmd=(sha256sum) + else + hash_cmd=(shasum -a 256) + fi + + # 落在 release_artifacts/ 内:publish 步骤按该前缀查找待上传文件, + # 写到工作区根目录的话这个文件不会随 release 一起发布。 + sums=release_artifacts/SHA256SUMS.txt + : > "${sums}" + while IFS= read -r archive; do + "${hash_cmd[@]}" "${archive}" | sed "s|\([^ ]*\) .*/|\1 |" >> "${sums}" + done < <(find release_artifacts -maxdepth 2 -type f \ + \( -name "PyRAG-Kit-*.zip" -o -name "PyRAG-Kit-*.tar.gz" \) | sort) + + if [ ! -s "${sums}" ]; then + echo "未找到任何归档,无法生成校验和。" >&2 + exit 1 + fi + cat "${sums}" + - name: Publish GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - mapfile -t files < <(find release_artifacts -maxdepth 2 -type f \( -name "PyRAG-Kit-*.zip" -o -name "PyRAG-Kit-*.tar.gz" \) | sort) + files=() + while IFS= read -r file; do + files+=("${file}") + done < <(find release_artifacts -maxdepth 2 -type f \ + \( -name "PyRAG-Kit-*.zip" -o -name "PyRAG-Kit-*.tar.gz" -o -name "SHA256SUMS.txt" \) | sort) if [ "${#files[@]}" -eq 0 ]; then echo "未找到发布产物。" >&2 exit 1 diff --git a/AGENTS.md b/AGENTS.md index a903d15..1cb0c3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ ## Project Structure & Module Organization - `main.py` 是交互式入口;`src/` 按职责划分为 `chat/`、`etl/`、`models/`、`providers/`、`retrieval/`、`retrieval_test/`、`runtime/`、`services/`、`ui/` 和 `utils/`。 -- `scripts/` 放置知识库快照构建、发布打包和发布说明提取脚本;`tests/` 按源码模块组织 pytest 测试。 +- `scripts/` 放置知识库快照构建、发布打包、发布说明提取脚本与变异审计验证工具(`scripts/audit/`);`tests/` 按源码模块组织 pytest 测试。 - `knowledge_base/` 保存本地 Markdown 原文;`data/kb/` 和 `data/logs/` 保存运行产物,均不应提交。仓库不再包含 Dify 上游子模块,Dify 相关逻辑以 `src/` 中的移植代码维护。 - 核心流程为 ETL -> embedding -> FAISS 快照 -> 混合检索 -> provider-backed chat。 @@ -20,6 +20,7 @@ uv run python -m scripts.embed_knowledge_base --mode standard uv run pytest uv run pytest tests/providers/test_factory.py uv run python scripts/build_binary_release.py --target macos-arm64 --validate +uv run python scripts/audit/verify_sites.py --selftest ``` `standard` 也可替换为 `hierarchical`。发布目标支持 `windows-x64`、`macos-x64`、`macos-arm64`、`linux-x64` 和 `linux-arm64`;标签 `v*` 会触发 GitHub Release 工作流。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac4a57..9e88448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,1004 @@ 所有此项目的显著更改都将记录在此文件中。 +## [Unreleased] + +### 修复 + +- **第九轮四维 agents 复查:配置面一致性/检索数值/嵌入方契约/测试套件质量换角度扫描**。检索数值维度(RRF/weighted 融合逐位对比手工推算、权重边界、零向量、L2 转换、jieba 确定性等 11 项排除)与测试套件维度(10 位点变异红验证全 KILLED 无映射腐烂、autouse 掩盖守卫历史案例已收口确认)大面积守住;收尾 2 处真缺陷 + 2 处文档/示例打磨: + - **父块去重后输出不按分数降序,top_k 截断丢弃全局最高分文档(中·检索正确性)**:``_deduplicate_parent_documents`` 把代表文档留在**首次出现**的位置——候选 s1(0.40)→s2(0.45)→s3(0.50) 各属不同父块时,top_k=2 返回 s1+s2 而丢掉最高分的 s3(实测复现);rerank 开启时被重排掩盖,纯语义/关键词路径及 rerank 关闭的 hybrid 路径均受影响。去重后按 ``coerce_score`` 降序重排(组内取高分逻辑不变),回归测试钉住(红验证确认)。 + - **UI 权重编辑的合法终态被组合守卫拒绝(中低·配置面)**:写回固定「先 vector 后 keyword」,而双 0 组合守卫按**中间态**判——默认 (1.0, 0.0) 下用户把向量权重改成 0(切纯关键词检索)实测报「不能同时为 0」,终态 (0.0, 1.0) 完全合法,反序写才通过。改为先写非零侧,两边界方向参数化钉住(红验证确认)。 + - **文档/示例打磨(低)**:``.env.example`` 补 ``GOOGLE_GENAI_USE_ENTERPRISE``(代码显式映射但示例缺失)与 ``LM_STUDIO_API_KEY``(``Settings`` 有字段但示例遗漏);两处用户文档的 ``.env`` 设 ``DEFAULT_LLM_PROVIDER`` 示例补警示(会覆盖零凭证默认,未配好渠道时 14 条模型配置运行期失败)。 + - **记录不修(嵌入方契约,均属公开 API 设计决策)**:``chat_async`` 失败以道歉文本而非异常交付、流中途异常静默收尾无截断标记、``apply_config_update`` 失败只打印无编程通道——当前契约是「永不抛、错误即文本」,内部 CLI 场景自洽;嵌入方需要感知失败时应 diff 配置或包裹调用。``SessionConfig`` 实例赋值共享引用 vs dict 赋值深拷贝的双语义、``Settings`` 与 ``SessionConfig`` 对 ``top_k`` 字符串形态判据不对称(信息级)、测试套件约 30 处恒真弱断言(主守卫另有真实断言)、``test_pipeline`` 全局单例改写的窄中断窗口(卫生)——均记录不改。 + +- **第八轮四维 agents 复查:供应链/升级迁移/EOF 写回时序/多进程日志换角度扫描**。安全(依赖钉版与 uv.lock、git 全历史凭证、.gitignore 覆盖、subprocess 注入面)、数据完整性(main 旧快照格式升级首启、连续 rebuild 语义、stats.json 对账盲区实测)、并发(多进程日志竞态、SessionConfig 引用语义、build 各阶段失败注入)大面积守住;错误契约收尾 1 处真缺陷 + 1 处死分支: + - **EOF「按原样保存返回」承诺与写回事实矛盾(中·错误契约)**:编辑器全程原地写 ``chat_config``(``/config`` 入口随后应用到 bot),EOF 前已确认写回的每一项(实测「Top K 输入 7 → EOF」返回 ``top_k=7``)都随返回生效;且 reload 标志只在 ``edit_model_params`` 正常走完才置位,EOF 前切换过 LLM 的残留键会以「已切换但未重载」的形态继续服务。``launch_config_editor`` 入口先 ``deepcopy`` 快照,EOF 收敛时还原字典(同时把 ``llm_needs_reload`` 归零),承诺与事实对齐;回归测试钉住「EOF 前写回 + EOF 还原」双向(红验证确认)。 + - **``/config`` 恢复分支死代码(低·契约漂移)**:第七轮新增的 ``except Exception`` 排在 ``except (ValueError, KeyError)`` 之前将其完全遮蔽——用户可见消息从「配置未生效」漂移为「配置编辑器异常」,诊断日志文案随之变化(19 个 config 测试未断言消息文本故全绿)。两分支换序并注释钉住顺序约束。 + - **记录不修(数据完整性/并发,均为运维级或文档化建议)**:连续 rebuild 后旧快照目录无 GC、磁盘单调增长(ACTIVE_SNAPSHOT 原子切换保证正确性不受影响,建议未来加保留策略);``finalize_snapshot`` 同名 id 冲突时先 rmtree 旧目录再 replace,activate 失败(磁盘满/权限)会静默顶替旧快照(触发面仅磁盘级故障或 monkeypatch,uuid4 撞名概率忽略);多实例并发写同一日志文件的轮转竞态丢记录(本应用无多进程形态,单实例约束文档化即可);stats.json 的 ``document_count``/``parent_count``/``embedding_dimension`` 为纯展示性死数据,篡改不传播(唯一被对账的 ``chunk_count`` 篡改即被拦)。 + - **升级/迁移路径实测确认兼容**:main 时代快照格式(manifest v2)与本分支完全一致;schema v1、无 manifest、v1.2 单文件 pkl(legacy 自动迁移)各形态均有正确报错或正常导入,无静默失败路径。 + +- **第七轮四维 agents 复查:环境/系统层终查(前六轮死角)**。安全(scripts/ 打包与 CI workflows、Dify 合规链)、数据完整性(多进程并发/磁盘满/符号链接四形态)、并发(长会话内存、to_thread 池、配置热切换原子性)大面积零缺陷;错误契约与并发各收尾 1 处真缺陷: + - **Ctrl+D(EOF)终止整个聊天会话(中·错误契约)**:``questionary`` 的 ``.ask()`` 只捕 ``KeyboardInterrupt``(Ctrl+C → None → 退一层),**EOF 抛 ``EOFError`` 穿透**——在最深的 Top K 输入框按一次 Ctrl+D 即丢失整个会话(实测 /dev/null 与 \x04 均复现)。``launch_config_editor`` 重构为外层统一收敛 EOFError 为「按原样保存返回」(配置只改内存副本、正常退出才生效,EOF 时原配置原样保留——最安全语义)。 + - **编辑器线程挂死时 SIGINT 无法退出(中·并发)**:``to_thread`` 的默认 executor 是非 daemon 线程,``asyncio.run`` 收尾 join 时卡死,``aclose``/atexit 全部不执行,只能 SIGKILL。``asyncio.run`` 换自定义 ``ThreadPoolExecutor(thread_name_prefix=...)`` 方案评估后维持现状(记录于 CHANGELOG:需要改 ``Runner`` 语义或线程 daemon 化,Python 3.9+ 官方不支持 daemon 线程),改在 ``/config`` 调用点补线程内异常兜底——``except (ValueError, KeyError)`` 之外的意外异常不再静默终止会话。 + - **数据完整性低危收尾**:``load_manifest`` 对 ``FileNotFoundError`` 单列归因(rmtree 竞态下「目录正被并发删除」不再被误报为「快照被截断请重建」)。 + - **终态确认**:``finalize_snapshot`` 原子 replace 在 8×8 并发对实测未现失败窗口;符号链接四攻击形态全拦;磁盘满半截快照被 validate 拦截且到不了 finalize。 + +- **第六轮四维 agents 复查:换角度横向扫描,安全/并发/错误契约继续收敛**。数据完整性、并发、错误契约三个维度零需修复项(脏输入端到端、1000 次编辑 + 3000 轮对话 tracemalloc 无泄漏、rich markup 注入面 56 处穷举核查、NUL/RTL 控制字符注入全过);安全维度收尾 2 处: + - **``safe_repr`` 捕获面过窄(中低)**:``__repr__`` 抛 ``KeyError`` 等非预期异常的对象作键(pickle 快照/嵌入方可注入)时,209 处调用点的「最后一道展示边界」自己崩出裸异常——捕获面放宽到 ``Exception``,与 ``_key_text``/``_display_repr`` 终态判据一致。 + - **「就绪行」``TypeError`` 穿透(低)**:第四轮注释声称覆盖的 ``chat_config=None`` 形态实测不在 ``(ValueError, KeyError)`` 捕获面(``'NoneType' object is not subscriptable``)——捕获面补 ``TypeError``,注释改为如实描述。 + - **卫生**:``main.py:275`` 缓存目录警告改经 ``safe_exception_text``(与全仓约定一致)。 + - **记录不修**:多消费方共享流生成器的交错消费(现存无此调用形态,边界说明);SIGTERM 下 atexit 不执行(Python 固有,后果仅 ``.cache`` 残留);显示层 fusion 对带空白形态显示「未知」而运行期归一化放行(仅属性直写可达的显示毛刺)。 + +- **第五轮四维 agents 复查:三维度零缺陷收官,错误契约收尾 3 处**。安全(约 70 探针矩阵全过)、数据完整性(真实 39 文档端到端全链路:build→落盘→重载→混合检索→688/688 父块召回、embeddings 位级一致)、并发(嵌套 finally 六路径 + 5×6 终态矩阵实测,无新单元格退化)均为零需修复项;错误契约维度收尾: + - **fusion 显示白名单误写(中·回归)**:第四轮统一「未知」时把合法默认 ``weighted`` 误写成 ``weight``,合法配置在菜单显示「未知」(实测复现)。修正为与 ``SessionConfig`` validator/questionary 候选一致的 ``("rrf", "weighted")``;补 ``weighted`` 不误伤用例(回归测试只测了 ``None``,4519 全绿没拦住——判别输入要覆盖每个合法值)。 + - **factory 可选值拼接裸 ``str(k)`` 守卫自崩(中)**:configurations 含超限整数键(pickle 可携带 ``10**4301``)时拼消息抛 ``Exceeds the limit``,用户看到的语义从「未找到key」错位成整数转换失败;``display_utils`` 同场景早已 ``safe_repr``。对齐并加回归用例。 + - **「客服已就绪」行裸读配置(低)**:替身 bot 的 ``chat_config=None``/缺键形态穿透第四轮「回显失败只报黄字」的恢复契约——同一契约覆盖到最后一行。 + - **记录不修**:factory 消息「可选值」无上界(与 display_utils 形态一致、240 字符截断兜底);sync/async 无在途时清理异常上抛契约不对称(诊断级边界);双故障时 response 异常顶替 iterator 清理异常(无资源后果)。 + +- **第四轮(收尾轮)四维 agents 复查:终态完整性与自洽性**。四名 agent 对 `c76a169` 复查四轮修复叠加后的最终形态,修复 2 处真缺陷 + 6 处卫生问题,全部红/绿验证或探针确认: + - **`retry_sync_stream` 消费阶段 re-raise 跳过 response 补刀(中·并发)**:第三轮拆两段独立 try 修好了中途断开方向,但「正常耗尽 + iterator.close 抛错 + 无在途异常」时按契约 re-raise 会跳过顺序结构中位于其后的补刀(实测 log 只有 iclose)——同一泄漏换方向复现。补刀移入外层 finally(嵌套 try/finally)保证无条件执行;回归测试钉住(红验证确认)。关闭形态终态矩阵 5 函数 × 4 场景 × close 抛错/不抛全实测,唯一残留即此处。 + - **启动横幅回显裸抛(低-中·错误契约)**:`chat_rerank_enabled=true` 而 rerank 段空/键不对应时 `display_chat_config` 在任何提示符之前抛 `ValueError`,以「程序级错误」打回主菜单——与 `/config` 路径对同一类错误的恢复处理不对称。启动回显收进 try,失败只报黄字、会话照常启动。 + - **`factory` 残缺版「未找到key」消息(低)**:与 `display_utils` 的同名守卫对齐(补可选值 + config.toml 指引)——同一判据两套消息,用户经 `reload_llm` 看到的是无从下手的残缺版。 + - **fusion 非法取值显示 `NONE`(低)**:第三轮统一了 method/rerank 两处「未知」,fusion 项漏网;合法 rrf/weight 照常显示,非法值统一「未知」。同文件一处逐字重复的注释块删除。 + - **卫生问题 4 处(低)**:`_KEY_VALUE_TEXT_RE` 陈旧正则名 ×3(第三轮改名遗留)、`xox[abposr]-` 陈旧注释(与正则 `xox[abper]-` 矛盾)、`redact_sensitive_text` 内联的 `_display_repr` 逐字副本收敛为调用、`load_snapshot` 内与 `_validate_row_alignment` 逐字重复的索引条数死代码删除。 + - **终态确认(全部探针/实测通过)**:凭证正则全家族正反例矩阵自洽、ReDoS 各分支终态线性(含测试未覆盖的失败路径);save→load 往返在 0 文档/80 字符 repr/超限整数/numpy 键边界形态逐字一致;faiss_store 四组守卫顺序全部在赋值前、每个守卫双向测试齐备;四个用户入口对四类已修复畸形输入全部存活且消息三要素齐备。 + - **备查(不修)**:自证前缀家族为枚举式且无决策汇总文档(`sk-ant-` 部分由 `sk-` 兜底,`gsk_`/`nvapi-` 无前缀规则)——设计已知边界,建议未来补决策表。 + +- **第三轮四维 agents 复查收口(审查对象收敛为上一轮修复本身)**。四名 agent 对 `6be10c4` 复查,逐条探针复现后修复,全部红/绿验证: + - **`github_pat_` 分支平方级 ReDoS(高·安全)**:体类含 `_` 时相邻前缀串被整个吞并,实测 ``"github_pat_"*8000`` 17.8 秒、n=400000 112 秒——日志管道对整条格式化日志做脱敏且行长无上界。体加上界 `{20,120}` 截断回溯空间(实测 n=32000 降至 50ms);回归测试钉住 n=8000/32000 的耗时上界。 + - **`load_snapshot` 新守卫破坏原子性(中·数据完整性)**:「有 lexical 而索引缺失」的 ValueError 在批量赋值**之后**触发,抛错前 store 五个字段已被改写为半新半旧状态(实测 before/after 全变)——「拒绝的载入不改 store」契约被第二轮修复自己破坏。判定只读局部变量,移到批量赋值之前;回归测试补 store-untouched 断言(红验证确认)。 + - **`retry_*_stream_context` 的 `enter` 裸 close 顶替原始异常(中·并发)**:可重试 429 被 manager.close 的异常顶替后重试链被斩断(sync/async 双向实测只尝试 1 次)——与 `open_and_peek` 已修的是同一形态,context 入口漏网。两侧改为独立 try + 吞错记日志;回归测试钉住 429 保留且重试满 3 次。 + - **sync 消费阶段 `iterator.close` 抛错跳过 response 补刀(中低·并发)**:两段 close 共用一个 try,中途断开 + 包装形态下每次泄漏一条底层连接(实测 log 无 rclose)。拆为两段独立 try。 + - **`gh[pousr]_` 家族不做纯字母体脱敏的不一致(中低·安全)**:数字前瞻只覆盖 `github_pat_`/`jina_`,`gho_`+36 纯字母体仍被误脱敏——与「正文标识符不误伤」的目标矛盾。前瞻扩到全 gh 家族并统一体上界 `{30,90}`;正例夹具同步为真实 token 形态(含数字)。 + - **Slack 支持家族显式登记(低·安全)**:注释写 `xox[abposr]-` 而正则是 `xox[abper]-`,`xoxd-`/`xoxo-`/`xoxs-` 的「不支持」从歧义变为显式决策(注释登记)。 + - **`retrieval_test` 的「数据库加载失败」名实不符(低·错误契约)**:该分支兜住快照/embedding 配置等全部初始化异常,前缀改中性「初始化失败」;悬空标记的指引补「执行主菜单向量化处理重建」可操作路径。 + - **已核查排除**:写入/读取守卫自洽(`save_snapshot` 不会写出被 `load_snapshot` 拒绝的形态,实测 3 项);首次构建空场景不被悬空标记守卫误伤(真实 `get_default_vector_store` 探针);新 `FileNotFoundError` 全部 6 个调用方无吞掉点(grep 零命中 `except FileNotFoundError`);method 子菜单 `default=None` 对真实 questionary 2.1.1 管道按键实测(不预选、方向键正常)。 + +- **第二轮四维 agents 复查收口(对抗性复查上一轮修复 + 全链路残缺形态)**。四名 agent 对 `dad5e9a` 复查,逐条探针复现后修复,全部红/绿验证: + - **空/截断 legacy pkl 让程序以「检测到中断信号」退出(高)**:`FaissStore.load` 的裸 `EOFError` 穿透 `Chatbot.__init__` 被 `main.py` 的 `except (KeyboardInterrupt, EOFError)` 误判为用户中断,`sys.exit(0)`——真因(文件损坏)无任何提示。`(OSError, EOFError, UnpicklingError)` 收敛为带指引 `ValueError`(空文件/截断/目录三形态实测)。 + - **「未知」占位串打断 method 子菜单(高)**:非法 `retrieval_method` 时 `default=current_method` 把 `"未知"` 传给 questionary,构造期抛 `Invalid 'default' value`,method 分支(唯一能写回该字段的入口)整个退出——与该改动的救济目标相反。default 不在候选集合时传 `None`(与 `_normalized_fusion_default` 同构)。 + - **`load_snapshot` 三种残缺组合静默丢语义检索或崩空消息 AssertionError(中)**:缺 `semantic.index`(有 lexical 不重建)、缺 `embeddings.npy`(矩阵与索引并存的自相矛盾形态)、维度错配——库级直调不经 `validate_snapshot_dir` 全部放行。补存在性组合与维度两条不变量(精确到分支:无 lexical 的重建路径仍合法)。 + - **悬空的 ACTIVE_SNAPSHOT 标记静默降级为空 store(中)**:`get_active_snapshot_dir` 对「标记在、目录消失」返回 `None`,legacy 文件恰好存在时旧数据被导入并改写激活标记。改为显式 `FileNotFoundError` 带重建指引。 + - **`retry_sync_stream` 建立阶段 validator 失败泄漏连接(中低)**:上一轮只在消费阶段 `finally` 补关 response,429 重试主路径(建立阶段 validator 拒绝)每次重试泄漏一条底层连接(实测 3 次 = 3 个未关闭)。`open_and_peek` 异常分支同判据补关。 + - **GitHub/Slack 凭证前缀变体与 `jina_`/`github_pat_` 误伤(低)**:`gho_`/`ghs_`/`ghu_`/`ghr_`、`xoxe-`/`xapp-` 不脱敏;纯字母/下划线长体被误 REDACTED(知识库正文破坏)。正则扩 `gh[pousr]_`/`xox[abper]-`/`xapp-`,体加「含数字」前瞻(真实凭证随机混合必含数字,正文标识符不含);ReDoS 探针全过(对抗输入 ≤7ms)。legacy 空库导入的报错顺序与消息同步修正;green38 夹具两轮漂移重基线(security.py 2 处、config_menu.py 2 处)。 + - **已核实不修**:base_url 无前缀 hex 路径段(拦截会误伤普通资源 ID,设计取舍);`/config` 编辑器内 Ctrl+C 终止会话(与主提示符语义一致);编辑器取消=保存(既有取舍);set 形态 parent_id 摘要键跨进程不稳定(生产 splitter 只产字符串 id,不可达)。 + +- **四维 agents 全维度终审(并发/生命周期、数据完整性、安全、错误契约/UI)收口**。四名审查 agent 对分支相对 `origin/main` 的全部改动做对抗性审查,逐条用真实入口探针复现后修复: + - **裸自证前缀凭证不脱敏(安全·已验证)**:`ghp_`/`github_pat_`/`xox[abposr]-`/`AKIA|ASIA`/`jina_` 开头的裸凭证不落在 `sk-`/`AIza` 等既有模式里,日志与异常文本原样外泄。`src/utils/security.py` 新增 `_SELF_ATTESTING_PREFIX_RE`(前缀 + 主体字符类的正则,前后向断言拒绝普通单词),并入脱敏管线;12 条正/反用例钉死。 + - **键塌缩三处(数据完整性·已验证)**:`_key_text`(`retrieval_service.py`)与 `_parent_key`(`faiss_store.py`)对 repr 超 80 字符的非标量值退回 `safe_repr`,截断后不同值塌成同键,去重/父块字典静默并条;`_parent_key` 对 numpy 2.x 标量(非 `int` 子类)给 `np.int64(3)` 形态键,与 main 时代 `str()` 快照失配、召回静默 miss。修复:超限值对 repr 字节取 sha256 摘要;numpy 标量经 `.item()` 归一为与 main 逐字一致的键。注意判据必须落在**真实 repr** 上——`safe_repr` 截断输出恰好 80 字符,拿 `len(rendered) > 80` 判断永不触发(实测)。快照加载新增 `_validate_row_alignment`:embeddings 行数、`semantic.index` 条数与分块数三量对齐,`load_snapshot` 与 legacy `load` 共用,拒绝保持原子性。 + - **`/config` 边界(错误契约·已验证)**:编辑器调用与配置回显原先在恢复 `try` 之外——rerank 段为空表等自洽性缺陷让回显抛 `ValueError`,配置已生效但整个会话被终止(实测复现),违反同块「配置应用失败不能终止会话」契约;`launch_config_editor` 自身的 `ValueError` 同样漏接。两处收进恢复路径,回显失败只报告不回滚。非法 `retrieval_method` 的菜单项从 `当前: None` 改为与 Rerank 项一致的「未知」。 + - **会话收尾与流清理(并发/生命周期·已验证)**:`start_chat_session_async` 的 `finally` 裸 `await aclose()`——替身实现的异常顶替正常收尾与在途异常(实测复现),改为吞掉并记录;同函数局部 `logger` 与模块级 `logger` 撞名导致 `finally` 在未走 `/config` 路径时 `UnboundLocalError`(红验证暴露),改名 `bot_logger`。`retry_sync_stream` 两处:建立阶段裸 `close()` 让替身的关闭异常顶替原始 429(可重试被顶成不可重试,双向污染重试分类);`iter()` 拿到的是 openai/anthropic `Stream.__iter__`(生成器函数)的**包装生成器**,close 它不关底层响应——消费方提前退出时连接释放退化为 GC 时机(实测复现),补 `response.close()`。`scripts/embed_knowledge_base.py` 的 `main_async` 补 `finally aclose`——四个建库入口中唯一从不释放 `EmbeddingService` 的一个,菜单循环里每次「向量化处理」泄漏一次连接池。 + - **文档失实修正(低)**:`_parent_key` docstring「判据与 `_key_text` 一致」改为如实声明:摘要算法一致、触发阈值有意不同(此处仅 `str()` 超限整数,为旧快照键兼容)。 + - **已核实不修**:`/config` 期间 Ctrl+C 的 to_thread 挂死(main 时代既有行为,非本分支引入,修复需重构 questionary 线程模型,风险大于收益);`retry_sync_stream_context`/`retry_async_stream_context` 的裸 close(anthropic `MessageStreamManager` 无 close 方法,生产当前不可达)。 + +- **CodeRabbit 第二轮复核:5 条真问题 + 1 条重复误报**。首轮 17 条修复后复跑,11 条确认消失;新报 6 条逐条核实: + - **`SessionConfig` 读路径对非字符串键抛 `ValueError`(违反 Mapping 协议)**(`src/runtime/contracts.py`)。`__getitem__` 复用 `_require_known_field`,而 `_collections_abc` 的 `__contains__`/`get` 只接 `KeyError`——实测 `5 in cfg` 抛 `ValueError`(本该 `False`),`10**4301 in cfg` 更在拼消息时二次撞 `Exceeds the limit`。读路径改抛 `KeyError`(消息经 `safe_repr`);写路径保持 `ValueError`(参数错误语义)。双向契约测试钉死,红验证:还原旧实现后 7 条用例红。 + - **配置编辑器被非法直写值炸死会话**(`src/ui/config_menu.py`)。`_top_k_ceiling`/`_multiplier_ceiling` 对直写的非法倍率抛 `ValueError`,调用点在 `try` 之外(内层只接 `KeyboardInterrupt/EOFError`)→ 整个编辑器会话终止,用户连改回合法值的机会都没有。非法时回落到单字段上界,组合校验推迟给应用配置时的 `SessionConfig` 守卫;`retrieval_method` 非法时显示「未知」并以 `default=None` 进菜单,与 `_rerank_menu_label` 同一契约。 + - **测试名/docstring 与实现不符(2 处)**(`tests/runtime/test_contracts.py`、`tests/test_config.py`)。`test_session_config_constructor_rejects_temperature_beyond_double_range` 声称覆盖构造器路径却只测 `__setitem__`;`test_empty_snapshot_root_does_not_widen_the_pickle_trust_root` 声称走端到端 `build_run_config → load_snapshot` 而体只构造 `Settings`。均改为真走声称的路径。 + - **误报 1 条**:`httpx2` 被重复报为 `httpx` 笔误,已核实为 `uv.lock` 声明的真实依赖(930 条相关测试全过)。 + +- **CodeRabbit 全量复核:3 处 major + 14 处 minor 逐条分诊修复**。对 PR #3 全量 diff(189 提交/81 文件)跑 CLI 复核,17 条 findings 逐条对照当前代码核实: + - **`_parent_key` 破坏旧快照父块召回(major,已复现)**(`src/retrieval/vdb/faiss_store.py`)。main 时代代码把父块索引按 `str(parent_id)` 写盘;本分支把整数 id 无差别换成 sha256 摘要键,而盘上键是字符串、原样保留——实测旧快照过 `_normalize_documents` 后父块键仍是 `"1"`,`resolve_parent_content(1)` 算出 `"<整数,4bf5…>"` 而 miss、返回 `None`,整数 `parent_id` 的父块召回整体失灵。摘要形态改为**只对 `str()` 表达不了的超限整数启用**:`str` 对可表达整数是精确转换、本身不塌键(塌键风险只来自 `safe_repr` 的截断),先试 `str()`、抛 `ValueError` 才落摘要——旧快照零破坏,超限整数仍唯一稳定。新增旧形态快照兼容回归测试(红验证:禁用 `str()` 回退后该测试红)。 + - **测试把删除的临时路径泄漏进全局(major)**(`tests/test_guard_coverage_regressions.py`)。两个用例直接赋值 `config.CONFIG_TOML_PATH` 且不还原,`TemporaryDirectory` 结束后全局仍指向已删路径。改 `monkeypatch.setattr`。 + - **`green39.txt` 夹具含重复位点(minor)**:第 64/65 行完全相同(`volcengine.py|1941|31|notin`),39 行实际只有 38 个唯一位点、KILLED 计数把它算两次。删重复行并更名 `green38.txt`(38 个唯一位点,34 KILLED/4 SURVIVED),`test_green38_fixture_parses_with_expected_verdicts` 断言与文档同步;本轮 `_parent_key` 等 `google.py` 修改又使 6 个坐标漂移,按上下文重基线(守卫语义逐一对照确认)。CHANGELOG 历史段保留当时文件名。 + - **`GoogleProvider` 两处 `safe_repr` 误用(minor,均实测复现)**(`src/providers/google.py`):`normalized_key` 经 `safe_repr` 规范化后 80 字符截断会把超长键名的 `apikey` 后缀切掉、`endswith` 判据失配而放行凭证键(实测 90 字符键名放行)——字符串直接规范化、非字符串才走 `safe_repr`;标量 `content` 无条件走 `safe_repr` 会把普通长文本消息加引号并截断成 `'前 77 字...'` 发给模型——字符串原样透传、非字符串保持 `safe_repr` 兜底(超限整数仍不崩)。各补长键名拒绝、长文本透传回归测试,均红验证。 + - **其余 minor**:`load_snapshot` 的词法缓存与 BM25 赋值推迟到全部可失败步骤之后(拒绝的载入不改 store 契约覆盖到最后一步);`background_poll_*` 错误消息把下界说成 1(实际 0.5 合法)改为「大于 0 且不超过」;`_weight_tuple` 溢出用例改 `pytest.raises` 并按实测把 `10**305` 归入值域判据分支;ExcelLogger 竞态用例冻结时钟(真实时钟跨秒会让断言空转);`_contents` 装饰器扫描按测试文件位置锚定仓库根(CWD 外启动不再扫空);`HIDDEN_IMPORTS` 前提检查补 `from X import` 形态;root 下跳过 `chmod 0o500` 权限用例;parse 用例改断言方法专属消息并修复两处 docstring 乱码;CHANGELOG 截断句补全(整数解析判据用例组)、重复段合并、「被红acted」改「被脱敏」。 + - **误报 1 条**:`httpx2` 不是 `httpx` 笔误——它是 `uv.lock` 声明的真实依赖(httpx2 2.12.0),930 条相关测试实测全过。 + +- **跨平台测试偏差:`/private/tmp` 不能进公共危险路径参数表**(`tests/test_cleanup.py`)。`test_reject_unsafe_cache_dir_rejects_system_level_roots` 的参数表把 `"/private/tmp"` 与 `"/tmp"` 并列,但两者只在 **macOS** 上解析为同一路径(`/tmp` 是指向 `/private/tmp` 的符号链接)。守卫 `_reject_unsafe_cache_dir` 的判据是「解析后与受保护根**相等**」,Linux 上 `/private` 不是系统目录、该路径也不存在,返回 `None` 是正确行为(`cleanup_temp_files` 在 `rmtree` 前另有 `os.path.exists` 检查,放行无害)。quality workflow 只跑 `ubuntu-24.04`,该参数在 CI 上必红(实测 `1 failed, 4449 passed`)。改为 darwin 专属用例 `test_reject_unsafe_cache_dir_rejects_macos_tmp_resolution_target`(`skipif(platform != "darwin")`),公共表只留跨平台成立的系统根。 + +- **测试夹具与 CHANGELOG 里的 base64 假凭证字面量改为运行时构造**(`tests/providers/test_security_boundaries.py`、`CHANGELOG.md`)。Basic 认证脱敏用例的夹具值是 `base64("user:password")`,被 GitGuardian 按 `Bearer Token` 检测器报为 PR #3 的硬编码秘密——它并非真实凭证(解码即教材占位符),但字面量本身会持续触发扫描并阻塞合并。夹具改为 `base64.b64encode(b"fixture-user:fixture-password").decode()` 在运行时构造,明文占位符不构成密钥,编码结果与断言语义逐字节不变;CHANGELOG 两处同源字面量一并改为 `` 描述。脱敏行为本身与断言强度未变(红/绿验证同前)。 + +- **布尔运算符互换(`boolop`)全量扫描:8 组判据覆盖缺口补齐**(`tests/providers/test_factory.py`、`tests/providers/test_rerank_contracts.py`、`tests/providers/test_security_boundaries.py`、`tests/retrieval/test_weighted_fusion.py`、`tests/retrieval/vdb/test_faiss_index_text.py`、`tests/test_guard_coverage_regressions.py`;工具在 `scripts/audit/mutate.py`、`scripts/audit/README.md`)。沿用既有变异器接口新增 `boolop`(`and`↔`or`,只翻每个 `BoolOp` 组的第一个运算符),站点键取**第一个值的结尾**——链式 `a and b or c` 的两层起点 `col_offset` 相同,用起点会碰撞而整片漏扫;运算符夹缝用 `tokenize` 精确定位(注释里出现 `and`/`or` 文本不会被误翻)。上一轮已扫「`src/` 全部 233 处 **raise 守卫内**的 BoolOp」(见下方历史条目),本轮把面扩到全仓 **735** 处(含非 raise 的赋值/兜底链),分两批扫描(高并行 + 低负载重启,前者因 CPU 争抢产生 7 例假 TIMEOUT)。两批合计 **SURVIVED 17 处**,逐条红验证(注入变异 → 候选测试确实变红 → 还原复绿)后: + - **真缺口(8 组 11 位点,补测试)**: + 1. `factory.py` 435 `runtime_verified_protocols` 的白名单守卫 `supported_protocols is not None and normalized_protocol not in supported_protocols`——`and` 互换后「协议在白名单内」也 raise(合法配置误拒),`supported_protocols=None` 时抛 `TypeError`。既有用例只测「协议不在白名单→拒绝」,从未走 `and` 的假分支。 + 2. `jina.py` 189/`siliconflow_rerank.py` 184 `_parse_response` 的超范围判据 `isinstance(raw_score, int) and not isinstance(raw_score, bool) and not is_double_convertible(raw_score)`——中间 `not isinstance(..., bool)` 有意排除 bool(`float(True)==1.0` 不是「超范围」);互换后 `raw_score=True` 误报「超出双精度范围」。既有用例只喂字符串/nan/inf/超大整数,bool 分支未覆盖。 + 3. `jina.py` 158/`siliconflow_rerank.py` 128 index 判据 `not isinstance(direct_index, int) or isinstance(direct_index, bool) or ...`——`or` 互换后 `index=True` 且 `len(documents)>=2` 时 bool 被当 index=1 接受。既有用例只喂合法/越界整数。 + 4. `faiss_store.py` 198 `_build_index_text` 的 `isinstance(source, str) and source`——前半 `isinstance` 有意短路(只有字符串才进 `Path(...).stem`);互换后非字符串 source(`123`/`["a"]`/`dict`/`bytes`)抛 `TypeError`。索引重建与快照加载都会走到该路径。 + 5. `faiss_store.py` 473 `resolve_parent_content` 的 `content if isinstance(content, str) and content else None`——后半个 `and content` 有意把空串归一成 `None`;互换后空串返回 `""`(契约是「无内容→None」)。 + 6. `faiss_store.py` 586 `semantic_search` 的 `if self.faiss_index is None or self.embeddings is None: return []`——两条件不互斥;互换后只有两者同时 None 才短路,半初始化状态落到 `None.search()` 抛 `AttributeError`。 + 7. `display_utils.py` 256 `coerce_score` 的 `isinstance(value, (int, float)) or hasattr(value, "__float__")`——后半接住 numpy 标量;互换后整条恒假,真实分数被静默降成 0.0,既影响融合排序又被分数阈值全部过滤。 + 8. `retrieval_service.py` 942/986 `first_error = first_error or exc`(`close`/`aclose`)——「保留第一个错误」守卫;互换后双失败时报后发生的错误,首个失败原因从栈上消失。既有用例只覆盖单失败。另有 `security.py` 661(URL userinfo 任一半单独出现须判凭证)、650(畸形 URL 回退分支的凭证消息须带真实字段路径)、655(该分支只对**敏感** query 键拒绝,普通键不该误报)。 + - **等价变异(6 位点,不补测试)**:`snapshot_repository.py` 106(空 `snapshot_id` 被后续「解析到 root 之外」判据兜住)、`faiss_store.py` 202(`"" in 任意字符串` 恒真,`"" not in X` 恒假兜住)、`faiss_store.py` 670(全 0 分时循环体 `if score<=0: continue` 兜住)、`retrieval_service.py` 77(`or` 链后项 `not is_double_convertible` 覆盖前项所有情形)、`config.py` 798(`_coerce_number` 已拒 bool 并保证 int/float,整条是死代码)、`security.py` 663(`scheme`/`netloc` 同真时无分歧)。 + - **2 例 TIMEOUT 非缺口**:`config_menu.py` 408/422——变异后 `while True` 循环的 break 失效,部分测试耗尽替身队列后死循环,掩盖了「已有测试杀死」的判定;定点复跑确认 4+ 条既有用例变红。 + - 工具边界:`boolop` 只翻每个 `BoolOp` 组的**第一个**运算符,因此「多运算符同翻」的变体不在面内(判据不要用它构造判别输入)。全量 4450 passed;8 项质量门 + 审计 selftest 全绿。 + +- **变异验证器的自指测试排除清单补上完整性核对**(`scripts/audit/verify_sites.py`、`tests/scripts/test_audit_tooling.py`、`scripts/audit/README.md`)。上一轮修好「自指元测试污染判定」后留下一个盲区:`_SOURCE_INSPECTING_TESTS` 只是一份人工清单,守卫测试只验证「清单里的条目**存在**」,没验证「清单**完整**」。`--deselect` 对漏登记/写错的名字**静默通过**——日后有人新增一个读 `src/` 源码的测试,它不会自己进清单,扫描会再次把它算进判定,假 KILLED 静默复现(正是上一轮 4 个 SURVIVED 被误报 KILLED 的同一机制)。 + - 新增静态探测器 `detect_source_inspecting_tests()`:按「**锚定仓库源码路径**(字面量 `src`/`scripts`/`main.py`,或模块级 `__file__` 派生的全局如 `REPO_ROOT`)+ **读文件/解析/导入动作**(`read_text`/`read_bytes`/`ast.parse`/`glob`/`rglob`/`importlib.import_module`…)」两个条件**同时成立**判定。先剥离各作用域的首条字符串,避免文档里提到 `read_text` 就误判;两个条件缺一不可,否则会把读临时目录产物(快照 pkl、tar 包)的正当行为测试也扫进来。 + - 新增守卫 `test_source_inspecting_registry_is_complete`,断言「探测器发现的一切 ⊆ 清单」,并在断言前先自证探测器**非空**(否则「⊆」在空集上恒真、用例形同虚设)。清单据此从 7 条补到 9 条(新增 `test_green38_fixture_parses_with_expected_verdicts` 与守卫自身——后者也读仓库源码,纳入使不变量闭合)。 + - 红验证:临时植入一个读 `src/providers/factory.py` 的未登记测试 → 守卫变红并**点名该 node**;删除后转绿、`git status` 干净。探测器单元用例亦以「去掉锚点条件」注入验证为真 killer(负例立即误报)。`--selftest` 增加同款核对与一组正反例,全绿;全量 4422 passed;8 项质量门全绿。 + +- **算术运算符互换(`arith`)变异扫描补齐 5 组判据覆盖缺口**(`tests/providers/test_google_genai.py`、`tests/retrieval/test_weighted_fusion.py`、`tests/retrieval_test/test_retrieval_cli.py`、`tests/ui/test_config_menu.py`;工具在 `scripts/audit/mutate.py`、`scripts/audit/README.md`)。补齐最后一个未覆盖的变异面:`+`↔`-`、`*`↔`/`。新 kind 只收数值二元运算(字符串字面量参与的 `BinOp` 排除),站点键取**左操作数结尾**——链式 `a + b + c` 的两层 `BinOp` 起点 `col_offset` 相同,用起点会让 `find_site` 命中两个节点而整片漏扫。对 `src/` 全部 105 个位点做全量扫描(6 worktree 并行,先跑未变异基线),判定 **KILLED=59、SURVIVED=45、TIMEOUT=1**。45 个 SURVIVED 逐一用真实入口探针分诊为 **5 组真缺口(9 个位点)** 与 **等值变异**: + - **真缺口(补测试,逐条红验证——注入变异后新测试红、还原后绿)**: + 1. `google.py` 2204/2216 `_partial_args_to_mapping` 的中间容器类型由**下一段** token 决定(``[] if isinstance(tokens[position + 1], int) else {}``)。既有用例的 json_path 全是单段(`$.a[0]`/`$.country`),走到那一步时 `position` 已是最后一个 token(`last=True`),`tokens[position + 1]` **从未被读**;把 `+ 1` 变异成 `- 1` 后整套测试仍全绿,而 `$.a[0][1]` 从 `{'a': [[None, 'x']]}` 变成抛 `ValueError`。补 `test_google_partial_args_nested_path_builds_container_from_next_token`(覆盖 list 与 dict 两条分支)与 `test_google_partial_args_nested_index_after_existing_list`。 + 2. `retrieval_service.py` 187 `_weight_tuple` 的 `self.vector_weight / total`(keyword 同)。既有断言(`HybridReranker(0.0, 1.0)`/`(0.5, 0.5)`)的 `total` 恰好都是 `1.0`,而 `x / 1.0` 与 `x * 1.0` 恒等——`/`→`*` 变异后仍全绿。只有两权重和 ≠ 1 的输入能区分:补 `test_weight_tuple_divides_by_total_not_multiplies`(`(0.2, 0.3)` → `(0.4, 0.6)`)。 + 3. `retrieval_service.py` 217 `_rrf_rerank` 的 `1.0 / (self.rrf_k + semantic_rank)`(keyword 同,同一行的两个独立位点)。既有用例只断言排序与「前者分更高」,而 `rrf_k + rank`→`rrf_k - rank` 后顺序不变(RRF 由排名位置决定,仍单调)。补 `test_rrf_rerank_score_uses_k_plus_rank`(让两路 rank 都为正、逐路钉住 `1/(k+rank)` 的实际分值)。 + 4. `retrieval_test/core.py` 68 `display_results` 的 `str(index + 1)` 与 `retrieval_test/excel_logger.py` 321 `rank = i + 1`。既有用例只断言正文/来源/查询列,从未断言排名列;`+ 1`→`- 1` 后排名从 `0`/`-1` 起。补 `test_display_results_ranks_start_at_one`(捕获 `Table.add_row` 实参逐行断言)与 `test_excel_log_ranks_start_at_one`(断言第 3 列)。 + 5. `ui/config_menu.py` 238 `weights` 分支的 `new_keyword_weight = 1.0 - new_vector_weight`。既有 weights 用例一律让 `questionary.text` 返回 `_Choice(None)`(空输入不写回),从未提交真实权重值;`-`→`+` 后用户设向量权重 0.2 会让关键词权重变 `1.2`(越过 `SessionConfig` 的 0..1 上界)。补 `test_weights_selection_writes_complement_to_keyword_weight`。 + - **等值变异(不补测试)**:36 个。其中 23 个是 `time.perf_counter() - start_time` 的**耗时日志**(变异为 `+` 只让日志显示绝对耗时,不影响任何判定);其余为日志计数(`splitter.py` 410 的 `len(all_chunks) - chunks_before`)、`safe_repr` 的位数**量级估算**(`config.py` 304/`security.py` 379/423 的 `bit_length() * 30103 // 100000 + 1`,测试只断言「位十进制」字样不断言具体位数)、`to_bytes` 的字节数(`retrieval_service.py` 512)、`faiss_store.py` 628 的 `1.0 + distances[0]`——`1/(1+d)` 与 `1/(1-d)` 对 d 同向单调,排序不变(高负载下曾 TIMEOUT,低负载复跑为 SURVIVED)、`security.py` 653 的 URL query 切片起点(`marker + 1`→`- 1` 后仍判出敏感键、仍拒绝,只改错误消息里的路径文本)、`log_manager.py` 165 的 `maxBytes` 常量(`1*1024*1024`)。 + - **工具边界**:AST 无法静态区分类型,`Path / x` 这类路径拼接除法也会进 `arith` Div 位点——其变异体(`Path * str`)在运行期抛 `TypeError` 被杀,只让 KILLED 计数虚高,不产生假结论(已在 `scripts/audit/README.md` 记录)。 + - 过程中发现验证基础设施的一个坑并规避:`+`↔`*` 是**同长度**替换,若源文件 mtime 落在同一秒,CPython 会复用陈旧的 `__pycache__/*.pyc`,导致红验证**假绿**——逐点位红验证须先清 `__pycache__` 并以 `-p no:cacheprovider` 重跑。 + +- **green38 回归夹具重跑暴露验证器自指假报,修复后夹具重新基线化**(`scripts/audit/verify_sites.py`、`scripts/audit/regressions/green38.txt`、`scripts/audit/README.md`、`tests/scripts/test_audit_tooling.py`)。在新 HEAD 上重跑 39 位点夹具,预期与历史一致(33 KILLED/5 SURVIVED/1 MUTFAIL),实际报 **7 条不一致**(4 条 SURVIVED 假报 KILLED、1 条 MUTFAIL 假报 KILLED、1 条 KILLED 假报 SURVIVED、1 条 SURVIVED 假报 KILLED)。逐条真判定(变异→`--deselect` 自指元测试→全量套件)后全部查清,**7 条里 5 条是验证器自身的假判**: + - **自指元测试污染判定(真验证器缺陷,4 条)**:`test_green38_fixture_sites_resolve_at_head` 读**工作区源码 AST** 校验夹具坐标——扫描期间工作区正处于变异态,变异点一旦落进它引用的节点它必红。于是夹具里 4 个 SURVIVED(`anthropic.py:972`、`factory.py:356`、`model_provider.py:1202`/`336`)全部假报 KILLED;剔除该元测试后全量 4420 passed,真判定 SURVIVED。修复:`run_pytest` 把读源码文本的测试节点(`_SOURCE_INSPECTING_TESTS`,6 个:元测试 + `test_no_duplicate_decorators_in_source_tree` + `test_config_branch_missing_update_method_raises_a_caught_type` + `test_rich_markup_entry_points_never_receive_raw_variables` + `test_untrusted_free_argument_allowlist_has_no_stale_entries` + `test_registered_provider_modules_have_no_static_import_path` + `test_user_facing_error_paths_do_not_print_raw_exceptions`,逐个用「只剩它红」实测确认)从判定中 `--deselect`——它们只在**未变异**的基线上跑(普通 pytest 与 `--selftest` 不受影响)。新增 `test_source_inspecting_tests_are_excluded_from_verdicts` 核对排除列表(`--deselect` 对不存在的 node id 静默通过,写错名字不报错)。 + - **位点本身已可施加(夹具「故意保留 MUTFAIL」的活样本过期,1 条)**:`openai_compatible.py:1391` 夹具记 MUTFAIL(「守卫已被修复移除」),但坐标在 HEAD 上能精确解析、变异被新测试杀死。夹具头部那段「刻意保留为活样本」的说明已随重基线化删除。 + - **假 KILLED 的另一面(1 条)**:`google.py:571` 记 SURVIVED,实测被 `5cb8fe8` 引入的 `test_google_validate_http_options_keeps_blank_query_values` 杀死——这是**真 KILLED**,历史上重新基线化时漏掉了 `drop` 面的复核。 + - **夹具记录的 killer 选择器已失效(1 条,红验证器自身防呆)**:`openai_compatible.py:1153` 记的 `test_openai_chat_request_forwards_metadata` 在定点模式下不杀它(`isnot` 落在 `top_k is not None` 守卫,该用例不触达)。换用实测能杀的选择器 `test_chat_request_forwards_extra_headers`(裸名,避免 parametrize id 漂移),并在夹具注释里写明「换 killer 必须定点重验,不能只看全量扫描的红」。 + - 夹具期望分布更新为 **35 KILLED/4 SURVIVED**,`test_green38_fixture_parses_with_expected_verdicts` 同步断言。 + +- **知识快照源码摘要的第二次读取漏了「带文件名」契约**(`src/services/knowledge_build_service.py`)。`06f6a67` 修过同文件 `_process_file` 的读取失败(`UnicodeDecodeError`/`OSError` 都收敛成带文件名的 `ValueError`),但 `*.md` 清单还有**第二处**读取点没跟上:`_compute_source_digest` 在 `build` 里于**全部 embedding 完成之后**再逐个 `read_bytes` 同一批文件。实测两种真实形态都抛裸 `OSError`——`glob` 之后文件被删(TOCTOU)抛 `FileNotFoundError`、`chmod 000` 后抛 `PermissionError`——此时最贵的一步(embedding)已经跑完,失败收场却是一条没有定位、没有「重试什么」指引的系统错误,不是 `build` 其余拒绝路径承诺的带指引 `ValueError`。按文件逐个收敛(而不是整体 `try`),消息与 `_process_file` 的两条 `except` 逐字对齐;正常路径的 digest 值逐字不变。新增 4 条回归断言(含一条真实时序用例:`embed_in_batches` 期间删文件,断言失败消息仍带文件名且临时快照被清理);把守卫还原成裸 `read_bytes` 后其中 3 条变红。 + +- **`Document.__repr__` 对不受控 metadata 抛异常:pytest 失败报告与日志的兜底渲染失效**(`src/models/document.py`)。顺着上一批 ETL 日志插值的线索往上游追,发现**模型自身**的渲染也有同一缺陷,且后果更靠前。`Document.__str__` 直接 f-string 插值 `self.metadata`(声明为 `dict[str, Any]`,取值不受控),一个位数超过 `sys.get_int_max_str_digits()`(默认 4300)的整数就让整条 f-string 抛 `ValueError: Exceeds the limit`。`__repr__` 是 pytest 失败报告、日志与调试器的**兜底渲染**,它一抛,报错现场反而什么都看不到——实测一个断言 `repr(doc)` 的用例失败时,pytest 把失败对象显示成 `<[ValueError('Exceeds the limit (4300 digits)...') raised in repr()] Document object at 0x...>`,真正要看的文档内容与 metadata 全部丢失。修法与仓库既有的 `ui.display_utils._render_value`/`_path_text` 同一取舍:**只在渲染真的失败时**才降级到 `safe_repr`(``try`` 原样 f-string,``except (ValueError, RuntimeError, RecursionError)`` 走 `safe_repr`)。不无条件套用 `safe_repr` 的理由与那两处相同:它在 80 字符处截断,而真实分片 metadata 的 `repr` 长约 127 字符,无条件套用会改变**所有**正常文档的调试输出(已用断言钉死正常路径逐字不变)。新增 3 条回归断言(参数化共 6 个实例);把守卫还原成裸插值后 `test_document_repr_survives_oversized_integer_metadata` 与 `test_document_repr_survives_malformed_metadata[huge-int]` 变红。全量 4406 passed;8 项质量门 + 审计 selftest 全绿。 + +- **ETL 日志插值绕过安全渲染:`metadata['source']` 形态异常时建库主路径崩溃**(`src/etl/pipeline.py`、`src/etl/splitters/recursive_text_splitter.py`)。继续沿着「未系统查过的模块」推进时,对 ETL 做了成对路径与日志边界两条线的排查,发现同一处缺陷的四个插值点。`Document.metadata` 声明为 `dict[str, Any]`,取值不受控;而 `Pipeline.process` 的五行日志与分片器三个分支都把 `source` 直接插进 f-string/`%s`。实测 `Document(content=..., metadata={"source": 10**4301})`(构造**成功**,pydantic 不校验 metadata 内部)后:`Pipeline.process` 抛 `ValueError: Exceeds the limit (4300 digits) for integer string conversion`,`RecursiveTextSplitter().split(...)` 与 `split_hierarchical(...)` 同样抛——一条 info/debug 日志让建库主路径失败,而 `KnowledgeBuildService._process_file` 的错误分类只按 `ValueError` 处理,用户看到的是「建库失败:整数转换超限」,与真实原因(来源字段形态异常)毫无关系。仓库里已有专为此设的助手(`ui.display_utils.document_source`,`save_snapshot` 的 `document_source(doc)` 集合推导即用它),但 ETL 不能反向依赖 `ui` 层,故按同一判据在 ETL 侧取最小实现 `_safe_source`:**字符串原样返回**(正常路径日志逐字不变,已用 caplog 钉死 `正在分割文档: a.md` 与 `文档 'a.md' 分割完成`),其余形态走 `safe_repr`(位数超限整数退回量级描述)。四处插值点全部改用它;`pipeline.py` 就地内联同一判据(该文件已因校验消息引了 `safe_repr`)。新增 4 条回归断言(参数化共 11 个实例,含正常路径逐字不变);把两处守卫还原成裸取值后 `test_pipeline_process_survives_oversized_integer_source` 与 `test_splitter_survives_malformed_source_in_logs[huge-int]` 变红。附带记一个测试写法坑:`@pytest.mark.parametrize` 默认对取值调 `str()` 生成用例 id,把 `10**4301` 直接放进参数列表会在**收集期**就抛同一条 `Exceeds the limit`——须显式给 `ids=`。全量 4400 passed;8 项质量门 + 审计 selftest 全绿。 + +- **并发/时序维度审查:`RetrievalService` 退出路径的资源泄漏**(`src/services/retrieval_service.py`)。本轮的检查面与此前各轮正交(前几轮几乎全是单线程同步路径),聚焦 `asyncio.gather`/`asyncio.to_thread`/线程池与共享可变状态。先做原语盘点:`src/` 内 `asyncio.to_thread` 五处(`chat/core.py:173,413`、`local_hash.py:96`、`retrieval_service.py:759,770`)、`asyncio.gather` 一处(`retrieval_service.py:723`),无 `ThreadPoolExecutor`/`threading.Thread`/`asyncio.Lock`。逐项排查后的结论与修复: + + - **真缺陷:`clear_rerank_cache`/`close`/`aclose` 在第一个关闭失败处中断,后续资源全部泄漏**。三处都是「先清空缓存字典、再逐个关闭」的形态,原实现直接调 `close_resource_sync(provider, ...)`/`close_embedding()`,第一个抛异常就让循环当场退出。实测(`RetrievalService.__new__` + 两个替身 Provider,一个 `close()` 抛 `RuntimeError`):`clear_rerank_cache` 只关闭了 `['bad']`,第二个 Provider 从未被碰;`close` 在重排失败后连 Embedding Provider 都不关(`['bad']`,期望 `['bad','embedding']`);`aclose` 同样只关 `['bad']`。危害是**双重**的:泄漏发生在退出路径上(用户不会再有机会重试),且 `src/retrieval/retriever.py:129` 在 `finally` 里裸 `await retrieval_service.aclose()`——异常顶替掉一次**成功**的检索返回值。契约修正为与四个 Provider 自己的 `close`/`aclose` 逐字对齐(`src/providers/*.py` 早已是 `first_error = first_error or exc` + `raise first_error` 的「关闭全部、报第一个错误」):同一件「释放资源」的事,Provider 层已是该契约,服务层此前漏了。 + - **同类的第二处:`_get_rerank_provider` 的 stale 清理循环**(同文件)。配置重载时逐个 `pop` + 关闭同 key 的旧实例,原实现同样是「第一个失败即中断」——而 `pop` 已把实例摘除,中断后每个 stale 实例都成了「已摘除但从未关闭」的泄漏,且这条路径发生在 `config_menu` 改完配置后立刻检索的热路径上。同一契约修正。实测两个 stale 实例(一个 `close()` 抛错)原只关 `['bad']`,修后 `['bad','good']`。 + - **第三处,也是危害最直接的一处:`aretrieve_documents` 的 `finally` 裸 `await aclose()`**(`src/retrieval/retriever.py:129`)。前两处修好之后,关闭失败仍会**顶替主流程的结果或异常**,而 `finally` 的语义让两种收场都丢信息:检索成功、关闭失败时,那批命中结果被丢弃,调用方只看到与检索无关的关闭异常(实测:返回 1 条命中的检索整体抛 `RuntimeError: 关闭失败`);检索已抛异常、关闭也失败时,关闭异常**替换**掉原始异常,真正的失败原因从栈上消失。改为与仓库内既有的两处退出清理同一契约(`retrieval_test.core._aclose_quietly`、`Chatbot.close`):失败只记**已脱敏**的警告,不打断主流程。 + - **第四处:三个流消费助手的 `finally` 用关闭异常顶替消费异常**(`src/providers/__base__/model_provider.py` 的 `iterate_async`/`retry_sync_stream`/`retry_async_stream`)。三者的 `finally` 都负责关闭迭代器,而 `finally` 里抛出的异常会替换掉 `try` 中正在上抛的那个。这里比前几处更严重——**调用方按异常类型分类**:`retry_async_stream`/`retry_sync_stream` 用 tenacity 的 `is_retryable_error`(类名判据含 `connection`)决定要不要重试。实测一个 ``ConnectionError``(可重试)的流在 `aclose()` 也失败时被 `ValueError`(不可重试)顶替,**一次可重试的连接错误只尝试了 1 次就放弃**(期望 3 次)。三处统一为「关闭是尽力而为的清理」:`finally` 第一条语句取 `sys.exc_info()` 记录是否有在飞异常,关闭失败时若有则保留原始异常、若无才把关闭异常报出去。 + - **已确认非缺陷(逐项证伪)**:`_gather_hybrid_results` 用 `asyncio.gather` 默认语义(一路失败丢弃另一路)——混合检索要求两路都成功才能融合,是有意设计;`EmbeddingService._get_model` 的无锁懒加载实测并发 300 次只构造 1 次(`local-hash` 无 I/O,`await` 前已完成赋值),不构成竞态;`FaissStore` 的 `semantic_search`/`keyword_search` 并发 300 次结果稳定(唯一结果组数 = 查询数,无串扰),且 `_rebuild_indices` 只在载入/写入期调用,不与检索并发;`is_retryable_error(asyncio.CancelledError())` 返回 `False`,取消不会被 tenacity 当可重试错误重放;`to_thread` 包装的 `embed_documents`/`vector_store` 方法均无共享可变状态。 + + 新增 12 条回归断言(`tests/test_guard_coverage_regressions.py` 6 条:`clear_rerank_cache` 失败后继续关闭其余 Provider、`close` 失败后仍关闭 Embedding、`aclose` 同契约、全部成功时不误抛、stale 清理失败后仍关闭其余旧实例、stale 全部成功时不误抛;`tests/retrieval/test_parent_child_retrieval.py` 3 条:关闭失败不丢弃检索结果、检索与关闭双失败时保留原始异常、关闭异常日志已脱敏;`tests/providers/test_failure_contracts.py` 3 条:`iterate_async`/`retry_async_stream`/`retry_sync_stream` 的关闭失败不顶替消费异常,其中异步重试那条还断言尝试次数保持 3 次)。红验证:移除三处吞掉后前 3 条各自变红(计数为 0);把条件 `raise` 改成无条件 `raise` 后第 4 条变红;把 stale 循环还原成中断形态后第 5 条变红;把 `aretrieve_documents` 的 `finally` 还原成裸 `await` 后新增的 3 条全部变红(含脱敏用例把 `sk-...` 明文回显);把三处流 `finally` 还原后对应的 3 条全部变红。全量 4393 passed;8 项质量门 + 审计 selftest 全绿。`green38.txt` 夹具随 `model_provider.py` 新增 `import sys` 偏移 +1 行,已按上下文重新定位两个位点(`test_green38_fixture_sites_resolve_at_head` 再次捕获到漂移)。 + +- **全维度审查:6 处守卫不对称与安全/入口缺陷修复**。对分支的 162 提交、`src/` 8104 行改动做分维度对抗审查(7 个独立审查 + 逐条实测复现),修的是「成对路径只修了一侧」这一类系统性缺陷: + + - **`security.redact_sensitive_text` 漏 Basic 凭证**(`src/utils/security.py`)。`Authorization: Basic ` 脱敏后逐字不变——`Basic` 是纯字母状态词,被键值正则按「裸关键词 + 状态字面量」(`_NON_CREDENTIAL_VALUE` 含 `basic`)放行,而 `_BEARER_TEXT_RE` 只认 `bearer`。新增 `_BASIC_AUTH_TEXT_RE`,要求前置 `(proxy-)?authorization` 键名以免误伤 `basic understanding` 这类普通文本。实测原 `Authorization: Basic ` → `Authorization: Basic [REDACTED]`。 + - **`coerce_score` 把 numpy 标量分数静默归零**(`src/ui/display_utils.py`)。`np.float32` 不是 `float` 子类,只判 `(int, float)` 会走兜底归零——第三方 store 的 score 常是 numpy 标量,实测 `coerce_score(np.float32(0.9))` 返回 `0.0`(`np.float64` 正常)。改为对带 `__float__` 的取值实测 `float()`,异常才归零。 + - **去重键/父块键把不同大整数塌成同一键**(`src/services/retrieval_service.py`、`src/retrieval/vdb/faiss_store.py`)。`_key_text` 对 >80 字符的整数走 `safe_repr` 截断、`_parent_key` 对整个非字符串 `parent_id` 走 `safe_repr`(量级描述),`10**100` 与 `10**100+1`、`10**4301` 与 `10**4301+1` 都得到同一键——`_merge_hybrid_results`/`_deduplicate_parent_documents` 静默并条、`resolve_parent_content` 张冠李戴。两处改为按二进制补码取 sha256 摘要;小整数(`chunk_index=3`)仍原样保留。 + - **Ark Classification 动态路径恒拒唯一合法用法**(`src/providers/volcengine.py`)。`_ARK_CLASSIFICATION_KEYS` 只含扩展字段,而动态路径 `resources.classification.create(...)` 把业务参数放进 `kwargs` → `create(query=..., labels=..., model=...)` 必抛「不支持请求参数」,与静态 `classify()`(这三个走命名参数、不经校验)结论相反。白名单补入 `query`/`model`/`labels`。 + - **Anthropic 非流式 refusal 超大整数 → 安全拦截变 `ValueError`**(`src/providers/anthropic.py`)。`_extract_result` 的 refusal 键直读未套 `_refusal_text`,`{"type":"refusal","refusal":10**4301}` 在 `_raise_for_refusal` 的 f-string 抛 `ValueError: Exceeds the limit`——同值走流式路径已正确收敛。补上 `_refusal_text`。 + - **Google 工具调用 id 重复静默覆盖**(`src/providers/google.py`)。`tool_call_names` 是普通 dict,两条 `function_call` 共用一个 id 时后写覆盖、无检测。两处登记点补「同名放行、异名报错」。 + - **主菜单取消会终止整个程序**(`main.py`)。`embed_knowledge_base` 在用户取消确认时 `sys.exit(0)`,`SystemExit` 不被菜单的 `except Exception` 捕获 → 选「1 知识库向量化」后输入 n 会退出整个程序而非回主菜单。捕获 `SystemExit(0/None)` 后回菜单。 + + 新增 4 条回归断言(`tests/providers/test_security_boundaries.py`:Basic 脱敏 + 不误伤普通文本;`tests/test_boundary_hardening_regressions.py`:小整数键原样保留)。全量 4369 passed;8 项质量门全绿。 + +- **审计工具链可靠性:基线对照、pytest 缺失嗅探、AST 选择器、夹具基线化**(`scripts/audit/`)。审查发现验证器自身有 4 处能产出假绿/假红的缺陷——它存在的意义就是抓这类假结论,自身却会犯: + - **无基线对照**(`verify_sites.py`):只看本次 pytest 退出码,不检查未变异套件本身是否绿。红套件会让真存活点报 KILLED(假绿)、真缺口被藏起来。现在先跑一次未变异全量套件,不绿即中止整轮(`--no-baseline` 可跳过,自担风险)。 + - **基础设施故障被当成 KILLED**:`No module named pytest`/`INTERNALERROR` 等与「测试失败」退出码同为 1。`--python` 指到没装 pytest 的解释器时,整轮位点全部误报 KILLED。新增 `pytest_output_is_infrastructure_failure` 嗅探,命中则归为非判定。 + - **选择器预检只认顶格 def**:旧 `^def name(` 正则把类方法、`async def` 全误判「测试不存在」→ 假 COLLECT_ERROR,而 pytest 本可收集。改为按 AST 遍历 `FunctionDef`/`AsyncFunctionDef` 取名字。 + - **`green39.txt` 夹具已陈旧**:39 个位点在 HEAD 上 10 个无法定位、10 个需漂移(其中 4 个标 `KILLED` 的窗口内已无同 kind 节点),却只按「字段可解析」放行。坐标已重新基线化到当前 HEAD(39/39 exact),并新增 `test_green39_fixture_sites_resolve_at_head` 把「坐标命中当前 HEAD」钉死;源码再演化时按当轮重新定位或删除对应行。 + - 新增 `test_verify_sites_baseline_guard_rejects_red_suite`:基线不绿时必须非零退出且不输出 KILLED。README 同步基线对照与夹具失效说明。全量 4373 passed;8 项质量门全绿。 + +- **测试假绿点修复:断言模板逐项化 + 替身掩盖补钉**(`tests/`)。上一轮审查的测试质量报告列出 7 条假绿风险点,逐条施加源码级变异(`raise`→`pass`、阶段标签替换、`>=`→`>`)实测复现后修复: + - **`test_unsupported_provider_type` 的替身掩盖**(`tests/providers/test_factory.py`):autouse `patch_settings` 把 `_get_provider_class` 整体换成 mock,断言来自替身,真实守卫从未执行。新增 `test_unsupported_provider_type_real_guard_not_mock` 用 `_ORIGINAL_FACTORY` 的 `__wrapped__` 直钉源码;实测 `raise`→`pass` 时该用例红。 + - **断言 `match=` 不含阶段标签**(`tests/services/test_chat_service.py`):`match="检索"` 在「语义检索」与「关键词检索」之间不可区分,把任一标签换成别的词都仍绿。`test_hybrid_retrieval_reports_which_side_violated_the_contract` 改为逐参数化断言**那一侧**的完整模板(列表级「X 返回的必须是文档列表」与元素级「X 返回的第 0 条不是文档映射」各钉各的),实测把 `retrieval_service.py` 里 `关键词检索` 标签改成语义后红。 + - **`test_retrieve_rejects_malformed_store_results` 断言过宽**(`match="文档"`):改为逐参数化断言完整模板(列表级 vs 元素级)。 + - **`test_rerank_if_needed_rejects_non_document_list` 断言过宽**(`match="文档列表"`):补「重排」前缀。 + - **`test_pipeline_stage_return_must_be_a_list` 只测了 extractor**(`tests/test_boundary_hardening_regressions.py`):`_StageReturning` 三阶段同值时最先失败的总是 extractor,cleaner/splitter 的守卫从未执行。新增 stage 参数化 + `_PASSTHROUGH` 哨兵(前段正常、只让指定阶段返回坏值),三个阶段的返回值校验与逐项校验都各自带阶段名。 + - **`test_pipeline_stage_return_items_must_be_documents` 断言不含阶段名**:同上,三个阶段的 `返回的第 0 项不是 Document` 各自指名。 + - **边界等号侧未覆盖**(`child_size >= parent_size`):现有用例只喂严格大于;源码变异 `>=`→`>` 后「子分片等于父分片」的用例红(`tests/etl/test_pipeline.py::test_hierarchical_mode_rejects_degenerate_child_sizes[...等于父分片]`),确认该边界确实被钉住——这一条是**已覆盖**而非缺口,未改动。 + - **`parse_sites` 不校验数值**(`scripts/audit/verify_sites.py`):实测空 target、负行号、负列全部静默通过。补三条校验,实测三类坏输入均被拒绝、合法输入不受影响。 + + 新增/修改回归断言 6 处;全量 4380 passed;8 项质量门全绿。 + +- **补漏:异常兜底分支脱敏、embeddings object dtype 收敛、夹具二次基线化**。存疑清单里逐条实测后确认 2 条真缺陷: + - **`safe_exception_text` 兜底分支跳过脱敏**(`src/utils/security.py`)。``str(exc)`` 抛异常(args 含超限整数)时,兜底分支逐元素 ``safe_repr`` 后**直接返回**,不经 ``redact_sensitive_text``——实测 `ValueError(10**4301, "Authorization: Basic ")` 的输出里 base64 凭证明文。该函数 docstring 自称「最后一道展示边界」,22 处 ``except`` 依赖它,与主分支的收场不一致。兜底分支补上脱敏,实测 Basic/Bearer 凭证均被脱敏。 + - **`_validate_embeddings_shape` 对 ``dtype=object`` 抛裸 `OverflowError`**(`src/retrieval/vdb/faiss_store.py`)。``embeddings.astype(np.float32)`` 对含 ``10**400`` 的 object 数组抛 ``OverflowError: int too large to convert to float``(不是 ``ValueError``,穿透调用方的错误分类);pickle 二进制格式让这种数组能正常往返。包成带指引的 ``ValueError``,与写入侧 ``_as_float32_matrix`` 一致。 + - **`green39.txt` 夹具二次漂移**:本轮新增的 ``security.py`` 两处位点(507:11、688:49)因 ``safe_exception_text`` 修改而失效,夹具核对用例当场报出。按当前源码重新定位到 512:11、693:49(列不变,守卫语义一致),39/39 精确命中。 + - **Responses 后台轮询无上界 → `time.sleep` 抛裸 `OverflowError`**(`src/providers/openai_compatible.py`)。``background_poll_interval``/``background_timeout`` 只判「是正数」,``1e19`` 通过校验后 ``time.sleep(1e19)`` 抛 ``OverflowError: timestamp out of range for platform time_t``(实测)——不是 ``TimeoutError``,也不在调用方按 ``ValueError`` 建立的分类里。两处补上界 ``_MAX_CLIENT_TIMEOUT_SECONDS``(与客户端 timeout 同一常量,该常量的存在理由正是「凡在其上做算术的下游都会炸」)。同步收紧既有测试的消息断言(``background_timeout 必须是正数`` → 含上界)并新增上界用例。 + - **`green39.txt` 夹具三次漂移**:上界修复在 ``openai_compatible.py`` 新增 9 行,11 个位点坐标随之失效。逐条按上下文重新定位(含 ``_response_retrieve_kwargs`` 的 590:33、资源调用的 4828:19、字典推导的 996:58/75),39/39 恢复精确命中。 + + 其余存疑条目查证后判为低危/未达:`Settings.model_dump` 对运行期注入的 ``options`` 不脱敏(无生产调用点,``security.py`` 的检查路径不受影响);rerank 缓存键 ``default=str`` 塌陷(TOML 产不出 set,需 Python 直构 ``rerank_configurations``);``FaissStore(file_path=不存在)`` 静默空 store(行为不一致但无真实调用方);``background_poll`` 的上界实测(``1e19`` 会 ``OverflowError``,已由上一条的两处上界修复一并收口)。全量 4381 passed;8 项质量门全绿。 + + + +- **死代码消融:删除 `ChatService.retrieve`**(`src/services/chat_service.py`)。本轮换用**覆盖率**判据(与前两轮的静态引用计数互补):全量套件在 `uv run --with coverage` 下 4365 passed、TOTAL 88%,导出 coverage JSON 后统计「整个函数体从未被执行」的定义。788 个零执行函数中,绝大多数是 `resources.py` 的 SDK 动态转发桩(408 个,`__getattr__` 派发)、CLI 入口(`main`/`parse_args`)与审计工具自身;逐条分诊后唯一的真死代码是 `ChatService.retrieve`。证据链:**生产零调用**——`chat/core.py` 的 `chat_async` 用 `_identify_intent_async`(L277)+ `_retrieve_knowledge_async`(L287,直连 `retrieval_service.retrieve`)+ `generate_reply`(L303)拼出等价流程,从未调用 `ChatService.retrieve`;**测试零调用**——coverage 证实该函数体(L53-55)从未执行,`test_chat_service.py` 里同名 `service.retrieve(...)` 的 `service` 全是 `RetrievalService`(由 `_retrieval_service_with_store` 构造);**无动态派发**——全仓 `getattr(..., "retrieve")` 只落在 SDK 的 `content`/`files` 对象上,`ChatService` 实例的外部访问只有 `identify_intent`/`generate_reply`/`_build_prompt`;**无文档化**——HANDOFF.md 与开发文档只提 `RetrievalService.retrieve()`;自引入提交 `8b504d8` 起从未改动。删除后全量仍 4365 passed(与删前逐字一致,证明无引用),8 项质量门全绿。同批零执行候选经证伪后**保留**:`Chatbot.__init__`/`_initialize_vector_store` 是**测试盲区**(测试用 `Chatbot.__new__()` 绕过构造器,生产由 `chat/core.py:386` 的 `Chatbot(console)` 调用);`google.acomplete`(`google.py:3309` 调用)、`anthropic._get_client`(20+ 生产调用点,测试 monkeypatch)是活的;`faiss_store.import_legacy_snapshot` 是文档化的旧快照迁移入口;openai_compatible/volcengine 的资源转发方法由 `resources.py` 的 facade 经 `self.provider.X(...)` 调用,属动态资源契约面。结论:**覆盖率判据同样只能筛出候选——测试盲区(`__new__` 绕过构造器)、动态派发与接口契约都会让承重代码显示为「从未执行」,必须逐项证伪**。 + +- **死代码消融:删除 `_AsyncOnlyResourceFacade`**(`src/providers/resources.py`)。`_NativeFacade` 的 9 个直接子类里,只有它是**孤儿**——无子类、无实例化、无注册表登记、无文档记载,全仓唯一引用是 `tests/providers/test_sdk_capabilities.py` 里 `test_facade_delegates_native_resources_for_every_declaring_class` 的一条参数化(该用例引入于 `5cb8fe8`,为覆盖 boolc 扫描的 `_delegate_native` 判据而把「每个声明类」都列进来,包括这个未被接线的类)。其余三个 facade 基类(`OpenAICompatibleResources`/`AsyncOpenAICompatibleResources`/`GoogleResources` 等)都有子类或实例化。红绿验证:仅删类、保留测试行 → 该参数化实例 `AttributeError` 变红;类与测试行同删 → 定向 3 passed、全量 4365 passed(比删前少 1,正是移除的那条实例)。8 项质量门全绿。 + +- **死代码消融:删除 `OpenAICompatibleProvider._chat_stream_event`**(`src/providers/openai_compatible.py`)。用 AST 对 `src/`、`scripts/`、`main.py` 的 1075 个函数/方法/类定义逐个统计真实引用(按 `ast.Name(Load)` 与 `ast.Attribute.attr` 计,排除注释与 docstring 噪声),并用「删掉它、跑全量」实测承重性。全仓对该方法零引用(唯一的同名调用在 `VolcengineProvider` 的**另一份**实现上),删除后全量 4366 passed,判定为死代码。同批排查的其余「静态零引用」候选经证伪后**保留**:13 个 provider 类由 `factory.py` 的 `importlib.import_module` + `getattr(module, "XxxProvider")` 动态加载;14 个 `beta_*` 资源属性由 `getattr(provider.resources, f"beta_{name}")` 动态访问且见于 `docs/user_guide/llm-providers.md`;VDB 的 `semantic_search`/`keyword_search`/`asearch`/`search`/`resolve_parent_content` 由 `RetrievalService._get_vector_store_method` 按字符串名取用;`apply_config_update`/`apply_config_update_async` 由 `chat/core.py` 的 `getattr(bot, ...)` 探测;`validate_*`/`split_separators`/`get_field_value`/`settings_customise_sources`/`warn_retired_base_urls` 由 pydantic 装饰器与 SettingsSource 协议按名回调;`import_legacy_snapshot` 是文档化的旧快照迁移入口。结论:**静态引用计数只能筛出候选,动态派发(`getattr` 字面量、f-string 名、装饰器注册)会让真死代码与承重代码同样显示为「零引用」,必须逐项证伪**。8 项质量门全绿。 + +### 测试 + +- **布尔字面量互换扫描:46 处「默认值/开关反向」盲区补齐**(`tests/` 下 10 个回归文件,22 条新用例 + 1 处既有用例补断言)。对 `src/` 的 354 个候选位点(`True`↔`False`)逐点互换字面量后跑全量:199 KILLED、152 SURVIVED、3 TIMEOUT。152 个存活点连同 3 个超时点(共 155 处)列为分诊对象,本轮用真实入口探针(base 与变异体逐字对比载荷/异常消息/日志)先分诊并补齐其中 **46 处真缺口**。真缺口:14 个 `Settings` 凭据字段的 `repr=False` 反向成 `True` 后,密钥经 `repr()`/`str()`/`model_dump()` 全量回显(此前用例只走了带 `repr=False` 的正路);层级分割器两处 `is_separator_regex=False` 反向后把字面分隔符当正则用,`"***"` 触发 `re.error: nothing to repeat`;Google `keep_blank_values`/`validate=True`/`saw_parts` 三处判据反向分别丢空查询值、放行非法 base64、吞掉 parts 兜底;Jina 与 SiliconFlow rerank 的 `reraise=True` 反向后重试耗尽的原始异常被静默吞掉;OpenAI/Volcengine 的 `require_tool_call_ids=True` 反向后无 id 的 tool_call 被静默放行;`Resources` 四个门面类的 `_delegate_native=True` 反向后原生资源委托失效;`_responses_has_content` 末行 `return True` 反向后,带 `tool_calls` 的 assistant 消息里 int/dict 等非法 `content` 被判「无内容」整段丢弃、助手文本无声消失;`resolve_app_root` 冻结分支、4 个 `kb_*` 清洗开关默认值(反向变 `True` 静默改变预处理)、`config_menu` 检索菜单 `while True` 循环与模型切换重载标志、`stream=True` 两处(chat 与 chat_service 默认走流式)、展示表头 `show_header`(chat 检索详情、retrieval CLI 结果、chat 配置表)、SiliconFlow rerank `return_documents` 默认请求体字段——共 46 处,每处均经选择器模式红验证 KILLED。等价变异(附反证):`usedforsecurity` 三处(`retrieval_service`/`snapshot_repository`,哈希收场逐字相同,实测 base 与变异体 digest 一致)、`openai 1829`(`parse_decimal_int("")` 返回 `None`、集合查找失败,空状态仍为 `False`)、Google `native_value is True` 死分支(1922,不可达)等 109 处。扫描 3 个 TIMEOUT 全部闭环:`config_menu:104` 与 `model_provider:561` 后续判 KILLED,`retrieval_service:498`(`usedforsecurity`)判等价。全量 4314 passed;8 项质量门全绿。(**勘误**:本条原先声称余下 109 处为「等价变异」——该结论不成立,且与同句的 152 对不上账(46+109≠152)。三个前置提交落地后于 HEAD 对 155 处存活点复扫:87 处已被用例杀死、余 68 处仍需分诊;那 68 处后续逐条判定为 50 处真缺口与 18 处等价变异,故原「109 处等价」不成立。同批复扫还给出自报数与实测的对账:三个前置提交自报合计 98 处真缺口(18+34+46),复扫实测 87 处(差 11);抽查确认 `1e86d10` 声称覆盖的位点在其**自身提交上**逐点变异仍存活(如 `_temperature_explicit` 的用例只断言被 `repr=False`/`compare=False` 屏蔽的观察面,变异前后逐字相同),这些位点真正的 killer 用例由其后的请求层批引入(`git log -S` 溯源)。**提交自报的覆盖数不作数,一律以复扫实测为准。**) +- **布尔字面量互换扫描(续):公共请求层、Anthropic 与工具/服务/检索批 50 处真缺口补齐**(`tests/providers/test_guard_coverage_regressions.py`、`tests/test_guard_coverage_regressions.py`,新增 52 个用例实例)。对上一轮扫描存活的 155 处(152 SURVIVED + 3 TIMEOUT)在三个前置提交落地后于 HEAD 复扫(87 处已被杀死),对**剩余 68 处**用 `scripts/audit/verify_sites.py` 在干净 worktree 上复核(全部 68 处确认在 HEAD 上 SURVIVED),再逐条用真实入口探针(base 与变异体逐字对比载荷/异常消息/日志)分诊:**50 处真缺口、18 处等价变异(附反证)**。真缺口:`RunConfig`/`KnowledgeSnapshotManifest` 的 `@dataclass(frozen=True)` 反向成 `False` 后配置与清单可被原地改写(且清单 `__hash__` 消失);`Settings.model_dump` 的 `_safe_dump_exclude` 里 `{name: True for name in secret_exclude}` 的 `True` 反向成 `False` 后 14 个凭据字段全部泄漏进导出结果;`log_manager` 两处 `with_location`(模块 logger 调用点 `True`、`_build_rotating_handler` 默认 `False`)反向后模块日志丢掉 `%(filename)s:%(lineno)d`、聊天日志反而带上;`FaissStore.save_snapshot` 的 `mkdir(parents=True)` 反向成 `False` 后写到嵌套缺失目录抛 `FileNotFoundError`;`RetrievalService._get_rerank_provider` 的 `json.dumps(..., sort_keys=True)` 反向成 `False` 后键序不同的等价 options 生成不同缓存键、每次检索重建 provider;各 Provider 的 `invoke`/`ainvoke` `stream=True` 默认值与私有 `_build_chat_request`/`_build_responses_request` 默认值;7 处独立的 `@retry(reraise=True)`(OpenAI 兼容与 Google、Ark 的 `embed_documents`/`aembed_documents`/`complete`)反向成 `False` 后重试耗尽的原始异常被包成 `RetryError`;Google 原生工具的 `native_value is True or is None`(`True` 标记被判非法 `ValidationError`)与分片关联的 `will_continue is True`(可关联分片被误报「无法确认是否为同一调用」);OpenAI 兼容 `_convert_responses_tools` 的 `setdefault("strict", False)`、`_chat_status_is_error` 对空白状态的 `return False`、`astream_events`/`_invoke_responses` 的 `terminal_seen = True`(完整流被误报「终止事件之前结束」)、`parse`/`async_parse` 的 `copy_with(stream=False)` 与 `stream_responses`/`async_stream_responses` 的 `copy_with(stream=True)`(background 请求的拒绝消息错位或被放行);Ark 异步 Responses 流的 `terminal_seen`。等价变异(附反证):Google `model_dump(exclude_none=True)`(真实 `types.HttpOptions` 各形态 base 与变异体逐字相同)、`count_tokens`/`compute_tokens` 的 `vertexai is False`/`is True` 分支(`_validate_compute_token_fields` 先拒 tools、`_contents` 提前返回)、`_response_text` 第二循环的 `thought` 死代码(第一循环已 return)、`usedforsecurity` 两处(摘要逐字相同)、`SessionConfig`/`get_field_value` 无观察入口、快照 `mkdir` 的 `parents`/`exist_ok`(`__init__` 已建目录、rmtree 先于 mkdir)、`finalized` 标志(rename 后 cleanup 为 no-op)、`faiss` 的 `ensure_ascii=False`(stats 仅 ASCII/int)、Ark `getattr(..., True)`(真实请求总有该属性)。50 处真缺口逐条经选择器模式红验证(施加变异后对应用例必红、还原后绿),18 处等价变异逐条附真实对象反证。全量 4366 passed;8 项质量门全绿。 +- **变异器新增 `boolc`(布尔字面量互换)**(`scripts/audit/mutate.py`、`scripts/audit/README.md`)。`True`↔`False` 只替换布尔字面量本身。`ast.Constant` 节点带完整位置信息,直接按字面量跨度替换,不像比较运算符节点那样只能靠区段正则。位点枚举先按取值类型筛(`isinstance(value, bool)`)——`bool` 是 `int` 的子类,只按 `Constant` 遍历会把 `1`/`0` 也当成布尔字面量,变异落到数字上、扫描结论随之失真。补两条单元测试(两个方向互换 + 非布尔常量不是位点)。 +- **比较运算符方向反转扫描:3 处「方向反向」盲区补齐**(`tests/` 下 2 个文件)。新增 `cmpdir` 变异器对 `src/` 的 99 个候选位点(`<`/`<=`/`>`/`>=`)逐点做方向反转后跑全量:94 KILLED、5 SURVIVED。存活点逐条用真实入口探针(base 与变异体逐字对比载荷/异常消息)分诊,**3 处真缺口、2 处等价变异**。真缺口:Jina 与 SiliconFlow rerank 的 index 范围判据 `not 0 <= direct_index < len(documents)` 反向成 `>=` 后,除 0 以外的**一切合法下标**都被拒(实测 index=1 报「index 无效」),而下标为负反而被放行、落到 `positions[...].remove(-1)` 抛裸 `ValueError`(穿透该方法承诺的 `RuntimeError`)——既有用例只喂过 index=0 与越界的 index=4;`RetrievalService._rrf_rerank` 中 keyword 一侧的 `if keyword_rank > 0` 反向成 `<` 后,关键词一路的贡献被静默清零(实测双路 rank 都为正时总分恰好减半、只给 keyword_rank 时得 0.0),而既有用例只验证了 semantic 一侧。等价变异(附反证):`BasicCleaner` 的代理项跳过守卫——哨兵无论取 0xD800/0xD801 还是 0xE000,随后都被 `str.replace` 完整换回双换行,且 `_WHITESPACE_RE` 与折叠步的 `\s` 同集合、用户文本从不与哨兵相邻,含「低于代理区间的全部非空白码位」的对抗文本在内 10 组输入收场逐字相同;`GoogleProvider._merge_tool_arguments` 的列表扩容守卫——`index < len` 时 multiplier ≤ 0 不扩容、`index == len` 时与 base 同样扩 1,而 `enumerate` 的连续下标使 `index > len` 不可达,9 组对抗输入收场逐字相同(含 base 未扩容的 `[1] + [1,2]` 边界)。全量 4232 passed;3 条新用例(参数化共 5 个实例)经 verify_sites 选择器模式逐条红验证(期望 KILLED),8 项质量门全绿。 +- **变异器新增 `cmpdir`(比较运算符方向反转)**(`scripts/audit/mutate.py`)。`<`↔`>`、`<=`↔`>=` 方向反转,与 `eqneq` 同构:多操作数比较只翻转第一个运算符。运算符节点(`ast.Lt` 等)不带位置信息,只能靠区段正则;模式按最长优先排列,避免 `<` 先于 `<=` 命中而切出 `> =`。补两条单元测试(四种方向形态 + 链式比较只翻第一个)。 +- **`==`↔`!=` 互换扫描:27 处「等于判据反向」盲区补齐**(`tests/` 下 7 个回归文件,26 条新用例)。对 `src/` 的 358 个候选位点(Eq 278 + NotEq 80)逐位点注入 `==`↔`!=` 互换后跑全量:330 KILLED、27 SURVIVED、1 TIMEOUT;28 处存活点逐条用真实入口探针(base 与变异体逐字对比载荷/异常消息)分诊,27 处真缺口、1 处等价变异。**该 TIMEOUT 本身就是存活证据**:`retrieval_test/core.py:122` 的 `/quit` 判据反转后交互循环不再退出,扫描 pytest 撞满 600 秒超时。关键发现:**陈旧 `.pyc` 会造出假判定**(`log_manager` 位点首探因此假性「等价」,强制清掉 `.pyc` 后重跑才还原为真缺口;通道本身已由验证驱动堵上,见上一条)。覆盖:import `src.chat.core`/`src.utils.log_manager` 不得触发 `if __name__ == "__main__"` 分支(变异后任何 import 都直接启动会话、执行演示日志块,污染全部调用方)、`/quit` 直接退出不把命令字送进检索、`Settings` 非 LLM 协议错误消息的角色名与字段一致(embedding 报 Embedding、rerank 报 Rerank)、配置菜单权重/融合策略提示只在非混合模式出现、「候选过量招募倍率」选项写回取值、主菜单仅 `exit` 时返回、rerank 提供者缓存只驱逐同 provider 的过期项(同位点两处判据)、`normalize_messages` 已有 system 消息时不重复插入、Responses `detail="original"` 仅 OpenAI 变体接受、capability report 非 LLM 角色清空已验证协议(`factory.py:484` 出厂数据下 registry 恒为空集、两分支收场逐字相同,**判为等价变异**——仍补测试用临时注入 registry 的方式区分两分支)、google `_as_bool` 对数值 0/非 0 的真值、流式顶层 refusal 交付与有内容时的 finish 保留、local-hash 嵌入向量的符号位、OpenAI 同步/异步 stream 的 `text_delta`、Responses 显式/默认端点判据、Ark 同步异步 `flush completed after delta` 与 stream `text_delta`。全量 4225 passed;26 条新用例经 `verify_sites` 选择器模式逐条红验证(期望 `KILLED`,28 个位点全中),8 项质量门全绿。 +- **变异验证驱动隔离 pytest 字节码缓存**(`scripts/audit/verify_sites.py`)。`==`↔`!=` 这类变异不改变文件长度,`git checkout` 还原与再变异落在同一秒内时,CPython 按(源码 mtime 整秒、文件大小)判定缓存有效——pytest 实跑的是上一轮的字节码,base 与变异体的行为会互换,判定与当前位点无关。`run_pytest` 现在把 pyc 写到 worktree 内独立前缀目录(`PYTHONPYCACHEPREFIX`)、跑完即清,强制按源码重新编译。 +- **变异审计验证工具链收口(`scripts/audit/`)**。把逐面一次性、且全部以 `returncode != 0` 作唯一判据的验证脚本,换成仓库内一套带防呆检查的工具:`mutate.py`(`drop`/`notin`/`isnot`/`eqneq` 四种守卫变异;坐标统一为 CPython 的 UTF-8 字节偏移,变异后先 `compile()` 校验再写盘)、`list_sites.py`(候选枚举)、`verify_sites.py`(注入变异 → 跑 pytest → 按退出码分类;支持站点选择器与期望判定两种模式)。收口的三类实测假结论通道:**陈旧 worktree/提交**(启动即校验每个 worktree 已注册、干净并 detach 到主仓 HEAD——曾用停在旧提交的 worktree 复核,12 个位点全部假绿);**失效选择器**(pytest 对不存在的测试名返回退出码 4,曾被 `!= 0` 误判成「已杀死」;现在先预检文件与测试名存在,且只有退出码 0/1 参与判定,4/5 与 2/3 一律单独上报);**行号漂移**(源码修复后按旧行号定位失败曾被当成「已杀死」;现在在 `--drift-window` 行内做唯一最近漂移定位、同距用列偏移消解,平局或无匹配一律 `MUTFAIL`,绝不施加到别的守卫)。另含 `--selftest` 防呆自检、`tests/scripts/test_audit_tooling.py`(含临时 git 仓库端到端:KILLED/SURVIVED/COLLECT_ERROR 三种判定与 worktree 无残留)、`regressions/green39.txt` 验收夹具(39 个已人工复核位点,其中 7 处覆盖行号漂移、1 处覆盖已修复位点的 `MUTFAIL`)与 `scripts/audit/README.md`。顺带修正历史变异器把 `col_offset` 当**字符**偏移用的隐患——含中文的行会切错位置,必须按 UTF-8 字节换算。 +- **否定变异扫描:34 处「``not`` 被删/反转」盲区补齐**(`tests/` 下 5 个测试文件)。新增 `mut_not.py` 变异器,对守卫里的 `not X`、`x not in y`、`x is not None` 三类否定各做一次删除/反转,全量 1337 个候选点中 159 处首轮存活。逐条用真实入口探针(base 与变异体逐字对比)分诊后确认 **34 处真缺口**、5 处等价变异。关键发现:首轮红验证跑在**陈旧的测试副本**上——新写的测试文件没有同步进验证用的 worktree,pytest 对「测试名不存在」返回非零退出码,被脚本误判成「已杀死」,26 个假 RED 掩盖了真实覆盖情况;同步测试文件后重跑才暴露 `volcengine.py:1177` 与 `google.py:1792` 两处未真正杀死(前者测试打在了原生入口、后者与新测试重名被 pytest 静默收集为旧定义)。覆盖:anthropic 采样字段与 `extra_body` 重复必须报错(options 侧非空也要判)、非 beta 调用传 `mcp_servers` 必须拒绝、`role=tool` 的列表内容不得被压平成字符串、`supports_sampling_option` 对旧/新模型的判据方向、异步流必须交付 `tool_call_completed`、google `tool_result` 只有 `tool_call_id` 时按已登记调用名反查、显式给出合法 `name` 必须通过(`not part_name.strip()` 反转后只有空名字被接受)、`type=refusal` 块缺 `refusal` 字段时回退到 `text` 且不得同时发 `text_delta`、candidate 级 `finish_message` 拒答原因必须交付、`async_validate_tuning_reward` 转发 `composite_reward_config`、Chat 请求转发 `metadata`、Responses 回退取 options 的 `top_logprobs`、官方端点拒绝 `options.extra_body` 里的 `stop`、`_response_retrieve_kwargs` 过滤 `None`、Responses/Ark 同步与异步流在后续事件之前**立即**交付已完成的工具调用、`async_compact_responses` 与 `async_update_vector_store_file` 只转发显式给出的字段、Ark Responses 拒绝不支持的请求字段、原生入口的 `extra_body` 凭证校验、Ark 异步 finish 分支不得重复交付已发过的调用、`_copy_nested` 对 `model_copy` 返回自身的对象必须退回 `deepcopy`、Excel 日志的 `all_scores` 列过滤 `None` 分数、`edit_retrieval_params`/`edit_model_params` 的 Rerank 开关与切换必须写回配置。**复核轮补充(同批):** 对首轮判为「等价变异」的 12 处存活点用载荷级探针重验,其中 5 处确认为等价(`anthropic.py:972` 的 `None` 分支回退重读同一个被 pop 的值、`factory.py:356` 的 raise 被更早的同判据检查遮蔽、`google.py:571` 的守卫只在 `str` 分支内调用、`model_provider.py:1201` 对无 `type` 的消息只是等价复制、`model_provider.py:335` 的赋值与 `setdefault` 默认值和键编码重复),另 7 处实为真缺口:OpenAI 兼容与 Ark 的 Responses 异步/同步 finish 分支会**重复交付**已发过的工具调用、Google 从凭证文件加载的 credentials 被静默丢弃、嵌套非字符串键的报错消息丢掉路径、`_merge_resource_kwargs` 的 `omit_none` 两个方向语义反转、`max_output_tokens=None` 会被并入请求体(后者连同源码一并修复:改为无条件移出 `configured_options`)。复核用当前 worktree 复跑了这 12 点:5 处等价变异仍存活、7 处被新测试杀死;并修正了原红验证脚本里两条在测试重命名后失效的选择器(pytest 对不存在的测试名返回退出码 4,被 `!=0` 判成红),用现名重验后确认为 `RED_GOOD`。全量 4185 passed;本次新增 7 条用例逐条红绿验证(注入变异后红、还原后绿),`max_output_tokens=None` 修复也完成旧源码红/修复后绿验证,8 项质量门全绿。 + +- **调用名互换扫描:29 处「内建名语义」盲区补齐,并修正变异器错位**(`tests/test_guard_coverage_regressions.py`)。把守卫里的 `any`↔`all`、`min`↔`max`、`bool`↔`int`、`str`↔`repr`、`sorted`↔`reversed` 互换后全量仍绿的存活点共 57 处,逐条用真实入口探针确认行为改变后补测试;29 处确认为真缺口。关键发现:上一轮 `mut_name.py` 用 `src.index(line_text)` 定位行首,而同一行文本(如 ` return any(\n`)在文件里出现多次时它返回**第一次**的位置——于是变异落到更早的那一行上,扫描结果变成「改的是 A 行、记的是 B 行」,57 处存活全部是假阳性。修正成按行号累加偏移后重扫,下列缺口才暴露出来。覆盖:`is_retryable_error` 类名含多个 token 时命中其一即可重试(`any` 不是 `all`)、`normalize_messages` 已有 system 消息时不再插入(`not any` 不是 `not all`)、google `_as_bool` 对非 bool/int/float/str 取值按真值兜底(`bool` 不是 `int`)、敏感键后缀匹配命中其一即拒绝、anthropic `is_error` 收敛成 `bool`、`model_dump` 与 `model_dump_json` 两份 `bool()` 各自收敛、`_require_int_value` 返回 `int` 不是 `bool`、`_stable_fingerprint` 用 `repr` 区分取值、`_NativeResourceProxy.__repr__` 用 `repr`、`_display_repr` 用 `str`、background 轮询睡眠取 `min(interval, remaining)`(同步与异步两路)、`_is_missing_required_argument_error` 用 `str` 判消息(`repr` 变异后类名被搜进关键词导致误判)、资源方法守卫对三个扩展名各自放行(`all` 变异后普通参数让全部为假)、兼容层 `complete`/`stream_events`/`astream_events` 只在签名没有 `**kwargs` 时裁剪参数(`all` 变异后 `temperature` 被丢掉)、google 流块终止判据对任一 candidate 生效、Ark Classification 的 labels 判据对任一非法项拒绝(同步与异步,`all` 变异后 `123.strip()` 抛裸 `AttributeError`)、`http_options` 违规消息按字典序列出(`sorted` 不是 `reversed`)、知识库文档 `source` 元数据用 `str`(`repr` 变异后变成 `PosixPath(...)`)、`_resource_provider_label` 用 `str`、重复扩展字段消息与受信来源消息用 `str` 列键名/根路径(`repr` 变异后带引号或 `PosixPath(...)`)、`_redacted_settings_error` 与 `TOMLDecodeError`/`RecursionError` 消息用 `str`(`repr` 变异后带类名与引号)。判为等价变异的 28 处(如 `str(1)` 与 `repr(1)` 对整数恒等、TOML 键恒为 `str` 使 `str`/`repr` 无差别、`int` 与 `bool` 对正整数同真)不写测试。全量 4030 passed;29 条新用例逐条红绿验证(调用名互换后红、还原后绿),8 项质量门全绿。 + +- **布尔运算符互换扫描:20 处「只满足单个条件」的组合补齐覆盖**(`tests/` 下 2 个回归文件)。把守卫里的 `and`↔`or` 互换后全量仍绿的存活点共 35 处,逐条用真实入口探针确认行为改变后补测试;20 处确认为真缺口。判别输入的关键是「只满足第二个条件」或「只满足第一个条件」——此前测试都取两条件同真或同假。覆盖:anthropic 只开 `for_tool_runner` 时 output_format 的处理、空 refusal 的 `refusal_delta` 不报拒答(同步与异步两路)、google 只设 `max_output_tokens` 别名不误报重复、Developer API 未提供 system prompt/tools 时 token 计数照常(同步异步各两路)、只传 `composite_reward_config` 不误报互斥(同步异步)、Responses 空 `json_schema` 必须拒绝(与 Ark 副本修复后同判据)、Facade 只以 `__` 结尾的属性名照常转发、Ark 只设 `max_completion_tokens` 不误报、请求级 `top_k`/`seed` 与模型 options 未冲突时不误报(Chat 与 Responses 两路)、`faiss_index` 单缺时仍拒绝保存快照、两个权重只一个为 0 时接受、`bool` 权重报「必须是数值」而非「有限数值」。判为等价变异的 15 处(如 `_chat_stream_events` 已保证 refusal 恒非空使 `and event.refusal` 冗余、上游 `normalize_messages` 同判据兜住)不写测试。全量 4001 passed;20 条新用例逐条红绿验证(and/or 互换后红、还原后绿),8 项质量门全绿。 + +- **边界比较符互换扫描:27 处「恰好等于阈值」的 off-by-one 盲区补齐**(`tests/` 下 5 个回归文件)。把守卫里的 `<`↔`<=`、`>`↔`>=` 互换后全量仍绿的存活点共 27 处,逐条探针确认边界值处行为改变后补测试:分隔符表恰好 64 项 / 客户端超时恰好等于上界 / `is_retryable_error` 的 500 与 `_chat_status_is_error` 的 400 本身算错误 / google `http_options.timeout` 恰好等于毫秒上界 / `json_path` 列表索引 0 合法(`< 0` 不是 `<= 0`)/ 工具参数列表在 `index == len` 时扩容 / local-hash 维度 0 退回默认 / 清单计数恰好到 64 位 / TOML 转义不转义普通空格 / `safe_repr` 恰在 limit 处不截断 / 快照 id 恰好 64 字符 / 空嵌入不建 FAISS 索引 / 同分去重保留先出现者 / 关键词检索恰好取满 top_k / 候选乘积恰好等于上界 / score 恰好等于阈值保留 / overlap 恰好等于上界减一 / 保留日志恰好达天数不删。全量 3981 passed;24 条新用例逐条红绿验证(比较符互换后红、还原后绿),8 项质量门全绿。 + +- **except 元组收窄扫描:49 处声称覆盖的第二异常类型补齐触发**(`src/utils/config.py` 与 20 余个源文件的测试、`tests/` 下 7 个回归文件)。扫描把 `except (A, B, ...)` 收窄成 `except A` 后全量仍绿的存活点共 29 处,探针逐条确认「命中被丢弃类型时行为改变」后补测试:`__signature__` 抛 `ValueError` 的资源参数校验(`_sanitize_resource_call`/`_validate_sdk_resource_call`,此前替身缺 `__call__` 实际只触发 `TypeError`)、循环引用 JSON 收敛、三处非 ASCII base64 收敛、四条「属性抛 `NotImplementedError` → 能力错误」链(google 资源/Facade `_delegated_resource`/`__getattr__`/Ark Classification 模块体 `AttributeError`)、五处 `str`/`repr` 抛 `RuntimeError` 的量级兜底、符号链接环 `RuntimeError` → `ValueError`、rerank 非数值 score 收敛、日志目录 NUL 字节 `ValueError` 降级、chat 与召回循环的 `KeyError`/`EOFError` 退出、部分凭证点名缺的另一半。判为等价或不可达的 4 处(`volcengine:632` timeout 解析、`google:1519` `file_uri`——两个调用点均已有同义前置守卫)不写测试。全量 3957 passed;33 条新用例逐条红绿验证(变异收窄后红、还原后绿),8 项质量门全绿。 + +- **`retrieval_candidate_multiplier` 的注释声称与实测不符**(`src/utils/config.py`、`tests/test_config.py`)。注释称 `Decimal('Infinity')` 走到 `int()` 抛 `OverflowError`、删掉 `except` 里的 `OverflowError` 会裸崩——实测 `_coerce_number` 的类型兜底先抛「必须是数值,但得到 Decimal」,`int()` 那行对普通输入恒不抛,两条声称均失实。修正注释:`except` 的三类型只为覆写 `__int__` 的数值子类保留,并补一条触达它的回归测试(`_EvilFloat` 抛 `OverflowError` → 收敛成脱敏 `ValueError`;删 `OverflowError` 即红)。`isfinite` 前置守卫的注释同步改为如实记录「删掉行为不变,因 `inf.is_integer()` 同样为 `False`,保留只为与同文件字段判据对齐」;对应测试 docstring 不再声称「删守卫变红」。 + +- **修 `_volcengine_init` 补丁在 reload 后静默落空**(`tests/providers/test_sdk_capabilities.py`)。`tests/providers/test_factory.py` 的 autouse 夹具把 `src.providers.volcengine` 从 `sys.modules` 删除并重导,类随后从旧模块字典取 `get_settings` 全局名,而 helper 用 `import ... as m` 拿到的是新模块对象——补丁打空,走真实 `get_settings`,新用例全量单跑绿、合跑红。改为直接在 `VolcengineProvider.__init__.__globals__` 上补丁;实测 `test_factory.py` + 目标用例合跑由红转绿,红验证复验仍红。 + +- **Ark 四条调用路径缺失失败日志包装**(`src/providers/volcengine.py`、`tests/providers/test_sdk_capabilities.py`)。同构副本对齐审计的第二处发现:`invoke`/`ainvoke`/`embed_documents`/`aembed_documents` 在 anthropic、google、openai_compatible 三家都有同一层包装——入口 `logger.info`、`start_time`、成功时「完成,耗时 Xs」、失败时 `except Exception: safe_exception_text + logger.exception + raise`(记日志后重新抛出,不吞掉异常);**Ark 四处全部缺失**。后果:Ark 渠道调用失败时零日志——用户只拿到异常,`safe_exception_text` 的凭证脱敏与截断也一并失效(该包装正是脱敏入口,见 `test_security_boundaries` 系列);成功路径也没有耗时观测。修法:四处补齐同构包装(消息文案与三家对齐:`"%s LLM (%s) 出错: %s"`/`"%s 嵌入 (%s) 异步出错: %s"` 等)。实现中两处细节:`embed_documents` 先算 `duration` 再 `return embeddings`(否则耗时日志是死代码,`git diff -w` 确认最终 diff 只含包装);日志取 `getattr(self, "_provider", "volcengine")`——Ark 的 `__init__` 不设 `_provider`(另外三家都设),与 oc 的 `_require_responses_resource` 同一缺省模式。红绿验证:4 条参数化用例(四条路径各一)修复前全红、修复后全绿;`git stash` 回退源码复验 4 红。全量 3924 passed。 + +- **Ark Responses 缺失的 JSON Schema 被伪造成空 schema**(`src/providers/volcengine.py`、`tests/providers/test_protocol_adapters.py`)。同构副本对(同名同语义的孪生函数)判据对齐审计发现:`_convert_responses_format` 有三份实现——`openai_compatible` 在 `453d096` 已修(缺 `schema` 时抛「缺少 schema 字段」,不许回退)、google 的 `_response_format_values` 对同一输入同样拒绝,**唯独 volcengine 的 Ark 副本漏修**:`schema.get("schema", ...)` 最终回退到 `{}`,`{"type": "json_schema"}` 缺字段时静默发出 `{"format": {"type": "json_schema", "json_schema": {"schema": {}, ...}}}`——客户端以为拿到了结构化输出约束,服务端收到空 schema 实际不做任何约束。更糟的是既有测试 `test_ark_responses_format_still_accepts_supported_shapes` 把伪造行为**钉成了反向守卫**(断言 `{"type": "json_schema"}` 照常转换成 `schema: {}`),形成「判据在其他实现有、这个副本没有、测试还写反了」的三重缺口。修法:volc 对齐 oc 的判据——`json_schema` 缺失或非非空 `Mapping` 时抛带指引的 `ValueError`;`isinstance(dict)` 收紧为 `Mapping`(与 oc 一致,任意 mapping 实现可用)。红绿验证:4 条新拒绝用例(no-json_schema / name-only / empty-schema / schema-not-mapping)修复前全红、修复后全绿;原反向守卫改用完整形态的 `json_schema` 输入,行为不变。全量 3920 passed。 + +- **补「吞掉异常」处理器(fail-open / 静默降级)的测试覆盖缺口**(`tests/providers/test_silent_fallback_regressions.py`、`tests/test_guard_coverage_regressions.py`、`tests/chat/test_guard_regressions.py`、`tests/ui/test_config_menu.py`、`tests/retrieval/vdb/test_factory.py`、`tests/services/test_knowledge_build_service.py`)。第三个变异算子:对 `src/` 下 87 条「吞掉型 except」(handler body 无 `raise`,即 fail-open / 静默降级)把 handler body 替换成 `raise`(取消吞掉、让异常冒泡)后扫描全量测试。结果 24 子集杀 + 13 全量杀 + **50 存活**;逐条用真实入口探测确认「取消吞掉后行为确实改变」,全部 50 条补上回归测试(50 个变异点逐条红验证)。影响面最大的:**四个渠道的 `close()`/`aclose()` 聚合契约**(anthropic/google/openai_compatible/volcengine 共 14 处)——一个客户端关闭失败后其余客户端**仍必须被关闭**,且报告的是**第一个**错误;取消吞掉后资源泄漏、或错误被后发生的顶替。`model_provider` 的 `retry_sync/async_stream_context` 对 `__exit__` 返回 True 的抑制语义(取消后 body 异常上抛且 `__exit__` 不被调用);空异步流的 `StopAsyncIteration` 收尾(取消后空流被当成「重试未返回结果」);`_provider_call_accepts_arguments`/`_is_provider_resource_method`/`_sanitize_resource_call`/`validate_sdk_resource_call` 对签名不可读的动态 SDK 包装的降级(取消后 `inspect.signature` 异常冒泡);`security._copy_nested`/`_model_dump_mapping` 的 fail-open(docstring 明示「读不到就不检查」);`google` 的 HTTP 配置 URL 解析失败跳过、`response.text` 取值失败回退;`openai_compatible` 的端口越界返回 False、不可哈希工具键跳过;`config.require_retrieval_method` 对取值属性抛错的对象给出带指引 `ValueError`;`display_utils.get_relative_path` 空路径原样返回;`log_manager` 的日志目录不可写降级为仅控制台、单个旧日志删除失败继续;`cleanup._reject_unsafe_cache_dir` 的 `Path.home()` 不可得与 `protected_paths` 链接环跳过(该函数在 atexit 里被调用,异常让清理完全没做);`retrieval_service` 的 rerank 缓存键对不可序列化 options 回退;`retrieval_test` 交互循环的 ExcelLogger 初始化失败提示、数据库加载失败提示、Ctrl-C 干净退出、`_aclose_quietly` 关闭失败只记警告、`ExcelLogger` 占位文件回收失败不顶替原始错误、写盘失败不打断召回循环;`chat` 会话的 Ctrl-C 干净退出;`config_menu` 权重校验器对非数值返回 False(取消后 `float("abc")` 冒泡让菜单崩溃);`vdb/factory` 与 `knowledge_build_service` 的临时快照清理失败不掩盖原始失败原因;`factory.capability_report` 对未安装 SDK 包报 version=None。修法是补测试而非改源码——fail-open 本身是设计,缺口在没有任何测试钉住它。新增回归测试 38 条,全量 3916 passed。 + +- **收口非 raise 守卫扫描的剩余存活项**(`tests/providers/test_guard_coverage_regressions.py`、`tests/test_guard_coverage_regressions.py`)。上一批 167 条存活守卫里还剩约 65 条未定性;本批用真实入口逐条探测收口,新增确认 **26 条真缺口**(其余为等价变异或死代码,结论见下)。影响面较大的:`openai_compatible` 后台轮询收到终态响应但缺 `id` 时抛「无法轮询」(同步与异步两处)——已完成的任务被当成错误;`retrieve_file_content`/`async_retrieve_file_content` 在 SDK 提供 `files.retrieve_content` 时被守卫绕过、直接落到 `NotImplementedError` 通道;Responses 流在终止事件之后重复送达的事件被再次转换成 `finish`(同步/异步、OpenAI 与 Ark 四处),调用方收到重复终止事件;`anthropic._convert_messages` 不跳过 `system` 消息(与 `system` 参数重复下发);异步 `_aiter_invoke_stream_text` 对 `ping` 等非文本事件解引用 `None` 抛裸 `AttributeError`(同步版上一批已补,本批补异步镜像);异步 `astream_events` 重复发出 finish;`google._validate_http_options(None)` 抛「必须是对象表」(该形态在资源 config 路径上无前置过滤)、值为 `None` 的敏感键被误判为携带凭证;Google 同步/异步流的重复工具调用完成事件被二次交付;`volcengine._normalize_verified_protocols(None)` 抛错(`openai_compatible` 的镜像上一批已补);`faiss_store.resolve_parent_content` 对 `None` 值条目抛裸 `AttributeError`;`resources._NativeResourceProxy.__getattr__` 绕过 `_children` 缓存;`ui.config_menu._choice_default` 命中时返回 None 导致默认选中项丢失;`config.ModelDetail.validate_options(None)` 抛错、`validate_retrieval_method` 对成员名/取值形态返回字符串而非枚举(下游按枚举比较静默失配);`SnapshotRepository.get_active_snapshot_id` 对空标记抛「快照 id 不合法」(首次构建前的空标记让启动失败);`RetrievalService` 的基类桩方法被当成已实现、`_legacy_search` 跳过 `asearch` 直接找同步 `search`、异步 `aclose` 对同一 provider 重复关闭。**等价变异(约 90 条,未补测试)**:删除后从兄弟守卫或 fall-through 得到逐字相同结果,如实测 `_as_bool` 的 bool 分支(`True != 0` 与 `bool(True)` 同值)、`_copy_nested` 的容器链(deepcopy 兜底同语义)、`anthropic._stream_error_message` 的四个 BFS 分支、`config.require_retrieval_method` 的枚举分支(`getattr(value,'value')` 兜底命中)等;**死代码/防御性分支**:如 `google._response_text` 第二循环被第一循环抢先、`openai_compatible` 流循环里的 `completed is None` 分支(真实路径上 `_responses_stream_event` 恒带非空 `tool_call`)。修法是补测试而非改源码——守卫本身正确。新增回归测试 24 条(含交互循环空查询跳过、`_aclose_quietly` 对无可关闭资源安静返回且不打警告),26 个变异点逐条红验证(删守卫则对应测试变红),守卫存在时全绿;另核实唯一一条 TIMEOUT 判定(`retrieval_test/core.py:122` 的 `/quit` 退出守卫):删除后循环退化为无限重试,属已覆盖(以挂起形式检出),非缺口。 + +- **补「非 raise 守卫」变异扫描存活项的测试覆盖缺口**(`tests/providers/test_guard_coverage_regressions.py`、`tests/test_guard_coverage_regressions.py`)。上一批扫描只覆盖 `raise` 语句;本批换用新的变异算子:把 `src/` 下**单语句 if 守卫**(body 恰为一条 `return`/`continue`/`break`,无 else,共 374 条)整条替换成 `pass`——即删掉守卫本身、控制流 fall-through。先跑 `sys.settrace` 归因(238 条在测试中被触发、136 条从未触发),再按「归属测试文件优先、绿则升级全量」扫描 374 条,得 178 子集杀、28 全量杀、167 存活、1 超时。167 条存活经真实入口逐一探测后分三类:**61 条真缺口**(删除后异常类型、返回值或消息确实改变)、约 90 条等价变异(删除后从兄弟守卫或 fall-through 得到逐字相同的结果)、其余为死代码或被上游守卫抢先。61 条真缺口里影响面较大的:`openai_compatible` 空 `choices` 流块索引 `choices[0]` 抛裸 `IndexError`(usage-only 收尾块是常规形态,Ark 同)、`_chat_completion_events` 非 finish 事件提前冲刷累积中的工具调用、`_extract_refusal` 把普通回答文本当成拒答返回、`_error_message` 丢掉字符串错误与 `code` 字段;`anthropic` 字典形态 `tool_choice={"type":"auto"}` 与 top_p≥0.99 兼容值被拒、`user=None` 时仍追加 user-profiles beta 头、`message_delta` 的 usage 与 `message_stop` 的 finish 事件被丢弃、ping 等非文本事件解引用 `None` 抛裸 `AttributeError`;`google` 顶层 `finish_reason` 与 `prompt_feedback.block_reason` 不再被识别为流终止事件、`thought` 之外 system 消息被并入 user 部件、`options.http_options` 配置被静默丢弃、无 client 时 `close`/`aclose` 抛裸 `AttributeError`;`security` 掩码凭证后跟 `键: 值` 时键名丢失导致**明文值落进日志**(实测 `sk-abc***token: hunter2` → `[REDACTED]: hunter2`);`embedding_service` 空批次 `np.vstack([])` 抛错、维度探针对 ndarray 返回 None(跳过上界校验)、空列表抛裸 `IndexError`;`retrieval_service` 非 dict 文档元数据抛裸 `AttributeError`、`chunk_index` 被忽略导致文档键漂移、同一 provider 在重排缓存中被关闭两次;`retrieval_test` Excel 单元格把 None/bool/int 字符串化(数字列变文本列);`local_hash` 零向量归一化除零;`config._coerce_number` 十进制整数返回 float。修法是补测试而非改源码——守卫本身正确,缺口在测试。新增回归测试 50 条,逐条经变异验证:62 个变异点(含一条测试覆盖多守卫)各自使对应测试变红、守卫存在时全绿。 + +- **补「已执行但无断言」判据的测试覆盖缺口,并钉住宽松 `match=` 掩盖的十一条守卫**(`tests/providers/test_protocol_adapters.py`、`tests/providers/test_sdk_capabilities.py`、`tests/providers/test_failure_contracts.py`、`tests/retrieval/vdb/test_snapshot_repository.py`、`tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py`)。上一批只覆盖了「从未执行」的判据;本批换用 `sys.settrace` 把全仓 761 条已执行的 `raise` 归因到具体测试文件,再对 802 条候选(761 + 41 未执行且未扫)逐条做「删 `raise` 换 `pass` → 先跑归因文件、绿则升级全量」的变异扫描,得 36 条存活。这 36 条经真实入口逐一探测后分三类:**11 条真缺口**、18 条等价变异(删除后从兄弟守卫/下游 fall-through 抛出同类型同消息的异常)、7 条死代码或被上游守卫抢先。11 条真缺口里,**5 条是既有测试的 `match=` 正则过宽**——正则同时匹配正确消息与下游退化消息,变异后仍变绿:`google.py` 1336 的 `"function.name"`(`.` 也匹配下划线,实际命中的是 pydantic 的 `allowed_function_names`)、`anthropic.py` 574/1803/2020/1900 的 `"不支持 tool_choice"` 等前缀式匹配(Beta 与非 Beta 两条消息都含)。删除后行为确实改变(实测,均经真实入口):`google.py` 1336 函数式 tool_choice 缺 `function.name` → 消息退化成 pydantic 的字段白名单提示(丢掉「本渠道要求 `function.name`」这一指引);`anthropic.py` 574 未知 tool_choice 字符串 → 落到 SDK 侧报错(丢掉取值本身);1803/2020 Beta `output_format` → 退化成非 Beta 分支的同名消息;1900 `compaction_control` → 退化成「SDK 不支持」的泛化消息;`volcengine.py` 1665 Responses `status="cancelled"` → **静默接受为成功响应**;`openai_compatible.py` 473 SDK 签名不匹配 → **裸 `TypeError`**(本应转成带资源名的 `ValueError`);1549 请求级 reasoning 与 `options.extra_body` 重复 → **静默让请求级覆盖**;`model_provider.py` 1443 token 计数为 `None` → 消息退化成「不是整数(NoneType)」(丢掉「响应缺字段」语义);`snapshot_repository.py` 422 分块数与向量数不一致(索引行数恰等于分块数,422 是唯一防线)→ **静默通过**;`security.py` 989 受信来源混入非路径元素 → **静默跳过该元素**。修法:新增 7 条回归测试(`test_anthropic_unknown_tool_choice_string_names_the_value`、`test_ark_responses_cancelled_status_is_rejected`、`test_call_sdk_resource_contracts_signature_mismatch_to_valueerror`、`test_openai_responses_rejects_reasoning_duplicating_model_extra_body`、`test_require_token_count_none_reports_missing_field`、`test_validate_snapshot_dir_rejects_embedding_row_gap_when_index_aligned`、`test_ensure_trusted_source_rejects_non_path_element_in_mixed_roots`),并把 4 条既有测试的 `match=` 收紧到只匹配正确消息。11 条逐条经变异验证(删源码守卫 → 对应测试变红)。未改源码。 + +- **补发布脚本与入口自检六条判据的测试覆盖缺口**(`tests/test_release_scripts.py`、`tests/test_main_entry.py`)。这六条判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 `raise` 换成 `pass` 后全量 3790 条仍全绿)。删除后行为确实改变(实测,均经真实入口):`scripts/build_binary_release.py` 102 未知发布目标 → **裸 `TypeError: cannot unpack non-iterable NoneType object`**(`validate_target_environment` 解包函数的隐式返回);295 tiktoken BPE 下载失败 → **静默返回 `None`**(坏缓存进入发布环境且无报错);377 发布包自检输出不含标记 → 消息退化成下游 383 的「版本不一致」(错误方向);383 发布包自报版本不符 → **静默通过**(错版本产物被接受);`scripts/extract_release_notes.py` 31 CHANGELOG 缺该版本 → **`lines[None:end]` 切片静默返回整个 CHANGELOG 头部**(整份更新日志被当成该版本的发布说明);`main.py` 359 分片器自检产出空列表 → **静默通过**(tiktoken 编码缺失时 `--smoke-test` 仍报 `smoke test ok`,构建脚本据此放行发布包)。同批变异被杀的:`build_binary_release` 110/267/342(已有覆盖,同时证伪行覆盖追踪器对这三处的「未执行」误报)与 `main.py` 341(`test_smoke_test_asserts_every_provider_module_is_importable`)。修法是补测试而非改源码。新增回归测试 7 条,逐条经变异验证:六条守卫各自都有变红测试。 + +- **补检索信任边界、运行期配置与 ETL 边界共五十四条判据的测试覆盖缺口**(`tests/retrieval/vdb/`、`tests/retrieval/`、`tests/runtime/test_contracts.py`、`tests/services/`、`tests/test_config.py`、`tests/test_display_utils.py`、`tests/etl/test_pipeline.py`、`tests/chat/test_core.py`、`tests/providers/test_security_boundaries.py`、`tests/retrieval_test/`)。这些判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 `raise` 换成 `pass` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,均经真实入口):`snapshot_repository` 149 快照 id 解析后逃出根目录(符号链接环)→ **静默通过**;367 manifest.toml 被截断 → **静默通过校验**;398/470/514/550 四个快照文件解析失败 → **裸 `UnboundLocalError`**;412/562 顶层非列表 → 裸 `TypeError: has no len()`、475 顶层非对象 → 裸 `AttributeError: has no 'get'`;418 非二维向量 → 消息退化成「行数不一致」;`faiss_store` 554/559/668/675/995 五个废弃入口 → **静默返回 None**;723 文件不存在 → 消息丢掉路径;831 快照目录不存在 → 误报成「未被激活」;595 查询向量二维/空 → 落到「维度不一致」的错误方向;`vdb/base` 49/53/61/69 四个默认实现 → **静默返回 None**;`vdb/factory` 38 不支持的 store 类型 → **静默返回 None**;`contracts` 244 `set_many` 两权重同零 → **静默接受**(检索恒空);428 `del cfg[...]` → **静默「成功」**;`config` 966 两权重同零 → 静默接受;987 无效日志级别 → 静默接受 `BOGUS`;1035 无效融合策略 → 静默接受;1202 空路径 → 静默接受成当前工作目录;867 负重叠 → 静默接受;984/1030/1184 非字符串形态 → 裸 `AttributeError`;831/937 消息退化(「不是整数」报成「重叠太大」、「必须小于」报成「超过一半」);`retrieval_service` 742/753 存储未实现接口 → 裸 `TypeError: 'NoneType' not callable`;1015/1045 rerank 返回值形态 → 裸 `UnboundLocalError`;`chat/core` 133/260/332 服务未初始化 → 拿 `None` 构造或静默空回答;`knowledge_build_service` 92 文档与向量数不一致 → 静默写入;113 活动快照切换失败 → 以 `None` 继续;`display_utils` 78 模板含 NUL → 误报成占位符数量不符;297 非映射配置 → 误报成 key 形态非法;`security` 715 options 非对象 → 裸 `TypeError`;794/868 资源参数形态 → 消息退化/静默接受;`etl` basic_cleaner 43/47 与 splitter 135/138 的容器/元素形态 → 裸 `TypeError` 或静默接受;`excel_logger` 126 文件名后缀耗尽 → 静默返回 `None` 当路径用。同批经甄别不补测试的:`config` 1087/1104/1120/610/612/614/799/839/864/869/883/888/895/903/939/1214/1229/93/1013(收场消息逐字相同或同为 `ValidationError`)、`security` 394/754/774/927/1014(同一 `ValueError` 消息或在本平台不可达)、`retrieval_service` 1045 之外的 1015 同族、`faiss_store` 24(模块导入期守卫,需子进程复现)。修法是补测试而非改源码。新增回归测试 65 条,逐条经变异验证:五十四条守卫各自都有变红测试。 + +- **补 Anthropic 构造期与流、OpenAI 兼容渠道 Responses 扩展字段、Jina/SiliconFlow Rerank 构造与 payload、原生资源代理与 LocalHash Embedding 共二十五条判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`、`tests/providers/test_sdk_capabilities.py`、`tests/providers/test_rerank_contracts.py`、`tests/providers/test_factory.py`)。这些判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 `raise` 换成 `pass` 后全量 3698 条仍全绿)。删除后行为确实改变(实测,均经真实入口):anthropic 192 构造期缺 `ANTHROPIC_API_KEY` → **静默构造**出无凭证的 provider;anthropic 279 `supports_sampling_option` 收到未知字段 → **静默返回 `False`**,调用方(`chat_service._temperature_kwargs`/`chat.core`)只按真假分支,非法字段名被静默吞掉;anthropic 1419 `type` 以 `.error` 结尾但无 `error` 子对象的流事件 → **静默返回 None**,错误信号在流里消失;anthropic 2204 `aclose` 首个关闭异常 → **静默吞掉**(同步 `close` 有覆盖,异步侧没有——sibling 缺口);jina 61/siliconflow 51 `options` 含未知字段 → **静默构造**,非法配置一路带进请求体;jina 66/siliconflow 58/61 缺 API Key 或 Base URL → **静默构造**出无凭证 provider(`_base_url` 落成 `None`);jina 96/siliconflow 90 `options` 与 `options.extra_body` 重复 → **静默产出** payload,其中一个取值被无声丢弃;jina 101/siliconflow 96 `options.extra_body` 覆盖请求字段 → **静默产出** `payload["model"]="hijack"`,把请求的模型整个换掉;openai_compatible 1415/1553 Responses 的 `options.stop`/`seed`/`reasoning_effort` 与 `extra_body` 重复 → 消息退化成下游合并器的「模型 options 的扩展字段重复」/「extra_body 不允许覆盖请求字段」,点名的是合并器而不是字段来源;openai_compatible 5678/5699 `close`/`aclose` 首个关闭异常 → **静默吞掉**;resources 273 `__getattr__` 的 dunder 探针 → `copy.deepcopy(facade)` **无限递归**(`RecursionError`,重建时 `hasattr(y, '__setstate__')` 触发 `__getattr__`,其中的 `self.provider` 又走同一条路径);resources 282 动态资源为 `None` → **静默返回 None**,调用方按属性访问惯例继续用、崩在更远处;resources 236 `_delegated_resource` SDK 抛 `AttributeError` → **裸 `UnboundLocalError`**(`value` 未绑定);resources 240 资源值为 `None` → **静默返回 None**,调用方拿 None 当资源用、在更远处才崩;local_hash 87 Embedding 请求参数 → **静默接受**并原样丢弃;volcengine 57 缺 Ark SDK → **裸 `UnboundLocalError`**,调用方按 `RuntimeError` 分类接不住;factory 326 协议不在 provider 白名单内 → 消息从「不支持 X 协议」退化成 360 行的「构造函数不支持 protocol=X」(上一轮被误判为等价变异,本轮证伪:两条消息确实不同,且原生渠道构造器不接受 `protocol` 关键字时 326 是唯一一道关卡);factory 679 `except` 块末尾裸 `raise` → **静默返回 None**,两个兄弟 getter 的同一条重抛都已有覆盖(sibling 缺口)。同批经甄别不补测试的:`model_provider` 1771/1785(tenacity 的 `reraise=True` 保证重试耗尽时抛原异常,末尾两条 `RuntimeError` 不可达)、2155(`client_getter()` 返回 None 时随即被下游以同一条消息拦住)、1126/1143(上游 `normalize_messages` 466/470 行先以同族消息拦住,两条不可达)、`resources` 275(非原生渠道的 `AttributeError` 探针:与 273 消息逐字相同,删除后由 273 兜住)、anthropic 335(bool/非数值落到 337 的 `is_double_convertible`,收场同一条消息)。修法是补测试而非改源码。新增回归测试 27 条,逐条经变异验证:二十五条守卫各自都有变红测试(jina/siliconflow 五对兄弟判据共用参数化用例;factory 的两条协议守卫经实测证伪上一轮的「等价变异」结论后合并为一条可达性断言)。其中 `resources` 273 的 dunder 探针最严重:删掉后 `copy.deepcopy(OpenAIResources)` 抛 `RecursionError`——重建时 `hasattr(y, '__setstate__')` 触发 `__getattr__`,而其中的 `self.provider` 又走同一条路径。 + +- **补 `GoogleProvider` 选项、凭证、资源、媒体、partial_args、工具调用分片合并、token 统计、Embedding、流终止与 close 共五十四条判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。这些判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 `raise` 换成 `pass` 后全量 3638 条仍全绿)。删除后行为确实改变(实测,均经真实入口):239/391 `options` 非 Mapping → 裸 `TypeError: 'int' object is not iterable`;401 options 键非字符串 → 裸 `TypeError: sequence item 0: expected str instance`;585 `http_options` 键非字符串 → **静默返回 None**、整段校验跳过;677 `debug_config` 键非字符串 → 裸 `TypeError`;448 `_require_resource` 资源为 None → **静默返回 None**;490 异步 `aio` 节点缺失 → 消息丢掉「异步」;370 缺 google-auth → 消息退化成「凭证文件无法加载」;374 凭证文件无法加载 → 裸 `UnboundLocalError`;925 `output_dimensionality` 非整数 → 裸 `TypeError`、布尔 → **静默接受 True**;929 维度越界 → **静默接受 0/10⁹**;1571 Vertex 模式非 gs:// HTTPS → 消息丢掉「Vertex 模式」;1590 `mime_type` 非字符串 → 裸 `AttributeError`;1795 工具结果缺标识 → **静默产出**无名 `FunctionResponse`;1958/1973/2000 `parts`/`embeddings`/`candidates` 非列表 → 裸 `TypeError`;2100 `partial_arg` 缺值 → **静默返回 None**;2141 json_path 索引过大 → **静默接受**(会按该下标填充列表);2153 json_path 不支持的写法 → **死循环**(`rest` 不推进,实测挂起);2164 `partial_args` 非列表 → 裸 `TypeError`;2172 根值非对象 → 裸 `ValueError: dictionary update sequence element`;2180/2192 json_path 无法写入 → 裸 `AttributeError`/`TypeError`;2286/2316/2354/2376/2403/2406/2409 工具调用分片合并的七条冲突判据 → **静默合并**两个不同调用;2781/2786/2828/3016/3021/3059/3065 token 统计的字段支持判据 → 消息退化成裸 `AttributeError`;3184/3307/4382/4488 流在终止事件前结束 → **静默返回已收片段**;3556/3659 `validate_reward` 双配置 → 裸 `AttributeError`;4012 Live 缺 `aio` → 裸 `UnboundLocalError`、4016 connect 非可调用 → 裸 `TypeError`;4530/4594 Embedding 覆盖 `model` → 消息退化;4554/4620 Vertex 单内容非唯一 → **静默接受多个 embedding**;4564/4632 数量不一致 → **静默返回不匹配的向量**;4692/4714 `close`/`aclose` 首个异常 → **静默吞掉**。同批的 1164/1171/1519/1862/1865/1869 经甄别是**等价变异**(分别落到下游 `validate_secret_free_payload`、调用方 `_contents`、上游 `normalize_chat_messages` 的同一条/同族消息),2832/3069 是**死代码**(被 `_validate_compute_token_fields` 先拦),均不补测试。修法是补测试而非改源码。新增回归测试 60 条,逐条经变异验证:五十四条守卫各自都有变红测试(2153 的用例在守卫删除后会死循环,用超时验证其变红)。 + +- **补 `VolcengineProvider` 构造、请求构造、资源入口与 tokenization 共二十八条判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。这些判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 `raise` 换成 `pass` 后全量 3598 条仍全绿)。删除后行为确实改变(实测,均经真实入口):106/125 `_require_ark_list`/`_require_ark_content` 非列表 → 裸 `TypeError: 'int' object is not iterable`,字符串还会被**逐字符**当成元素;558 `__init__` 未知协议 → 不再拦、`_protocol` 落空;582 构造期缺全部凭证 → **静默构造**出无凭证的 provider;605 `_client_options` 认证不完整 → **静默返回空字典**、SDK 拿到无认证客户端;648 region 非字符串 → 裸 `AttributeError`,纯空白 → **静默接受**成空串;660/671 `_normalize_verified_protocols` 非序列 → 裸 `TypeError`,元素非法 → 裸 `AttributeError`;675 登记未知协议 → **静默接受** `frozenset({'grpc'})`;801 `_require_classification_resource` 资源未挂载 → **静默返回 None**;857/889 Responses 的 `response_format`/`tools` 形态 → 裸 `AttributeError`/`TypeError`;1035/1040/1084/1088 Ark Chat 的 top_k/seed 在模型 options 与 extra_body 两处配置 → **静默接受**、其中一个被无声丢弃;1108 extra_body 与模型 options 重复 → **静默覆盖**;1507 `_prepare_batch_kwargs` 缺必填参数 → 消息退化成「不支持请求参数」,把「缺字段」说成「字段不受支持」;1510 资源 model 非字符串/空白 → **静默接受**;1663 Responses 状态 `incomplete` → **静默返回 None**,不完整响应当成成功;2396/2718 `create_response`/`async_create_response` 同时传 request 与原生关键字参数 → 裸 `AttributeError`;2525/2881 `count_tokens`/`async_count_tokens` 响应缺 `data` → **静默返回 0**,把「响应不完整」报成「没有 token」;2608/2957 Classification 资源导入失败 → 裸 `UnboundLocalError`;3028/3051 `close`/`aclose` 首个关闭异常 → **静默吞掉**、不向上报告。同批的 578(部分 AK/SK)与 631(timeout 非数值)经甄别是**等价变异**,不补测试:578 删掉后落到 582 的同一条消息、631 删掉后落到下游 `normalize_client_timeout` 的同一条消息,结论都不变。修法是补测试而非改源码。新增回归测试 40 条,逐条经变异验证:二十八条守卫各自都有变红测试(2608/2957 的用例用 `sys.meta_path` 拦截器,并先逐出 `sys.modules` 缓存,否则全套运行时更早的用例已导入该模块、拦截器形同虚设)。 + +- **补 `OpenAICompatibleProvider` 构造、请求构造、资源入口与 Embedding 共二十九条判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。这些判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 `raise` 换成 `pass` 后全量 3552 条仍全绿——同批唯一被抓住的 1208/1706 是覆盖器的假阳性、既有用例已覆盖)。删除后行为确实改变(实测,均经真实入口):289 构造期缺 `api_key` → **静默接受**,无凭证的 provider 被构造出来;343 `_normalize_protocol` 未知协议 → **静默返回 None**,`_protocol` 变成 `None`、后续 `== "responses"` 判断全部落空;478 `_validate_sdk_resource_call` 的 `Signature.bind` 抛 `ValueError`(位置专用参数)→ **静默跳过**参数校验,未知参数一路发到 SDK;568 `_background_poll_config` 的 `timeout` 非正/非数值 → 0 与负值被**静默接受**,`None` 抛裸 `TypeError`、超大整数抛裸 `OverflowError`(正是该判据注释说要拦的那类);585/617 后台响应缺 `id` → 裸 `AttributeError`(`_client`/`_aclient`),而调用方按 `RuntimeError` 分类;920 `_require_responses_resource` 非 responses 协议 → 不再拦,把本地 SDK 资源当成远端 Responses 能力;936 `_merge_options` options 覆盖请求字段 → **静默覆盖** `model`/`input`;987 `_merge_resource_kwargs` 调用方覆盖 Facade 已绑定字段 → **静默覆盖**;1145 Chat `seed` 与 options.extra_body 重复 → 消息退化成「extra_body 不允许覆盖请求字段」;1264 `_convert_responses_tool_choice` 既非字符串也非 Mapping → 裸 `TypeError`;1273/1279 function tool_choice 缺 name → 1273 删掉后**静默产出** `{"type": "function", "name": None}`、1279 删掉后**静默返回 None**;1627 `_convert_responses_format` 的 json_schema 非对象 → 裸 `AttributeError`;1649 response_format 的 type 不支持且无 format → **静默返回 None**,结构化输出约束消失;1885 Chat 错误体含 `error` 且状态码正常 → 消息退化成「响应缺少有效 choices」;1906 `allow_empty_choices` 下 `choices=None` 且有 detail → **静默接受**;2290 Responses 终止事件非 `response.completed` → **静默产出一个 finish 事件**;2580/2617 `parse`/`async_parse` 缺 text_format → 消息退化成 `normalize_messages` 的「聊天请求必须提供 prompt 或 messages」;2598/2632 `parse` 同时用 request.response_format 与 text_format → **静默接受**,两者之一被无声丢弃;3078/3137/3117 Embedding 请求参数与模型 options 重复/覆盖 `model` → **静默接受**或消息退化;3195/4465 `file_content` 缺 `files.content` 资源 → 裸 `TypeError: 'NoneType' object is not callable`,而同构的 `container_file_content` 有专属 `NotImplementedError`(sibling 缺口);3355/4612 `stream_responses`/`async_stream_responses` 同时传 request 与原生关键字参数 → 裸 `AttributeError`。同批的 951(`_validated_extra_body` 非对象)经甄别是**等价变异**,不补测试:删掉后落到下游 `validate_secret_free_payload`,抛同一条消息的同一个 `ValueError`,结论不变。修法是补测试而非改源码。新增回归测试 46 条,逐条经变异验证:二十九条守卫各自都有变红测试。 + +- **补 `GoogleProvider._contents` 工具调用/工具结果四条判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。这四条在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3544 条仍全绿)。删除后行为确实改变(实测,均经 `GoogleProvider._contents`):1811 `function_call` 缺 name → **静默产出** `name=None` 的 `FunctionCall`,name 非字符串时漏出裸 `ValidationError: 1 validation error for FunctionCall`;1819 `arguments` 是非法 JSON 字符串 → 消息从「不是有效 JSON」退化成「必须是 JSON 对象」,把「JSON 语法错了」说成「JSON 顶层不是对象」;1823 `arguments` 是非对象(数组)→ 裸 `TypeError: cannot convert dictionary update sequence element #0 to a sequence`(`dict(arguments)`);1837 `function_response` 无可关联的 `tool_call_id`/`name` → 缺标识时 **静默产出**无名的 `FunctionResponse`(Google 侧无法关联回任何调用),非字符串标识时漏出裸 `ValidationError`。同批的 1795 经甄别是**等价变异**,不补测试:它是 `role="tool"` 路径的同一条判据,删掉后落到 1837 那条 `function_response` 分支、抛同一条消息的同一个 `ValueError`,结论不变。修法是补测试而非改源码。新增回归测试 8 条(`function_call` 缺名与 name 非字符串两形态 + 非法 JSON arguments + 非对象 arguments + `function_response` 三形态缺标识 + 一条「合法工具块照常转换」的反向守卫),逐条经变异验证:四条守卫各自都有变红测试。 + +- **补 provider 基类 `sdk_client`/`async_sdk_client` 三条守卫的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。这两个属性是 provider 基类上「供原生资源调用」的出口,三条判据在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3540 条仍全绿)。删除后行为确实改变(实测):2140 同步侧没有 `_get_client` → 带 provider 类名的 `AttributeError` 变成裸 `TypeError: 'NoneType' object is not callable`;2150 `_get_aclient()` 返回 `None` → **静默返回 None**,调用方拿到 `None` 当客户端用、在更远处才崩;2158 客户端没有 `aio` 节点 → **静默返回 None**,把「没有异步客户端」说成「有,但它是 None」。同批的 2155(`client_getter()` 返回 `None`)经甄别是**等价变异**,不补测试:删掉后 `getattr(None, "aio", None)` 得到 `None`,随即被 2158 以同一条消息拦住,结论不变。同一文件的 `retry_sync_call`/`retry_async_call` 末尾两条 `RuntimeError` 兜底经甄别**不可达**,不补测试:tenacity 的 `reraise=True` 保证重试耗尽时抛原异常,函数正常路径必然在循环内 `return`。修法是补测试而非改源码。新增回归测试 4 条(三条拒绝 + 一条「同步客户端可用时异步出口从 `aio` 节点解析」的反向守卫),逐条经变异验证:三条守卫各自都有变红测试。 + +- **补 `GoogleProvider` 配置边界与响应格式十八条判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。这些判据在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3505 条仍全绿)。删除后行为确实改变(实测,均经真实入口):681 `debug_config` 含未知字段 → **静默接受**,`nope` 被 pydantic 静默忽略、产出一个什么都没设的 `DebugConfig`;685 当前 SDK 没有 `DebugConfig` 时 → 漏出裸 `NameError`;689 `DebugConfig` 构造失败(`replay_id=5` 这类非法值)→ **静默返回 None**,配置被无声丢弃;947 `http_options` 与请求级 HTTP 扩展同时出现 → **静默接受**;951 `config` 与同级配置关键字同时出现 → **静默返回 config**,同级 `temperature` 等被丢弃;979 Embedding 请求含不支持参数 → **静默接受**;1127/1134/1137 `response_format` 及其 `json_schema`/`schema` 非对象 → 裸 `AttributeError`/`TypeError`;1144 `type` 不支持 → **静默返回 None**,结构化输出约束消失;1180 `extra_body` 与模型 options 重复 → **静默覆盖**;1191 `http_options` 与请求级扩展冲突 → **静默接受**、两者叠加生效;1391/1406/1412 同一量在两处配置 → 前两者静默丢弃一个(1406 实测被 pydantic 拒绝成 `ValidationError`)、1412 静默接受请求级值;1477 data URI 无逗号 → 退化成裸 `ValueError: not enough values to unpack`;1481 data URI 缺 MIME → **静默接受**、产出 `mime_type=""` 的块;1541 `urlsplit` 解析失败 → 裸 `UnboundLocalError: cannot access local variable 'parsed'`。同批的 1164/1171 经甄别是**等价变异**,不补测试:两条与共享 helper `validate_secret_free_payload` 抛同一条消息的同一个 `ValueError`,删除后结论不变。修法是补测试而非改源码。新增回归测试 35 条,逐条经变异验证:十八条守卫各自都有变红测试。 + +- **补 Anthropic 工具选择/图片 data URI/流终止/parse 必填参数十四条判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`、`tests/providers/test_sdk_capabilities.py`)。这十四条在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3479 条仍全绿)——既有用例只钉住「未知字符串」「function 缺 name」「data URI 不是有效 Base64」与 `beta_tool_runner` **同步**侧的 `compaction_control`。删除后行为确实改变(实测):576 `tool_choice` 既非字符串也非 Mapping(整数)→ 裸 `TypeError: 'int' object is not iterable`(`dict(tool_choice)`);600 非字符串非工具对象 → **静默返回 None**,请求不带 `tool_choice`、等于让模型自己决定用不用工具;627 data URI 无逗号 → 退化成裸 `ValueError: not enough values to unpack (expected 2, got 1)`;636 data URI 缺 MIME 或 base64 标记 → **静默接受**,产出 `media_type` 为空串的图片块发给上游;1175/1210 `invoke`/`ainvoke` 的流在 `message_stop` 之前结束 → **静默返回已收片段**(实测 `["hi"]`);1541/1634 `stream_events`/`astream_events` 同判据 → 静默产出一串 `StreamEvent`,结构化事件流的调用方连「流是否完整」都无从判断;1663/2146 `parse`/`async_parse` 与 1815/2032 `beta_parse`/`async_beta_parse` 缺 `output_format` → 消息从「必须提供 output_format」退化成 `normalize_messages` 的「聊天请求必须提供 prompt 或 messages」,把「缺少结构化输出」说成「缺少提问」;944 流式路径 `output_format` 传字典 schema → **静默接受**,整个字典被当作 Python 类型原样放进请求;2124 `async_beta_tool_runner` 的 `compaction_control`(异步侧是另一处守卫)→ 消息退化成 `CompletionRequest.__init__() got an unexpected keyword argument 'compaction_control'`,既不提 Anthropic 也不提 SDK 不支持。修法是补测试而非改源码。新增回归测试 32 条,逐条经变异验证:十四条守卫各自都有变红测试(2124 那条断言收紧到专属措辞「当前 SDK 不支持」,避免被下游的同名 `ValueError` 误杀)。 + +- **补 `ModelProviderFactory` 诊断与导入路径七条守卫的测试覆盖缺口**(`tests/providers/test_factory.py`)。这些判据在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3471 条仍全绿)。删除后行为确实改变(实测,经公开诊断入口 `capabilities`/`capability_report`/`protocol_status` 与 `_ORIGINAL_FACTORY`):236 模块导入失败 → `ProviderClass` 未赋值,函数**静默返回 `None`**,调用方拿到 `None` 后在更远处崩,错误现场与病因分开;244 模块里找不到 provider 类 → 同上;277 渠道名不在 `_provider_map` → 带指引的 `ValueError: 不支持的模型提供商: nope` 变成裸 `AttributeError: 'NoneType' object has no attribute 'get'`(`info.get(...)`),诊断界面拿到的是崩溃而不是「渠道名写错了」;280 角色不在该渠道 `capabilities` 内 → **静默返回**该渠道的 provider 信息(实测返回 `local-hash` 的 embedding 实现),角色契约被无声破坏;412 `server_verified_protocols` 非字符串非序列 → 裸 `TypeError: 'int' object is not iterable`;431 登记未知协议名 → `normalized_protocol` 未赋值,漏出裸 `UnboundLocalError: cannot access local variable 'normalized_protocol'`;554 `role=None` 且渠道名不存在时 → 消息从「无法为提供商 nope 推断模型角色,请显式传入 role。」退化成「模型角色必须是字符串,但得到 None。」——把「渠道名不存在」说成「角色名不是字符串」。同批的 360 行经甄别是**死代码**,不补测试:其条件是 326 行的严格子集,326 先命中,该分支在现有配置下不可达。修法是补测试而非改源码。新增回归测试 8 条,逐条经变异验证:七条守卫各自都有变红测试。 + +- **补 Ark Responses 转换与 Chat/Embedding/Classification 共十三条判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`、`tests/providers/test_sdk_capabilities.py`)。`VolcengineProvider` 的 `_convert_responses_format`/`_convert_responses_tools`/`_convert_responses_tool_choice` 与 OpenAI 兼容侧同构,但七条判据在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3434 条仍全绿)——既有用例只覆盖容器层(`tools` 非列表)与「`function` 非对象」。删除后行为确实改变(实测):863 `json_schema` 非 dict → 裸 `AttributeError: 'int' object has no attribute 'get'`;878 `type` 既非 `json_schema` 也非 `json_object` 且无 `format` 键 → **静默返回空字典**,请求体里 `format` 消失、用户配置的结构化输出约束被无声丢弃;893 工具元素非 Mapping → 整数抛裸 `TypeError: argument of type 'int' is not iterable`;911 `function` 非对象 → 裸 `AttributeError`;942 未知字符串 → 消息退化成通用兜底;955 `function` tool_choice 缺 name → **静默接受**,产出 `name: None`;959 非字符串非工具对象 → **静默返回 None**,请求不带 `tool_choice`、等于「让模型自己决定用不用工具」。另六条在 `invoke`/Embedding/Classification 路径:1027 模型 options 同时设 `max_tokens` 与 `max_completion_tokens` → **静默接受**,只有 `max_tokens` 进请求;1404 原生 Responses 缺 `input`/`input=None` → **静默接受**;1420 `session` 非对象 → 裸 `TypeError`;2249/2296 Embedding 请求参数覆盖 `input`/`model` → 消息从「不允许覆盖请求字段」退化成 `require_text_list` 的「不支持请求参数」,把参数冲突说成参数不受支持;2978 `async_classify` 的 `labels` 形态判据 → 非列表抛裸 `TypeError`、元素非字符串抛裸 `AttributeError`。修法是补测试而非改源码。新增回归测试 46 条,逐条经变异验证:十三条守卫各自都有变红测试。 + +- **补消息归一化、客户端重试、流形态与 embedding 结果共十条判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`、`tests/providers/test_failure_contracts.py`)。`normalize_chat_messages`/`normalize_messages`/`normalize_responses_input` 是四个 provider 公共的**统一请求边界**,上面六条形态判据在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3405 条仍全绿)——既有用例覆盖的是元素**内部**字段(缺 id、缺 name、非法 arguments),形态判据全绕开了。删除后行为确实改变(实测):459 消息元素非对象 → 整数抛裸 `TypeError: 'int' object is not iterable`、字符串抛 `dictionary update sequence element #0 has length 1`;466 `tool_calls` 非序列 → 裸 `TypeError`,字符串则退化成元素级错误消息;515 `messages` 非序列 → 裸 `TypeError`;526 `prompt` 为空/全空白 → **静默接受**,产出 `{"role": "user", "content": None}` 或全空白内容发给模型;942 Responses `content` 非序列 → 裸 `TypeError`;1161 `role="function"` → **静默接受**,产出没有 `tool_call_id` 的 function 消息,Responses 无法关联回任何调用。另四条在 `src/providers/__base__/model_provider.py`:1566 `normalize_client_retries` 兜底分支 → `numeric` 从未赋值,漏出裸 `UnboundLocalError: cannot access local variable 'numeric'`;1665 `iterate_async` 非迭代器分支 → **静默产出空序列**,SDK 返回了无法消费的对象却被当成「流正常结束」,模型答的内容完全丢失;2300/2306 `TextEmbeddingModel.embed_query`/`aembed_query` 空结果判据 → 裸 `IndexError: list index out of range`,把「provider 没返回向量」说成「下标越界」。同批的 1126/1143 经甄别是**等价变异**,不补测试:上游 `normalize_chat_messages` 已用同措辞判据拦下(消息文本变成 `messages[0]...`),删除后结论不变。修法是补测试而非改源码。新增回归测试 23 条,逐条经变异验证:十条守卫各自都有变红测试。 + +- **补 `_guard_provider_resource_method` 五条 `TypeError` 收敛判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。该守卫由 `__init_subclass__` 自动装到任何带 `**kwargs` 或 `extra_headers`/`extra_query`/`extra_body` 参数的 provider 资源方法上,把 SDK 抛出的 `TypeError` 收敛成带渠道名与原始消息的 `ValueError`;它按消息内容分三个子类(unexpected keyword/multiple values/missing required),同步与异步各有一份闭包(2016/2020/2024 与 2046/2048/2050)。既有用例只钉住**同步**的「multiple values」一条,其余五条在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3397 条仍全绿)。删除后收场从 `ValueError` 退化成裸 `TypeError`(实测,经 `GoogleProvider.get_interaction` 与 `async_get_cache` 两个真实资源入口):调用方的错误分类按 `ValueError` 写,而 SDK 的 `TypeError` 文本只说「哪个参数不对」、不带渠道名——排查时无法定位到是哪个 provider 的资源方法。修法是补测试而非改源码。新增回归测试 8 条(三类 `TypeError` × 同步与异步,加一条「与参数无关的 `TypeError` 必须原样透出、不能被误分类」的反向守卫),逐条经变异验证:六条守卫各自都有变红测试。 + +- **补流终止与拒答守卫三十条的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。`OpenAICompatibleProvider` 与 `VolcengineProvider` 各有四条「流在终结事件之前结束」与四条「返回拒答」守卫,分布在 `invoke`/`ainvoke`(纯文本生成)与 `stream_events`/`astream_events`(结构化事件)两套入口,合计 30 条。这些判据在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3361 条仍全绿)——既有用例只钉住 Responses 异步流的一条拒 delta 收敛(`test_openai_responses_async_streaming_refusal_delta_is_coerced`)。删除后的后果分两类(实测,均经公开入口):**流提前结束**(oc 2459/2479/2549/2570、volc 1868/1898/1967/1997 与各 `invoke` 侧)静默返回 `["hi"]` 或一串 `StreamEvent`,调用方无法区分「模型答完了」与「连接断了」,半截答案当成完整答案进入对话历史;**拒答**(oc 2730/2763/2770/2778/2874/2889/3003/3009/3020、volc 2099/2103/2113/2130/2134/2147/2188/2192/2202/2219/2223/2236)非流式**静默返回空列表**,流式退化成相邻的「流提前结束」消息——把「模型明确拒绝回答」说成「流断了」,两者需要完全不同的处置。修法是补测试而非改源码。新增回归测试 36 条(4 provider×协议组合 × 流式拒答/非流式拒答/流未终结/stream_events 未终结 × 同步与异步,另加一条「带终结事件的正常流照常产出内容与 finish 事件」的反向守卫),逐条经变异验证:30 条守卫各自都有变红测试。 + +- **补 `GoogleProvider._validate_http_options` 四条输入形态判据的测试覆盖缺口**(`tests/providers/test_google_genai.py`)。该方法有两条输入路径——`Mapping`(直接 `dict()`)与对象(走 `model_dump`)——两条路径上的形态判据从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3353 条仍全绿)。既有用例 `test_google_http_options_reject_credentials_or_custom_clients` 等只喂合法 `Mapping` 或真实 `types.HttpOptions`,585 那条(嵌套键非字符串)已被 `tests/test_boundary_hardening_regressions.py:5157` 钉住、其余四条全绕开了。删除后行为确实改变(实测,全在 `src/providers/google.py`):509 非 Mapping 且无 `model_dump` → 取值落到 `model_dump` 分支,消息从「必须是对象表或 google-genai HttpOptions」退化成「无法读取」,把一个**类型错误**说成**读取失败**,照提示排查的人会去查权限而不是配置形态;518 与 520 两条 `except` 分支 → `payload` 从未赋值,漏出裸 `UnboundLocalError: cannot access local variable 'payload'`,而调用方的错误分类按 `ValueError` 写、接不住它;522 `model_dump` 返回非 Mapping → 裸 `TypeError: argument of type 'int' is not iterable`(`"timeout" in payload`)。修法是补测试而非改源码。新增回归测试 8 条(非 Mapping 且无 `model_dump` 四形态 + 带参 `model_dump` 抛 `TypeError` 后回退成功一条 + 回退也失败一条 + `model_dump` 抛其它异常一条 + 返回非 Mapping 一条),逐条经变异验证:509/518/520/522 四条守卫各自都有变红测试。 + +- **补两个 Rerank provider 响应解析八条形态判据的测试覆盖缺口**(`tests/providers/test_rerank_contracts.py`)。`jina.py` 与 `siliconflow_rerank.py` 是近似副本(源码注释明确要求「修改任一侧的响应解析时必须同步另一侧」),`_parse_response` 既有四条形态判据、`rerank`/`arerank` 各有一条「响应必须是 JSON 对象」判据从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3341 条仍全绿)。既有用例 `test_rerank_invalid_index_is_explicit`/`test_rerank_unknown_document_is_explicit` 命中的都是「index 值越界」与「document 映射不到输入」两条**取值**判据,形态判据全绕开了。删除后行为确实改变(实测,全经公开 `rerank`/`arerank` 入口):jina 234/279 与 sf 226/272 响应非 JSON 对象 → 带指引的 `RuntimeError` 变成裸 `AttributeError: 'list' object has no attribute 'get'`;jina 154/sf 149 结果项非 Mapping → 变成裸 `TypeError: argument of type 'int' is not iterable`(`"index" in res`),而本模块承诺的解析契约是 `RuntimeError`,调用方的错误分类与日志脱敏都按 `RuntimeError` 写、接不住 `TypeError`;jina 169/sf 164 响应声明的 index 与随附 document 不匹配 → **静默接受**,实测返回 `([0], [1.0])`,把「命中的是另一篇文档」当成有效排序交给检索层。同批的 jina 145/237/282 与 sf 146/229/275 经甄别是**等价变异**,不补测试:删除后落到相邻的同消息守卫,仍抛 `RuntimeError` 且结论不变。修法是补测试而非改源码。新增回归测试 12 条(两 provider × 结果项非对象三形态 + 两 provider × index/document 不匹配 + 两 provider × 非列表 results + 两条走公开入口的同步/异步 JSON 对象校验),逐条经变异验证:八条守卫各自都有变红测试。 + +- **补工具调用归一化六条形态判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。`_chat_tool_call_item`、`_responses_function_call_item`、`_responses_json_text`、`validate_complete_tool_call` 里的六条判据在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3332 条仍然全绿)。既有用例覆盖的是**元素内部**字段(缺 id、缺 name、非法 JSON),这六条**形态**判据全绕开了。删除后行为确实改变(实测,全在 `src/providers/__base__/model_provider.py`,均已用公开入口 `normalize_chat_messages`/`normalize_responses_input`/`OpenAICompatibleProvider._chat_completion_events` 复现):379 工具调用元素非对象 → 整数抛裸 `TypeError: 'int' object is not iterable`、字符串抛 `dictionary update sequence element #0 has length 1`(`TypeError` 会穿透只捕获 `ValueError` 的调用方);384 与 969 嵌套 `function` 非对象 → **静默接受**,把字符串 `function` 丢弃、改用顶层同名字段拼出请求(两处是独立代码,Chat 路径的测试杀不掉 Responses 路径的那条);973 Responses 原生 `function_call` 缺函数名 → **静默接受**,产出 `name: None` 的 item;551 `arguments`/`output` 不可 JSON 序列化 → 收敛消息变成裸 `TypeError`,且实测删除后两条路径**静默接受**、把 `arguments: None` 发给上游;1331 流式工具调用缺 name → **静默接受**,无名调用被当作完整事件交付(Chat Completions 与 Responses 两条流式路径共用此闸)。修法是补测试而非改源码。新增回归测试 9 条(元素非对象三形态 + 两处 `function` 非对象各一条 + 缺函数名 + 两类不可序列化载荷 + 流式两条路径缺 name),逐条经变异验证:删除守卫后 1~3 条对应变红(合计 9 条,六条守卫各自都有测试覆盖)、守卫存在时全绿。 + +- **补 `normalize_embedding_vector` 三条 base64/类型判据的测试覆盖缺口**(`tests/providers/test_failure_contracts.py`)。这三条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3323 条仍然全绿)——既有用例只喂 list 形态(走 `isinstance(value, (list, tuple))` 分支),三条都绕开了。删除后行为确实改变(实测,全在 `src/providers/__base__/model_provider.py`):1397 非法 base64 → `encoded` 未赋值,抛 `UnboundLocalError`(不是 `ValueError`,会穿透 `EmbeddingService` 的 `except ValueError`);1399 不支持的类型 → 同样抛 `UnboundLocalError`,把「SDK 返回了奇怪类型」说成一个与类型无关的名字错误;1402 空字节串 → **静默返回空列表**(一个零维 embedding 被当成合法结果),长度非 4 的倍数 → 裸 `struct.error: unpack requires a buffer of 4 bytes`。修法是补测试而非改源码。新增回归测试 12 条(非法 base64 + 不支持类型四形态 + 非 float32 字节长度三形态 + 一条「合法 base64 与字节串照常解出浮点列表」的反向守卫),逐条经变异验证:删除守卫后 8 条同时变红、守卫存在时全绿。 + +- **补 `_responses_content_part` 二十六条形态判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。`_responses_content_part` 把 Chat 风格多模态内容块转成 Responses 形状,二十六条判据在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3286 条仍然全绿)。既有用例 `test_responses_content_part_detail_must_not_raise_type_error` 只钉住两个分支里的 `detail` 成员判据,其余形态判据全绕开了。删除后行为确实改变(实测,全在 `src/providers/__base__/model_provider.py`):**二十五条静默接受**——畸形取值原样进 `converted` 字典再发给上游(如 `text: 5`、`file_id: 5`、`detail: "ultra"`、整块缺 URI 却产出 `{"type": "input_video"}`);**两条变成裸 `TypeError`**——`dict(...)` 对非映射取值抛 `TypeError: 'int' object is not iterable`。最值得记的是四条 `prompt_cache_breakpoint` 判据(700/810/858/901):它们拦住的是「把 OpenAI 专属字段发给 Ark SDK」——删掉后 Ark 侧静默丢弃该字段,而调用方以为缓存已生效。同函数的 779 经甄别是**等价变异**,不补测试:删除后落到紧邻的「缺少 image_url 或 file_id」判据,仍抛 `ValueError`。修法是补测试而非改源码。新增回归测试 37 条(26 条非法形态 + 11 条「各分支合法内容块照常转换」的反向守卫),逐条经变异验证:删除守卫后 26 条同时变红、守卫存在时全绿。 + +- **补 `GoogleProvider._merge_tool_call` 三条「分片指向多个调用」判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。这三条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3283 条仍然全绿)。既有用例 `test_google_tool_call_merge_rejects_ambiguous_unidentified_parallel_calls` 命中的只是「先建一条、再补一条」这条路径,三条全绕开了。删除后行为确实改变(实测,全在 `src/providers/google.py`):2303 同 index 已有两条 name 不同的记录 → **静默接受**,两个并行调用的参数被并进同一条记录;2341 同 id 已挂在两条不同 key 的记录上 → **静默接受**,同样合并;2326 index 分片匹配到多条「无 index 且同名」的记录 → **静默接受**。这三处的后果与源码注释写明的意图正相反(注释明确「绝不能从分片位置合成 index——该位置每个 chunk 都会重置,会把并行的独立调用合并在一起」)。同批的 2316 与 2376 经甄别是**等价变异**,不补测试:删除后落到相邻守卫,仍抛 `RuntimeError` 且消息仍指向同一类问题。修法是补测试而非改源码。新增回归测试 3 条(同 index 不同 name、同 id 挂两条、index 匹配多条无标识记录),逐条经变异验证:删除守卫后 3 条同时变红、守卫存在时全绿。 + +- **补 Anthropic `_convert_messages` 三条 assistant `tool_calls[]` 形态判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。这三条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3260 条仍全绿)。既有用例 `test_anthropic_rejects_malformed_tool_call_arguments`/`test_anthropic_rejects_tool_call_without_id_or_name` 命中的都是**元素内部**字段(`arguments` 非法 JSON、缺 `id`、缺 `function.name`),三条形态判据都绕开了。删除后行为确实改变(实测,全在 `src/providers/anthropic.py`):707 `tool_calls[]` 元素非对象 → 裸 `AttributeError: 'int' object has no attribute 'get'`;710 `tool_calls[].function` 非对象 → 裸 `AttributeError: 'str' object has no attribute 'get'`;730 `arguments` 非对象 → 裸 `TypeError: 'int' object is not iterable`(`dict(arguments)`)。修法是补测试而非改源码。新增回归测试 10 条(元素非对象三形态 + `function` 非对象三形态 + `arguments` 非对象三形态 + 一条「JSON 字符串与对象两种合法 `arguments` 照常转换」的反向守卫),逐条经变异验证:删除守卫后 9 条同时变红、守卫存在时全绿。 + +- **补 Anthropic `_build_message_params` 六条「配置与请求参数不冲突」判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。六条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3260 条仍全绿)。删除后行为确实改变(实测,全在 `src/providers/anthropic.py`):916 `allow_deprecated_sampling="yes"` → **静默接受**,非空字符串为真,等同于显式打开「转发已废弃采样字段」,与调用方写 `False` 的意图正好相反;920 `options.response_format` 非对象 → 带字段名的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`;922/930 `response_format`/`output_format` 同时出现在 options 与请求参数 → **静默接受**,请求侧取值被丢弃;962 请求级 `extra_body` 与模型 options 的 `extra_body` 有同名键 → **静默覆盖**;995 `extra_body` 的键与已建好的请求字段(如 `model`)重名 → **静默接受**,该字段会被 `extra_body` 覆盖掉再发给服务端。修法是补测试而非改源码。新增回归测试 13 条(`allow_deprecated_sampling` 五类非布尔形态 + `options.response_format` 非对象三形态 + 两个字段 options/请求重复各一条 + 两类 `extra_body` 冲突各一条 + 一条「不冲突组合照常构造请求」的反向守卫),逐条经变异验证:删除守卫后 12 条同时变红、守卫存在时全绿。 + +- **补 Anthropic `_normalize_sampling_value` 三条判据的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。这三条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3251 条仍全绿)——既有用例 `test_anthropic_modern_models_omit_deprecated_sampling_defaults` 只喂**合法数字**(`temperature=0.2`/`top_k=5`),三条都绕开了。删除后行为确实改变(实测,全在 `src/providers/anthropic.py`):299 `temperature="abc"` → 消息从「Anthropic temperature 必须是数字。」换成「当前 Claude 模型不支持该 temperature;请省略该字段或使用 1.0。」,把**类型错误**说成**值域错误**,照提示改的人会把 `abc` 换成 `1.0`;309 `top_p="abc"` → `to_finite_float` 返回 `None`,`None >= 0.99` 抛裸 `TypeError: '>=' not supported between instances of 'NoneType' and 'float'`;312 `top_p=0.5`(合法数字但低于阈值)→ 落到函数末尾的 top_k 分支,消息变成「当前 Claude 模型不支持 top_k;请移除该字段。」——用户传的是 `top_p`,报错却说 `top_k`。修法是补测试而非改源码。新增回归测试 9 条(`temperature` 非数字四形态 + `top_p` 非数字四形态 + `top_p` 低于阈值一条),逐条经变异验证:删除守卫后 9 条同时变红、守卫存在时全绿。 + +- **补 `GoogleProvider._contents` 两条媒体 URI/MIME 判据的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。这两条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3251 条仍全绿)。删除后行为确实改变(实测,全在 `src/providers/google.py`):1571 Vertex/Enterprise 模式下非 `gs://` 也非 HTTPS 的媒体 URI → 消息从「仅支持 gs:// URI 或可公开访问的 HTTPS URI」换成开发者模式的通用消息(把 `vertex_mode` 下特有的限制说成不存在);1590 媒体块的 `mime_type` 非字符串 → 裸 `AttributeError: 'int' object has no attribute 'strip'`,空串 → **静默接受**并产出 `mime_type=''` 的 `FileData`。同函数的 1519 经甄别是**死代码**(同文件 1767 行有同条件同措辞的判据先命中),不补测试。修法是补测试而非改源码。新增回归测试 3 条(Vertex 非 https 媒体 URI + `mime_type` 非字符串/空串),逐条经变异验证:删除守卫后 3 条同时变红、守卫存在时全绿。 + +- **补 `GoogleProvider._contents` 两条守卫的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。这两条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3245 条仍然全绿)。删除后行为确实改变(实测,全在 `src/providers/google.py`):1641 工具结果既无 `tool_call_id` 也无 `name` → **静默接受**,产出一个没有 `name` 的 `FunctionResponse`,Google 侧无法把它关联回任何一次函数调用;1881 assistant `tool_calls[]` 的 `arguments` 非对象 → 带字段名的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`dict(arguments)`)。同函数的 1862/1865/1869 经甄别是**等价变异**,不补测试:上游 `normalize_messages` 已用同措辞判据拦下(收场消息变成 `messages[0].tool_calls[0] ...`),删除后结论不变。修法是补测试而非改源码。新增回归测试 6 条(工具结果无可关联 id 两形态 + `arguments` 非对象三形态 + 一条「JSON 字符串与对象两种合法 `arguments` 照常转换」的反向守卫),逐条经变异验证:删除守卫后 5 条同时变红、守卫存在时全绿。 + +- **补 Google 工具转换两条守卫的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。`GoogleProvider._convert_tools` 的容器判据与 `_convert_tool_choice` 的「function 非对象」判据在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3238 条仍然全绿)。既有用例看似覆盖了同一入口,其实都绕过了这两条:`test_google_convert_tools_rejects_malformed_definitions` 传的是「列表**内含**畸形元素」(`[{"type": ["google_search"]}]`),命中的是元素级判据;`test_google_rejects_malformed_function_tool_choice` 传的 `function={}` 是合法 Mapping,命中的是缺 name 那条。删除后行为确实改变(实测):1245 `tools=5` → 带序号的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`enumerate(tools)`);1333 `function` 非对象 → 带字段名的 `ValueError` 变成裸 `AttributeError: 'str' object has no attribute 'get'`。修法是补测试而非改源码。新增回归测试 7 条(`tools` 非序列四形态 + `function` 非对象三形态),逐条经变异验证:删除守卫后 7 条同时变红、守卫存在时全绿。 + +- **补 `VolcengineProvider._build_responses_request` 九条守卫的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。该函数与 OpenAI 兼容版同属一组「同一字段两处配置」与「取值形态」判据,九条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3226 条仍然全绿)。删除后行为确实改变(实测,全在 `src/providers/volcengine.py`):1218 模型 options 同时给 `conversation` 与 `session` → **静默接受**,只有 `session` 生效、`conversation` 被丢弃;1222/1227/1256/1261 同一标量字段在模型 options 与 `extra_body`(或请求级参数)两处出现 → **静默覆盖**,实测 `{"top_k": 1}` 被 `{"top_k": 2}` 顶掉;1303/1311/1315 `session`/`conversation` 非对象 → 带字段名的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`dict(...)`);1336 请求级 `extra_body` 与模型 options 的同名键重复 → **静默覆盖**。修法是补测试而非改源码。新增回归测试 12 条(`conversation`/`session` 互斥 + `top_k`/`seed` 与 extra_body 重复 + 两个采样字段与 extra_body 重复 + 请求级 `top_k` 与 extra_body 重复 + 请求级 `session`/`conversation` 非对象 + 模型 options 的 `session`/`conversation` 非对象 + 请求级 extra_body 键重复 + 一条「合法 `session` 与不重复 `extra_body` 照常进请求」的反向守卫),逐条经变异验证:删除守卫后 10 条同时变红(另 1 条 `non_object_request_state[conversation]` 命中的 1307 守卫已被既有用例覆盖)、守卫存在时全绿。 + +- **补 `_build_responses_request` 五条守卫的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。该函数与 `_build_chat_request` 同属一组「同一字段两处配置」判据,下面五条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3216 条仍然全绿)。删除后行为确实改变(实测,全在 `src/providers/openai_compatible.py`):1407 `options.text` 非对象 → 带字段名的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`dict(configured_text)`);1456 显式 `frequency_penalty`/`presence_penalty` 与模型 options 的 `extra_body` 重复 → **静默接受**,两处配置谁生效取决于调用顺序;1596 `background` 非布尔 → **静默接受**,`"yes"` 原样进请求体;1598 `background=true` 配流式 → **静默接受**,把官方不支持的组合发出去;1610 请求级 `extra_body` 与模型 options 的同名键重复 → **静默覆盖**。同函数的 1415(`options.stop`/`seed` 与 extra_body 重复)与 1553(`reasoning_effort` 与 extra_body 重复)经甄别是**等价变异**,不补测试:删除后仍抛 `ValueError`,只是消息换成 `_merge_extra_body`/`_validated_extra_body` 那条,结论不变。修法是补测试而非改源码。新增回归测试 10 条(`options.text` 非对象 + 两个采样字段与 extra_body 重复 + `background` 四类非布尔形态 + `background` 配流式 + 请求级 extra_body 键重复 + 一条「不重复取值照常进请求」的反向守卫),逐条经变异验证:删除守卫后 9 条同时变红、守卫存在时全绿。 + +- **补 `_build_chat_request` 三条「与模型 options.extra_body 重复」守卫的测试覆盖缺口**(`tests/providers/test_sdk_capabilities.py`)。该函数用一组同构判据挡住同一字段被两处配置,三条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3212 条仍然全绿)。删除后行为确实改变(实测):`factory.py` 无关,全在 `src/providers/openai_compatible.py`——1167(`reasoning` 带 effort)与 1171(`reasoning` 无 effort)→ **静默接受**,显式 `reasoning` 覆盖模型 options 里的 `reasoning`,谁生效取决于调用顺序且无任何提示;1185(请求级 `extra_body` 与模型 options 的同名键重复)→ **静默覆盖**,实测 `{"foo": 1}` 被 `{"foo": 2}` 顶掉。同批的 1145(`seed` 重复)经甄别是**等价变异**,不补测试:删除后仍抛 `ValueError`,只是消息换成 `_validated_extra_body` 那条「不允许覆盖请求字段: seed」,结论不变。修法是补测试而非改源码。新增回归测试 4 条(`reasoning` 带/不带 effort 两形态 + 请求级 `extra_body` 键重复 + 一条「不重复的 `reasoning` 与 `extra_body` 照常进请求」的反向守卫),逐条经变异验证:删除守卫后 3 条同时变红、守卫存在时全绿。 + +- **补 Responses 工具转换五条元素级守卫的测试覆盖缺口(OpenAI 兼容与 Ark 两份同构实现)**(`tests/providers/test_protocol_adapters.py`)。`OpenAICompatibleProvider._convert_responses_tools` 与 `VolcengineProvider._convert_responses_tools` 逐字同构,各有五条元素级判据。既有用例只覆盖了「容器非序列」(`test_responses_converters_reject_non_sequence_tools`)与「元素非对象」两条,另外五条从未执行;行覆盖证实,把它们的 `raise` 换成 `pass` 后全量 3200 条仍然全绿(只有容器那条变异后变红)。删除后行为确实改变(实测,两个 provider 同形):原生工具缺有效 `type` → **静默接受**,`{"native": 1}` 原样透传;原生 `type=function` 缺 `name` → **静默接受**,产出没有 `name` 的工具;原生 function 的 `parameters` 非对象 → 带序号的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`dict(parameters)`);统一 schema 缺 `function.name` → **静默接受**,产出 `name: None`;统一 schema 的 `function.parameters` 非对象 → 同样变成裸 `TypeError`。修法是补测试而非改源码。新增回归测试 12 条(两 provider × 五类非法形态 + 一条「原生工具与统一 schema 两种合法形态照常转换」的反向守卫),逐条经变异验证:删除守卫后 10 条同时变红、守卫存在时全绿。 + +- **补 Anthropic 工具转换三条守卫的测试覆盖缺口**(`tests/providers/test_protocol_adapters.py`)。`AnthropicProvider._convert_tools` 与 `_convert_tool_choice` 各有四条以上判据,既有用例只覆盖了一部分:`test_anthropic_rejects_malformed_tool_definitions` 覆盖「元素非对象」「function 非对象」「parameters 非对象」,但工具**缺 name** 与 **description 非字符串**两条从未执行;`test_anthropic_rejects_malformed_tool_choices` 覆盖「未知字符串」「function 缺 name」「tool 缺 name」,但 **function 取值非对象**那条从未执行。行覆盖证实这三条在整套测试里从未执行;把它们的 `raise` 换成 `pass` 后全量 3188 条仍然全绿(同批的「tools 非序列」「tool 缺 name」「tool_choice 兜底」三条已由既有用例钉住,变异后确实变红)。删除后行为确实改变(实测):缺 name → **静默接受**,`{"function": {}}` 产出 `{'name': None, ...}` 并把 `name: None` 的工具定义发给上游;description 非字符串 → **静默接受**,整数 `description` 原样进请求体;function 取值非对象 → 带指引的 `ValueError` 变成裸 `AttributeError: 'str' object has no attribute 'get'`。修法是补测试而非改源码。新增回归测试 12 条(缺 name 四形态 + description 四形态 + function 非对象三形态 + 一条「合法定义照常转换」的反向守卫),逐条经变异验证:删除守卫后 11 条同时变红、守卫存在时全绿。 + +- **补 `ModelProviderFactory._create_provider` 两条守卫的测试覆盖缺口**(`tests/providers/test_factory.py`)。该函数是建 provider 的枢纽(三个公开 getter 都经它),内部四条守卫只有「构造函数不支持 options」那一条被 `test_factory_rejects_options_for_provider_without_options_parameter` 钉住。`sys.settrace` 行覆盖证实另外两条(`factory.py:315` 的空协议、`factory.py:329` 的 `issubclass`)在整套测试里从未执行,变异扫描(换成 `pass`)后全量 3186 条全绿、且没有任何测试断言这两条消息。删除后行为确实改变(实测,均经公开 getter 可达):315 行 `protocol=""` 归一化为空 → 从带字段名的 `ValueError: provider protocol 不能为空。` 变成裸 `AttributeError: 'NoneType' object has no attribute 'value'`;329 行 `issubclass(provider_class, expected_type)` 失败 → **静默返回**一个不是 `expected_type` 子类的实例(`local-hash` 传 `expected_type=LargeLanguageModel` 时拿到 `LocalHashEmbeddingProvider`),类型契约被无声破坏。同一函数的 326 行(协议不在该 provider 的 `protocols` 白名单内)经甄别是**等价变异**,不补测试:删除后仍是 `ValueError`、结论不变(OpenAI 兼容渠道落到适配器自己的 `_normalize_protocol`,google/anthropic 落到 360 行),三种收场都拒绝且都带指引。修法是补测试而非改源码。新增回归测试 2 条,走未被打补丁的原始类 `_ORIGINAL_FACTORY`(`patch_settings` 把真实 `_get_provider_class` 换成了替身,只有原始类能走到 `_provider_map` 里的真实 provider 信息),逐条经变异验证:删除对应守卫后同时变红、守卫存在时全绿。 + +- **补两个 Rerank provider `_validate_inputs` 前半段守卫的测试覆盖缺口**(`tests/providers/test_rerank_contracts.py`)。`JinaProvider` 与 `SiliconflowRerankProvider` 的 `_validate_inputs` 逐字同构(同一段复制逻辑),各有四条判据:query 非空字符串、documents 非空列表、documents 元素全为字符串、top_n 范围。既有用例只钉住了最后一条——`test_rerank_rejects_invalid_top_n` 逐值扫 `top_n`,而 `test_rerank_accepts_top_n_larger_than_document_count` 与其它用例喂的是合法 `"query"`/`["document"]`,**从来不触发前三条**。行覆盖证实 6 条 query/documents 守卫在整套测试里从未执行,变异扫描(把它们的 `raise` 换成 `pass`)后全量 3162 条测试仍然全绿。删除后行为确实改变(实测):`query=5`/`" "`、`documents=[]`、`documents=["a", 5]` 全部**静默接受**并原样进入请求体发给上游;`documents=5` 从带指引的 `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`any(...)` 迭代失败,`documents` 是公开 `rerank` 入口的形参,经 `src/providers/jina.py:218`/`siliconflow_rerank.py:209` 直达)。修法是补测试而非改源码——守卫本身正确。新增回归测试 24 条(两 provider × query 四形态 + documents 容器四形态 + 元素非字符串三形态 + 一条「合法参数照常通过」的反向守卫),逐条经变异验证:删除守卫后 22 条同时变红、守卫存在时全绿。 + +- **补 `ModelProviderFactory._get_provider_class` 白名单守卫的测试覆盖缺口,且缺口被 `autouse` 夹具的替身掩盖**(`tests/providers/test_factory.py`)。与前三批同法(删掉守卫的 `raise` → 跑全量 → 看是否变红)发现 `factory.py:220` 的 `raise ValueError("不支持的模型提供商: ...")` 删除后 3160 条测试**仍然全绿**。但这一处比前几批更深一层:文件里**看起来有**测试钉住它——`test_unsupported_provider_type` 断言的就是这条消息。真正的缺口在于同文件 `patch_settings` 是 `autouse=True`,它把 `_get_provider_class` **整体换成** `mock_get_provider_class`(内含一份自己的同名 `ValueError` 分支),于是那条断言命中的是替身,源码里那份守卫在整套测试里从未执行。这个判据的价值在于它是**唯一**的白名单执行者:`require_provider_name` 的 docstring 明确写着「判据只要求『是字符串』,不查 `_provider_map` 白名单:白名单是**工厂**的职责(`_get_provider_class` 会给『不支持的模型提供商』)」,而 `_create_provider` 在 `_provider_map.get(...)` 返回 `None` 时会落到这里(`factory.py:327`)。真实入口探针实测删除后行为改变:`get_llm_provider`(经 `_create_provider`)与直调 `_get_provider_class` 都从带指引的 `ValueError: 不支持的模型提供商: nope` 变成裸 `KeyError: 'nope'`(`capabilities` 路径不受影响,它先经 `_provider_info_for_role`,那里的等价守卫仍在)。修法是补测试而非改源码——守卫本身正确,缺的是钉住它的用例。新增回归测试 2 条,都走**未被打补丁**的原始类(模块顶部导入的 `_ORIGINAL_FACTORY`,其 `_get_provider_class` 用 `__wrapped__` 取回 `@cache` 下的原函数):一条直调 `_get_provider_class`,一条经 `_create_provider`。两条都经变异验证:删除守卫后同时变红(`KeyError: 'definitely_not_a_provider'`)、守卫存在时全绿。 + +- **补 `FaissStore.upsert_embeddings` 二维守卫的测试覆盖缺口**(`tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py`)。与上两批同法:变异测试发现把写入侧 `ndim != 2` 那道 `raise` 删掉后全量套件仍全绿。写入侧与载入侧 `_validate_embeddings_shape` 都判 `ndim != 2`,但只有载入侧有测试钉住。可达性不是理论上的——**等长**的一维输入(`[4 条文档]` + `np.zeros(4)`)会先骗过「数量一致」判据(`len` 都是 4),随后 `_rebuild_indices` 读 `self.embeddings.shape[1]` 抛 `IndexError: tuple index out of range`(实测)——不是本方法的 `ValueError` 契约,且 store 已被写坏。新增回归测试 4 条(等长一维 + 一/三/零维三类形态),逐条经变异验证:删除守卫后全部变红、守卫存在时全绿。 + +- **补 `Settings` 六处整数/数值守卫的测试覆盖缺口**(`tests/test_config.py`)。与上一批同法:用变异测试(把守卫的 `raise` 删掉后跑全量套件)发现六处删除后测试仍全绿。甄别方法是「删除守卫 + 真实 `Settings(...)` 入口」,只保留**行为确实改变**的那些——静默接受、静默截断,或把带字段名的 `ValueError` 换成别的异常;纯等价变异(删除后落到下一个守卫、结论不变)不计。六处都在 `src/utils/config.py`:`chat_top_k`/`log_retention_days`/`kb_chunk_overlap`/`kb_child_chunk_overlap` 四处的非整数浮点判据(删除后 `int(1.5)` 把 `1.5` **静默截断**成 `1`,用户写 `chat_top_k = 1.5` 得到「取 1 条」而无任何报错;`1.5` 是合法 TOML 浮点,无需技巧即可到达)、`validate_hybrid_fusion_strategy` 的成员判据(删除后 `"bogus"` 被 `Settings` 静默接受,随后 `HybridReranker` 的 `if == "weighted" else _rrf_rerank` 按 RRF 出结果——静默错误结果)、`_coerce_number` 的两条分支(非数值容器的 `必须是数值,但得到 ...` 判据删除后 `Settings(chat_top_k=[1])` 抛没有字段语义的 `TypeError`;`"1e400"` 的 `isfinite` 判据删除后 `inf` 被放行并落到上界判据、把「字面量溢出」说成「类型不对」)。新增回归测试 9 条(四个非整数浮点字段 + 一条未知融合策略 + 三个非数值容器 + 一条溢出字面量),逐条经变异验证:删除对应守卫后对应测试变红、守卫存在时全绿。 + +- **补 `HybridReranker` 构造期五处守卫的测试覆盖缺口**(`tests/test_boundary_hardening_regressions.py`)。这些守卫本身正确,缺口在**测试**:用变异测试(把守卫的 `raise` 删掉后跑全量套件)发现五处删除后 3129 条测试**仍然全绿**——没有任何一条会在守卫被删时变红。五处都在 `src/services/retrieval_service.py`:`HybridReranker.__init__` 对「合法字符串但不在 `{"rrf", "weighted"}`」的成员判据(删除后 `rerank()` 的 `if == "weighted" else _rrf_rerank` 会把未知策略**静默当 RRF**,与 `retrieval_method` 静默退化同类,而 `_coerce_fusion_strategy` 只做 `.strip().lower()`、不判成员,这是生产路径的唯一拦截点)、`rrf_k` 的非有限/非数值判据(删除后 `nan` 静默通过、`"60"` 被 `float()` 悄悄转成 60.0、`10**400` 把干净的 `ValueError` 换成裸 `OverflowError`)、`rrf_k` 的非正数判据(`rrf_k = -rank` 时分母为零,`rerank()` 抛 `ZeroDivisionError`)、`_weight_tuple` 对单个权重的 `is_double_convertible` 判据(删除后 `float(10**400)` 抛裸 `OverflowError`)、`retrieve` 里 `top_k × retrieval_candidate_multiplier` 的乘积判据(两个字段各自在上界内、只有乘积越界,删除后 2000×2000 静默传给 FAISS)。五处都实测可达(`HybridReranker` 经 `src/retrieval/retriever.py` 的 `__all__` 对外导出;`SessionConfig` 属性无 `__setattr__` 守卫,直写即命中)。新增回归测试 18 条(四个非法策略字面量 + 六个非有限 `rrf_k` + 四个非正 `rrf_k` + 两个不可转换权重 + 一条乘积越界 + 一条「合法取值照常」的反向守卫),逐条经变异验证:删除对应守卫后对应测试变红、守卫存在时全绿。 + +- **`chat_temperature` 的读取侧只判了下界,漏了 `1.0` 上界,同一个值在配置边界与运行期给相反结论**(`src/utils/config.py`)。`require_chat_temperature` 是 `SessionConfig` 构造器、`SessionConfig.__setitem__`(经 `_VALIDATORS` 表)、`ChatService.generate_reply` 与 `Chatbot.chat_async` 兜底分支四处共用的收敛函数,它的 docstring 明确声称「与两处校验器判据一致」,实际只判了 `math.isfinite(number) or number < 0.0`——上界那一半漏掉了。契约本身没有歧义:`Settings.validate_chat_temperature` 写死 `if not (0.0 <= value <= 1.0)`,`config.toml.example` 的注释是「LLM 输出温度」,`tests/test_config.py:79` 早已断言 `Settings(chat_temperature=1.5)` 被拒。实测分歧:`Settings(chat_temperature=1.5)` 抛 `ValidationError`,而 `build_session_config(get_settings())` 之后 `config["chat_temperature"] = 1.5`(以及构造器直传、属性直写)**全部接受**,`ChatService.generate_reply` 再把 `1.5` 原样塞进 SDK 的 `temperature` kwarg(`_temperature_kwargs` 直接透传,实测 `model.calls[0]["temperature"] == 1.5`)。同一个值写进 `config.toml` 报错、经配置菜单或库调用方设置却一路到服务端。修法:上界补进 `require_chat_temperature` 的 `isfinite` 判据,措辞与 `Settings` 对齐(「必须在 0.0 到 1.0 之间」),并订正 docstring 里那句与实现不符的「判据一致」。回归测试 5 条(`1.5`/`2.0`/`2`/`100` 四个越界取值走真实 `generate_reply` 路径并断言被拒前未发起请求,外加一条 `__setitem__` 与构造器同收场、端点 `0.0`/`1.0` 仍放行的守卫),全部在未修源码时实测变红;另把既有 `test_generate_reply_keeps_working_for_valid_chat_temperature` 的合法列表里的 `2` 移除(它本就不是合法取值)。 + +- **embedding 配置的两个入口对不可哈希的 provider key 抛裸 `TypeError`**(`src/services/embedding_service.py`、`src/retrieval/vdb/factory.py`)。`EmbeddingService.embedding_model_detail` 与 `VectorStoreFactory._require_embedding_detail` 是同一段逻辑的两个副本(同一字段、同一来源、同样的「先判映射再判键」),两处都写 `key not in configurations`——而该表达式对不可哈希的取值抛裸 `TypeError: unhashable type: 'list'`,早于它们任何拒绝路径,也穿透调用方按 `ValueError` 建立的分类(`embed_query` 的 Google 分支、`knowledge_build_service` 的清单写入、`VectorStoreFactory._load_existing_state` 的启动路径都按 `ValueError` 写)。实测 `default_embedding_provider=["local-hash"]`(或 `{"a": 1}`/`{1, 2}`)在两处都复现;同一个 key 走 `ModelProviderFactory._get_model_detail` 早已收敛成带字段名的 `ValueError`,`display_utils._require_model_detail` 也用 `try/except TypeError` 收敛过——判据只差这两个落点。可达性:`default_embedding_provider` 没有 validator(`Settings` 里只是普通 `str`),而 `RunConfig` 是 frozen dataclass、字段无校验器,嵌入方可自拼。修法:两处在成员判断之前先判「必须是字符串」,措辞对齐。回归测试 7 条(两处各三个不可哈希形态 + 各一条「字符串 key 照常取到配置」的反向守卫),其中 6 条在未修源码时实测变红。 + +- **读取侧的两个「单位区间」字段只判了形态,没判值域,缺口是静默的**(`src/services/retrieval_service.py`)。`vector_weight`/`keyword_weight`/`score_threshold` 在**写入侧**都有完整的值域判据(`SessionConfig._validate_unit_weight` 与 `Settings.validate_hybrid_weights`/`validate_chat_score_threshold` 都要求 0.0 到 1.0),而**读取侧**只收敛形态:`_weight_tuple` 只判 `is_double_convertible` 与 `total > 0`,`_coerce_threshold` 只判是不是有限数值。两个缺口都实测可达(`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫,直写即命中;`retrieve` 把取值原样交给这两处),且都**静默**:`vector_weight=-0.5`/`keyword_weight=1.5` 的和恰为 1.0,归一化返回的就是 `(-0.5, 1.5)` 本身,加权融合于是按**反相关**打分——实测走 `retrieve`(语义路 0.9 与 0.1 两条、关键词路 0.9 一条)结果从 3 条变成 2 条,语义路的高分文档直接消失,「排序反了」被伪装成「结果少了几条」;`score_threshold=2.0` 时 `coerce_score(...) >= 2.0` 对所有文档恒为假,**每一次检索都返回空列表且无任何提示**(`-1.0` 则恒为真,阈值形同不存在)。修法:两个读取侧都补上与写入侧同源的值域判据。不担心过度收紧——`coerce_score` 的输出已经被收敛到 `[0, 1]`,两个权重都在区间内且和为正时归一化结果与任何合法加权等价,端点 `(0, 1)`/`(1, 0)`/阈值 `0.0`/`1.0` 都照常放行。回归测试 8 条(三组越界权重 + 一条端到端「结果少了一半」+ 三个越界阈值 + 一条「`[0, 1]` 之内含端点照常工作」的反向守卫),其中 7 条在未修源码时实测变红。 + +- **清单的计数字段读写两侧判据不一致,`from_mapping` 把写入侧拒绝的取值静默读成另一个数**(`src/runtime/contracts.py`)。`to_toml`(写入侧)早已经 `_manifest_count` 收敛——拒绝 `bool`、拒绝非整数浮点、拒绝负数、拒绝超过 `2**64` 的取值——而同一份 manifest 的读取侧 `from_mapping` 写的是裸 `int(data[...])`,这些取值**全部被接受**,读出来的计数与磁盘上写的不是同一个数。实测(走完整的 `load_manifest` 路径):`document_count = 1.5` 截断成 `1`、`true` 当成 `1`、`-5` 原样接受、`10**300` 原样接受(写入侧判它超出 `2**64`)。危险的不是抛异常那一类(`"abc"` 抛没有字段名的 `ValueError`、`None`/`[1]` 抛 `TypeError`——都不是本函数的 `ValueError` 契约),而是**静默改写**:manifest 是快照的准入凭据,`VectorStoreFactory` 拿 `load_manifest` 的返回值去比对 embedding 配置,于是准入校验建立在一个磁盘上并不存在的数字上。两处 docstring 都写着「与另一侧同源」,判据却只对齐了异常分类那一半。修法:读取侧复用写入侧同一个 `_manifest_count`;数字字符串(`"3"`)仍照旧接受,因为 `.env`/TOML 来源本来就会以字符串到达。回归测试 5 条(`1.5`/`true`/`-5`/300 位整数四种写入侧拒绝的形态 + 一条「数字字符串计数照常读出」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`_should_apply_score_threshold` 的 `fusion_strategy` 形参只做了「兜底分支」那一半判据**(`src/services/retrieval_service.py`)。该方法的 docstring 已经写明兜底分支必须自己收敛(`"RRF"` 走 RRF 融合却不等于字面量 `"rrf"`,阈值过滤会被错误地套用在排名倒数的量纲上),并说明「原先的兜底分支直接读原属性,等于把那条缺陷留在库调用方这条路径上」——但**调用方显式传入**的取值仍然原样参与 `== "rrf"` 的比较,同一条缺陷换了个入口。实测 `_should_apply_score_threshold(cfg, fusion_strategy="RRF")` 返回 `True` 而默认分支返回 `False`:同一个字段的两种来源给出**相反**的结论;传入 `" rrf "`/`["rrf"]`/`5` 同样静默按「非 RRF」套上阈值。与上面两条不同,这条是**静默的错误结果**而不是异常——RRF 的分数是排名倒数之和,被阈值一过滤整批结果就没了,用户看到「检索无结果」而配置里写着 RRF,没有任何报错。修法与同文件 `_rerank_if_needed` 的 `top_k`/`rerank_enabled` 逐字对齐:显式传入的取值也过一遍同一个 `_coerce_fusion_strategy`,`None` 保留给「未传入」这一哨兵语义。回归测试 5 条(`"RRF"`/`" rrf "`/`["rrf"]`/`5` 四类形态 + 一条「已收敛取值照常判定」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`RetrievalService._rerank_if_needed` 的两个关键字入参只做了「默认值」那一半判据**(`src/services/retrieval_service.py`)。该方法的 `rerank_enabled` 与 `top_k` 都是可选关键字,docstring 写明「由调用方传入**已收敛**的取值」「默认 `None` 时自行收敛,供库调用方直接调本方法的场景使用」——但**调用方显式传入**的取值原样使用,而库调用方恰恰正是「直接调本方法」的那个场景,它没有 `retrieve` 开头那道收敛。于是同一个形参的两种来源判据不一致:不传时收敛,传了反而跳过。实测两处收场都不在契约内:`top_k="abc"`/`[1]` 在 `max(1, min(...))` 处抛裸 `TypeError: '<' not supported between instances of 'int' and 'str'`;`rerank_enabled="no"` 被真值判断当成 `True`,**真的去调 rerank provider**(实测触发建 provider 的整条链路,凭证缺失时以 `ValueError: SILICONFLOW_API_KEY is required` 收场),而 `SessionConfig` 对同一个值直接报「必须是布尔值」——`0`/`[]` 则是靠真值语义的巧合才与 `False` 同分支。修法:显式传入的取值也过一遍与默认分支**同一组**助手(`require_scale_field`/`_coerce_rerank_enabled`),`None` 保留给「未传入」这一哨兵语义。回归测试 11 条(`top_k` 五类形态 × `rerank_enabled` 五类形态 + 一条「已收敛取值照常重排」的反向守卫),其中 10 条在未修源码时实测变红。 + +- **`_merge_hybrid_results` 的两个入参缺形态判据**(`src/services/retrieval_service.py`)。本函数经 `src/retrieval/retriever.py` 的 `__all__` 对外导出,是公开边界(与 `HybridReranker` 同一处置)。`retrieve` 内部调用它之前已经用 `_require_document_list` 校验过两路结果,但**库调用方直接调用**这条路径没有——函数体第一件事就是 `for rank, document in enumerate(semantic_results)` 再对 `document.get(...)` 取值。实测非列表取值的收场都不在契约内:`5`/`None`/`object()` 抛裸 `TypeError: ... is not iterable`;`"abc"`/`{"a": 1}` 抛 `AttributeError: 'str' object has no attribute 'get'`(逐字符/逐键迭代)。两者都不是 `ValueError`,穿透调用方按 `ValueError` 建立的分类。修法:两个入参复用生产路径的 `_require_document_list`。回归测试 6 条(`int`/`None`/`str`/`dict`/`object` 五类形态 × 两路入参 + 一条「两路合法文档列表照常合并」的反向守卫),其中 5 条在未修源码时实测变红。 + +- **`HybridReranker.rerank` 的 `documents` 入参缺形态判据**(`src/services/retrieval_service.py`)。本类的 docstring 明确「库调用方可能绕过 `Settings` 与 `SessionConfig` 直接构造」,构造器与 `_weight_tuple` 都为此做了收敛(已有回归用例钉住),但**入参**这一半漏了。两个内部分支(`_weighted_rerank`/`_rrf_rerank`)都直接 `for doc in documents` 再对 `doc.get(...)` 取值,非列表取值的收场都不在契约内:`5`/`None`/`object()` 抛裸 `TypeError: ... is not iterable`;`"abc"`/`{"a": 1}` 抛 `AttributeError: 'str' object has no attribute 'get'`(逐字符/逐键迭代)。两者都不是 `ValueError`,穿透调用方按 `ValueError` 建立的分类。可达路径:`HybridReranker` 经 `src/retrieval/retriever.py` 的 `__all__` 对外导出,是公开边界。修法:入参复用生产路径的 `_require_document_list`。回归测试 11 条(`int`/`None`/`str`/`dict`/`object` 五类形态 × `rrf`/`weighted` 两种融合策略 + 一条「合法文档列表照常融合排序」的反向守卫),其中 10 条在未修源码时实测变红。 + +- **`ensure_trusted_source` 的 `trusted_roots` 不可迭代时抛裸 `TypeError`**(`src/utils/security.py`)。该函数已显式排除 `str`/`bytes`/`bytearray`(它们**是**可迭代,但会被逐字符拆成信任根——其中 `/` 会放行任意绝对路径),却漏了「根本不是序列」这一类:`for root in trusted_roots` 对 `5`/`True`/`object()`/位数超限的整数抛裸 `TypeError: 'int' object is not iterable`——不是本函数承诺的带 label 的 `ValueError`,而调用方 `FaissStore.load` 按 `ValueError` 分类。可达路径:`load` 是 `VectorStoreBase` 的契约方法,嵌入方直接调用时把 `trusted_paths` 写错形态即命中(`VectorStoreFactory` 的 legacy 分支也经此)。修法:在 `for` 之前补一条 `Iterable` 判据,消息与字符串分支同措辞。回归测试 5 条(`int`/`bool`/`object`/位数超限整数四类形态 + 一条「合法路径列表照常加载」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`RetrievalService._rerank_if_needed` 的 `ranked_results` 入参缺形态判据**(`src/services/retrieval_service.py`)。该方法的 docstring 明确「供库调用方直接调本方法」,因此对 `top_k`/`rerank_enabled` 都做了收敛(已有回归用例钉住),但**同一个方法的第一个入参** `ranked_results` 这一半漏了——而它的契约正是 `_require_document_list` 覆盖的「文档映射的列表」。`retrieve` 内部调用本方法时入参已经过该校验,库调用方直接调用的这条路径却与生产路径判据不一致。实测非列表取值的收场都不在契约内:`"abc"` 是合法可迭代对象,逐**字符**进入循环后 `document["score"] = ...` 抛 `TypeError: 'str' object does not support item assignment`;`5`/`10**4301`/`object()` 抛裸 `TypeError: ... is not iterable`;`{"a": 1}` 抛 `KeyError: 0`(字典迭代出的是键)。三种都不是 `ValueError`,穿透调用方按 `ValueError` 建立的分类。修法:入参复用生产路径的 `_require_document_list`。回归测试 6 条(`str`/`int`/`dict`/`object`/位数超限整数五类形态 + 一条「合法文档列表照常重排」的反向守卫),其中 5 条在未修源码时实测变红。(同一次改动修正了测试文件里 `_RerankStub` 的返回值——它此前引用一个不存在的 `RerankResult`,旧用例因 `top_k` 校验先抛而从未执行到该分支。) + +- **`FaissStore.__init__` 的 `file_path` 入参缺形态判据,整数会被当成文件描述符**(`src/retrieval/vdb/faiss_store.py`)。构造器是同一个 `file_path` 的另一条公开入口,而它第一件事就是 `os.path.exists(file_path)`——那里早于 `load` 里的形态守卫,本类的 `save`/`load`/`save_snapshot`/`load_snapshot` 都已按同一判据收紧,构造器这条漏了。实测非路径取值的收场都不在契约内:`["a"]`/`{"a": 1}`/`object()` 抛裸 `TypeError: stat: path should be string, bytes, os.PathLike or integer, not ...`;位数超限的整数(`10**4301`)抛裸 `OverflowError: fd is greater than maximum`——`ArithmeticError` 子类而**不是** `ValueError`,穿透调用方全部 `except ValueError` 分类;普通整数(`5`)更隐蔽,`os.path.exists` 把它当成**文件描述符**探测,fd 未打开时静默返回 `False`,构造「成功」并把整数留在 `self.file_path` 上——与 `save(5)` 把字节写进 fd 5 是同一类「整数被当成 fd」的问题,只是后果从写错文件变成静默空 store。可达路径:`FaissStore` 是公开类,且 `VectorStoreFactory.get_vector_store(store_type, file_path)` 把 `file_path` 原样透传到这里。修法:构造期判形态,判据与同类四处一致。回归测试 7 条(`list`/`dict`/`object`/位数超限整数/普通整数/`bytes` 六类形态 + 一条「指向不存在文件的合法路径照常构造出空 store」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`SnapshotRepository.load_manifest` 的 `snapshot_dir` 入参缺形态判据,把路径错误误报成「快照不自洽」**(`src/retrieval/snapshot_repository.py`)。它是 `write_manifest` 的读取侧兄弟:写入侧刚按 `__init__`(`snapshot_root`)、`finalize_snapshot`(`temp_dir`)的同一判据收紧,读取侧这一半漏了,而它的失败形态比写入侧更坏。非 `Path` 取值在 `snapshot_dir / "manifest.toml"` 处抛 `TypeError: unsupported operand type(s) for /: 'str' and 'str'`,被紧随的 `except Exception` 接住后**误报成「知识快照不自洽:manifest.toml 无法解析(TypeError)。快照可能在写入或复制过程中被截断,请重建。」**——一个完好的快照会被要求重建,真正的错因(路径传成了字符串)在消息里完全不可见,用户按指引重建后仍会复现。字符串形态尤其现实:`VectorStoreFactory._load_existing_state` 里相邻两行分别以 `Path`(`validate_snapshot_dir(active_snapshot_dir)`)与同一变量传给 `load_manifest`,而 `FaissStore.save_snapshot` 的标注是 `str`;调用方按任一侧的惯例取值都会命中。修法:在 `try` **之外**先判形态(形态问题不是「快照坏了」),判据与 `write_manifest` 逐字对齐。回归测试 6 条(`int`/`None`/`list`/`str`/位数超限整数 5 条 + 一条「`create_temp_snapshot_dir()` 的返回值照常可读」的反向守卫),其中 5 条在未修源码时实测变红,并断言错误消息不含「不自洽」「请重建」。 + +- **`SessionConfig.__getitem__` 与 `__setitem__` 判据不一致,破坏 `Mapping` 协议**(`src/runtime/contracts.py`)。本类声明 `MutableMapping[str, Any]`,`__setitem__` 会经 `_require_known_field` 拒绝未知字段(`KeyError`)与非法形态(`ValueError`),而读取侧直接写 `getattr(self, key)`——判据只做了一半。后果不是「消息不好看」:`in`/`.get(k, default)`/`.pop(k, default)`/`.setdefault` 这些 `MutableMapping` 的混入实现**只接 `KeyError`**,实测 `"不存在的字段" in cfg` 与 `cfg.get("不存在的字段", "dflt")` 都抛 `AttributeError`,而它们本该分别返回 `False` 与 `"dflt"`——一个「探测字段是否存在」的调用方拿到的是异常而不是布尔值。另外 `getattr` 会把 `__class__`/`__dict__`/`to_dict` 这类非字段属性一并返回(实测 `cfg["to_dict"]` 拿到绑定方法),而写入侧对同名 key 抛 `KeyError`——读写两侧对「这个 key 是不是字段」的答案必须一致。修法:复用写入侧的 `_require_known_field`,两侧共用同一份判据与措辞。回归测试 7 条(未知字段/方法名/dunder/不可哈希 key/位数超限整数/`None`/`Mapping` 协议混入实现),其中 7 条在未修源码时实测变红;另加一条「已知字段照常可取且与属性访问一致」的反向守卫。 + +- **`Chatbot._display_retrieved_docs` 对非列表 `docs` 静默渲染伪结果**(`src/chat/core.py`)。与召回测试侧的 `display_results`(本组稍早的一条)同判据——两者消费的是检索管线输出的同一种值,而那一侧已有这条守卫,这一侧此前漏了。实测非列表取值两种收场都不在契约内:`"abc"` 是合法可迭代对象,逐**字符**迭代后每个字符经 `document_*` 助手收敛成占位符,渲染出一张 3 行来源与预览都是空串的伪结果表格(用户看到「检索详情」里有 3 条命中,而实际返回的是字符串);`5`/`True` 抛裸 `TypeError: 'int' object is not iterable`,而本方法在 `chat_async` 的 `try` 内,异常会被兜成「LLM 异步生成出错」——一次成功检索的展示失败被显示成模型故障。修法:按「非列表视为无结果」收敛,与 `display_results` 同判据。回归测试 6 条(`str`/`int`/`bool`/`dict`/`None` + 一条「正常文档列表照常逐条渲染」的反向守卫),其中 4 条在未修源码时实测变红。 + + (同一次改动把 `tests/test_display_utils.py` 里 `_UNTRUSTED_FREE_ARGUMENTS` 的两条条目从 `src/chat/core.py:369`/`:370` 同步到 `:380`/`:381`——该清单按 `文件:行号` 索引,新增守卫行会让旧行号失效。) + +- **`SnapshotRepository.write_manifest` 的 `snapshot_dir` 入参缺形态判据**(`src/retrieval/snapshot_repository.py`)。与同类 `__init__`(`snapshot_root`)、`finalize_snapshot`(`temp_dir`)同一处判据,这一处此前漏了:非 `Path` 取值在 `snapshot_dir / "manifest.toml"` 处抛裸 `TypeError: unsupported operand type(s) for /: 'str' and 'str'`——没有字段名、也没有「该传什么」的指引,而本类其余拒绝路径一律给带指引的 `ValueError`;读取侧 `load_manifest` 对同一形态同样给 `ValueError`,但那是一条**误报**(把路径错误说成「知识快照不自洽…请重建」,见上方对应条目,其后已单独修正),写读两侧的收场都不在契约内。字符串形态尤其现实:同一个 `temp_dir` 在 `KnowledgeBuildService.build` 里紧邻的两行分别以 `str`(`vector_store.save_snapshot(str(temp_dir))`)与 `Path`(`write_manifest(temp_dir, ...)`)传给两个 API,调用方按前者的惯例传字符串就会命中。修法:判据与同类两处一致(只接受 `Path`,不静默强转)。回归测试 5 条(`int`/`None`/`list`/`str`/位数超限整数),全部在未修源码时实测变红;另加一条「`create_temp_snapshot_dir()` 的返回值照常可用」的反向守卫。 + +- **`SnapshotRepository.validate_snapshot_dir` 对字符串路径抛裸 `AttributeError`**(`src/retrieval/snapshot_repository.py`)。符号链接判据看的是**原始**(未解析)取值——`resolve_within` 已把链接解析掉,对 `resolved` 调 `is_symlink()` 恒为 False,等于没有这条判据。而原始取值的形态不受本方法标注约束:`resolve_within` 显式接受 `str | os.PathLike`(它自己就为 `str`/`os.PathLike` 之外的取值给带指引的 `ValueError`),因此调用方传字符串是合法输入,而 `str` 没有 `is_symlink`。实测传字符串时本方法在 `resolve_within` **通过之后**抛裸 `AttributeError: 'str' object has no attribute 'is_symlink'`——本类其余拒绝路径一律给带指引的 `ValueError`,而调用方(`VectorStoreFactory._load_existing_state`)按它分类。现实性:同项目 `FaissStore.save_snapshot(snapshot_dir: str)` 与 `FaissStore.load_snapshot(snapshot_dir: str | os.PathLike[str])` 的标注都是 `str`,调用方按那个惯例传字符串就会命中。修法:符号链接判据先包一层 `Path`(判据落在 `resolve_within` 之后,`int`/`None`/`list` 已被它拒掉,剩下的必然是 `str` 或 `os.PathLike`)。回归测试 2 条(字符串形态的合法目录照常通过、字符串形态的符号链接仍被拦下),两条都在未修源码时实测变红。 + +- **`SnapshotRepository.finalize_snapshot` 的 `temp_dir` 入参缺形态判据**(`src/retrieval/snapshot_repository.py`)。同一方法的两个参数,此前只有 `snapshot_id` 有判据(`_require_snapshot_id_within_root`),`temp_dir` 这一半没有。`temp_dir.replace(final_dir)` 是 `Path` 的方法,非 `Path` 取值在那里抛的是与「快照目录写错了」无关的裸异常:`5`/`None`/`["a"]` 抛 `AttributeError: ... object has no attribute 'replace'`,而 `str` 更隐蔽——字符串**有** `replace`,只是语义完全不同,抛 `TypeError: replace expected at least 2 arguments, got 1`。字符串形态尤其现实:同项目里目录参数普遍标注 `str | os.PathLike`(`FaissStore.save_snapshot(snapshot_dir: str)` 等),调用方按那个惯例传字符串就会命中这里。修法:判据与 `__init__` 对 `snapshot_root` 的一致(只接受 `Path`,不静默强转)。回归测试 5 条(`int`/`None`/`list`/`str`/位数超限整数),全部在未修源码时实测变红;另加一条「`create_temp_snapshot_dir()` 的返回值照常可用」的反向守卫。 + +- **`SnapshotRepository.activate_snapshot` 漏掉快照 id 判据,写侧接受、读侧拒绝**(`src/retrieval/snapshot_repository.py`)。它是四个接收 `snapshot_id` 的公开方法里**唯一**没有调 `_require_snapshot_id_within_root` 的(`create_temp_snapshot_dir`/`cleanup_temp_snapshot_dir`/`finalize_snapshot` 都有)。它把取值直接写进 `ACTIVE_SNAPSHOT`,而标记文件正是两个读取侧(`get_active_snapshot_id`/`get_active_snapshot_dir`)的输入,两者都会用同一份判据校验——于是同一个值在写侧被接受、在读侧被拒绝:实测 `activate_snapshot("../evil")` 正常返回并把 `../evil` 写进标记文件,随后 `get_active_snapshot_id()` 抛「快照 id 不合法」,每一次启动都在 `VectorStoreFactory._load_existing_state` 的第一步失败。非字符串(`5`/`None`)抛裸 `TypeError: data must be str`、位数超限的整数抛裸 `ValueError: Exceeds the limit`,都不是本类承诺的带指引 `ValueError`。修法:写入之前先过同一份判据,与另外三个方法同源。回归测试 8 条(相对逃逸/绝对路径/分隔符/`..`/空串/`int`/`None`/位数超限整数),全部在未修源码时实测变红,并断言失败后既有标记未被改写;另加一条「生成 id 的激活路径照常走通」的反向守卫。 + +- **`FaissStore.upsert_embeddings` 的 `embeddings` 入参缺形态判据**(`src/retrieval/vdb/faiss_store.py`)。上一条给同一方法的 `documents` 补了顶层容器判据,而**兄弟入参** `embeddings` 这一半仍裸抛:`np.array(..., dtype=np.float32)` 的三类失败全部穿透契约——`10**4301` 抛 `OverflowError: int too large to convert to float`(`ArithmeticError` 子类,**不是** `ValueError`,调用方的 `except ValueError` 接不住)、`{"a": 1}` 与 `object()` 抛 `TypeError: float() argument must be a string or a real number`、`"abc"` 抛 numpy 的 `ValueError: could not convert string to float`。三者的消息都不指向「这批向量形态不对」,而 `documents` 那一半已给出带指引的 `ValueError`——同一方法的两个入参,收场一个可读一个裸抛。判据只做了一半:同文件 `semantic_search` 的查询向量转换早已按同一分类收敛,这里对齐。修法:`np.array` 调用包进 `try`,三类失败统一收敛成带指引的 `ValueError`。回归测试 4 条(位数超限整数/`str`/`dict`/`object()`),全部在未修源码时实测变红,并断言失败后 store 未被改动(`documents == []` 且 `embeddings is None`)。 + +- **`display_results` 对非列表 `documents` 静默渲染伪结果**(`src/retrieval_test/core.py`)。与同一次提交里修的 `ExcelLogger.log_results` 同处、同类——两者在召回测试主循环里拿的是同一个 `documents`。实测 `documents="abc"` 是合法可迭代对象,逐**字符**迭代后每个字符经 `document_*` 助手收敛成占位符,渲染出 3 行内容与来源都是 `N/A` 的伪结果表格(用户看到「召回了 3 条」,而实际返回的是字符串);`5` 抛裸 `TypeError: 'int' object is not iterable`。修法:按「非列表视为无结果」收敛,与 `log_results` 同判据。回归测试 4 条(`str`/`int`/`bool` + 一条「正常文档列表照常逐条渲染」的反向守卫),其中 3 条在未修源码时实测变红。 + + (同一次改动把 `_UNTRUSTED_FREE_ARGUMENTS` 里两条按行号登记的条目从 `60`/`61` 同步到 `68`/`69`——该清单按 `文件:行号` 索引,新增守卫行会让旧行号失效。) + +- **`FaissStore.upsert_embeddings` 的 `documents` 顶层容器缺形态判据**(`src/retrieval/vdb/faiss_store.py`)。条目形态早已由 `_validate_document_shape` 逐条判,顶层容器这一半漏了:`upsert_embeddings` 第一行就是 `if not documents: return`,随后 `len(documents)`。实测 `documents=5` 抛裸 `TypeError: object of type 'int' has no len()`,不是本方法其余拒绝路径承诺的 `ValueError`(载入侧 `_normalize_documents` 对同一形态给的是带指引的 `ValueError`);`documents=None` 更糟——它被 `if not documents` 当成「无写入」**静默返回**,调用方以为写入成功而 store 一个字都没变,写入路径上的静默无操作比报错更难查。修法:判据与载入侧同名守卫一致(拒绝 `str`/`bytes`/映射,要求有 `__len__`)。回归测试 5 条(`int`/`None`/`str`/`dict` + 一条「正常文档列表照常写入」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`ChatService._build_prompt` 的第三个入参 `documents` 缺形态判据**(`src/services/chat_service.py`)。上一轮给同一次调用的两个标量参数 `user_input`/`intent` 补了判据,`documents` 这一半漏了。实测两种收场都不在契约内:`5`/`None` 抛裸 `TypeError: 'int' object is not iterable`(不是 `ValueError`,两个调用点的错误分类接不住);`"abc"` 更隐蔽——字符串是合法可迭代对象,逐**字符**迭代后每个字符都被「内容为空则跳过」滤掉,于是**静默退化成「没有找到相关知识」**,一次带着知识调用的请求被无声降级,模型据此回答「我不知道」。修法:容器形态判据与两个标量参数同处、同措辞。回归测试 5 条(`int`/`None`/`dict`/`str` + 一条「正常文档列表照常产出带知识的 prompt」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`ExcelLogger.log_results` 对非列表的 `results` 静默写出垃圾行**(`src/retrieval_test/excel_logger.py`)。方法签名声明 `list[dict]`,但它是**公开方法**,两种非列表取值都不在契约内:`"abc"` 是合法可迭代对象,`enumerate` 逐**字符**迭代,每个字符经 `document_*` 助手收敛成占位符——实测写出 3 条内容与来源都是占位符的伪结果行,用户看到日志写着 3 条命中,而实际返回的是字符串;`5`/`True`/`1.5` 抛裸 `TypeError: 'int' object is not iterable`,而本方法的调用点位于 `try` **之外**(与 `_append_text_row` 同因),异常直接冒到召回测试主循环的 `except Exception`——一次**成功**的召回被显示成「召回测试出错」。修法:按「非列表一律视为无结果」收敛,与既有的 `if not results` 分支共用同一条出口。回归测试 5 条(`str`/`int`/`bool`/`float` + 一条「正常文档列表照常逐条落盘」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **OpenAI 兼容构造器的 `provider` 参数无形态守卫**(`src/providers/openai_compatible.py`)。与上一条 `model_name` 同一处、同一类:`__init__` 里 `provider.lower()` 直接调用,非字符串抛裸 `AttributeError: 'list' object has no attribute 'lower'`。而 `provider` 随后要拼出 `{provider}_api_key` 字段名,本类的其余拒绝路径(缺 key/缺 base_url/协议非法)都给带指引的 `ValueError`。工厂路径已用 `require_provider_name` 收敛,直接构造这条路径漏了。修法:构造期先判形态,与 `require_provider_name` 同判据。回归测试 4 条(三类非字符串形态 + 一条「字符串照常构造」的反向守卫),其中 3 条在未修源码时实测变红。 + +- **另外六个 provider 构造器同样漏了 `model_name` 形态判据**(`src/providers/google.py`、`anthropic.py`、`jina.py`、`siliconflow_rerank.py`、`openai_compatible.py`、`volcengine.py`)。与上一条 `local-hash` 同一类:这些 `__init__` 都把 `model_name` 原样存下、不做形态判据,而它们都是**公开类**,嵌入方可以绕过工厂直接构造(本仓测试大量这么用)。实测 `10**4301`/`["m"]`/`None`:google/anthropic/jina/siliconflow_rerank/openai_compatible 在 `logger.info(f"...模型: {model_name}")` 处抛裸 `ValueError: Exceeds the limit`(消息与「模型名写错了」无关),列表/`None` 则静默通过、原样发给 SDK;`volcengine` 连日志都没有,非字符串全程静默接受,原样进 `"model": self._model_name`。判据只做了一半:工厂路径已用 `require_model_name` 收敛(`cc10585`),直接构造这条路径漏了。修法:六处构造器统一调 `require_model_name`,与工厂同判据。回归测试 3 条(六个构造器各覆盖位数超限整数与列表两类形态,外加一条「字符串照常构造」的反向守卫),在未修源码时实测变红。 + +- **`local-hash` 的 `model_name` 无形态守卫,非字符串在构造期抛裸 `AttributeError`**(`src/providers/local_hash.py`)。`_parse_dimension` 第一行就是 `model_name.rsplit("-", maxsplit=1)`,而 `LocalHashEmbeddingProvider` 是**公开类**,嵌入方可以绕过工厂直接构造(本仓测试就是这么用的)。实测 `10**4301`/`["m"]`/`None`/`5` 全部抛裸 `AttributeError: 'int' object has no attribute 'rsplit'`——不在本模块其余拒绝路径承诺的 `ValueError` 契约内(维度超上界给的是带指引的 `ValueError`)。`local-hash` 是默认 Embedding provider,工厂路径已用 `require_model_name` 收敛,直接构造这条路径漏了。修法:构造期先判字符串形态,与 `require_model_name` 同判据。回归测试 5 条(四类非字符串形态 + 一条「字符串照常解析」的反向守卫),其中 4 条在未修源码时实测变红。 + +- **`_chat_status_is_error` 对容器形态的 `status` 抛裸 `ValueError`,错误分类被大整数打断**(`src/providers/openai_compatible.py`)。本方法的 docstring 明确承诺「一个形态奇怪的 `status` 值不能让错误分类本身崩掉」,它也确实挡住了「字符串形态但位数超限」的取值(上一条回归钉住)。但收敛只做了一半:非 `int` 的非字符串走 `str(status).strip().lower()`,而 `str([10**4301])` 自己抛 `ValueError: Exceeds the limit`——于是「网关在响应体里返回了业务错误」变成一条整数转换错误,调用方的错误分类与日志脱敏都接不住。`status` 来自网关 JSON,形态不受控。修法:只有字符串才谈得上「是不是某个错误状态名」,非字符串一律判为「不是错误状态」,与它落在集合外的原语义一致。回归测试 3 条(列表/映射各持大整数 + 一条从 `_raise_for_chat_response_error` 进入的用例),全部在未修源码时实测变红。 + +- **Google `partial_args` 的 `json_path` 拒绝消息自身用 `!r` 插值,位数超限时抛裸 `ValueError`**(`src/providers/google.py`)。`_partial_arg_tokens` 的两条拒绝分支写 `f"...: {json_path!r}"`。判据是 `isinstance(json_path, str)`,因此**非字符串**取值会走到第一条分支——而 `repr(10**4301)` 自己抛 `ValueError: Exceeds the limit`:**正要报出「json_path 无效」**的那一步变成整数转换错误。`json_path` 来自模型的流式 function call 分片(`partial_args`),形态不受控;上一条回归只覆盖了「字符串形态、索引位数超限」,这条覆盖「取值本身是位数超限的整数」。修法:两处改用 `safe_repr`(对字符串取值与 `!r` 等价,正常路径显示形态不变)。回归测试 3 条(位数超限整数/列表 + 一条从公开入口 `_partial_args_to_mapping` 进入的用例),其中 2 条在未修源码时实测变红。 + +- **流错误事件的兜底文案原样插值网关 `code`,位数超限时抛裸 `ValueError`**(`src/providers/__base__/model_provider.py`)。`raise_for_stream_error_event` 在 `message` 提取失败后回落到 `field(error, "code") or f"{provider} 流返回错误"`。`code` 来自网关 JSON、形态不受控:位数超限的整数让 f-string 抛 `ValueError: Exceeds the limit`——**正要报出流错误**的那一步变成整数转换错误,而调用方按 `StreamEventError` 分类;非字符串取值(`["a"]`)则把 Python 字面量当成错误码渲染。判据只做了一半:同函数提取 `message` 的路径只收字符串,这一支漏了。修法:字符串原样,其余走 `safe_repr`,与 `message` 分支同口径。回归测试 3 条(`code` 为位数超限整数/列表 + 一条「字符串 `code` 照常出现在消息里」的反向守卫),其中 1 条在未修源码时实测变红。 + +- **Ark Responses 的 `failed` 分支原样 `str()` 网关 `error`,位数超限时抛裸 `ValueError`**(`src/providers/volcengine.py`)。`_raise_for_response_error` 的 `status == "failed"` 分支把 `error`(或它的 `message` 字段)写进 `RuntimeError(str(message))`。`message` 来自网关 JSON、形态不受控:`error = {"message": 10**4301}` 让裸 `str()` 抛 `ValueError: Exceeds the limit`——**正要报告网关错误**的那一步变成整数转换错误,而调用方按 `RuntimeError` 分类;非字符串取值(`error = ["a"]`)则把 Python 字面量 `['a']` 当成错误文案渲染出去。判据只做了一半:同函数的 `incomplete` 分支已按 `safe_repr(reason)` 收敛,`openai_compatible._error_message` 也是同一口径,唯独这一支漏了。修法:与两处对齐——字符串原样,其余降级成 `safe_repr` 的量级描述。回归测试 3 条(`error` 为位数超限整数/其 `message` 为位数超限整数 + 一条「字符串 `error` 照常出现在消息里」的反向守卫),其中 1 条在未修源码时实测变红。 + +- **Responses background 轮询的超时消息原样插值网关 `id`,位数超限时抛裸 `ValueError`**(`src/providers/openai_compatible.py`)。`_poll_background_response` 与 `_poll_background_response_async` 在超时分支里用裸 f-string 插值 `response_id`(`f"...响应 {response_id} 在 {timeout:g}s 内未完成。"`)。`response_id` 来自网关 JSON 的 `id` 字段,形态不受控:`10**4301` 让 f-string 抛 `ValueError: Exceeds the limit`——**正要报告超时**的那一步变成整数转换错误,而调用方按 `TimeoutError` 分类。判据与同文件 `_error_message`/`_extract_refusal` 一致(字符串原样,其余走 `safe_repr`);`retrieve` 调用本身仍用原始取值。回归测试 5 条(同步/异步 × 位数超限整数 + 非字符串 + 一条「字符串 id 照常出现在消息里」的反向守卫),其中 2 条在未修源码时实测变红。 + +- **`ACTIVE_SNAPSHOT` 标记的读取未收敛,目录或非 UTF-8 时启动抛裸 `OSError`/`UnicodeDecodeError`**(`src/retrieval/snapshot_repository.py`、`src/retrieval/vdb/faiss_store.py`)。两处都先判 `marker.exists()`(**目录也为真**)再 `read_text`,因此快照根目录下一个名为 `ACTIVE_SNAPSHOT` 的目录会让 `read_text` 抛裸 `IsADirectoryError`,内容不是 UTF-8 的标记文件则抛裸 `UnicodeDecodeError`。两者都不是这两处其余拒绝路径承诺的带指引 `ValueError`,而 `VectorStoreFactory._load_existing_state` 的**第一步**就是 `get_active_snapshot_dir()`——启动会以裸 `OSError` 收场,而不是「标记坏了,请重建快照」。标记文件是磁盘上的任意字节,外部工具写坏即触发。修法:把读取本身包进 `(OSError, UnicodeDecodeError)` 收敛成带路径与重建指引的 `ValueError`,判据与两处已有的 `resolve()` 符号链接环收敛一致。回归测试 4 条(两处 × 目录/非 UTF-8),全部在未修源码时实测变红。 + +- **`KnowledgeBuildService.build` 把名为 `*.md` 的目录当成知识库文件,抛裸 `IsADirectoryError`**(`src/services/knowledge_build_service.py`)。`Path.glob("*.md")` 只按**名字**匹配、不看类型,因此一个名为 `notes.md` 的目录(或 FIFO、符号链接)会被当成知识库文件送进 `_process_file`。那里只接 `UnicodeDecodeError`,`read_text` 抛的 `IsADirectoryError`(`OSError` 子类)原样冒到调用方——不是 `build` 其余拒绝路径承诺的带指引消息,而 `build` 由 CLI 与 `main.py` 的知识库向量化入口直接调用。知识库目录里混入同名目录是导出工具与手工整理都会留下的常见形态。修法:glob 结果只收 `is_file()` 的条目(跳过非文件条目,其余正常文档照常构建;若一个可读文件都不剩仍是原来的「未找到 Markdown 文件」);`_process_file` 同时接住 `OSError` 并带上文件名(权限不足、读到一半被删、条目是特殊文件都会走到它,裸 `OSError` 消息里没有「是哪个文件」这层定位)。回归测试 4 条(目录名同 `.md` 不崩、只有非文件条目时的消息、权限不足带文件名、非 UTF-8 仍带文件名),其中 3 条在未修源码时实测变红。 + +- **`normalize_messages` 的 `messages` 分支有完整判据,`prompt`/`system_prompt` 这一半没有**(`src/providers/__base__/model_provider.py`)。本函数是四个 provider 公共 `invoke`/`ainvoke` 的**统一请求边界**(`google.py`/`anthropic.py`/`openai_compatible.py`/`volcengine.py` 都调它),`messages` 分支对非序列、非 `Mapping` 条目都给带指引的 `ValueError`,而 `prompt`/`system_prompt` 此前**完全没有形态判据**:`prompt=5`/`["a"]` 静默生成 `{"role": "user", "content": 5}`——非字符串字面量被当成真实用户输入发给模型(与 `ChatService._build_prompt` 钉住的「字面量 `None` 当成真实问题」是同一形态);`prompt=10**4301` 在 `str(prompt).strip()` 处抛 `ValueError: Exceeds the limit`,消息与「请求内容写错了」无关;`system_prompt` 同理,真值非字符串直接进 `content`。可达路径:`invoke`/`ainvoke` 是公开方法,参数一路透传到本函数。修法:在函数入口判两个标量形态(`None` 合法——`prompt=None` 走「必须提供 prompt 或 messages」,`system_prompt=None` 表示不带系统提示),只拒绝非字符串的真值形态,判据与 `messages` 分支对齐。回归测试 7 条(`prompt`/`system_prompt` × 三类形态 + 一条「字符串照常构造」的反向守卫),其中 6 条在未修源码时实测变红。 + +- **建 provider 的入口没判 `provider`/`model_name` 形态,与诊断入口判据只做了一半**(`src/providers/factory.py`、`src/utils/config.py`)。`_get_model_detail` 校验了键、容器与取值(detail)的形态,却把取值的**内部字段**原样交给 `_create_provider`:后者把 `provider_name` 拿去 `_role_aliases.get(...)`/`_provider_map.get(...)` 做字典查找、`_get_provider_class` 做成员判定,并在「不支持的模型提供商」消息里 f-string 插值——不可哈希取值(`provider=["a"]`)抛裸 `TypeError: unhashable type: 'list'`,位数超限的整数(`provider=10**4301`)抛 `ValueError: Exceeds the limit`;三个 getter 的 `logger.info(f"...{provider_name}...")` 对后者更早一步抛同一个整数转换错。`model_name` 则全程无形态校验:`local-hash` 的 `_parse_dimension` 直接 `.rsplit()`(`model_name=["m"]`/`None`/`5` 抛裸 `AttributeError`),其它 provider 透传给 SDK。判据只做了一半的痕迹很清楚——`832abf0` 给**诊断**入口 `_provider_info_for_role` 加了同一守卫,展示层与 `RetrievalService._get_rerank_provider` 也各用 `require_provider_name` 收敛,唯独**真正建 provider** 的这条路径漏了:同一个 `provider=["a"]` 走诊断入口得到带指引的 `ValueError`,走 getter 得到裸 `TypeError`。可达路径:`SessionConfig` 的 `*_configurations` 的 `_VALIDATORS` 明确不登记(「按原样写入,由使用方负责其内部结构」),且属性赋值没有 `__setattr__` 守卫(实测 `detail.provider = ["a"]` 直接成功)。修法:抽出共享助手 `require_model_name`(`require_provider_name` 同步改用同一 `_require_text` 实现),在 `_get_model_detail` 与 `_create_provider` 两处收敛——前者覆盖三个 getter 路径(含它们自己的日志插值),后者覆盖直接调 `_create_provider` 的嵌入方与测试。回归测试 10 条(`provider` 三类形态 × LLM + Embedding/Rerank 一致性;`model_name` 五类形态;合法配置照常建出实例),其中 9 条在未修源码时实测变红。 + +- **`KnowledgeBuildService.build` 对非 `Path` 的 `knowledge_base_path` 抛裸 `AttributeError`**(`src/services/knowledge_build_service.py`)。`build` 第一件事就是 `self.run_config.knowledge_base_path.glob("*.md")`,而 `RunConfig` 是 frozen dataclass、字段没有校验器(同仓库多处已认领这条可达性),嵌入方可以自拼 `knowledge_base_path=5` 的配置。实测抛裸 `AttributeError: 'int' object has no attribute 'glob'`——不是本服务其余拒绝路径(空目录给 `FileNotFoundError`、非 UTF-8 文件给带文件名的 `ValueError`)承诺的带指引消息,而 `build` 由 CLI 与 `main.py` 的知识库向量化入口直接调用。修法:读取路径之后先判形态,只接受 `Path`(`RunConfig` 的标注就是它),不做静默强转。回归测试 3 条(`int`/`list`/`None`),全部在未修源码时实测变红。 + +- **`SnapshotRepository.__init__` 对非 `Path` 的 `snapshot_root` 抛裸 `AttributeError`**(`src/retrieval/snapshot_repository.py`)。构造器直接对 `run_config.snapshot_root` 调 `.mkdir()`,而 `RunConfig` 是 frozen dataclass、字段没有校验器(同仓库多处已认领这条可达性),嵌入方可以自拼 `snapshot_root=5` 的配置。实测抛裸 `AttributeError: 'int' object has no attribute 'mkdir'`——不是本类其余拒绝路径承诺的带指引 `ValueError`。修法:构造期判形态,只接受 `Path`(`RunConfig` 的标注就是它),不做静默强转。回归测试 3 条(`int`/`list`/`None`),全部在未修源码时实测变红。 + +- **`_load_existing_state` 的 legacy 分支对非路径的 `legacy_pkl_path` 抛裸 `TypeError`**(`src/retrieval/vdb/factory.py`)。该方法第一件事就是 `Path(run_config.legacy_pkl_path)`,而 `RunConfig` 是 frozen dataclass、字段没有校验器。非路径取值在那里抛裸 `TypeError: expected str, bytes or os.PathLike object, not ...`,位数超限的整数抛 `ValueError: Exceeds the limit`——两条都不在本模块其余拒绝路径承诺的带指引 `ValueError` 契约内,而调用方(`get_default_vector_store` 在 `Chatbot` 初始化时调用)按它分类。修法:构造 `Path` 之前先判形态,与 `FaissStore.load`/`resolve_within` 同判据。回归测试 3 条(`int`/`list`/位数超限整数),全部在未修源码时实测变红。 + +- **`FaissStore.save`/`save_snapshot` 对非路径入参无形态守卫,整数会被当成文件描述符**(`src/retrieval/vdb/faiss_store.py`)。两个写入侧入口是 `load`/`load_snapshot` 的契约兄弟(`VectorStoreBase` 的公开方法),读取侧早已按同一判据校验入参形态,写入侧这一半漏了。后果比 `TypeError` 更重:`open()` 对**整数**入参不报错,而是把它当成**文件描述符**——`save(5)` 会把 pickle 字节写进 fd 5(实测复现),既没落到预期路径,也可能覆盖进程里恰好占着该 fd 的文件;`save(['a'])` 抛裸 `TypeError: expected str, bytes or os.PathLike object, not ...`,位数超限的整数抛 `ValueError: Exceeds the limit`。三条都不在本类承诺的带指引 `ValueError` 契约内。修法:两个入口在第一次使用路径之前判形态。回归测试 6 条(`save`/`save_snapshot` × `int`/`list`/位数超限整数),全部在未修源码时实测变红。 + +- **`FaissStore.load_snapshot` 对非路径入参抛裸 `TypeError`,与 `load` 判据不一致**(`src/retrieval/vdb/faiss_store.py`)。`load`(legacy 入口)此前已按同一判据修过,而 `load_snapshot` 这一半漏了:它在调 `resolve_within` **之前**先写 `Path(snapshot_dir)`/`Path(snapshot_root)`,非路径取值在那里抛裸 `TypeError: expected str, bytes or os.PathLike object, not ...`;位数超限的整数抛 `ValueError: Exceeds the limit`——类型对、内容却与快照路径无关。两条都不在本方法其余拒绝路径承诺的带指引 `ValueError` 契约内,而调用方 `VectorStoreFactory._load_existing_state` 按它分类。修法:把原始取值直接交给 `resolve_within`(它先判形态再解析),不再预先包 `Path`。回归测试 6 条(`snapshot_dir`/`snapshot_root` × `int`/`list`/位数超限整数),全部在未修源码时实测变红。 + +- **`_require_snapshot_id_within_root` 对非路径形态的快照 id 抛裸 `TypeError`**(`src/retrieval/snapshot_repository.py`)。该方法第一行就写 `Path(snapshot_id)`,而 `Path(5)`/`Path(["a"])`/`Path(b"abc")` 抛裸 `TypeError: expected str, bytes or os.PathLike object, not ...`——不是本类承诺的带指引 `ValueError`,也没有「快照 id 不合法」的语义。位数超限的整数更隐蔽:`Path(10**4301)` 抛 `ValueError: Exceeds the limit`,类型对、内容却与快照 id 无关。快照 id 的来源之一是磁盘上的标记文件(`get_active_snapshot_id`),而三个 `*_temp_snapshot_dir`/`finalize_snapshot` 都对拼出的路径做 `rmtree`。修法:构造 `Path` 之前先判形态,与同仓库的 `resolve_within`/`ensure_trusted_source` 同判据。回归测试 4 条(`int`/`list`/位数超限整数/`bytes`),全部在未修源码时实测变红。 + +- **Responses 流式拒答的 `delta` 原样插值,位数超限时抛裸 `ValueError`**(`src/providers/openai_compatible.py`)。`_invoke_responses`/`_ainvoke_responses` 的流式循环里,正文分支走 `_stream_delta`(对 `delta` 判过 `isinstance(..., str)`),非流式拒答走 `_extract_refusal`(同样只认字符串),唯独**流式拒答分支**把 `field(event, "delta")` 直接插进 f-string。`delta` 来自网关 JSON、形态不受控:位数超限的整数让 f-string 在**求值期**抛裸 `ValueError: Exceeds the limit`——正要报出拒答的那一步变成整数转换错误,而本 provider 的错误分类按 `RuntimeError` 写;非字符串取值还会把 `{'a': 1}` 这类字面量当成拒答文本渲染出去。修法:与 google 的拒答消息同口径——字符串原样,其余降级成 `safe_repr` 的量级描述。回归测试 6 条(同步/异步 × 三种取值),位数超限那两条在未修源码时实测变红。 + +- **Responses 路径的 `choices` 形态守卫只覆盖一个提取器,另三个抛裸 `TypeError`**(`src/providers/openai_compatible.py`、`src/providers/volcengine.py`)。`complete()`/`acomplete()` 用同一份响应依次调用多个提取器,但只有 `_extract_response_text` 把 `choices` 过了 `_require_response_list`;`_extract_tool_calls`/`_extract_reasoning`/`_extract_refusal` 直接 `for choice in choices`,而 `_extract_response_text` 在 Responses 路径下会**提前返回**(`output_text` 是字符串时根本不碰 `choices`),于是畸形取值留给另外三个提取器,抛裸 `TypeError: 'int' object is not iterable`——与同一次调用里另一个提取器给出的 `RuntimeError` 收场不同,而调用方的错误分类按 `RuntimeError` 写。Ark 的 `_extract_result` 是同一个形状:Chat 分支在它之前调了带守卫的 `_raise_for_chat_response_error`,Responses 分支走 `_raise_for_response_error`(只查 `error` 与 `status`),`choices=5` 时在 `choices[0]` 抛裸 `TypeError: 'int' object is not subscriptable`。修法:三处提取器改走 `_require_response_list`/`_require_ark_list`,与 `_extract_response_text`/`output`/`tool_calls` 同判据。回归测试 15 条(三个提取器 × 三种畸形取值、Ark 三种取值,外加一条从 `complete()` 入口走完整链路的用例),全部在未修源码时实测变红。 + +- **`chat_async` 内联分支自造 prompt,绕开 `ChatService._build_prompt` 的字段收敛**(`src/chat/core.py`)。`chat_async` 有两条生成路径:`chat_service` 存在时走 `ChatService.generate_reply`(内部调 `_build_prompt`,逐字段用 `display_utils` 助手收敛),而 `chat_service` 缺失、`llm_model` 仍在时走内联分支,它把整份 `retrieved_docs` 直接 f-string 进 prompt。两个后果都实测复现:`page_content=None` 生成 `知识: [{'page_content': None, ...}]`,模型把字面量 `None` 当成一条真实知识;`score=10**4301` 抛裸 `ValueError: Exceeds the limit`,`chat_async` 捕获后只回一句「抱歉,处理请求时遇到错误」——用户看到莫名其妙的失败,而不是那条知识被正常跳过。检索结果可以来自任何 `VectorStoreBase` 实现,第三方 store 不做载入期归一化。`_build_prompt` 的 docstring 本就声明「两个调用点是 `generate_reply` 与 `chat_async` 的内联分支」,但内联分支从未真正调用它——这条声明此前是错的。 + + 修法:内联分支改调 `ChatService._build_prompt(user_input, intent, retrieved_docs)`,与 `generate_reply` 共用同一份模板,不再维护第二份。回归测试 2 条(`page_content=None` 不得进 prompt、位数超限的 `score` 不得让整次问答失败),均在未修源码时实测变红。 + +- **Anthropic `text_stream` 兜底分支原样透传非字符串,打破 `Generator[str]` 契约**(`src/providers/anthropic.py`)。`_iter_invoke_stream_text`/`_aiter_invoke_stream_text` 各有三条取文本的路径:主路径(`__iter__`/`__aiter__` 事件)走 `_stream_event`,那里的 `text_delta` 已按 `_block_text` 收敛(此前同类修复);而两条 `text_stream` 兜底分支直接 `yield text`,非字符串原样进入声明为 `Generator[str]` 的生成器。消费方 `"".join`/`+=` 抛裸 `TypeError`,位数超限的整数更早抛 `ValueError: Exceeds the limit`。真实 SDK 的 `MessageStream` 只提供 `__iter__` 与 `current_message_snapshot`(实测无 `text_stream`),但本仓测试正是用只带 `text_stream` 的替身构造这条路径,因此它是被支持、被测试的输入形态——契约与主路径必须一致。 + + 修法:两处兜底分支改走 `_block_text`(与 `_stream_event` 同一收敛)。回归测试 3 条(同步/异步的「非字符串必须收敛」+ 一条「字符串分片原样透传」的反向守卫),其中 2 条在未修源码时实测变红。 + +- **Google 流式拒答的兜底文案未收敛 `block_reason`,与 `_extract_refusal` 判据不一致**(`src/providers/google.py`)。流式路径的 `refusal = message or f"Google 提示被安全策略拦截: {block_reason}"` 里,`message` 那一半早已收敛(上一条同类修复),而 `block_reason` 直接插值。`field` 支持字典形态的响应,`block_reason` 不受 SDK 枚举约束——位数超限的整数让裸插值抛 `ValueError: Exceeds the limit`,于是**正要报出安全拦截**的那一步变成整数转换错误;而且 f-string 在**求值期**就拼好,即使 `message` 非空、兜底文案根本不会被用到,这条路径仍会崩。非流式的 `_extract_refusal` 早已把 `block_reason` 收敛成 `block_reason_text`(枚举取 name、字符串取 strip、其余空串),流式这一半漏了——同一份响应在两条路径上的收场必须一致。 + + 修法:把 `_extract_refusal` 的归一化逐字搬进流式分支。回归测试 2 条,其中「位数超限的整数 `block_reason`」在未修源码时实测变红。 + +- **路径形态判据只做了一半:`resolve_within` 与 `FaissStore.load` 对非路径取值抛裸 `TypeError`**(`src/utils/security.py`、`src/retrieval/vdb/faiss_store.py`)。上一条修的是**受信根**列表里的元素,候选路径与根本身这一半漏了:`resolve_within` 把 `candidate`/`root` 直接交给 `Path(...)`,而 `Path(None)`/`Path(5)`/`Path([...])` 抛裸 `TypeError: expected str, bytes or os.PathLike object, not ...`——不是 `ValueError`,消息里也没有字段名,而两个调用方(`ensure_trusted_source` 与 `FaissStore.load_snapshot`)都按 `ValueError` 分类。`FaissStore.load` 更早一步:第一行的 `os.path.exists(path)` 就对非路径抛同一个 `TypeError`。`ensure_trusted_source` 还有第三条:它的逐根试探用 `except ValueError: continue` 吞掉 `resolve_within` 的守卫,最后落到兜底消息里的 `Path(candidate)`,在那里再抛一次 `TypeError`。 + + 修法:`resolve_within` 在构造 `Path` 之前判 `candidate`/`root` 形态(消息带上是哪个参数错了);`ensure_trusted_source` 在兜底消息之前判 `candidate`;`FaissStore.load` 在第一次使用路径之前判。回归测试 2 条,均在未修源码时实测变红(裸 `TypeError`)。 + +- **`ensure_trusted_source` 的字符串信任根被逐字符拆开,信任边界静默失效**(`src/utils/security.py`)。`trusted_roots` 是**可迭代**,而 `str`/`bytes`/`bytearray` 也是合法的可迭代:直接 `for root in trusted_roots` 会把一个字符串信任根拆成单字符根。实测 `trusted_paths="/etc"` 拆出 `"/"`(解析为文件系统根),于是**任意绝对路径都被接受**——`/anywhere/evil.pkl` 也返回成功,正好绕开 `AGENTS.md` 那条「不导入不可信来源的 `.pkl`」的边界。同一模块对 `Sequence` 参数的判据早已显式排除 `str`/`bytes`/`bytearray`(`normalize_messages`/`_require_documents`),这里必须对齐。另外每个根还要能构成 `Path`:`Path(None)`/`Path(5)`/`Path([...])` 抛裸 `TypeError: expected str, bytes or os.PathLike object, not ...`——不是 `ValueError`,穿透 `load` 调用方按 `ValueError` 建立的错误分类,且消息里没有字段名;同模块的 `resolve_within` 对**候选路径**的同类形态给的是带 label 的 `ValueError`,两条判据必须一致。 + + 修法:`trusted_roots` 若是 `str`/`bytes`/`bytearray` 直接拒绝;逐根构造 `Path` 时把 `TypeError` 收敛成带 label 的 `ValueError`。回归测试 2 条,均在未修源码时实测变红(一条 `DID NOT RAISE`——字符串根静默放行;一条抛裸 `TypeError`)。 + +- **`split_hierarchical` 绕过尺寸守卫,与 `split` 判据不一致**(`src/etl/splitters/recursive_text_splitter.py`)。`split` 在层级分支里会调 `_validate_hierarchical_sizes()`(子分片 `overlap < size`、比例上界、`child_size < parent_size`),但 `split_hierarchical` 是**公开方法**(同 `_require_documents` 认领的那条理由:「只守卫 `split` 会留下直接调 `split_hierarchical` 的口子」),直接调它时这些判据全部被绕过。实测 `child_overlap == child_size` 时 `split` 抛带指引的 `ValueError`,而 `split_hierarchical` 照常返回——1000 字符的文档产出 694 个分片(子分片器几乎不前进),全部进入 embedding 与 FAISS 写入且无任何提示;`child_size >= parent_size` 则让层级结构静默退化为一层。上一条同类修复只钉了「documents 形态」那一半,尺寸这一半漏了。 + + 修法:把 `_validate_hierarchical_sizes()` 一并移进 `split_hierarchical`(`_require_documents` 已在那里),两条入口同判据。回归测试 2 条,均在未修源码时实测变红(`DID NOT RAISE`)。 + +- **legacy 导入分支缺 embedding 配置时抛裸 `KeyError`,与活动快照分支判据不一致**(`src/retrieval/vdb/factory.py`)。`_load_existing_state` 的两条分支都要按 `default_embedding_provider` 从 `embedding_configurations` 取 `model_name` 去构造 manifest,但判据不一致:活动快照分支显式判过「键不在映射里」,给出带字段名与指引的 `ValueError`;legacy 分支直接下标 `run_config.embedding_configurations[...]`。配置里把 provider 名打错(或漏写整段 `embedding_configurations`)时,用户拿到的是裸 `KeyError: 'local-hash-typo'`——没有字段名、没有可选值,也不是调用方按 `ValueError` 建立的错误分类。同一个「embedding 配置缺失」的错误,收场不该取决于磁盘上恰好有没有活动快照。 + + 修法是抽出 `_require_embedding_detail` 供两条分支共用:既判「键不在映射里」,也判取值可用性(`{"k": None}` 的键在、值却读不出 `model_name`,原抛裸 `AttributeError`)与容器形态(`RunConfig` 是 frozen dataclass、字段无校验器,嵌入方可自拼非映射的配置),判据与 `EmbeddingService.embedding_model_detail` 一致。回归测试 2 条,均在未修源码时实测变红(`KeyError: 'local-hash-typo'` 与 `AttributeError: 'NoneType' object has no attribute 'model_name'`)。 + +- **Ark Chat Completions 流式工具参数未收敛,与另两条路径判据不一致**(`src/providers/volcengine.py`)。同一个 `arguments` 字段在三条路径上的判据必须一致:非流式 `_extract_result` 走 `normalize_tool_arguments`、OpenAI 兼容的 `_chat_stream_events` 也走它,而 **Ark 的** `_chat_stream_events` 原样透传 `field(function, "arguments", "")`。网关可以把 `arguments` 写成对象/数组/整数(`construct_type` 不做校验),于是同一个输入在 Ark 的两条路径上给出不同类型——调用方按字符串 `json.loads` 时拿到 dict 并抛裸 `TypeError`;位数超限的整数则让拼接/`json.dumps` 抛 `ValueError: Exceeds the limit`。修法是让 Ark 的流式路径也走 `normalize_tool_arguments`,与另两条对齐。回归测试 4 条(对象/整数参数必须收敛成字符串、超大整数得到可读的「必须可序列化为 JSON」、一条「字符串片段原样透传」的反向守卫),其中 3 条在未修源码时实测变红。 + +- **Anthropic 流式增量的文本字段原样透传非字符串,打破 `Generator[str]` 契约**(`src/providers/anthropic.py`)。`_stream_event` 的 `text_delta`/`thinking_delta` 用 `field(delta, "text", "") or ""` 取值——`or ""` 只挡住 `None` 与空串,**真值非字符串**(`5`/`{"a": 1}`)原样进 `StreamEvent.text`/`.reasoning`,而这两个字段声明为 `str`。消费方 `_iter_invoke_stream_text` 直接 `yield converted.text`,`Generator[str]` 的契约因此被打破:实测调用方 `"".join`/`+=` 抛 `TypeError: can only concatenate str (not "int") to str`,位数超限的整数更在更早的拼接处抛 `ValueError: Exceeds the limit`。同一 `tool_use` 块的标量 `input` 走裸 `str()`(`initial_arguments`),超大整数同样抛 `ValueError`。 + + 修法沿用上一条抽出的 `_block_text`:`text`/`reasoning`/`input_json_delta` 的 `partial_json`/`tool_use` 的标量 `input` 四处改用它。回归测试 9 条(正文与思考增量 × 三种非字符串取值、`tool_use` 标量 `input` × 两种取值、一条「正常字符串照旧保留」的反向守卫),其中 7 条在未修源码时实测变红。 + +- **Anthropic 响应文本块的拼接对真值非字符串抛裸 `TypeError`**(`src/providers/anthropic.py`)。`_extract_result` 用 `"".join(field(block, "text", "") or "" ...)` 拼正文与思考,而 `or ""` 只挡住 `None` 与空串——**真值非字符串**(`5`/`{"a": 1}`/`10**4301`)原样进 `join`,抛裸 `TypeError: sequence item 0: expected str instance, int found`。本 provider 的错误分类按 `RuntimeError` 写(同文件对拒答用的就是它),且 SDK 在 `_strict_response_validation=False` 时走 `construct_type`、不执行 pydantic 校验,因此块字段形态不受本地控制。 + + 同一文件的 `_refusal_text` 早已认领同一判据(非字符串走 `safe_repr`,「非字符串一律给可读描述而不是空串」),而正文/思考块这一半漏了。修法是抽出 `_block_text` 供两处共用(`_refusal_text` 委托给它),并在 `text`/`reasoning` 两处改用它。回归测试 7 条(正文与思考块 × 三种非字符串取值 + 一条「正常字符串照旧保留」的反向守卫),其中 6 条在未修源码时实测变红。 + +- **Google 消息 content 的标量回退用裸 `str()`,超大整数让 `_contents` 崩掉**(`src/providers/google.py`)。`_contents` 对既不是 `str` 也不是 `list` 的 `content` 走 `str(raw_content)` 兜底,而 `normalize_messages` 只校验 `messages` 是「对象序列」、**不校验 content 的形态**(实测 `{"role": "user", "content": 10**4301}` 原样通过),因此这是标量 content 的唯一收口。位数超限的整数让 `str()` 抛 `ValueError: Exceeds the limit`——类型对、内容却与「消息内容写错了」无关,且异常发生在 `complete`/`invoke` 的 `try` 之外(`_contents` 在调用 SDK 之前求值),用户拿到的是整数转换错误。 + + 同一文件的 `_normalize_finish_reason` 早已认领同一判据(非字符串走 `safe_repr` 而不是裸 `str()`),这里对齐。回归测试两条(超大整数 content 必须保住、普通标量渲染形态不变),前者在未修源码时实测变红。 + +- **检索参数菜单的融合策略 `default=` 未归一,未归一取值让菜单崩掉**(`src/ui/config_menu.py`)。`edit_retrieval_params` 的融合策略分支把 `chat_config["hybrid_fusion_strategy"]` 的**原始**取值当 `questionary.select(default=...)`,而候选只有 `"rrf"`/`"weighted"` 两个字面量。`SessionConfig` 的普通属性没有 `__setattr__` 守卫,嵌入方或携带异常取值的 pickle 快照可以直写该字段。实测三种收场都不在契约内:`"RRF"`/`" rrf "`(大小写/空白未归一)与 `1` 让 questionary 抛 `ValueError: Invalid 'default' value passed`,位数超限的整数抛 `ValueError: Exceeds the limit`。异常落在 `edit_retrieval_params` 内、由 `launch_config_editor` 经 `asyncio.to_thread` 调用——调用点只接 `(KeyboardInterrupt, EOFError)`,会终止整个会话,而菜单是用户唯一还能改配置的入口。 + + 同一函数里 `current_fusion_strategy` 的**标签**早已走 `_render_value`(上一条同类修复),`default=` 这一半却漏了——判据只做了一半。修法是新增 `_normalized_fusion_default`:复用运行期的 `_coerce_fusion_strategy` 归一(口径与 `RetrievalService` 读取路径一致,`"RRF"`/`" rrf "` 都收敛成 `"rrf"`),归一后不在候选集合里(`"bogus"`/非字符串/超限整数)退回 `None`——questionary 的「未作答」哨兵。回归测试 6 条(五种异常取值 + 一条「规范值照旧选中」的反向守卫),其中 4 条在未修源码时实测变红;修后另用**真实** questionary 复核全部形态可构造。 + +- **模型配置菜单把原始键直接交给 questionary,非字符串键让菜单崩掉**(`src/ui/config_menu.py`)。`edit_model_params` 把 `_configuration_keys` 返回的**原始键**当候选传给 `questionary.select`。`SessionConfig` 的 `_VALIDATORS` 明确不登记 `*_configurations`(「按原样写入,由使用方负责其内部结构」),嵌入方或携带异常取值的 pickle 快照可以放进任意可哈希键;questionary 的 `Choice.build` 只对 `str` 有特例,其余类型落到 `c.get(...)`,实测 `int`/`tuple`/`None` 键一律抛裸 `AttributeError: 'int' object has no attribute 'get'`,位数超限的整数键更早在渲染标题时抛 `ValueError: Exceeds the limit`——菜单根本显示不出来,而它正是用户唯一还能改配置的入口。 + + 同一处还有第二条:`default=` 传的是**原始**激活键,而候选若被渲染成字符串标题,questionary 会拿它去候选集合里找,找不到就抛 `ValueError: Invalid 'default' value passed`——激活项被删掉、或它本身是位数超限的整数时都会命中。另外 `new_llm != current_llm` 拿渲染后的字符串比较,非字符串键永不等于自身,会让「选中同一个配置」被误判成切换、白白触发一次 LLM 重载。 + + 修法:新增 `_configuration_choices`(候选一律包成 `questionary.Choice(title=_render_value(key), value=key)`,`value` 保留原始键)与 `_choice_default`(`default` 不在候选集合里时退回 `None`——questionary 的「未作答」哨兵,合法),切换判据改与**原始**键比较。回归测试 4 条(三种非字符串键 + 一条陈旧激活项),均在未修源码时实测变红;修后另用**真实** questionary 复核五种键形态与陈旧 `default` 全部可构造。 + +- **非字符串键的判据只加在顶层,嵌套层级与四个兄弟校验器仍漏**(`src/utils/security.py`、`src/providers/google.py`)。上一条修的是 `validate_secret_free_options` 的**顶层**循环,注释还专门写了「只在顶层判」。但共享递归扫描器 `find_sensitive_option_paths` 对**每一层**映射都做 `str(key)` 建路径,`normalize_option_key`/`_is_credential_key` 也各自 `str(key)`;同一判据在嵌套层级和四个兄弟校验器(`validate_secret_free_payload`/`validate_secret_free_request_overrides`/`validate_secret_free_resource_kwargs`/`validate_secret_free_resource_args`)上都没有,而它们共用同一个扫描器。 + + 两种收场都不在契约内:`{10**4301: "x"}` 抛 `ValueError: Exceeds the limit`——类型对、内容与字段无关;`{1: "x"}` 则**静默通过**,随后在调用方的 `", ".join(sorted(...))` 处抛裸 `TypeError: sequence item 0: expected str instance, int found`(实测 `GoogleProvider("m", {"extra_body": {1: "x"}})` 构造成功,首次请求合并 `extra_body` 时才崩)。Google 自己的 `_validate_http_options` 递归与 `_coerce_debug_config` 也各有一处 `str(key)`/`sorted(...)`。 + + 修法:把判据收敛到唯一的递归点 `find_sensitive_option_paths`(新增 `label` 参数,错误消息带上具体路径),让 `normalize_option_key`/`_is_credential_key` 对不可 `str()` 的键走 `_display_repr` 降级,并在 `_coerce_debug_config` 与 `_validate_http_options` 各补一条同判据。回归测试 11 条(五类校验器 × 嵌套/顶层非字符串键、三个 provider 的嵌套键、Google 的 `http_options` 与 `debug_config`、`normalize_option_key` 的降级、以及一条「合法嵌套字符串键照常」的反向守卫),其中 10 条在未修源码时实测变红。 + +- **Google 拒答消息在非字符串取值下抛裸 `ValueError`**(`src/providers/google.py`)。两处 `str(message or ...)`(`_extract_refusal` 与 `_stream_events_from_chunk`)对位数超限的整数抛 `ValueError: Exceeds the limit`。`field()` 明确支持**字典形态**的响应(docstring:「兼容 Pydantic SDK 对象和字典响应」),而字典里的 `prompt_feedback.message` 不受 SDK `Optional[str]` 约束,因此该形态在文档化的输入契约内可达。 + + 这两处的职责恰恰是**报告拒答**:在正要报出「提示被安全策略拦截」的原因时自己崩掉,用户拿到的是与该语义毫无关联的整数转换错误,而本 provider 的错误分类按 `RuntimeError` 写(`ValueError` 不在调用方的 `except` 里)。修法是字符串原样、非字符串才降级成量级描述——与 `_render_value`/`_path_text` 同一取舍(直接 `safe_repr` 会给正常字符串加引号,改掉显示形态)。同文件的 `_candidate_refusal` 用 `isinstance(value, str)` 守卫,本就安全。回归测试三条(两处崩溃各一条 + 一条「字符串原样」的反向守卫),前两条在未修源码时实测变红。 + +- **`ExcelLogger.log_results` 的 `query` 列未收敛,超大整数会丢掉整批记录**(`src/retrieval_test/excel_logger.py`)。`query` 是该方法的行里**唯一**没走收敛的列:其余列分别经 `document_*`/`_cell_value`/`_json_safe`,而 `query` 直接进 `row`。签名声明 `query: str`,但它是**公开方法**,且这里正是「日志是旁路、失败不该丢记录」这条契约的入口。 + + 实测 `query=10**4301`(位数超限的整数)时 `openpyxl` 在 `save()` 期对每个数值调 `safe_string` → `isnan(value)`,抛 `OverflowError: int too large to convert to float`。后果正是 `_cell_value` 的 docstring 已经写明要防的那一种:`save` 外的 `except Exception` 把它吞成一行日志,整个工作簿**一个字都没落盘**——同一次查询里 3 条完全正常的结果一并丢失,用户看到「召回测试完成」而日志文件是坏的(`load_workbook` 连 `[Content_Types].xml` 都找不到)。修法是让 `query` 与其余列同判据,过一遍 `_cell_value`。回归测试两条(超大整数 query 必须保住整批、字符串 query 逐字落盘),前者在未修源码时实测变红。 + +- **provider options 的非字符串键漏成裸 `TypeError`**(`src/utils/security.py`、`src/providers/google.py`)。`ModelDetail.options` 声明为 `dict[str, Any]`,pydantic 会强制字符串键,因此**配置来源**的 options 到不了这里。但 provider 是**公开类**——嵌入方可以直接 `JinaProvider("m", {1: "x"})`(本仓测试就这么构造 provider),而共享前置校验 `validate_secret_free_options` 此前只判容器形态与敏感键名、**放行任意键类型**。非字符串键一路穿到各 provider 的 `sorted(set(options).difference(_OPTION_KEYS))`,抛裸 `TypeError: sequence item 0: expected str instance, int found`(混合类型键则是 `'<' not supported between instances of ...`)——不是 `ValueError`,而各 provider 的其余判据都按 `ValueError` 收敛。 + + 实测受影响的构造路径:`JinaProvider`/`SiliconflowRerankProvider`/`GoogleProvider`/`AnthropicProvider`/`VolcengineProvider`。修法分两处:共享入口 `validate_secret_free_options` 加一条顶层键类型判据(覆盖除 Google 外的全部),Google 因为不走共享入口、自己调 `_validate_options`,在其内部单列同一判据。回归测试覆盖三个 provider × 三种非法键形态,另加一条「字符串键照常」的反向守卫,均在未修源码时实测变红。 + +- **「未找到 key」消息里的可选值列表在混合类型键下抛裸 `TypeError`**(`src/ui/display_utils.py`、`src/services/embedding_service.py`)。两处在 key 未命中时都用 `sorted(configurations)` 生成「可选值」列表——直接比较**原始键**。键来自可直写映射(`SessionConfig.llm_configurations`/`rerank_configurations` 的普通属性没有 `__setattr__` 守卫,`RunConfig.embedding_configurations` 虽为 frozen dataclass 但嵌入方可自拼),混合类型键(`{1: .., "b": ..}`)在排序时抛裸 `TypeError: '<' not supported between instances of 'str' and 'int'`——不是 `ValueError`。`display_utils` 这条在 `start_chat_session_async` 里于任何提示符**之前**被调用,会话随即终止;`embedding_service` 这条在检索与建库的主路径上。 + + 讽刺的是这条消息的**用途正是**「告诉用户可选值是什么」,却在拼消息时自己崩掉。修法是先 `safe_repr` 渲染成字符串再排序(用 `safe_repr` 而非 `str`:位数超限的整数键连 `str()` 都抛)。两处各加回归测试,均先在未修源码时实测变红。同项目的工厂侧 `_get_model_detail` 不列可选值,因此不受影响。 + +- **快照标记与路径配置里的符号链接环漏成裸 `RuntimeError`**(`src/utils/config.py`、`src/retrieval/snapshot_repository.py`、`src/retrieval/vdb/faiss_store.py`)。`ACTIVE_SNAPSHOT` 是磁盘上的任意文本,其内容会被拼进路径后 `resolve()`;相对路径字段(`knowledge_base_path`/`pkl_path`/`snapshot_root`/`log_path`/`cache_path`)在 `resolve_path` 里也会被 `resolve()`。而 `Path.resolve()` 对**符号链接环**抛 `RuntimeError: Symlink loop from ...`——它不是 `ValueError`。三处都只在这条判据上漏了: + + | 入口 | 修复前的收场 | + |---|---| + | `Settings.resolve_path`(相对路径字段) | 裸 `RuntimeError`,穿透 `get_settings` 全部四个 handler | + | `SnapshotRepository._require_snapshot_id_within_root`(`get_active_snapshot_id` 等四个方法) | 裸 `RuntimeError` | + | `FaissStore.load_snapshot` 的标记解析 | 裸 `RuntimeError` | + + 三处都与各自的契约相反:`resolve_path` 明确拒绝非字符串与 NUL,理由都是「不能让配置错误变成裸 traceback」;`SnapshotRepository` 的其余判据(路径形态、长度、逃逸)一律抛带指引的 `ValueError`,测试注释也写明「本类的契约是带指引的 `ValueError`」;`FaissStore.load_snapshot` 的其余拒绝路径(目录未激活、不是活动快照)同样抛 `ValueError`,调用方 `VectorStoreFactory._load_existing_state` 按 `ValueError` 分类。`resolve_path` 这条尤其要紧:`get_settings()` 在 **import 期**就被调用(`log_manager.get_module_logger`),早于任何入口的 `try`,实测 `data` 是符号链接环(`data -> data`)时 `import main` 直接以裸 traceback 终止。 + + 同项目的两个兄弟判据早已为同一情况收敛,这次让三处对齐:`utils/security.resolve_within` 明确把 `(OSError, RuntimeError)` 收成 `ValueError`(注释写明「一条『拒绝加载』的消息变成 pathlib 的内部错误」),`utils/cleanup._reject_unsafe_cache_dir` 同源。三处各加一条回归测试(另加一条「绝对路径不做 resolve,不受影响」的反向守卫),均在未修源码时实测变红。 + +- **`OpenAICompatibleProvider._normalize_request_timeout` 被写了两个 `@classmethod`**(`src/providers/openai_compatible.py`)。`e95158d` 引入请求级 `timeout` 校验时叠了两个同名装饰器。当前 CPython 下它**恰好能用**——`classmethod.__get__` 对「被包装对象也是 classmethod」会委托到内层,最终绑定的仍是真实函数,实测类/7 个子类(`openai`/`ollama`/`lm-studio`/`qwen`/`deepseek`/`grok`/`siliconflow`)/实例/`super()`/`inspect.signature` 五条路径的行为与单个装饰时逐字一致,因此此前没有测试拦下它。但它是**结构缺陷**而非风格问题:其一,它依赖 CPython 的委托行为、不在语言规范里;其二,`__func__` 的类型从 `function` 变成 `classmethod`,任何按「`classmethod.__func__` 是函数」写的内省(装饰器、签名收集、`functools.wraps` 链)都会错判;其三,函数体一旦开始用到 `cls`,语义就会漂移而无人察觉。现删去多余的一行,并加两条回归测试钉住:一条 AST 不变量扫描整个 `src/`(任何函数都不得有重名装饰器),一条运行时断言 `__func__` 是函数。 + +- **`cleanup.py` 的 `/private` 挡了符号链接解析点而非其指向目标**:macOS 上 `/etc`/`/var`/`/tmp` 都是指向 `/private/*` 的符号链接,用户把 `cache_path` 配成这三个目录之一时,`Path("/etc").resolve()` 产出 `/private/etc`、与清单里的 `Path("/etc")` **不相等**(它没 resolve),放行删除。实测 `Settings(cache_path="/etc")` 通过,退出时 `rmtree("/etc")` 被执行(需 root 权限才真删成,普通用户会得到权限错误)。同理 `/private` 自己也会放行——用户配成它时与清单比对的是 `/private` resolve 后的自己。三条都是「清单里有、但比对逻辑接不住」。现把 `/private` **提前**到遍历清单之前单独判(它是 macOS 符号链接树的总入口,挡一层即覆盖其下全部),并让清单里每个路径都先 resolve 再比对——与 `protected_paths` 那一轮的逻辑对齐(那里早就 resolve 了)。(`src/utils/cleanup.py`) + +- **修正 `/config` 分支的异常分类泄漏**:`start_chat_session_async` 的 `/config` 分支在检测到嵌入方传入的 bot 替身缺少约定接口(`apply_config_update` / `apply_config_update_async` 都不可调用)时抛出 `AttributeError`,而同一个 `try` 的 `except (ValueError, KeyError)` 接不住它,更内层的 `except (KeyboardInterrupt, EOFError)` 也接不住——实测会一路穿透到 `finally`,**终止整个会话**。这与该块开头注释的意图(「配置应用失败**不能**终止整个会话…报错、保留原配置、回到提示符」)直接矛盾:同一个恢复路径对 `SessionConfig` 校验失败等情形都生效,唯独这条会把用户丢出会话。现已改为抛出 `ValueError`(语义上也确实是一条配置错误——嵌入方的实现缺少约定接口),从而与同 `except` 里其它失败同收场。回归测试用 AST 静态对照「最近包裹的 `try` 的 `except` 分类」与 `raise` 的类型,精确验证异常分类不再泄漏。(`src/chat/core.py`) + +- **公开诊断入口对非法 `provider_name`/`role` 的收场与工厂其余入口不一致**(`src/providers/factory.py`)。`ModelProviderFactory` 有三个**公开**诊断入口——`capabilities`/`capability_report`/`protocol_status`,源码注释写明供「配置界面和诊断」使用——它们的入参直接来自调用方,此前完全没有形态守卫: + + | 入参 | 修复前的收场 | + |---|---| + | `provider_name=["a"]` / `{"k":1}` | 裸 `TypeError: unhashable type: 'list'/'dict'` | + | `provider_name=10**4301` | `ValueError: Exceeds the limit (4300 digits)` | + | `role=10**4301` | 同上(各入口开头的 `str(role)` 转换) | + + 两条都不是「渠道名/角色名写错了」。同文件的 `_get_model_detail` 早已用同一判据收敛(键先判字符串再进成员判断、消息经 `safe_repr`),同一个错误的收场不该取决于走的是哪条入口。 + + 判据要落在**两处**才覆盖全:`_provider_info_for_role`(三入口的共同路径)以及各入口开头**早于**它的转换与推断分支——`protocol_status` 在 `role is None` 时会先对 `provider_name` 做两次字典查找、`capabilities`/`capability_report` 会先 `str(role).strip().lower()`。新增共享 helper `_normalize_role_text`(与既有的 `_normalize_protocol_text` 同一判据),四个调用点全部接上;`provider_name` 的判据在 `protocol_status` 里提到函数最前。`role=None`(自动推断)的既有语义保持不变。 + +- **`ChatService` 的标量入参:与同文件的 `` 收敛同一判据**(`src/services/chat_service.py`)。`_build_prompt` 对检索结果里的文档字段已经收敛(`page_content=None` 时整条跳过,绝不把字面量 `内容: None` 喂给模型),但**同一次调用**的两个标量参数 `user_input` 与 `intent` 完全没有守卫。实测修复前 `user_input=None`/`5`/`["a"]` 静默生成 `用户问题: None`/`用户问题: 5`/`用户问题: ['a']`,模型会把字面量 `None` 当成一个真实的用户问题去回答;`10**4301` 则抛裸 `ValueError: Exceeds the limit (4300 digits)`。同一个「非字符串值以字面量进 prompt」的形态,文档字段那一半修了、标量这一半漏了。`identify_intent` 的 f-string 更靠前(在 `try` **之外**),位数超限时抛的甚至不是本类承诺的 `RuntimeError: 意图识别失败`。两处现按同一判据拒绝并给出字段名。 + +- **`embed_in_batches` 的批次规模:`max(1, ...)` 只挡下界**(`src/services/embedding_service.py`)。原实现只做 `max(1, run_config.kb_embedding_batch_size)`,不挡形态。`RunConfig` 是 `frozen dataclass`、字段无校验器,生产路径的唯一构造点 `build_run_config` 直接照搬 `Settings` 的值(那里有 `1.._MAX_KB_CHUNK_TOKENS` 的双向界),因此嵌入方自己拼一个 `RunConfig` 就能送进任意形态:实测 `"32"`/`None` 抛裸 `TypeError: '>' not supported between instances of ...`,`10**4301` 抛 `ValueError: Exceeds the limit`——两条都与「批次配错了」无关,而同文件其余判据都收敛成带字段名的 `ValueError`。 + + **上界用的是本字段自己的语义上界**:初版复用了 `require_scale_field`,而它的上界是 `_MAX_RETRIEVAL_CANDIDATES`(2000,检索候选数),比本字段的 `_MAX_KB_CHUNK_TOKENS`(1,000,000)小三个数量级——实测 `kb_embedding_batch_size=2001` 被 `Settings` 正常接受,却会被那个 helper 判为「超出合理范围」。那会把一个合法配置在运行时拒掉,比原缺陷更严重,因此改为按 `Settings.validate_positive_sizes` 的同一对界判定,并加了两条对偶回归用例(2001 与 1000000 必须放行)钉住这一点。 + +- **`Pipeline.process` 守卫了入参,却没守卫三个阶段的返回值**(`src/etl/pipeline.py`)。`process` 第一行就校验 `document` 是 `Document`(数据问题 → `ValueError`),而 `self.extractor.extract`/`self.cleaner.clean`/`self.splitter.split` 三个**协作者**的返回值此前没有任何守卫。实测 `splitter.split` 返回 `None` 时,紧随其后的日志行 `len(final_chunks)` 抛裸 `TypeError: object of type 'NoneType' has no len()`——报错现场在日志行,消息里既没有阶段名也没有「是谁违反了契约」。 + + 可达性:三个协作者都是 `BaseExtractor`/`BaseCleaner`/`BaseSplitter` 的实现,而 `Pipeline.__init__` 接受任意实现(抽象基类正是为替换实现而存在的)。同项目的 `RetrievalService._require_document_list` 对「协作者返回值」已有同一处置,其 docstring 写明:这是**接口契约**问题、抛 `TypeError`(入参形态错误才是数据问题、用 `ValueError`)。现新增 `_require_chunk_list` 并接在三个阶段上,两半都补:非列表(`None`/`5`/`"abc"`)与列表里的非 `Document` 项(`[None]`/`["x"]`)。不静默丢弃不合规条目——丢弃会让「实现错了」表现为「分片变少了」。 + +- **子分片的 50% 比例上界缺失:上一轮只修了父分片**(`src/etl/splitters/recursive_text_splitter.py`)。`_validate_parent_sizes` 有 `overlap * 2 > size` 的比例上界,而 `_validate_hierarchical_sizes` 里的**子**分片只有 `child_overlap >= child_size` 的相等判据——`child_overlap < child_size` 成立即通过,比例上界那一条漏了。子分片器同样产出分片(每个父块都要过它一次),同一个静默放大在子侧完全存在。实测父=1500/150、子 `size=300`、一篇 15000 字符的文档: + + | 子 overlap | 子块数 | 放大倍数 | + |---|---|---| + | 30(10%) | 66 | 1.0x | + | 150(50%) | 99 | 1.5x | + | 270(90%) | 451 | 6.8x | + | 299(99.7%) | 13211 | **200.2x** | + + 全部进入 embedding 与 FAISS 写入。**配置侧同样放行**:`Settings.validate_overlap_smaller_than_size` 只判 `kb_chunk_overlap` 与 `kb_chunk_size`(子分片的两条关系按该处注释所述有意留给分片器,因为模式是运行时参数),实测 `Settings(kb_child_chunk_size=300, kb_child_chunk_overlap=299)` 被静默接受,而父分片的同一比例取值被正确拒绝。现按父侧同一判据补上子侧的比例上界;恰好 50%(`overlap * 2 == size`)照旧放行,与父侧一致。 + +- **`HybridReranker._weight_tuple` 同一判据的两个分支用了两种渲染**(`src/services/retrieval_service.py`)。该函数对同一句「必须是和为正的有限数值」有两个触发点:前者是 `is_double_convertible` 判单个权重失败(用 `safe_repr`),后者是**派生值** `total` 判和溢出(用裸 `!r`)。裸 `!r` 的两条后果都实测复现: + + - `HybridReranker(10**308, 10**308)`——**普通 int**,两个权重各自都在 double 范围内、只有和溢出——报错 **759 字符**,而 `safe_repr` 的截断上限是 80;这正是 `safe_repr` 的 docstring 写明的「一条校验消息不该刷屏」。 + - `repr` 自己会抛的 `int` 子类(`is_double_convertible` 走 `isinstance` 放行后,`{v!r}` 在 f-string 插值处抛)让本方法的 `ValueError` 契约变成 `ValueError: Exceeds the limit (4300 digits)` 或裸 `RuntimeError`——与「权重配错了」毫无关系,且穿透调用方的 `except ValueError` 分类。 + + 可达性:`HybridReranker` 是公开类,docstring 自己写明「库调用方可能绕过 `Settings` 与 `SessionConfig` 直接构造」,本方法即为此存在的最后一道兜底。两个分支现统一走 `safe_repr`。 + +- **`src/utils/cleanup.py` 的 `/tmp` 清单项让 `bandit -ll` 不再归零**(质量门回归,由补齐系统目录清单的那一轮引入)。bandit 的 `B108 hardcoded_tmp_directory` 按 `context.string_val.startswith("/tmp")` 匹配**字符串字面量前缀**,认不出 `Path("/tmp")` 是「禁止删除的目录清单」而非「用固定路径建临时文件」——本行既不创建也不写入任何文件,语义与 CWE-377 恰好相反。它是清单里唯一以临时目录前缀开头的项。实测 `uv run bandit -r main.py src scripts -ll` 在 HEAD 上退出码为 1(CI 的 Security scan 步骤会失败),现按仓库既有的 `# nosec <规则> - 理由` 惯例标注理由,quality gate 恢复零告警、退出码 0。 + +- **补齐分片重叠的比例上界(`kb_chunk_overlap <= kb_chunk_size / 2`)**。原先只判 `overlap < size`,而 LangChain 的 `RecursiveCharacterTextSplitter` 只在 `overlap > size` 时报错、**接近** size 时静默接受——每个分片只前进 `size - overlap` 个字符,冗余随比例急剧放大。实测一篇 15000 字符的文档在 `size=1500` 时:overlap=150(10%)产出 14 个分片(1.1x),overlap=1490(99%)产出 1651 个(137.6x),overlap=1499 产出 13751 个(**1145.9x**)——全部进入 embedding 与 FAISS 写入,是一次「配置看着正常、代价上千倍」的静默放大。50% 是主流 RAG 实践的通行上界(它同时保证每个分片至少有一半是新内容),且实测在该点上放大仅 1.9x。判据同时加在 `Settings.validate_overlap_smaller_than_size`(拦配置来源)与 `RecursiveTextSplitter._validate_parent_sizes`/`_validate_hierarchical_sizes`(拦直接构造,它是公开类)——两个入口必须一致。 + +- **修复 anthropic 与 volcengine 渠道的 5 处边界缺陷**(第二轮对抗审查,同样逐条实测复现): + + - **Anthropic 的服务端工具被静默降级成空 schema 的普通函数工具**:`_convert_tools` 的白名单只认 `server_` 前缀与三个 2025-01-24 时代的字面量,而 SDK 里真实存在二十种服务端工具类型(`web_search_20250305`、`code_execution_20250522`、`memory_20250818`、`web_fetch_20250910`、`browser_toolset_20260801` …)。实测发出的请求体从 `{'type': 'web_search_20250305', 'name': 'web_search', 'max_uses': 5}` 变成 `{'name': 'web_search', 'description': '', 'input_schema': {...}}`——`max_uses` 等专属字段被丢弃,服务端收到自定义函数工具而非服务端检索工具,用户看到 web_search 静默失效。现从 SDK 的 `ToolUnionParam` 联合类型**推导**集合(除普通 `ToolParam` 外每个成员的 `type` 字面量),SDK 新增工具时自动跟随;再与旧字面量取并集——SDK 会**移除**旧版本的工具模型(实测 `computer_20250124` 已被 `computer_toolset_20260801` 取代),只取推导集合会让这些仍在服务端生效的取值从「原样透传」退回「降级」,比修复前更糟。 + - **`response_format` 非 Mapping 抛裸 `AttributeError`**(两处,同一语义两个来源):`anthropic._response_format_config` 与 `volcengine._convert_responses_format` 都直接 `.get("type")`。`options.response_format` 那一侧早有守卫,但**请求参数**来源直接进这两个函数,实测 `"json_object"`/`42`/`['a']` 都得到 `AttributeError`——不是 `ValueError`,穿透调用方的错误分类。`google.py` 的 `_response_format_values` 对同一判据给的是显式 `ValueError`,现三处对齐。 + - **Anthropic 请求级 `max_tokens` 完全无校验**:`_default_max_tokens` 对 `options.max_tokens` 有完整判据(含专门的有限性注释),而请求参数那条路原样塞进 params,由 SDK 深处的 `_calculate_nonstreaming_timeout` 执行 `maximum_time * max_tokens / 128_000` 时才炸——实测 `10**400` 抛 `OverflowError: integer division result too large for a float`(`ArithmeticError` 子类,既不落 `except ValueError` 也不落 `except RuntimeError`),`"abc"`/`[1]` 抛 `TypeError`,而 `1.5`/`-5`/`True` 静默通过、原样发给服务端。现抽出 `_require_positive_max_tokens` 供两个入口共用。 + - **Anthropic 非流式路径读不到 `refusal` 键,拒答被静默丢弃**:流式 `_stream_event` 读 `refusal or text or reason`,非流式 `_extract_result` 只读 `reason or text`。实测 `content=[{"type": "refusal", "refusal": "被拒绝"}]`、`stop_reason="end_turn"` 时非流式返回 `text=''`、`refusal=None`、`finish_reason='end_turn'`,`invoke(stream=False)` 输出 `[]`——一次**安全拦截**变成「空成功」,而流式路径正确抛 `RuntimeError: Anthropic 请求被拒绝`。`refusal` 是 SDK `RefusalBlock` 的正式字段名,现三处取值优先级逐字一致。 + - **Ark 动态资源路径在未命中触发字段时完全跳过参数校验**:`_require_provider_resource` 只在参数里出现 `_ARK_RESPONSES_VALIDATED_KWARGS` 之一时才调 `_validate_native_response_kwargs`,于是 `resources.responses.create()`/`create(store=True)`/`create(text={...})` 直达 SDK,撞上 `_utils/_utils.py` 的 `assert "model" in kwargs`,抛 **`AssertionError`**——既不落 `except ValueError` 也不落 `except RuntimeError`;同样参数走 Facade 得到的是带指引的 `ValueError`。现对创建方法(`create`/`async_create`)无条件执行校验,其余(retrieve/delete/list/子资源)仍按触发字段判断,避免把 `retrieve("r1")` 误要求提供 `model`。 + +- **收敛 24 处边界缺陷(异常分类泄漏、读写不对称、静默语义破坏)**。全部来自一次六维对抗审查,每条都在修复前实测复现、修复后加了回归测试(`tests/test_boundary_hardening_regressions.py`,82 个用例;回退源码后其中 58 个变红)。 + + **异常分类泄漏**(守卫抛出的异常不在调用方的 `except ValueError`/`RuntimeError` 之内): + + - 流式工具调用的累加器把网关给的 `index`/`id` 直接当字典键。`openai` SDK 的流式解析走 `construct_type`、**跳过已声明字段的 Literal/int 校验**,实测 `{"tool_calls": [{"index": [1, 2]}]}` 原样产出 `ChoiceDeltaToolCall(index=[1, 2])`(声明类型是 `int`),随后 `setdefault` 抛裸 `TypeError: unhashable type`。新增 `hashable_key_part`,不可哈希等价于「没有稳定标识」,按既有优先级退回 `id`/位置键;Responses 路径的 `_merge_response_tool_call` 与同文件 `_response_tool_keys` 已有的 `hash()` 守卫对齐。 + - `choices` 非序列时 `_raise_for_chat_response_error` 的两条判据都不命中,调用方的 `choices[0]` 抛裸 `KeyError: 0`/`TypeError`。补一条容器形态判据(流式 usage chunk 不带 `choices` 的既有语义保持不变)。 + - 请求级 `timeout` 未经校验直达 httpx,SDK 把 `OverflowError`/`TypeError` 统一包成 `APIConnectionError('Connection error.')`,而 `is_retryable_error` 按类名匹配到 `connection` 判定**可重试**——同一个注定失败的请求被重试 3 次,用户看到的是「连接错误」而不是「超时配错了」。现与构造期 `options.timeout` 共用 `normalize_client_timeout`(`httpx.Timeout` 对象原样保留)。 + - Google 的 `_convert_tools` 对畸形工具定义抛裸 `AttributeError`/`TypeError`;同一形态在 `anthropic.py` 早已转成带序号的 `ValueError`。 + - `cleanup_old_logs` 的 `os.path.exists` 对**文件**也返回 True,`log_path` 指向普通文件时抛裸 `NotADirectoryError`。写入侧对此的处置是降级为仅控制台,清理侧由退出钩子调用——异常让程序在退出路径上以裸 traceback 终止。改用 `isdir`。 + - 融合策略的显示路径:`display_utils` 与 `config_menu` 对可直写的 `hybrid_fusion_strategy` 用裸 `str()`/`.upper()`,位数超限的整数抛 `ValueError: Exceeds the limit`、非字符串抛 `AttributeError`。统一走 `_render_value`(`safe_repr` 会给字符串加引号,改掉正常取值的显示形态,不能用)。 + - `SessionConfig.__setitem__` 的组合校验读兄弟字段时不做收敛,与同文件 `set_many` 不对称:`top_k` 的兄弟是 list 时抛裸 `TypeError`;兄弟权重是 `nan` 时**静默放行**(`nan == 0.0` 恒为 False),两个权重同时失效。现复用 `set_many` 的 `_require_finite_number`/`_require_int_value`。 + - `_runtime_verified_protocols` 在容器形态校验之前就把 `options` 当 Mapping 用:`"http_options" in options` 对整数抛裸 `TypeError`、命中后 `options.items()` 抛裸 `AttributeError`。非 google 渠道走 else 分支本来就得到 `ValueError`,两个分支的分类必须一致。 + - 深层嵌套的 TOML 让 `tomllib` 抛 `RecursionError`——它继承 `RuntimeError` 而**不是** `ValueError`,`get_settings` 的四个 handler 一个都接不住,`import main` 直接以数千帧裸 traceback 终止(深度 500 即触发)。新增 `except RecursionError` 分支。 + + **读写/入口校验不对称**: + + - 流式工具调用的 `arguments` 未归一化:同一个输入在流式路径交付 dict/list/int,非流式路径经 `normalize_tool_arguments` 交付 JSON 文本——调用方按字符串 `json.loads` 时拿到 dict 并抛 `TypeError`。 + - `options.max_tokens` 赋值给 `values["max_output_tokens"]` 时绕过了 `_validate_generation_count`(`-1`/`0`/`True`/`10**400` 全部原样进入 `GenerateContentConfig`);请求级 `max_tokens`/`candidate_count` 同样无校验。 + - Google 的 `output_dimensionality` 此前只过键名白名单,取值原样透传(`-1`/`0`/`"abc"`/`1.5`/`True` 全部通过,`10**4301` 更在 `json.dumps` 时才炸);`openai_compatible._validate_embedding_dimensions` 已在同一语义上校验。 + - `_validate_hierarchical_sizes` 只校验**子**分片器的 `overlap < size`,父分片器缺同一判据,而父分片器在两种结构模式下都会构造。LangChain 对 `overlap == size` **静默接受**(实测 size=100/ov=100 通过、ov=101 抛错)。 + - `upsert_embeddings`(写入侧)不校验向量有限性,而载入侧 `_validate_embeddings_shape` 已校验——同一份内容「写得进、读不出」:FAISS 把 NaN/inf 照单收下(`ntotal` 正常增加),那个下标在查询时静默消失,而 `save_snapshot` 会把它落盘。 + - `load_snapshot` 只把 `lexical.index` 的条数对齐 `documents`,未对齐 `semantic.index` 的行数——`documents=2` 而索引 1 个向量时加载成功、无任何提示,第 2 篇文档永远搜不到;反向则抛 `IndexError`。 + + **静默语义破坏/数据丢失**: + + - Vertex 的流式 tool_call delta 字典漏写 `will_continue`(读侧专门为它写了判据、`_merge_tool_call` 的字段循环也早已列入复制名单,唯独构造 delta 时漏掉),于是 `stream_function_call_arguments` 模式下**每一个**多分片调用都抛 `RuntimeError: 缺少稳定 id/index`。用 google-genai 自带 fixture 的真实形状验证。 + - `_partial_args_to_mapping` 的 `json_path` 下标无上界,写入循环按该下标把列表填充到那么大——模型返回 `$.items[20000000]` 即分配 2000 万个元素(实测 RSS 峰值 +76MB、耗时 6.5 秒)。新增 `_MAX_PARTIAL_ARG_INDEX = 100_000`。 + - `load_toml_config` 的 `normalize_mapping` 把 `options` 内每一层嵌套字典的键小写化,破坏用户载荷:`required = ["OrderId"]` 与 `properties = { OrderId = ... }` 被拆成 `["OrderId"]` 与 `["orderid"]`,JSON Schema 的 required 与 properties 不再对应,结构化输出静默失效。现区分「选项名」(项目/SDK 词汇,照旧归一,`MAX_TOKENS` 仍有效)与「选项载荷」(原样保留)。 + - `retrieval_candidate_multiplier` 的 `int(v)` 与其它整数字段**判据不一致**:`1.9` 被静默截断成 `1`,`"1_0"` 被接受为 10、`"+7"` 被接受为 7——而 `parse_decimal_int` 的 docstring 明确写着这两类「安静的错值」要明确拒绝。改走 `_coerce_number`,与 `chat_top_k` 对齐。 + - `_key_text` 的 `safe_repr` 降级描述只保留量级,不同的超限整数塌成同一个键(实测 `10**4301` 与 `10**4301 + 1` 相同),`_merge_hybrid_results` 于是把两条不同的文档并成一条、其中一条静默消失。改按二进制补码取摘要(`to_bytes` 不经过十进制转换),符号一并编进去。 + - `_should_apply_score_threshold` 用**原始**取值与字面量 `"rrf"` 比较,而 `HybridReranker` 会先 `.strip().lower()`:`"RRF"` 走 RRF 融合(分数是排名倒数之和)却不被识别为 RRF,阈值过滤照常套用——两种量纲混在一起,结果被静默清空。归一化收敛到 `_coerce_fusion_strategy` 一处。 + +- **重写流式工具调用完整性校验的判据方向**:`validate_complete_tool_call` 里相邻两条判据方向相反——「必须有非空 arguments」用黑名单(`type != "custom_tool_call"`),「必须校验 JSON」却用白名单(`type == "function_call"`)。任何不在白名单里的取值于是同时满足「必须有参数」与「不必校验参数」,半截 JSON 被当作完整调用交付。四个问题同源于此: + - **`function` 绕过校验**:流式片段的 `type` 在两条协议上取值不同——Chat Completions 与各家 OpenAI 兼容片段用 `function`(`merge_tool_call_fragment` 的补默认值与四个 provider 的片段构造都写这个值),Responses 用 `function_call`。判据只认后者,实测四条真实流式路径(OpenAI Chat Completions、Google Gemini、Anthropic Messages、Ark Chat Completions)传 `'{"order_id":'` 时都把这段文本当完整调用交付。 + - **未知取值绕过校验**:白名单方向使任何其它取值(网关改写过的 `type`、大写形式、空串)同样豁免。实测 `type="tool_call"` 与 `type="FUNCTION"` 都交付半截 JSON。这一条**原本就有**,不是前一轮引入的。 + - **非字符串 `type` 抛裸 `TypeError`**:白名单写成集合成员测试后,`list`/`dict` 触发 `TypeError: unhashable type`。可达性走 SDK 流式解析路径——`_process_response_data` 在非严格校验下用 `construct_type` 构造 chunk,**跳过已声明字段的 `Literal` 校验**,`tool_calls[].type` 写成 `["function"]` 能到达这里(实测 `construct_type` 后 `call.type` 就是 `['function']`;这与 `enum_field` 注释里那条「`extra="allow"` 保留未知**顶层**字段」是同一后果、不同机制)。 + - **Anthropic 的两条流式路径完全没有交付前校验**:`stream_events`/`astream_events` 直接 `yield` 累积结果,其余三个 provider 的同类路径都调了 `validate_complete_tool_call`。因此它在判据修好之前连「缺 arguments」都不拦。 + - 现统一为**黑名单**:只做一次类型收敛(非字符串无法与 `custom_tool_call` 相等,按函数调用处理),只有 `custom_tool_call` 豁免——它的任意文本是 Responses 的 SDK 契约(`input: str`,无 JSON 约束),保持不变。 + - 顺带收敛同一处 `json.loads` 的另两类失败:`except json.JSONDecodeError` 只接住「JSON 语法不完整」,而 `json.loads` 还会因整数位数超过 `sys.get_int_max_str_digits()`(默认 4300)抛 `ValueError`、因嵌套深度超过解释器栈抛 `RecursionError`。前者**不是** `RuntimeError`,会穿透调用方按 `RuntimeError` 做的错误分类,消息只有 `Exceeds the limit (4300 digits)`;后者虽是 `RuntimeError` 子类、类型上落在分类内,消息却只有解释器文案、没有工具调用名。两者都让一段**结构完整**的 arguments 变成与工具调用无关的解析异常,现分别收敛成带 provider 与工具名的 `RuntimeError`。 + +- **修复 provider key 与配置映射的形态校验**(三处,同一缺陷类): + - `ModelProviderFactory._get_model_detail`:`if provider_key not in configurations` 对不可哈希取值抛裸 `TypeError: unhashable type: 'list'`(不是 `ValueError`,穿透 `Chatbot.reload_llm` 的 `except Exception` 与上游 `except ValueError` 分类),消息里的 f-string 对位数超限整数抛 `ValueError: Exceeds the limit`。同一判据在 `runtime/contracts.py` 的 `_require_known_field` 里已认领过,工厂漏了。 + - `RetrievalService._get_rerank_provider` 的 `provider_key`:不可哈希取值在 `configurations.get()` 处就抛裸 `TypeError`,**早于**工厂守卫,因此工厂那侧修不到这条路径。 + - 同函数的 `configurations`:非映射时 `.get()` 抛裸 `AttributeError: 'int' object has no attribute 'get'`。 + 三者来源都是可直写的 `SessionConfig.active_llm_configuration`/`active_rerank_configuration`/`rerank_configurations`,且 `rerank_enabled=True` 时每次检索都会走到。 +- **补齐 `RetrievalService.retrieve` 其余字段的形态守卫**:上一轮修了 `top_k` 与候选倍率,但同一函数里还有三个字段直接读取、同样可被直写(`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫): + - `score_threshold`:`coerce_score(...) >= threshold`,非数值抛裸 `TypeError: '>=' not supported between instances of 'float' and 'NoneType'`; + - `rerank_enabled`:`if not ...` 的真值语义把 `None`/`0`/`[]` 静默当成「禁用」、把 `"no"` 当成「启用」——用户看到的是 rerank 行为与配置不符,而不是一条配置错误; + - `hybrid_fusion_strategy`:`_should_apply_score_threshold` 先按它判分支,非字符串在那里不会报错,要等 `HybridReranker` 才被拒(报错字段名指向构造参数而非配置项)。 + 现统一在检索开始前收敛(失败早于任何检索工作),并把已收敛的值透传给下游,避免下游再读原属性绕过校验。 +- **修复 `RetrievalService.retrieve` 对 `top_k`/候选倍率的形态假设**:`retrieve` 直接算 `max(1, session_config.top_k) * max(1, session_config.retrieval_candidate_multiplier)`,而 `SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫——嵌入方或携带超大整数的 pickle 快照可以把这两个字段直写成任何形态,此时 `max()` 抛裸 `TypeError: '>' not supported between instances of 'NoneType' and 'int'`。这是**生产入口**(`Chatbot.chat_async` 每次提问都调它),而异常落在 `retrieve` 既有的错误分类之外。现新增 `_coerce_scale_field`:非整数形态(含 `bool`、`nan`、`inf`、超长整数)给出带字段名的 `ValueError`,非正数沿用既有的 `max(1, ...)` 语义收敛成 1(**不改行为**),并补上乘积上界。 +- **修复 Google FunctionCall `partial_args` 的 `json_path` 索引解析**:`_partial_arg_tokens` 用 `re.match(r"^\[(\d+)\]")` 取索引后直接 `int(...)`,而 `\d+` 对任意长度数字串都匹配——`int("9" * 4301)` 撞上 CPython 的 `sys.get_int_max_str_digits()`(默认 4300),抛 `ValueError: Exceeds the limit`。类型虽在本函数契约内,**内容**却是整数转换问题,与「json_path 不合法」无关,而同一函数另外两条拒绝分支都带着原始 `json_path` 与指引。`json_path` 来自模型的流式 function call 分片,形态不受控。现改走 `parse_decimal_int`(解析不了返回 `None`,绝不抛),与全项目同一判据。 +- **修复检索入口的参数形态校验**:`FaissStore.semantic_search` 与 `keyword_search` 的两个参数此前没有形态守卫,失败类型都穿透调用方的 `except ValueError` 分类: + - 查询向量:`np.asarray(10**4301, dtype=np.float32)` 抛 `OverflowError: int too large to convert to float`(`ArithmeticError` 子类,**不是** `ValueError`),容器里混入这类元素同样;字符串抛 numpy 的 `could not convert string to float`,集合/生成器抛 `TypeError`。现把转换失败统一收敛成带指引的 `ValueError`。 + - 查询文本:非字符串直接进 `jieba.cut`,抛 `AttributeError: 'int' object has no attribute 'decode'`——消息指向 jieba 内部,与「参数传错了」无关。 + 本函数已按同一判据修过 `_require_top_k`,这两个参数漏了:同一个「参数形态非法」的处置,同一函数里三种收场。**不逐元素预检**:`np.zeros(4, dtype=np.float32)` 是 `embed_query` 的真实输出,逐元素判有限性会把合法的 numpy 标量一并拒掉(实测 `to_finite_float(np.float32(0.1))` 返回 `None`——它只认 Python 的 int/float),因此守卫包在 `np.asarray` 外层而不是逐元素。 +- **修复快照载入的两个形状校验缺口**:`FaissStore.load` 与 `load_snapshot` 只校验了 `documents` 的条目形态与 `lexical.index` 的**顶层**形态/条数,没有校验语料的**条目**形态,也没有校验 `embeddings` 的维度。实测三组都能在生产路径上穿过 `SnapshotRepository.validate_snapshot_dir`(它同样只看顶层与条数)并让坏快照被 finalize 成**活动快照**: + - `lexical.index` 写成 `[7]`/`[10**4301]`/`[1.5]`/`[None]` 时,`BM25Okapi._initialize` 对每条语料调 `len(document)`,抛裸 `TypeError: object of type 'int' has no len()`; + - `embeddings.npy` 是 0 维/1 维时,`_rebuild_indices` 抛 `TypeError: len() of unsized object` 与 `IndexError: tuple index out of range`; + - legacy pkl 里 `embeddings` 是普通列表时抛 `AttributeError: 'list' object has no attribute 'shape'`。 + 这些异常类型与「快照坏了」无关,也没有重建指引,而同一份文件顶层写坏时给的正是有指引的 `ValueError`。更要紧的是 `embeddings` 的形状检查发生在赋值**之后**,因此失败会留下半更新的 store——实测一次被拒的 `load` 之后 `documents` 已是 2 条而 FAISS 索引仍是旧的 1 条,「失败必须让 store 保持原状」这条契约被绕过。现新增 `_validate_embeddings_shape`(判据与写入侧 `upsert_embeddings` 的 `ndim != 2` 对齐),并在两处补齐语料条目守卫;两个边界都在赋值前校验。 +- **补齐配置编辑器里权重字段的四处渲染**:上一轮修了 `top_k` 与候选倍率的菜单项/提示串,但同一函数里**权重**的四处仍是裸插值——`current_weights` 菜单串、`输入新的向量权重 (0.0-1.0, 当前: …)` 提示串、`default=str(...)`,以及更新后的 `logger.info` f-string。同一个 `chat_config` 字典、同一个「属性可被直写」前提,只修了一部分字段。四处现统一经 `safe_repr` 收敛(logger 那处顺带改成惰性 `%s` 参数,避免 DEBUG 关闭时也eagerly 求值)。 +- **修复配置面板与会话启动横幅的数值渲染**:`display_chat_config` 的五个数值字段(`vector_weight`/`keyword_weight`/`retrieval_candidate_multiplier`/`top_k`/`score_threshold`)走裸 `str()` 与 f-string 插值,`markup_text` 的值渲染同样是裸 `str(value)`。`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫(见 `_require_finite_number` 的 docstring),嵌入方或携带超大整数的 pickle 快照可以直写这些字段,于是 `str(10**4301)` 撞上 CPython 的 `sys.get_int_max_str_digits()`(默认 4300)抛 `ValueError: Exceeds the limit`。后果比「显示不好看」严重:`display_chat_config` 在 `start_chat_session_async` 里于**任何提示符之前**被调用,用户刚看到「知识快照加载成功」就收到一条整数转换错误,会话随即终止;`markup_text` 那处则让「倍率已更新为 …」的提示自己抛错。现新增 `_render_value`(与同文件的 `_path_text` 同一取舍:只在 `str()` 真的失败时才降级,正常取值形态不变),两处共用。 +- **修复配置编辑器菜单与提示里的取值渲染**:`edit_retrieval_params` 把 `chat_config` 的字段直接插进 questionary 的菜单项与提示串(`Top K (当前: …)`、`候选过量招募倍率 (当前: …)`、`输入新的Top K值 (1-…, 当前: …)` 及 `default=`)。`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫(见 `_require_finite_number` 的docstring),嵌入方或携带超大整数的 pickle 快照可以直写 `cfg.top_k = 10**4301`,随后裸插值抛 `ValueError: Exceeds the limit`——异常落在 questionary 的渲染阶段,菜单根本显示不出来,用户看到的是整数转换错误。现这些取值统一经 `safe_repr` 收敛。另加反向守卫确认裸插值确实会抛(否则前一条断言证明不了什么),并锁住「合法取值范围内上界不退化为 0」这一不变量。 +- **修复越界消息自身在渲染超大整数时崩溃(三处)**:`faiss_store._require_top_k`、`recursive_text_splitter._require_optional_size`、`embedding_service._as_float32_matrix` 都是「**类型**分支用 `safe_repr`、**范围**分支裸插值 `{value}`」的同一形态——同一参数、同一份消息,只有一处防住了 CPython 的十进制位数上限。实测正要报「越界」的那一步自己抛 `ValueError: Exceeds the limit (4300 digits) for integer string conversion`,用户拿到的是整数转换错误,与参数毫无关系,也没有上界信息。三处现统一经 `safe_repr` 收敛成 `<整数,约 N 位十进制>`(与 `runtime/contracts.py` 早已在用的判据一致)。可达性各不相同:`top_k` 走 `VectorStoreBase` 的公开方法、`expected_rows` 走公开静态方法,`value` 只有**负的**超限整数才会走到范围分支(`-(10**4301) < 1` 成立,而 `10**4301 >= 1` 直接返回)——因此三个方向的用例都要写,只测正数会让 splitter 那条守卫永远绿着。 +- **修复两个载入入口的半更新状态**:`load` 先把 documents/embeddings/parent_documents 赋进 `self` 再调归一化,`load_snapshot` 每读一个文件就写一次 `self`——中途任何一步失败都留下**半更新**的 store。实测一次被拒的 `load` 之后 documents 与 embeddings 是新值,而 `faiss_index` 与 `_tokenized_docs_cache` 还是旧的(索引 2 条对应 1 条文档),随后 `semantic_search` 抛 `TypeError: 'str' object does not support item assignment`,报错现场与真正的原因(载入被拒)毫无关联;调用方本可以继续用旧数据,半更新让这个选项也消失了。现改为全部解析、校验到局部变量,最后一次赋值。归一化函数相应从就地改 `self` 的 `_normalize_loaded_documents` 改为返回新值的 `_normalize_documents`,并对条目做浅拷贝——原先 `metadata.pop("parent_content")` 会写进调用方持有的那份数据。 +- **修复载入路径对 `documents`/`parents` 顶层形态的裸异常**:`SnapshotRepository.validate_snapshot_dir` 已覆盖这些形态,但 `load`/`load_snapshot` 是 `VectorStoreBase` 的契约方法,直接调它们的嵌入方不经过那道校验(与 `_validate_document_shape` 同一取舍)。实测原先的收场方式都与「快照不自洽」无关:分块列表是 `int`/`None` 时抛裸 `TypeError: 'int' object is not iterable`;是 `dict`/`str` 时逐键/逐字符当成文档、报出误导性的「第 0 条文档不是映射」(`len("ab") == 2` 更是静默通过);父块索引是 `int`/`None`/`list` 时抛 `AttributeError: 'int' object has no attribute 'items'`;legacy pkl 顶层不是映射时抛裸 `AttributeError`。现统一为带重建指引的 `ValueError`。 +- **修复 `upsert_embeddings` 在校验文档形态之前就改动 store**:该方法原先只校验 `embeddings`(维度、条数),条目形态则放任 `self.documents.extend(...)` 之后由 `_rebuild_indices` 在派生索引时撞出来。实测一条非映射条目(第三方 store 的返回值、手工构造的 pkl)让 `_build_index_text` 抛 `AttributeError: 'str' object has no attribute 'get'`——异常看似「拒绝写入」,实际 documents 已多出这条孤儿:`save_snapshot` 会把它写进快照(实测盘上 `chunks.pkl` 2 条、`embeddings.npy` 只有 1 行),同一进程内 `semantic_search` 的 `indices` 与 `documents` 下标错配(top_k=2 只返回 1 条真命中),而 `keyword_search` 的 `argsort` 下标可能越过 tokenized 缓存。现把载入边界的同一份 `Document` 契约校验(原 `_validate_loaded_document`,现更名 `_validate_document_shape`)前移到写入之前,两个调用点的消息分开:载入侧仍是「请重建快照」,写入侧改为「已拒绝写入」,避免让写入侧的用户去重建一个还没写坏的旧快照。 +- **修复 `_rebuild_indices` 会因 `documents` 里的脏数据永久卡死 store**:索引重建是「由 documents 派生的缓存」,却在派生期对条目形态抛异常。实测混进一条孤儿后,**每一次** `upsert_embeddings` 都在重建索引时重复抛出——连续 3 次喂完全合法的文档全部报同一个 `AttributeError`,documents 停在 5 条不再增长,向量库事实上报废。同时 `_tokenized_docs_cache` 的赋值改为整体求值后再落属性,避免异常时留下「缓存比 documents 短」的下标错配。新增 `_index_text_of`:派生期把非法形态降级为空串(该条目缺席检索,其余条目照常可用),校验交给写入侧入口。 +- **修复 `load_snapshot` 直接调用时对 `lexical.index` 的条数不做校验**:该方法在 `lexical.index` 存在时**直接使用盘上的语料**、不重建 BM25,而 `keyword_search` 拿 `np.argsort(doc_scores)` 的下标去索引 `self.documents`。实测语料 3 条、文档 2 条时 `IndexError: list index out of range`;语料 1 条、文档 2 条时静默丢结果(用户看到「搜不到」而不是「快照坏了」);顶层写成 `None` 时 `_build_bm25_index` 返回 `None` 而上述静默丢结果同样发生。生产路径已由 `SnapshotRepository.validate_snapshot_dir` 覆盖这些形态,但 `load_snapshot` 是 `VectorStoreBase` 的契约方法,直接调它的嵌入方不经过那道校验。现补上「顶层是序列且条数与 documents 对齐」这条最小不变量,并放在 `_build_bm25_index` 之前——否则 `int` 会在 `any()` 上先抛裸 `TypeError: object of type 'int' has no len()`,`str` 则会因 `len("ab") == 2` 静默通过。 +- **修复检索层的派生值溢出(权重之和、RRF 分母)**:`_weight_tuple` 对两个权重各自判了 `is_double_convertible`,但它们的**和**是派生值、可以超出 double 范围——`HybridReranker(10**308, 10**308)` 构造成功,随后任意一次 `rerank()` 在 `math.isfinite(total)` 上抛 `OverflowError`(`ArithmeticError` 子类,**不是** `ValueError`)。同类的还有 `rrf_k + rank`:`rank` 来自 store 返回的文档,实测 `semantic_rank = 10**400` 让分母抛同一个异常,`retrieval_service.retrieve()` 的 HYBRID 路径整批失败。两处现分别改为 `float()` 相加(溢出表现为 `inf`,被 `isfinite` 正常判非法)与新增 `_coerce_rank`(非法 rank 视为「未参与该路检索」,与缺失时同义)。 +- **修复 rerank 缓存键的兜底自身抛错且丢失区分度**:`json.dumps(default=str)` 对超大整数无效(`default=` 只对**未知类型**生效,`int` 是已知类型),而 `repr()` 兜底抛的是同一个 `ValueError`。可达性已确认:`ModelDetail.options` 是 `dict[str, Any]`,`validate_secret_free_options` 只查敏感键名、不做递归数值校验,实测 `ModelDetail(..., options={"x": 10**4301})` 被接受,随后 `rerank_enabled=True` 时每次检索都在构造缓存键处崩。现新增 `_stable_fingerprint`——不能直接用 `safe_repr`,它对渲染失败的**容器**只退回 ``,会让两份不同的 options 撞到同一个 provider 实例(比崩溃更糟);逐元素递归到叶子并对超大整数补 `bit_length` 与 `hash`,既保住区分度又不抛。 +- **修复文档去重键与父块索引的 `str()` 残留**:`_key_text` 的替换在 `retrieval_service.py` 里漏了两处——`_document_key` 的 `page_content` 兜底支与 `_deduplicate_parent_documents` 的 `parent_key`;`faiss_store.py` 的 `parent_id` 四处(`_normalize_loaded_documents`/`register_parent_documents`/`resolve_parent_content`)也全是裸 `str()`。这些都来自磁盘上的快照/legacy pkl(pickle 是二进制格式,不受 CPython 十进制位数限制),实测经真实路径复现:`chunks.pkl` 里 `metadata.parent_id = 10**4301` 时快照被激活、加载宣布成功,但每一次检索都崩在父块解析上;`_deduplicate_parent_documents` 是三条检索路径的公共出口,因此三条全中。现统一经 `safe_repr` 收敛。 +- **修复快照加载的两步顺序与 `load()` 相反**:`load_snapshot` 原先「先建索引、后归一化文档」,而 `load()` 是反的。于是「`lexical.index` 是否存在」决定了用户看到哪条错误——实测同一份畸形 chunks(`page_content` 是 `int`):有 `lexical.index` 时得到 `ValueError: 知识快照不自洽:…请重建快照。`,缺它时得到 `AttributeError: 'int' object has no attribute 'decode'`。而 `validate_snapshot_dir` 的必需文件清单里没有 `lexical.index`,缺它是合法状态。现两处顺序对齐。 +- **修复 BM25 索引对「所有 token 都为空」的除零**:`_build_bm25_index` 只判外层 `not tokenized_docs`,而 `rank_bm25` 的 `_calc_idf` 在**所有 token 都为空**时也除零(`idf_sum / len(self.idf)`)。实测 `[[]]` 与 `[[], []]` 都抛 `ZeroDivisionError`——`jieba.cut("")` 返回 `[]`,因此一条 `page_content=""` 的文档就足以构造出这个形态,而空 `page_content` 在快照里合法。两种形态语义相同,现都返回 `None`。 +- **修复快照校验的三处形态缺口**:`parents.pkl` 的错误消息用 `str(key)` 拼装,键来自磁盘(含 pickle 来源),实测 `{10**4301: [1, 2]}` 让**正要报「父条目形态不对」**的那一步自己抛位数错误;`lexical.index` 的 `len()` 没有形态守卫(`is not None` 只挡 `None`,顶层是 `int` 时抛裸 `TypeError`;字符串形态更隐蔽——`len("ab") == 2` 被当成 2 条语料静默通过),而同函数的 `chunks.pkl` 有完整守卫;快照 id 无长度上界,实测 300 字符的名字通过全部判据后在 `.exists()` 抛 `OSError: [Errno 63] File name too long`——而 `VectorStoreFactory._load_existing_state` 的第一步就是它,一个被外部工具写坏的标记文件会让**启动**以 `OSError` 失败。三处现分别改用 `safe_repr`、补上与 `chunks.pkl` 同源的形态守卫、加上长度上界。 +- **修复 `save_snapshot` 对不可哈希 `source` 取哈希**:统计文档数时用集合推导 `{(doc.get("metadata") or {}).get("source") for doc in self.documents}`,而 `or {}` 只挡 `None`/空值,非空的非映射会留下——实测 `source=["a"]` 抛 `TypeError: unhashable type: 'list'`。这条路径正是把文档写进快照的那一步(legacy 导入分支),失败后 `finally` 清理临时目录,legacy 快照**永远无法导入**。现改用同文件已在用的 `document_source`(对非字符串安全返回 `"N/A"`)。 +- **修复 `retrieval_candidate_multiplier` 的 `inf` 分支抛 `OverflowError`**:`int(v)` 只接了 `(ValueError, TypeError)`,而 `int(inf)` 抛的是 `OverflowError`——`ArithmeticError` 子类,**不是** `ValueError`。同文件其它五个整数 validator 都有 `isinstance(number, float) and not number.is_integer()` 前置守卫,只有这一处没有,而 `float('inf').is_integer()` 恰好是 `False`、走不进那个分支。触发输入是合法 TOML(`tomllib` 把 `inf`/`1e400` 都解析成 `inf`),而 `get_settings` 在**导入期**就被 `cleanup.py` 调用,早于 `run_cli` 的任何 `try`——三个 handler 全部失效,程序以裸 traceback 退出。 +- **修复路径字段接受 NUL 字节**:`os.makedirs`/`open` 对含 NUL 的路径抛 `ValueError: embedded null byte`——**不是** `OSError`,因此 `log_manager` 那些「目录不可写就降级为仅控制台」的 `except OSError` 全部接不住。实测 `config.toml` 写 `log_path = "/tmp/a\u0000b"`(TOML 的 `\u0000` 转义)时,`cleanup.py` 在**模块导入期**调用 `get_module_logger`,程序连 `run_cli` 都到不了就崩。现 `resolve_path` 显式拒绝 NUL(六个路径字段共用该 validator),`log_manager` 的两处降级也一并接住 `ValueError` 作为纵深防御。 +- **修复 `_reject_unconvertible_numbers` 不递归 `ndarray`**:该函数只递归 `list`/`tuple`,而 `ndarray` 与「`ndarray` 的 list」都是 `_probe_dimension` 显式支持的输入形态。`dtype=object` 的数组带着 `10**400` 直接进 `np.array(..., dtype=np.float32)`,由 numpy 抛裸 `OverflowError`——同一批数据,`list` 形态得到设计好的干净 `ValueError`,`ndarray` 形态得到 `ArithmeticError` 子类,穿透调用方的全部 `except ValueError` 分类与日志脱敏。现补上 `ndarray` 递归(只遍历 `object` dtype,数值 dtype 的取值一定在 double 范围内)。 +- **修复 `cache_path` 是符号链接环时退出钩子整体失效**:`_reject_unsafe_cache_dir` 首行就 `Path(cache_dir).resolve()`,而 `resolve()` 对链接环抛 `RuntimeError: Symlink loop from ...`。该行此前没有守卫(遍历 `protected_paths` 的那轮有),于是本函数整体抛异常——而它在 atexit 里被调用,用户看到 `Exception ignored in atexit callback` 加 traceback,**清理完全没做**。链接环不是可安全删除的目标,现返回原因而不是抛。 +- **修复 `get_relative_path` 的兜底分支自己抛错**:`str(absolute_path)` 抛 `ValueError: Exceeds the limit` 后,`except ValueError` 里的 `logger.warning` **用同一个值再做一次 f-string 插值**,把刚接住的异常原样重抛。兜底分支存在的唯一理由就是不再抛——它抛了,且抛在展示检索结果的最后一环,把一次成功的检索变成「LLM 异步生成出错」。实际来源是 `document_source(document)`,而文档元数据来自磁盘上的快照/legacy pkl。 +- **修复激活的 provider key 缺失时抛裸 `KeyError`**:`display_chat_config` 直接写 `chat_config["llm_configurations"][active_llm]`(两处,另一处在 API Key 段落),而 `default_llm_provider`/`default_rerank_provider`/`active_*_configuration` **没有任何 validator**(`Settings` 里只是普通 `str`,`SessionConfig` 的 `_VALIDATORS` 也没登记)。实测 `default_llm_provider = "not-a-provider"`(打错字)时,调用点 `chat/core.py` 的 `start_chat_session_async` 在任何提示符之前抛裸 `KeyError`——用户刚看到「知识快照加载成功」,紧接着会话终止。同一个 key 走 `ModelProviderFactory._get_model_detail` 得到的是 `ValueError("在…配置中未找到key: …")`,现两处对齐到该契约并列出可选值。 + +- **修复配置校验失败的消息泄漏凭证**:`_redacted_settings_error` 只把 pydantic 的 `input` 交给脱敏,而 `msg` 原样拼接——但 validator 普遍把**原始取值**写进消息(`f"无效的日志级别: {value}"`)。实测 `CHAT_TEMPERATURE=sk-live-...` 时异常消息里原样带着该凭证,而这条消息经 `get_settings` 的 handler 抛出后进终端与日志,正是该函数 docstring 声称要挡住的那一类。现 `msg` 与 `input` 一并脱敏。 +- **修复报告配置校验失败这一步自己会崩**:同一函数里写的是 `str(error.get("input"))`,而 `input` 是 pydantic 记下的原始输入值、可以是任意大的整数。实测 `Settings(chat_top_k=10**4301)`(库调用方与测试都走这条构造路径)让**报告校验失败**这一步抛 `ValueError: Exceeds the limit`——用户看到的是位数上限错误,而真正的问题是 `chat_top_k` 越界。现改用 `safe_repr`。 +- **统一 26 处异常日志出口到 `safe_exception_text`**:这些站点写的是 `redact_sensitive_text(str(e))`,绕过了同为展示边界的 `safe_exception_text`——后者是前者的严格超集(脱敏 + 长度上限 + 兜住 `str(exc)` 自身的位数超限异常)。实测异常 args 含位数超限整数时,这 26 处会在**正要记录日志**时抛 `ValueError`,而 `safe_exception_text` 能正常渲染。涉及四个 provider 适配器、`factory.py`、`cleanup.py` 与 `chat_service.py`。 + +- **修复融合策略、向量存储类型、快照清单的非字符串取值漏出裸异常**:三处都是「先调字符串方法再判取值」的形态,非字符串抛 `AttributeError`,而契约是 `ValueError`。`HybridReranker(fusion_strategy=["rrf"])`、`VectorStoreFactory.get_vector_store(123)` 均实测复现。项目已按同一判据修过 `SessionConfig._validate_fusion_strategy` 与 `RecursiveTextSplitter.structure_mode`,这三处是同类残留。`Settings` 能拦住配置来源的非字符串,但这些是公开入口(UI 与库调用方直接调用)。 +- **修复快照清单字段与文档去重键的位数超限崩溃**:`KnowledgeSnapshotManifest.from_mapping` 写的是 `str(data[key])`,`_document_key` 对元数据同样做 `str()`。清单文件与元数据都来自磁盘,而本项目**加载 pickle 格式的快照**——pickle 是二进制格式、不受 CPython 十进制位数限制(实测 `pickle.loads(pickle.dumps(10**4301))` 正常往返),因此畸形快照能让 `str()` 在正要解析清单、或正要计算去重键合并检索结果时抛 `ValueError: Exceeds the limit (4300 digits)`,异常类型与内容都与真实原因无关。现两处都经 `safe_repr` 收敛;去重键对同一取值仍稳定,不会因降级而误合并。 +- **修复 rerank provider 返回值在形态校验之前就被解包**:`a, b = await provider.arerank(...)` 直接解包,`None`/单值抛裸 `TypeError`、三元组抛裸 `ValueError`——两条都在 `_validate_rerank_output`(正是「校验 provider 返回值」的边界)**之前**,用户拿到的是「无法解包」而不是「provider 返回值不合法」。现提取 `_unpack_rerank_result`,先把形态收敛成带指引的 `RuntimeError` 再进入既有校验。 +- **修复召回测试的 Excel 日志初始化失败被静默吞掉**:`ExcelLogger()` 构造失败只写一行日志(且日志级别由 `log_level` 决定,配成 `ERROR` 以上连那行都看不到),之后每轮查询走 `if excel_logger:` 静默跳过——用户照常看到召回结果,以为测试记录下来了,实际一个文件都没有。而召回测试的**唯一产出**就是这份 Excel 日志。现同时用 `console.print` 显式告知,与本文件其它失败路径(数据库加载、服务初始化)的呈现方式一致。 + +- **修复客户端 `timeout` 缺上界,Google 的毫秒派生值抛 `OverflowError`**:四个渠道原先只判「有限正数」,没有上界。失效点不在超时值本身,而在**派生值**上——Google 是唯一把秒换算成毫秒再交给 SDK 的渠道(`_build_http_options` 的 `int(timeout * 1000)`),任何超过 `DBL_MAX/1000 ≈ 1.8e305` 的秒数都让该派生抛 `OverflowError: cannot convert float infinity to integer`。该异常是 `ArithmeticError` 的子类而**不是** `ValueError`,穿透项目全部以 `ValueError` 为契约的 handler(`get_settings`、chat 主循环、questionary validator),以裸 traceback 终止会话。输入是普通 TOML 浮点,无需任何位数技巧:`[llm_configurations.x.options] timeout = 1e306` 让配置加载成功、provider 构造成功,首次提问才炸。现新增共用的 `normalize_client_timeout`(正数、有限、上界一天,保留 `httpx.Timeout` 形态),Google/Anthropic/OpenAI 兼容/Ark 四个渠道共用;`options.http_options.timeout`(毫秒)补上同源上界——此前 `1e308` 能通过,到真实 `genai.Client(...)` 构造时才抛 pydantic 的 `Unable to parse input string as an integer`,错误消息与 Google 配置毫无关联。Ark 渠道原本接受数字字符串,该行为保留。 +- **修复 Ark `count_tokens` 与其余七个 token 计数入口契约不一致**:八个 provider 的 token 计数入口都已改用 `require_token_count`,只有 Ark 的同步/异步 `count_tokens` 漏了,写的是 `int(field(item, "total_tokens", 0))`。于是 `"abc"` 抛裸 `ValueError`、`None` 抛裸 `TypeError`、位数超限的数字串抛与 token 计数无关的位数错误,且消息里连字段名都没有;`0` 默认值更把「响应不完整」静默算成「0 个 token」。现与其余七个入口共用同一 helper。 +- **修复不可哈希取值让 provider 的枚举守卫抛 `TypeError`(约 30 处)**:`field(x, "type") in {"a", "b"}` 对 `list`/`dict` 抛 `TypeError: unhashable type`,而调用方按 `ValueError`/`RuntimeError` 分类错误。这些值来自网关 JSON——openai SDK 对 SSE chunk 的未知顶层字段用 `extra="allow"` 原样保留,实测用真实 SDK 解析一条 `data: {"type": ["error"]}` 的 SSE 流即可构造,`_stream_delta` 随即抛 `TypeError`,本该报「网关返回错误」的地方变成裸 `TypeError`,日志脱敏与错误分类都接不住。现新增 `enum_field` helper(非字符串收敛成空串,落在与「未知类型」相同的 else 分支上),覆盖 Responses 的 `input_image`/`input_file` 的 `detail`、流式事件的 `type`,以及 Google/Anthropic/Ark/OpenAI 兼容四个渠道的响应解析与请求转换路径。`_normalize_responses_native_item` 原有的显式 `isinstance` 守卫是这批里唯一做对的写法,本次统一到它。 +- **修复错误消息插值超大整数时改抛位数错误**:`f"{10**4300}"` 撞上 CPython 的 `sys.get_int_max_str_digits()`(默认 4300)抛 `ValueError: Exceeds the limit`。`status`/`code`/`error_code`/`message`/`reason` 这些字段来自网关 JSON,而 openai SDK 能解析出 4300 位的整数(实测经真实 SDK 解析成功,`bit_length()` 为 14281),于是「网关返回了业务错误」变成「整数转字符串超限」,`RuntimeError` 变 `ValueError`、错误分类失准。现这些插值点统一走 `safe_repr`。`status` 的读取刻意**不**用 `enum_field`:它随后要进 `_chat_status_is_error` 做 `>= 400` 的数值比较,收敛成空串会让数字型业务错误(`{"status": 503}`)漏判——形态防护只加在插值处。 +- **修复 Embedding `options.dimensions` 完全不校验**:该值从 `config.toml` 直达请求体,此前 `-1`、`0`、`"abc"`、`1.5`、`True` 全部原样发给远端,由服务端返回语义不明的 400;`10**4301` 更在 `json.dumps` 编码请求体时才抛位数错误。项目里 `_MAX_EMBEDDING_DIMENSION` 早就是这个量的上界(`local_hash` 与 `embedding_service` 都在用),provider 边界漏了同一判据。 +- **修复 Google 整数型生成参数无校验**:`max_output_tokens`/`candidate_count` 只做键名白名单,取值原样透传给 `types.GenerateContentConfig`。实测 `options.max_output_tokens = 10**400` 时构造与调用都成功,请求体里就带着那个 401 位整数发给服务端(`httpx.MockTransport` 抓包确认);`candidate_count = -1`/`0` 同样静默通过。现两者都判正整数并给上界。 +- **修复 rerank 的 `top_n` 缺上界**:判据是 `top_n < 1`,于是 `10**400` 与 `10**4301` 都被静默接受,后者在 `json.dumps` 编码请求体时才抛位数错误——而同一份文件里 `options.timeout` 与响应 `relevance_score` 都已判过双精度范围,只有这里漏了。上界取 `_MAX_RERANK_TOP_N` 而**不是**文档数:上游 API 自行截断,`top_n` 超过文档数是合法输入。Jina 与 SiliconFlow 两个文件是逐字副本,两侧同步修改。 +- **修复 Ark Classification 的动态资源路径绕过 Facade 门禁**:AGENTS.md 要求该资源「需通过显式 Facade/入口调用,不能把缺少资源挂载静默当成成功」。Facade 侧做对了(`_get_classification_resource` 在 SDK 未挂载时抛带指引的 `NotImplementedError`),但动态路径 `resources.classification.create(...)` 既不过 Facade、也不做参数白名单——实测 `恶意字段=1` 一路放行,最终拿到 SDK 内部的 `AttributeError: 'Ark' object has no attribute 'classification'`。现动态路径与 Facade 受同一约束(同一份 kwargs 白名单 + 显式资源检查),与 `_require_responses_resource` 的既有设计对齐。 + +- **修复非字符串取值让配置层漏出裸异常**:`log_level` 与五个路径字段的 validator 直接对传入值调用字符串方法,而 `config.toml` 的 TOML 值可以是任意类型。`log_level = 123` 抛 `AttributeError: 'int' object has no attribute 'upper'`(实测 14 帧裸 traceback),`knowledge_base_path = 123` 抛 `TypeError: expected str, bytes or os.PathLike object, not int`(实测 23 帧)。两者都**不是** `ValueError` 的子类,穿透 `get_settings` 的全部三个 handler。`log_level` 尤其要紧:它在**模块导入期**就被 `log_manager.get_module_logger` 读取,早于任何入口的 `try`,连脱敏边界都还没建立。现两处都在取值前显式判类型,统一收敛成带字段名的脱敏 `ValueError`。顺带用 59 个字段 × 9 种非法形态(531 组)扫过整个 `Settings`,确认再无漏出非 `ValueError` 的站点。 +- **同一缺陷的 Anthropic 侧**:`anthropic.Anthropic(...)` 同样接收原样透传的 `timeout`/`max_retries`,且该 SDK 的重试循环也是 `for retries_taken in range(max_retries + 1)`。实测 `max_retries=-1` 时 **HTTP 请求数为 0**,最终抛 `RuntimeError: could not resolve response (should never happen)`;`max_retries=10**400` 同样被原样接受。现与另两个渠道共用 `normalize_client_retries`,`timeout` 也在构造期校验(保留 `httpx.Timeout` 形态)。Google 渠道经核实无此问题:`max_retries` 不在其 `_OPTION_KEYS` 内会被显式拒绝,`timeout` 走已校验的 `http_options` 路径。 +- 修复新增回归测试自身的一处**假绿**:`monkeypatch.setattr("src.providers.anthropic.get_settings", ...)` 的字符串形式解析到的是**当前** `sys.modules` 里的模块对象,而 `tests/providers/test_factory.py` 会把 `src.providers.anthropic` 从 `sys.modules` 删除再重新导入,于是 patch 打到新模块、而 `AnthropicProvider.__init__` 仍从旧模块字典取 `get_settings`——patch 静默落空,**单独跑该文件全绿、只有全量运行才失败**。现改为 `monkeypatch.setitem(AnthropicProvider.__init__.__globals__, ...)`,打类实际读取的那个字典。 +- **修复客户端级 `max_retries` 完全不校验(OpenAI 兼容渠道)**:`timeout` 与 `max_retries` 是 openai SDK 的**客户端构造参数**,项目把它们从 `options` 原样透传给 `openai.OpenAI(...)`,而 SDK 自己不做任何检查。于是 `config.toml` 的 `options.max_retries` 可以造成三类失败:**负数**时 SDK 的重试循环 `range(max_retries + 1)` 一次都不执行,HTTP 请求根本没发出去(实测 `max_retries=-1` 请求数为 **0**),最终抛的是 SDK 内部的 `AssertionError: could not resolve response (should never happen)`;**超大值**(TOML 整数无上界,实测 `10**400` 被原样存进 `client.max_retries`)等价于无上限重试;**非整数**(`"abc"`/`1.5`/`True`)则要等到真正重试时才在 `max_retries - retries_taken` 上抛 `TypeError`,报错点离配置处很远。Ark 渠道早已校验 `max_retries`,但同样**没有上界**。现在新增共用的 `normalize_client_retries`(`0` 到 `100` 的整数,接受十进制数字字符串),两个渠道共用同一套判据,并在**构造期**校验 `timeout`(拒绝非有限、非正数与非法类型,保留 SDK 支持的 `httpx.Timeout` 形态),不再拖到首次发请求。 +- **修复超出双精度范围的整数让校验层集体失效**:`float()` 与 `math.isfinite()` 对任意大的 Python int 都会先尝试转成 C double,超出 `DBL_MAX`(约 1.8e308)时抛 `OverflowError: int too large to convert to float`。该异常继承 `ArithmeticError` 而**不是** `ValueError`,因此穿透了项目里**全部**以 `ValueError` 为契约的守卫——`get_settings` 的三个 handler、`SessionConfig` 的 `__setitem__`、各 provider 的 options 校验、`EmbeddingService` 的 `except ValueError`、questionary 的 validator。输入完全可达:`config.toml` 的整数是无上界的 TOML 整数,`tomllib` 原样解析成 Python int,而 `options` 是文档指定的扩展入口——实测 `[rerank_configurations.x.options] timeout = <401 位十进制整数>` 让 `JinaProvider._request_timeout` 抛裸 `OverflowError`,配置里写一个超大整数同样让 `chat_temperature` 绕过脱敏直接打出 16 帧裸 traceback。更隐蔽的是,把 `math.isfinite(x)` 当守卫等于没有守卫:它自己就会抛 `OverflowError`。现在新增 `is_double_convertible`/`to_finite_float`/`safe_repr` 三个 helper,判据改为「先确认在双精度范围内,再转 `float`」,并覆盖 **21 处**收敛点:`Settings` 的 `chat_temperature`/`chat_vector_weight`/`chat_keyword_weight`/`chat_score_threshold`,`SessionConfig` 的权重、阈值与温度(构造器与 `__setitem__` 两条入口),Jina/SiliconFlow 的 `options.timeout` 与响应 `relevance_score`,`openai_compatible` 的后台轮询参数,Volcengine 的客户端 timeout 与文件等待参数,Google 的 `http_options.timeout` 与请求 timeout,Ark Responses 的 `fps`,`normalize_embedding_vector` 的向量项,`coerce_score`(展示层与检索层共用的分数收敛规则,契约是「绝不抛异常」),以及 `RetrievalService._validate_rerank_output`。 +- 修复错误消息里的 `{value!r}` 反过来破坏异常:CPython 的 `sys.get_int_max_str_digits()`(默认 4300)让 `repr(10**4301)` 抛 `ValueError: Exceeds the limit (4300 digits)`,于是 `top_k` 的消息变成与字段无关的「整数转字符串超限」。新增 `safe_repr`(超长截断、超出位数限制时用 `bit_length()` 给出量级),`Settings` 与 `SessionConfig` 的校验消息统一改用它。 +- **补上同一缺陷在裸插值站点的残留**:上面那条只覆盖了用 `!r` 的格式化,但**裸插值**(`{value}`)同样会在正要报告错误时改抛另一个错误。逐站点复查后又修了三处,都带红绿验证:`require_token_count` 的**负数**分支(`{numeric}` → `safe_repr`)——正数在 `numeric < 0` 之前就返回了,因此原有那条「永不抛非 `RuntimeError`」的用例(探针全是正数)碰不到它,实测 `-(10**4301)` 让契约里承诺的 `RuntimeError` 变成 `ValueError: Exceeds the limit`;`Settings.retrieval_candidate_multiplier` 的**下界**分支(正数在**上界**判断处被拒、消息里是常量,同样只有负数能走到);以及 `RecursiveTextSplitter._validate_hierarchical_sizes` 的两条尺寸消息。另修两处同类但**判据不同**的缺陷:`SessionConfig._validate_fusion_strategy` 与 `RecursiveTextSplitter` 的 `structure_mode` 用集合成员判断(`value not in {...}`),对不可哈希的取值抛 `TypeError: unhashable type`——本类契约是 `ValueError`,调用方的 `except ValueError` 接不住;两者都补上显式判类型。回归用例刻意做成**全字段扫描**(59 个字段 × 正负两个方向,断言消息里绝不出现 `Exceeds the limit`)而不是逐字段补断言——这类站点散落在各 validator 里,逐个补挡不住下一个。 +- **修复 `kb_chunk_overlap`/`kb_child_chunk_overlap` 缺上界**:两个字段原先只有下界,是 59 个字段里**唯一**能被超大整数静默通过的两个(实测全字段扫描)。这是**配置可达**的:`config.toml` 写 `kb_child_chunk_overlap = 10**400`(401 位,`tomllib` 照常解析)后配置加载成功,直到分片期才在 `_validate_hierarchical_sizes` 抛错——报错现场离写错的配置很远,消息里还会打出整个 401 位数字。上界取 `_MAX_KB_CHUNK_TOKENS` 与 `kb_chunk_size` 同源(overlap 的语义是「必须小于 chunk_size」,而 chunk_size 的上界就是它),不是另写一个数。另外 `RecursiveTextSplitter` 的构造器参数是 `Settings` 之外的第二条入口,上界拦不住它,因此那两条尺寸消息同时改用 `safe_repr`——4301 位的值经这条入口时,消息插值自己会抛,把「overlap 必须小于 size」换成与重叠参数无关的位数上限错误。 +- **修复 Excel 召回测试日志静默丢失**:`log_results` 的 `workbook.save()` 外面是 `except Exception: logger.exception(...)`,因此**任何**写入期异常都让整个工作簿一个字都没落盘,用户只看到日志里一行「保存 Excel 日志文件失败」。而「形态完全合法的 int」正是这样的输入——两条下游路径的容忍度不同:`openpyxl` 写单元格前会调 `isnan(value)`,对超出双精度的整数(`10**400`,401 位)抛 `OverflowError: int too large to convert to float`;`json.dumps` 则对位数超限的整数(`10**4301`)抛 `ValueError: Exceeds the limit (4300 digits)`。这两个值来自第三方 store 的 `page`/`semantic_score`/`keyword_score`,而 `_cell_value`/`_json_safe` 的职责正是「收敛成下游能接受的形态」,却都用 `isinstance(value, int)` 直接放行。现在两个 helper 各自按下游的真实容忍度收敛(`openpyxl` 侧用 `is_double_convertible` 判双精度范围,`json` 侧判 `str()` 是否可转换),降级一律走 `safe_repr`——位数超限时连 `str()` 都会抛,用它等于把一类错误换成另一类。位数在上限内的整数照常保留原类型,日志的用途是记录真实取值。 +- 顺带修掉这条路径上的一处**假绿**测试:`test_excel_log_survives_malformed_documents` 原判据是 `os.path.exists(logger.filepath)`,而 `save()` 失败时 `openpyxl` 已经写下了部分 ZIP 内容——文件存在且非空(实测 2290 字节),`load_workbook` 却抛 `KeyError: There is no item named '[Content_Types].xml' in the archive`。因此「文件存在」在未修复的代码上也成立,用例一直是绿的。判据改为读回工作簿并断言数据行存在。 +- **修复 `safe_exception_text` 自己会抛异常**:它是**最后一道展示边界**,22 处 `except` 都经它输出——它再抛,用户看到的就是解释器默认 handler 打出的裸 traceback,连脱敏都没有,比「消息不好看」严重得多。触发条件是异常自身的 args 含位数超限的整数:`str(10**4301)` 撞上 CPython 的 `sys.get_int_max_str_digits()`(默认 4300)抛 `ValueError`。可达性经核实:JSON 与 TOML 两条路径送不进来(解析器自己就抛),但 **`pickle` 是二进制格式、不受十进制位数限制**——实测 `pickle.loads(pickle.dumps(10**4301))` 正常往返,而本项目加载 pickle 知识快照,因此快照里的畸形值可以携带这种整数进入异常对象。现在兜住 `str()`,降级时**逐个渲染 `exc.args`** 而不是把整个元组交给 `safe_repr`(后者会递归到元素上、兜住后只能退回 ``,把「是哪个值出的问题」也丢掉)。`safe_repr` 走局部导入:`config` 在模块层导入本模块,模块层反向导入会成环。 +- **修复 `VERSION.txt` 编码损坏让程序无法启动**:`_resolve_version()` 在**模块导入期**执行,早于 `main()` 的任何 `try`,异常会直接终止进程。它自带两级兜底(`VERSION.txt` → `pyproject.toml` → `"0.0.0"`),但原实现只覆盖了「文件不存在」——读取本身也会失败。实测 `VERSION.txt` 被写成 UTF-16 或被截断时 `read_text` 抛 `UnicodeDecodeError`,程序连横幅都打不出来就退出(exit code 1,消息是裸 traceback),这与兜底链的设计意图相反。现在读取一并兜住 `OSError` 与 `UnicodeDecodeError`。 +- 修复测试自身的同类问题:`10**4301` 作为 pytest 参数值时,pytest 自动生成 id 会调 `str(val)` 并抛 `ValueError`,测试在**收集阶段**就崩掉、一条都跑不到。新增用例一律显式给出参数 id。 +- **修复二进制发布包的核心功能不可用**:PyInstaller 的静态分析看不见 `tiktoken` 用 `pkgutil.iter_modules` 对 `tiktoken_ext` 命名空间包的运行时扫描,漏收 `tiktoken_ext.openai_public` 后 `get_encoding("cl100k_base")` 抛 `Unknown encoding`,知识库向量化在分片第一步即失败。`--smoke-test` 此前只打印版本号与路径,从不触达该路径,因此构建校验通过而产物是坏的。现把该模块加入 `HIDDEN_IMPORTS`,并让自检真实构造 `RecursiveTextSplitter` 并调用 `split()`。 +- **修复二进制包离线首次运行失败**:`tiktoken` 在缓存未命中时会联网拉取编码文件。构建脚本现把 `cl100k_base` 的 BPE 预置进包内 `tiktoken_cache/`,并按 `sha256` 校验后写入,坏数据不会进包;缓存缺失时由构建脚本自行下载,不再要求构建者先手工跑一次知识库构建(此前 CI runner 上没有该缓存,五个发布目标会全部构建失败)。 +- **修复预置 BPE 被环境变量旁路**:`main.py` 原用 `os.environ.setdefault("TIKTOKEN_CACHE_DIR", ...)`,而 `tiktoken` 的 `read_file_cached` 只看该变量**是否存在**,不存在「先查它、再回落包内」的语义。环境里一旦已有该变量(CI、容器、用户显式设置),包内文件就被完全旁路,离线运行退回联网并失败。现改为赋值,并在目录缺失时保持不设置。 +- **修复只读安装目录下无法启动**:日志层与缓存目录两处都在模块导入期或 `main()` 的 `try` 之外无条件 `makedirs`,安装在 `/opt`、`/Applications`、`C:\Program Files` 这类只读位置时报裸 `PermissionError` 且无脱敏。现两处均降级:日志仅输出到控制台,缓存目录回落到系统临时目录。 +- **修复二进制包内运行时目录位置错误**:`prepare_runtime_layout` 把 `knowledge_base/`、`data/kb`、`data/logs` 建在包根,而冻结时 `resolve_app_root()` 返回可执行文件所在目录(包内 `PyRAG-Kit/` 子目录),用户按 README 放入文档只会拿到 `FileNotFoundError`。现统一以该子目录为根,`config.toml.example` 与 `.env.example` 也一并移到那里(配置与 `.env` 都按该目录解析,放在包根会被静默忽略)。 +- **修复 `.env` 从进程工作目录读取**:`env_file` 原为相对路径 `".env"`,由 pydantic-settings 按 CWD 解析,而 `config.toml` 走 `ROOT_DIR`。冻结产物中可执行文件目录与调用者 CWD 通常不同,用户放在安装目录旁的 `.env` 会被静默忽略。现两者同源,均以 `ROOT_DIR` 为基准。 +- 修复构建脚本的 BPE 缓存查找与 `tiktoken` 语义不一致:原按变量真值判断,空串会落回临时目录,而 `tiktoken` 把空串视为「禁用缓存」并直接走网络。 +- 修复混合检索权重可被设成同时为零:`Settings` 启动时校验过,但 `SessionConfig` 是运行期独立入口,单字段界拦不住这种组合。两个权重同时为零时 `weighted` 融合的每个文档得分都是 0.0,再被 `chat_score_threshold` 全部过滤,检索静默返回空结果且无报错。现在 `SessionConfig` 的设值入口与构造器都拒绝,`HybridReranker._weight_tuple` 另有兜底以覆盖绕过前两者的库调用方。 +- 修复发布包版本号可能漂移:包名来自 `pyproject.toml`,而横幅与自检输出来自 `main.py` 的硬编码常量,只改一处会让两者不一致,而 `--validate` 只 grep `smoke test ok`、不带版本号,构建照样通过。现由构建脚本把版本写入包内 `VERSION.txt`,`main.py` 优先读取(按应用根目录查找,而非 `__file__`——冻结后后者指向 `_internal/`),并在 `validate_bundle` 中断言自报版本与 `pyproject.toml` 一致。 +- 修复归档顺序与自检残留:原先先归档再校验,校验失败时归档已落盘且内容是校验前的状态;自检产生的空日志与 `.cache` 还会被一并打进发布包(目录版与归档版条目数因此不一致)。现改为校验 → 清理残留 → 归档。 +- 发布工作流补上产物体检与校验和:此前归档只按名字 glob 上传,包内混进 `.env`、`config.toml` 或漏掉 `tiktoken_cache/` 都不会被发现,用户下载到的包可能既泄露凭证又无法离线运行。现在归档后显式拒绝这两类文件、要求缓存目录存在,并生成 `SHA256SUMS.txt` 一并发布。 +- 修复发布工作流的归档校验步骤形同虚设:该步骤用 `release_artifacts/*.tar.gz` 单层 glob 查找归档,而 `download-artifact` 把每个 artifact 解到 `release_artifacts//` 下一层,glob 一个都匹配不到,循环体不执行、`fail` 保持 0,整步静默通过——校验逻辑写了等于没写。现改为递归查找(与 publish 步骤的 `maxdepth 2` 一致),并在归档数为 0 时直接失败,避免下载步骤出问题时以「没有可校验的东西」收场。同时把 `.zip` 与 `.tar.gz` 合并到一条分支,Windows 目标此前只在被匹配到的情况下才校验。 +- 修复校验和的落盘位置与条目格式:`SHA256SUMS.txt` 原写在 `release_artifacts/` 内(正确),但条目保留了 `release_artifacts//` 前缀,用户单独下载某个归档后 `shasum -c` 找不到文件。现改为只写文件名,并把哈希工具从 `shasum`(依赖 perl)换成优先 `sha256sum`(coreutils 标配),无归档时显式失败。 +- 发布流程补上测试门禁:`release.yml` 此前只构建、从不跑测试,打标签的提交若没走过 PR 门禁(例如直接 push tag 或 `workflow_dispatch`),产物会在测试全红的情况下被发出去。现新增独立的 `test` job 并让 `publish` 依赖它。刻意不塞进 `build` 矩阵——那是五平台,其中四个平台本仓库从未在 CI 上跑过测试,拿它们当发布门禁等于用一个没人验证过的信号去拦发布;这里固定在 ubuntu-24.04,与 `quality.yml` 同一环境。 +- 包内缺少分词编码缓存时显式警告:`tiktoken` 在缓存未命中时走 `requests.get(blobpath)`,而该调用**没有超时**(见 `tiktoken/load.py` 的 `read_file`),网络黑洞下会长时间挂起而非快速失败。包内 `tiktoken_cache/` 是唯一的离线兜底,缺失时现在向 stderr 输出提示,用户至少知道卡在哪。 + +- **修复退出钩子可能删除整个应用目录**:`cache_path` 是用户可配置项,而 `cleanup_temp_files()` 在 atexit 里对其执行 `shutil.rmtree`。原有保护只挡 `/`、家目录与一份硬编码的 POSIX 系统目录清单,拦不住指向应用自身的取值:实测冻结产物中把 `cache_path` 配成 `"."`(解析为应用根目录)后正常退出,整个安装目录被删光——`.env`(内含 API Key)、`knowledge_base/`(用户放入的源文档)与 `config.toml` 一并消失且无法恢复。`"data"`、`"knowledge_base"`、`"data/logs"`、`"data/kb"` 同样会被删掉,分别波及知识快照、源文档与日志。现在判定改为与应用真实使用的路径(应用根目录、`config.toml`、`.env`、`knowledge_base_path`、`snapshot_root`、`log_path`)比对,相等或位于其下即拒绝,不再依赖目录名。 +- **修复日志清理会删除其他程序的日志**:`cleanup_old_logs()` 原匹配「目录下所有 `*.log` 且文件名含任意 `YYYY-MM-DD`」,而 `log_path` 同样是可配置项。指向共享目录(`/var/log`、挂载的日志盘)时,退出前的清理会连带删掉 `nginx_access_2020-01-01.log`、`postgres_2020-01-01.log` 乃至 `backup_2020-01-01.log` 这类与本应用无关的文件,且不可逆。现在要求文件名严格等于本应用的命名形态(`app_log_<日期>.log` / `chat_log_<日期>.log`),前缀由写入与清理共用的常量定义,避免两侧规则各自演化。 + +- 修复终端展示 `base_url` 时不脱敏:自建网关常把凭证写在 URL 里(`https://user:token@gateway/v1`),`api_key` 一直有掩码,但 Base URL 是原样打印的——日志侧有 `RedactingFormatter` 兜底,终端这一路没有,凭证会直接出现在屏幕上、终端回滚与用户的截图里。现在显示前先经 `redact_sensitive_text`,主机名仍可见以便确认连的是哪个网关。 + +- 活动快照标记文件的内容不再直接当路径分量使用:`get_active_snapshot_id()` 读取 `ACTIVE_SNAPSHOT` 后把它拼进 `self.root / snapshot_id`,而该文件是磁盘上的任意文本——绝对路径会**顶掉** root(`Path("/a") / "/b"` 得 `/b`),`..` 与 `a/../..` 则向上逃逸,都让「活动快照」指到快照根目录之外。当前两个调用方之后都会再走 `resolve_within`/`validate_snapshot_dir` 复核,尚不构成实际逃逸,但本方法的对外语义就是「root 之下的活动快照」。现在要求解析后必须直接位于 root 之下,判定用解析结果而非字符串是否含分隔符(后者漏掉不含分隔符却能跳出 root 的 `..`)。 + +- 快照 id 的路径校验覆盖到全部拼路径的方法:上一轮只收紧了 `get_active_snapshot_id()`,而 `create_temp_snapshot_dir`、`cleanup_temp_snapshot_dir`、`finalize_snapshot` 同样把 `snapshot_id` 拼成 `self.root / snapshot_id` 并对其执行 `rmtree`——实测 `finalize_snapshot(root/".tmp-x", "../../")` 会把快照根目录**之外**的目录整个删掉,而当时的入口校验拦不住(调用方传参不经过标记文件那条路)。校验现在集中到一个 helper,四个方法共用,不再各判各的。 + +- 修复混合检索在缺少分片标识时会把两篇不同文档并成一条:`_document_key` 的兜底分支取 `page_content[:64]` 作键,同来源、前 64 字符相同的两篇文档因此碰撞——实测 `_merge_hybrid_results` 把语义结果里的那一篇覆盖掉、只返回一条,用户少拿一篇且没有任何提示(`_deduplicate_parent_documents` 同理,丢的是得分较低的那篇)。现改为对整段内容取 sha256:既保持定长,又不因截断而碰撞。`chunk_id` 在真实快照里恒存在,这条是兜底路径,但兜底同样不能产生错误结果。 + +- 修复 `SessionConfig(...)` 构造器绕过逐字段校验:这是**整类**绕过而非单个字段——实测修复前构造器对 `top_k=10**9`、`top_k=True`、`score_threshold=nan`、`chat_temperature=-1`、`hybrid_fusion_strategy="bogus"`、`rerank_enabled="yes"` 等每一个受校验字段都照单全收,而 `__setitem__` 会逐一拒绝。其中 `top_k=10**9` 会一路走到 `faiss_index.search()`,FAISS 既不报错也不截断,按 10 亿条分配内存;`nan` 权重则让 `HybridReranker` 算出 `nan` 得分,排序结果不确定且与阈值的比较恒为 False,整批结果被静默丢弃。构造器现在与 `__setitem__` 共用同一组校验器,两条入口对同一取值的结论一致。 +- 修复 `HybridReranker._weight_tuple` 对非有限权重的兜底:原判据 `total <= 0` 对 `nan` 恒为 False(`nan <= 0` 不成立),`vector_weight=nan` 会一路算出 `nan` 得分。现在同时要求和为正的**有限**数值。这是所有构造路径的最后一道兜底,覆盖绕过 `Settings` 与 `SessionConfig` 的库调用方。 + +- 修复候选规模的上界没有落在乘积上:真正送进 `faiss_index.search()` 的是 `effective_top_k = chat_top_k × retrieval_candidate_multiplier`,而两个字段此前只有各自的上界(10_000 × 100 = 10⁶)。FAISS 对超过索引条数的 k 既不报错也不截断,按 k 全量分配——实测 dim=1536、k=10⁶ 时峰值 RSS 涨约 35 GB。现在 `Settings` 与 `SessionConfig` 都按乘积判定,单字段上界同步收窄到 2_000 使两个约束不再互相矛盾(此前 `top_k=10_000` 能通过字段校验却被组合校验拒绝)。 +- 修复配置编辑器放行「字段合法、组合越界」的取值:UI 的两个校验器只按单字段上界判断,用户把 `top_k` 设成上界值(UI 放行)后,应用配置时 `__setitem__` 抛 `ValueError`——而该异常落在 chat 主循环之外(主循环只捕获 `KeyboardInterrupt`/`EOFError`,外层 `try` 只有 `finally`),会以裸 traceback 终止整个会话。现在 UI 的上界按当前另一字段的取值动态计算,放行的值必定能被 `SessionConfig` 接受。 + +- 修复配置批量更新不是原子的:`Chatbot.chat_config` 的 setter 收 dict 时逐个写 `session_config`,任一字段被拒时前面的字段已经落进去了——实测 `{"top_k": 42, "retrieval_method": "不存在"}` 会留下「`top_k` 已改成 42、检索模式还是旧的」这种改了一半的状态,而调用方只看到一个异常,以为整批都没生效。现在先在副本上写完再整体替换。 + +- 修复快照校验漏掉两个索引文件的行数:`validate_snapshot_dir` 的文档说明写着要把各数据源「对齐」,但实际只比对了 `chunks.pkl`、`embeddings.npy` 与 `stats.json`,**没有检查 `semantic.index` 与 `lexical.index`**——而加载路径两者都用盘上的文件。实测 `semantic.index` 的 `ntotal=5` 而 `chunks.pkl` 为 3 条时校验放行,加载后 `semantic_search` 拿 `indices` 去索引 `self.documents` 抛 `IndexError`;`lexical.index` 语料 5 条而分块 3 条时同样放行,`keyword_search` 在命中越界位置时抛 `IndexError`。现在两个索引文件的行数都纳入校验。 + +- 修复基础清洗器会改写用户内容:`BasicCleaner` 把双换行临时替换成硬编码的 `"__DOUBLE_NEWLINE_PLACEHOLDER__"`,与用户文档里同名字面量碰撞。实测 `"本文档说明 __DOUBLE_NEWLINE_PLACEHOLDER__ 的用途。"` 清洗后变成 `"本文档说明 \n\n 的用途。"`(原文被改写),而整段由该串构成的文档清洗后得到空串(内容全部丢失)。哨兵现在按「内容里不存在的字符」现算(NUL,存在时继续追加),不再是可与正文碰撞的固定字面量。 +- 修复向量写入在维度校验前就已改动存储:`upsert_embeddings` 原先先 `documents.extend(...)` 再比对维度,维度不一致时抛异常但 `documents` 已经多出一批没有对应向量的条目。实测拒绝之后 `documents=2` 而 `embeddings` 仍为 `(1, 4)`,store 已不自洽,而 `save_snapshot` 会照常把这份不自洽落盘——之后的语义检索拿 `indices` 去索引 `documents` 会越界或错配。校验现在前置,失败不留痕。 + +- **修复终端输出把不可信文本当作富文本标记解析**:`console.print(str)`、`Table.add_row(str)`、`Panel(str)` 在 rich 里默认按**标记**解析,而本项目的这些位置里插着知识库文档正文、用户输入、配置值与异常消息。后果有两类。一是崩溃:正文里出现字面量 `[/]`、`[/color]`、`[/if]` 时抛 `MarkupError`,实测知识库文档里「匹配斜杠用 [/] 表示」这一行即可触发;真正致命的是**异常处理走的是同一个通道**——`safe_exception_text()` 拼出的消息里带着 `[/]`,于是召回测试的内层 `except` 抛、`main()` 的兜底 `except` 也抛、`run_cli` 的兜底 `except` 再抛,三层兜底全部失效,程序以裸 traceback 退出(已端到端复现)。二是静默改写:`[b]粗体[/b]` 的标记被吃掉、markdown 链接 `[https://x](https://x)` 丢掉方括号部分,实测真实知识库 3038 行里有 12 行渲染后被改写,用户看到的与检索到的不一致。现在新增 `display_utils.markup_text(template, *values)`:**模板**继续走标记解析(样式照旧生效),**值**通过 `Text.append` 原样追加并继承所在位置的样式;聊天、召回测试、知识库构建、配置编辑与两个顶层错误出口共 30 处调用点全部改走它。`display_chat_config` 的 Base URL 单元格另有同类问题:该值来自 `config.toml`/`.env` 且未经校验,IPv6 字面量本身带方括号,现改用 `Text` 包装(脱敏解决凭证泄漏,不解决标记解析)。 +- 修复快照校验漏掉 `parents.pkl` 的解析与形态:`validate_snapshot_dir` 已把 `chunks.pkl` 与 `lexical.index` 的解析失败转成「快照不自洽」的 ValueError,却漏了同批 `pickle.load` 的 `parents.pkl`。实测它写坏时校验照常通过,直到 `FaissStore.load_snapshot` 抛裸的 `UnpicklingError: invalid load key, 'n'.`;顶层写成 `None`/`int` 则在 `dict(self.parent_documents)` 处抛 `TypeError: 'NoneType' object is not iterable`;父条目不是 dict/str 时在 `_normalize_loaded_documents` 抛 `'list' object has no attribute 'get'`。三者都没有「快照坏了」的语义与重建指引,而同一份快照里 `chunks.pkl` 坏掉时给出的正是有指引的 ValueError——同类失败不该有两种收场方式。现在三处都在加载前拒绝。 + +- 修复召回测试的 Excel 日志把文档内容当公式写入:`openpyxl` 按值猜类型,以 `=` 开头的字符串会被存成**公式**(`data_type='f'`),Excel 打开日志时求值。文档内容、来源与查询都来自知识库与用户输入,实测 `=1+1` 落盘后是公式而非文本,`=cmd|'/c calc'!A0` 这类 DDE 载荷同样照单全收。两条后果:日志不再忠实记录「检索到了什么」(内容列显示的是计算结果),以及在打开日志的机器上执行文档里的内容。现在字符串单元格显式写 `data_type = "s"` 固定为文本——用类型而不是加 `'` 前缀,因为前缀会改写内容,而日志的用途正是逐字记录。 + +- 修复 manifest 的 TOML 序列化不做转义:`to_toml` 用 `f'key = "{value}"'` 直接拼字符串,而这些值都来自 `config.toml`/`.env`——`embedding_model` 可以是自建网关的模型路径(`models\qwen-7b`),`chunk_mode` 与 `snapshot_id` 也由调用方决定。实测模型名含反斜杠时 `write_manifest` 正常落盘、`load_manifest` 抛 `TOMLDecodeError: Unescaped '\' in a string`:快照**写得出、读不回**,报错现场离真正的原因很远;含引号或换行的值则连落盘的 TOML 都不可解析。同一份 manifest 在内存里是好的,落盘一圈就坏了。现在按 TOML 1.0 转义 `"`、`\` 与不可打印字符(可打印字符含中文原样保留),计数字段显式 `int()` 以免被写成带引号的字符串。 + +- 修复 `config.toml` 语法错时绕过脱敏边界:`get_settings` 的 `try` 只接 `ValidationError` 与 `SettingsError`,两者都覆盖不到 `tomllib.TOMLDecodeError`——它继承 `ValueError`,但既不是前者也不是后者(pydantic-settings 在 `load_toml_config` 里把 tomllib 的异常原样放出来)。而 `get_settings` 在 import 期就会被调用(`log_manager.get_module_logger`),早于任何入口的 `try`:实测 config.toml 语法错时 import 直接抛裸 traceback,**35 帧全打在终端上**;作为对照,同一份文件里字段**取值**非法时(`chat_top_k = "abc"`)得到的是 11 帧的脱敏消息。同属「配置写错」,收场方式不该有两种。现在两条路径都产出 `配置解析失败 - ...` 的一行消息,并保留行号列号供定位。 + +- 修复 embedding 维度没有上界:`local-hash` 的维度直接来自用户可写的 `model_name` 数字后缀(`local-hash-256` -> 256),而 provider 会**先按该维度分配出完整向量**再返回。实测配成 `local-hash-100000000` 时 `Settings` 照单全收,单次 `embed_texts(["测试"])` 耗时 136 秒、峰值内存 3.9 GB;按真实知识库 115 个分块算,落盘前的 `np.vstack` 需要约 4.3 GB,进程会被 OOM 杀掉而不是得到一条可读的错误。这与已经加过界的 `chat_top_k × retrieval_candidate_multiplier` 是同一类问题——用户可配的规模没有上界。现在上界为 65536(现有各家的最大维度是 3072,留 20 倍余量),在 `local_hash` 的**构造期**就拒绝,因此没有任何分配发生过(实测同一配置从 146 秒 / 3.9 GB 降到 6.8 秒 / 4 MB);`EmbeddingService` 另在 `np.array` 之前按同一常量兜底,覆盖其它 provider 返回超大维度的情况。 + +- 修复载入快照时未校验文档是否合规:`Document` 的契约是 `content: str` + `metadata: dict`(见 `src/models/document.py`),但载入路径把快照/legacy pkl 里的条目直接当 `Document` 用,只做 `metadata or {}` 的兜底。实测 `page_content=None` 的快照能通过 `validate_snapshot_dir`、加载成功,直到 `display_results` 里 `.replace()` 才抛 `AttributeError: 'NoneType' object has no attribute 'replace'`——报错现场离真正的原因(快照里这一条不是字符串)很远;条目本身不是映射、`metadata` 非映射同理。现在在 `FaissStore` 的载入边界拒绝,理由与 `chunks.pkl` 的解析检查一致:这是快照内容不自洽,不是显示层的问题。`page_content` **缺失**仍放行(各消费者都写了默认值),`None` 则拒绝——它在 `Document` 里是必填的 `str`。 +- 修复展示与 prompt 构造被形态异常的检索结果打断:`display_results`(召回测试)、`Chatbot._display_retrieved_docs`(聊天)与 `ChatService._build_prompt` 都直接对结果做 `.get(...).replace(...)` 与 `f"{score:.4f}"`。检索结果可以来自任何 `VectorStoreBase` 实现——`FaissStore` 会在载入期校验并归一化,**第三方 store 不会**,而抽象基类正是为替换实现而存在的。实测一个返回 `{"page_content": None, "metadata": None, "score": None}` 的 store 能让这三处分别抛 `AttributeError` 与 `TypeError`;更隐蔽的是 `page_content=None` 在 prompt 路径上**不抛错**,而是把字面量 `内容: None` 当作一条真实知识喂给模型。现在 `display_utils` 新增 `document_content` / `document_source` / `document_score` 三个提取助手统一收敛形态(`bool` 与 `nan` 也退回默认值——`True` 会显示成 `1.0000`,看起来像真实得分),`RetrievalService` 另在 `retrieve` 出口校验「文档映射的列表」这一接口契约,`metadata` 非映射则归一化为空 dict 后继续(元数据异常不该让整批检索失败)。 + +- 修复 `markup_text` 静默丢弃样式:该助手原用 `Text.from_markup(...).split(占位符)` 切段、再从**切出来的相邻段**上取样式,而占位符本身正是被切掉的那一段——标记解析出的样式恰恰只挂在它身上(`[bold cyan]\x00[/bold cyan]` 的 span 是 `(0,1,bold cyan)`),切段后右侧是空串、没有样式。于是「值独占标记内容」这一**最常用**的写法(仓库里 13 处 `[bold magenta]{}[/bold magenta]` 都是这个形状)渲染出来完全没有样式,且不报错。反向的错位同样存在:靠「已拼接结果末字符」兜底会让 `[bold]x[/bold]{}` 把值染成粗体,而值在标记**之外**。现在按占位符的位置把整段解析结果切开重组,样式取自占位符自身覆盖的 span;实测 22 种模板形态与 `escape` 参考逐字符一致,真实知识库 4489 行 0 改写、0 崩溃、0 样式不符。 + +- 修复配置面板把 `config.toml` 派生值当标记解析:上一轮只把「融合策略」与 Base URL 两个单元格包成了 `Text`,其余仍是裸 f-string/`str()`。这些取值同样没有白名单——provider 工厂只校验 `provider`(`_provider_map` 白名单),`model_name` 与配置**键名**(`active_llm_configuration`/`active_rerank_configuration`)原样透传。实测 `model_name = "gemini[/]"` 时 `reload_llm()` 返回 True(模型确实建起来了),紧接着的 `display_chat_config` 抛 `MarkupError`——聊天会话在启动横幅这一步就崩,用户连配置都看不到;`active_*_configuration` 是配置表的键名,任意字符串即可,同样工厂放行、面板崩溃。现在整张表的单元格与行标签统一按字面呈现。构建脚本 `embed_knowledge_base.py` 的配置总览表有同类问题,且它在**任何校验之前**运行(`main_async` 先调它、再建服务),`provider` 里的 `[/]` 会让「是否使用以上配置继续?」这一步直接崩溃;该函数此前没有任何测试覆盖。 + +- 新增结构性守卫,把「rich 解析不可信值」这一类问题钉死:此前每个调用点各有一个回归测试,但这类问题的共同点是「新写的代码又把裸字符串交给了 rich」,逐个补测试挡不住下一次。现在用 AST 检查 `main.py`/`src/`/`scripts/` 的全部源码,凡是会解析标记的入口(`console.print`、`Table.add_row`、`Panel`)其参数必须是字面量或已由 `Text(...)`/`markup_text(...)` 包装。确属外部输入不可达的少数位置逐条登记理由,并有单独的测试断言登记表没有失效条目(行号漂移即报错)。 + +- 修复快照校验漏掉 `manifest.toml` 的解析:`validate_snapshot_dir` 把 `chunks.pkl`/`parents.pkl`/`stats.json`/`semantic.index`/`lexical.index` 的解析失败都转成了带重建指引的「快照不自洽」ValueError,唯独 manifest 只查文件**是否存在**、不查能否解析。而 `VectorStoreFactory` 启动时正是「先 `validate_snapshot_dir`、再 `load_manifest`」,实测把 manifest 写成 `a = "unterminated` 后校验照常通过、紧接着抛裸的 `TOMLDecodeError: Unterminated string`——用户看到的是纯语法描述,既没有「快照坏了」的语义也没有重建指引。现在两个入口都按同一方式收场。 + +- 修复融合链路不收敛分数字段的形态:展示层已有 `document_score` 收敛(`bool`/`nan`/非数字退回默认值),但 `_merge_hybrid_results`、`HybridReranker`、`_deduplicate_parent_documents` 与阈值过滤都直接 `float(doc.get("score") or 0)`。检索结果可以来自任何 `VectorStoreBase` 实现——`FaissStore` 归一化,第三方 store 不会。实测两种失败方式:`score="abc"` 抛 `ValueError`、`score=[1]` 抛 `TypeError`,异常从融合函数一路冒到 chat 主循环之外;更隐蔽的是 `nan`——`_normalize_scores` 里 `max()`/`min()` 遇到它双双返回 `nan`,`score_range` 也是 `nan`,每个文档的归一化结果都成了 `nan`,而 `sorted` 对 `nan` 的比较恒为 False,该文档保持原相对顺序**留在首位**:一条分数不可用的结果被排到正常结果之前,且没有任何提示。`inf` 同理,`max()` 取到它之后其余文档的归一化结果全被压成 0。现在新增 `_finite_score` 统一收敛(要求有限性,`bool` 也退——`True` 会算成 `1.0`,看起来像真实得分),七处取值点共用;融合产出的 `score` 字段本身也一并收敛,不让中间产物带着不可用的值往下游走。 + +- 修复 Excel 日志不收敛检索结果的形态:`log_results` 直接对每个条目做 `doc.get(...)`/`metadata.get(...)`,并把取值原样交给 `openpyxl` 与 `json.dumps`。检索结果可以来自任何 `VectorStoreBase` 实现,第三方 store 不经 `FaissStore` 的载入期归一化,实测六类形态抛异常:`metadata` 为 `None`/列表与条目本身不是映射都 `AttributeError`;`page` 是映射/列表时 `openpyxl` 抛 `ValueError: Cannot convert ... to Excel`;`semantic_score` 是集合时 `json.dumps` 抛 `TypeError: Object of type set is not JSON serializable`;`score` 是映射/列表同样进不了 Excel。日志是**旁路**——它失败不该让一次成功的召回测试以异常收场,也不该带走同一批里其余正常条目的记录。现在与展示层共用 `document_content`/`document_source`/`document_score`,并对单元格与 JSON 值各加一层形态收敛。 + +- 统一分数收敛规则到 `display_utils.coerce_score` 一处:上一项修完发现展示层与检索层各写了一份判据,且**结论不一致**——展示层拒绝数字字符串、检索层接受它。后果是同一份结果出现自相矛盾的解释:一个返回 `score="0.8"` 的第三方 store,其文档会被检索层按 0.8 通过 0.5 的分数阈值、却由界面显示成 `0.0000`,即「0.0000 排在最前面」,而它恰恰是通过了阈值的那些。现在两处共用同一实现,并明确三条规则与理由:数字字符串照旧转换(旧实现 `float(x or 0)` 接受它,一律拒绝会让字符串得分的 store 被静默过滤成空结果)、`bool` 与 `nan`/`inf` 退回默认值(`True` 会显示成 `1.0000` 像真实得分;`nan` 会让 `sorted` 的比较恒为 False 而把坏分数留在首位)。同时 `retrieve` 出口统一收敛 `score`:单路检索不经 `HybridReranker`,不会重算得分,第三方 store 的原始形态会一直流到调用方。 + +- 修复 legacy 导入的清单统计不收敛 `metadata` 形态:`factory.py` 的 legacy 分支用 `{doc.get("metadata", {}).get("source") for doc in store.documents}` 统计来源数,而该分支正是为**第三方 store** 准备的(`get_default_vector_store` 可以返回任何 `VectorStoreBase` 实现)。`FaissStore` 会在载入期归一化,第三方 store 不会——`metadata=None`/列表、甚至条目本身不是映射都会让这行抛 `AttributeError`,且抛在 `store.load()` 之后、`create_temp_snapshot_dir` 之内,正好落在需要 `finally` 清理的窗口里。现在与展示层共用 `document_source` 收敛。 + +- 修复批量配置更新按**旧值**做组合校验:`Chatbot.chat_config` 的 setter 在副本上逐个写字段,而每个字段的组合校验读的是**尚未被这批更新覆盖的旧值**。于是同一个合法终态能否被接受取决于 dict 遍历顺序——初始 `(top_k=5, mult=3)`、目标 `(top_k=2000, mult=1)`(乘积 2000 完全合法),先写 `top_k` 会拿旧的 `mult=3` 算出 6000 而拒绝,先写 `mult` 则通过。这不是理论问题:配置编辑器的动态上界会**放行**这两个字段(先设 `mult=1` 时上界 100,再设 `top_k=2000` 时上界正好 2000),用户以为设置成功,而应用配置时抛 `ValueError`——该异常落在 chat 主循环之外,会以裸 traceback 终止会话;目前只因 `to_dict()` 的键顺序恰好把 `retrieval_candidate_multiplier` 排在 `top_k` 之前才没暴露,嵌入方传入自己的 dict 或将来调整字段顺序就会踩到。现在 `SessionConfig` 新增 `set_many()`:先逐字段校验收敛到本地,再按「这批更新全部生效之后」的取值做组合校验,最后整体落盘;被拒时不留改了一半的状态。等值约束(两权重同时为零)保持原样——最后一次写入必然能看到另一个字段的新值,因此那条不受影响。 + +- **修复退出清理可能删除 legacy 知识库文件**:`_protected_app_paths()` 的保护清单漏了 `pkl_path`(legacy 知识库文件,用户投入的原始数据)。把 `cache_path` 指向 `data`、同时让 `knowledge_base_path`/`snapshot_root`/`log_path` 都落在 `data` 之外时,`data` 不覆盖任何受保护路径,`_reject_unsafe_cache_dir` 放行,退出清理随即 `rmtree("data")` —— 连带删掉 `data/employee_kb.pkl` 且不可恢复(已复现)。它与 `knowledge_base_path` 同属用户数据,现一并保护。 +- **修复快照 id 的路径校验可被路径别名绕过**:判定原为「解析后的父目录等于 root」,它拦不住 `"x/../victim"`——该值解析成 `root/victim`,父目录**正是** root,于是放行。而三个方法实际操作的路径是 `self.root / f".tmp-{snapshot_id}"`,前缀 `.tmp-` 会**改变** `..` 的消解结果(`.tmp-x` 是一个真实路径分量),`".tmp-x/../victim"` 最终落在 `root/victim`,即一个**正式快照**:实测 `cleanup_temp_snapshot_dir("x/../victim")` 对 `root/victim` 执行了 rmtree,「只清理任务临时目录」的意图被绕过。现改为「必须是单一目录名」(不含分隔符、不是 `.`/`..`、非绝对路径),`generate_snapshot_id()` 的产物不受影响。 +- 修复快照校验漏掉**向量维度**一致性:只比行数是不够的——实测分块数/embedding 行数/`ntotal` 都是 1,而 `embeddings.npy` 是 4 维、`semantic.index.d` 是 3 时校验放行,加载后 `semantic_search` 把 4 维查询向量送进 3 维索引,FAISS 抛的是**空消息的 `AssertionError`**(没有「快照坏了」的语义,也没有重建指引)。现在两个维度必须一致。 +- 修复快照校验把 `chunks.pkl = None` 当作 0 个分块放行:加载后 `_normalize_loaded_documents` 对 `None` 迭代,抛裸的 `TypeError: 'NoneType' object is not iterable`。这是逐项校验(`_validate_loaded_document`)之前没检查**容器本身**的缺口,现在顶层必须是序列。 +- 修复 hybrid 检索在校验前就合并两路结果:`_require_document_list` 原先只在合并与 rerank **之后**运行,而 `_merge_hybrid_results` 已经对每个条目调了 `.get(...)`——store 返回 `[None]` 时抛的是 `AttributeError: 'NoneType' object has no attribute 'get'`,而不是该校验承诺的可定位 `TypeError`。三条检索路径的收场方式因此不一致(单路给可读错误、hybrid 给裸 AttributeError),现在两路结果在合并前各自校验,错误消息指明是哪一路。 +- 修复子分片尺寸关系在**非层级模式**下也生效:`kb_child_chunk_size < kb_chunk_size` 与 `kb_child_chunk_overlap < kb_child_chunk_size` 两条约束原先写在 `Settings` 的 model_validator 里,但**模式不在 `Settings` 里**——它是分片时的运行时参数(`build(splitter_structure_mode)`/CLI 的 `--mode`)。后果是一组「standard 下完全合法」的取值连配置都加载不了:实测 `kb_chunk_size=100, kb_child_chunk_size=300` 被 `ValidationError` 拒绝,而 standard 模式只调用普通分片器,子分片器的参数根本不参与。判定移到 `RecursiveTextSplitter._validate_hierarchical_sizes()`:层级分支一定会构造子分片器,非层级分支一定不会。 +- 修复 `markup_text` 把转义的 `\[` 误判为标记:rich 的 `_parse` 按「前导反斜杠的奇偶」决定 `[` 是转义字面量还是真标记,而助手的守卫只看正则是否匹配到 `[`,于是 `r"\[example {}]"` 这类**合法**模板被拒绝,而 rich 自己会把它解析成 `[example <值>]`。现在按同一奇偶规则判定;`\\[`(双反斜杠)里的 `[` 仍是真标记,照旧拒绝。 +- 修复一条 prompt 测试的假绿:`test_build_prompt_survives_malformed_documents` 只断言 `"问题" in prompt`,而该字符串在「没有找到相关知识」那一支里也有。实测把 `_build_prompt` 换成一个「丢弃全部知识、只回显问题」的实现,这条用例照样通过——它能发现崩溃,却**发现不了知识正文被静默丢弃**(那正是本项目最该防的失败:检索退化被伪装成模型答不出来)。现在对「有可用正文」的输入额外断言正文确实进入了 prompt。 + +- 修复**无键名的固定前缀凭证漏检**:SDK 的异常消息常回显请求头或 URL(`safe_exception_text` 的 docstring 写明了这一点),此时凭证是**裸串**,没有键名可锚定。既有的 `_TEXT_CREDENTIAL_KEYS` 覆盖的是键名——`volc_access_key=...` 会被脱敏,但 `invalid access key 'AKLT...'` 这种回显形态不会,实测 `RedactingFormatter` 对它原样输出。火山引擎(Ark)与阿里云都用这种固定前缀的长串凭证。现新增 `_AK_SK_PREFIXED_KEY_RE`(`AKLT`/`LTAI` 前缀 + 12 位以上),与既有的 `AIza`(Google)规则同类:前缀可自证,因此不依赖上下文。误伤评估:对真实知识库 4489 行、全部源码与文档扫描零命中,且 `AKLT 是前缀` 这类短串不受影响。 + +- 修复 Rerank 入参未收敛成字符串:`docs_to_rerank = [doc.get("page_content", "") for doc in ranked_results]` 在内容非字符串时会把 `None`/`42` 这类值直接交给 provider,由 provider 的 `_validate_inputs` 抛「documents 必须全部是字符串」——错误信息指向 provider 的参数,而真正的原因是上游 store 返回了形态异常的文档。现在与展示层共用 `document_content` 收敛,错误离原因更近。 + +- 修复整数解析在两处判据上都不完整:`str.isdigit()` 对 128 个 Unicode 码位为真(`²` `³` `①` `፩` `₅` …)却让 `int()` 抛 `ValueError`;即便换成 `isdecimal()`,**位数超过 `sys.get_int_max_str_digits()`(CPython 默认 4300 位)的数字串**`int()` 照样抛错——`"9" * 4301` 是合法的十进制整数。只堵住前一类,同样的崩溃只是换了个触发条件。现抽出 `parse_decimal_int`(`src/utils/config.py`):`isdecimal()` 判形状 + `try/except ValueError` 兜住位数上限,契约是「解析不了返回 `None`,永不抛异常」,五处调用点统一改走它。受影响的三类现场各不相同:配置编辑器里输入 `²` 或粘贴一长串数字后回车,questionary 只把 `ValidationError` 当「输入无效」,其余异常会一路冒到 prompt_toolkit 事件循环之外,用户看到 `Unhandled exception in event loop` 加一段 traceback 且输入框卡死(实测经真实 `PromptSession` + 管道输入复现,异常自 `prompt.py::_accept_input` → `buffer.validate_and_handle` 抛出);`local-hash-²` 这类 `model_name` 让构造期抛出一条只有 `invalid literal for int() with base 10` 的裸解析错误,既不提 `model_name` 也不提配置;`is_retryable_error` 与流错误事件提取则让**错误分类本身**崩掉——本该重试的 5xx、本该报出的流错误都变成解析异常;`_chat_status_is_error` 同理,会让「HTTP 200 但响应体写着失败」的兼容网关被误报成空文本。 +- 修复 `config.toml` 里位数超限的整数逃出脱敏边界:`tomllib` 解析整数走的是 `int()`,位数超过 `sys.get_int_max_str_digits()` 时让裸 `ValueError` 穿出来——它**不是** `TOMLDecodeError`,`ValidationError`/`SettingsError`/`TOMLDecodeError` 三个 handler 全接不住。实测`chat_top_k = "9" * 4301` 得到 36 帧裸 traceback,消息只有 `Exceeds the limit (4300 digits)...`,既没有「配置解析失败」前缀也没说是哪个文件;而同属「配置写错」的语法错是 5 帧带前缀的脱敏消息。现补一个 `except ValueError` 兜底(排在三个具体分支之后——它们都继承 `ValueError`,顺序颠倒会让前面的分支永远接不到),五类配置写错(语法错、超长整数、字段取值非法、未知字段、坏分隔符)现在收场一致。 + +- 修复快照元数据的**字段取值**解析逃出「快照不自洽」的收场约定:`load_manifest` 与`validate_snapshot_dir` 此前只包住了 `tomllib.load`/`pickle.load` 的失败,解析**之后**的字段取值没包。`KnowledgeSnapshotManifest.from_mapping` 直接 `int(data[...])`/`data[...]`,于是 `chunk_count = "abc"` 抛裸 `ValueError: invalid literal for int()`、位数超限的数字串抛裸 `ValueError: Exceeds the limit (4300 digits)`、字段缺失抛裸 `KeyError`——`KeyError` 甚至**不继承** `ValueError`,连启动路径上的 `except ValueError` 都接不住。`stats.json` 的 `int(declared)` 同理(`"abc"`/列表/超长数字串分别抛 `ValueError`/`TypeError`/`ValueError`)。这些都属于「快照写坏了」,却都没有「快照不自洽」的语义与重建指引,而同一份文件语法错时给的正是那种消息。现两处都包住解析后的取值,五类形态(非数值字符串、浮点字符串、列表、位数超限的数字串、字段缺失)统一收场为带重建指引的 `ValueError`,并保留原始异常类型便于定位。经真实启动入口(`VectorStoreFactory.get_default_vector_store`)逐类复现确认。 + +- 修复 `save_snapshot` 在 `metadata` 为 `None` 时崩溃:`doc.get("metadata", {})` 的默认值只在**键缺失**时生效,键存在而值为 `None` 时它返回 `None`,随后的 `.get` 抛 `AttributeError: 'NoneType' object has no attribute 'get'`。同文件另外三处(`_build_index_text`、`_normalize_loaded_documents`、`_normalize_parent_document`)用的都是 `or {}`,只有 stats 计算这一处不一致。实测 `upsert_embeddings` 接受 `metadata=None` 的条目并正常建好索引(`_rebuild_indices` 走的是 `or {}`),到知识库构建的最后一步 `save_snapshot` 才崩——同一份数据两条路径的判据不一致。 +- 修复 `Anthropic options.max_tokens` 的有限性判据晚于 `int()`:`int(float("inf"))` 抛 `OverflowError: cannot convert float infinity to integer`,`int(float("nan"))` 抛 `ValueError: cannot convert float NaN to integer`——两者都不是该方法承诺的「必须是正整数」消息,而 `OverflowError` 连 `except ValueError` 都接不住。实测 `max_tokens = inf` 正是这种收场,而同为非法取值的 `1.5`/`0`/`-1`/`True` 都能得到带指引的消息。现把有限性判据提到 `int()` 之前。 +- 修复八个 token 计数方法的响应解析只判了 `None`:Anthropic 同步/异步 × 普通/Beta、Google 同步/异步、OpenAI Responses 同步/异步此前都是「判 `None` 后直接 `int(value)`」,那只挡住了缺字段。SDK 或兼容网关返回 `"abc"`/`1.5`/`[1]`/位数超限的数字串/`inf` 时,抛的是裸 `ValueError`/`TypeError`/`OverflowError`,与「token 计数」这件事无关,调用方也拿不到是哪个字段出的问题。现抽出 `require_token_count`:非负整数是这份数据的真实契约(token 计数不可能为负,也不可能带小数),八处统一改走它,形态异常一律报成带 provider 与字段名的 `RuntimeError`。 + +- 修复 provider 模块漏收无人守卫:十三个 provider 模块**没有任何静态导入路径**——它们只经 `importlib.import_module(provider_info["module"])` 按运行时字符串加载(`src/providers/factory.py`)。PyInstaller 的静态分析看不见这一层,因此构建脚本的 `HIDDEN_IMPORTS` 是它们在冻结包里能否被找到的**唯一**依据。两个清单分处两个文件、各自演化,而此前**没有任何守卫把两者绑在一起**,也没有任何构建期检查会碰这些模块——自检只走分片器与日志路径,不导入也不实例化任何 provider。后果与当初 `tiktoken_ext.openai_public` 漏收完全一致:PyInstaller 不报错、`--validate` 通过,用户在那个渠道上才拿到 `ModuleNotFoundError`;只是那次被发现了,这次没有守卫。现补两道防线:`run_smoke_test` 逐个导入注册表里的 provider 模块(只导入不实例化,因此不需要凭证),让漏收在**构建期**就被拦下;另加双向一致性用例,注册表新增模块而未同步清单、或清单残留已删除的模块名都会被判失败。 + +- 修复 PEM 私钥正文逃出脱敏边界:这是本项目**自己点名要防**的一类凭证——`SENSITIVE_OPTION_KEYS` 里同时列了 `privatekey`/`sshkey`/`sshprivatekey`,Google Vertex AI 的 ADC 服务账号 JSON 里就带 `private_key`。但键名规则只挡得住「键名 + 值」的形态:私钥**正文**作为一段独立文本出现时没有任何锚点。实测两条真实泄漏路径:`validate_secret_free_options` 把私钥当键名拒绝时,回显的消息里带着完整正文(该消息经 `safe_exception_text` 直接展示给用户);裸 PEM 文本走 `redact_sensitive_text` 原样返回。现补一条 PEM 块规则,覆盖 RSA/通用/OpenSSH/EC 私钥,并同时匹配「BEGIN/END 成对」与「只有 BEGIN」两种形态——后者是必需的,`safe_exception_text` 会把消息截到 240 字符,END 标记可能已被切掉,只匹配成对形态会漏。证书与公钥不受影响(只匹配 `PRIVATE KEY`)。实测该规则对普通文本不增加可测开销(6k/60k 字符的对照基准由既有规则主导)。 + +- 修复旧日志清理漏掉轮转备份:写入侧是 `RotatingFileHandler(maxBytes=1MB, backupCount=5)`,单个文件超过 1 MB 就轮转出 `.log.1` ~ `.log.5`。清理用的文件名模式以 `$` 结尾,那些备份**一个都不匹配**,于是「清理旧日志」只删掉主文件,把绝大部分体积留在盘上——实测造一批 30 天前的日志(主文件 + 5 个备份),清理后仍剩 83% 的字节;本机真实日志目录里也躺着 2.0 MB 的备份、占该目录体积的 31%。`log_path` 是用户可配置项,指向磁盘时这份残留会一直累积,而 `cleanup_old_logs` 每次启动都跑。现把模式放宽为 `\.log(?:\.\d+)?$`,后缀限定为 `.` + 数字——`RotatingFileHandler.doRollover` 的备份名就是 `baseFilename + "." + 序号`,不会产生其它形态,因此 `app_log_2020-01-01.log.bak` 这类仍不受影响。 + +- **修复发布工作流的凭证检查被 SIGPIPE 静默跳过(本轮最严重)**:步骤用 `printf '%s\n' "${listing}" | grep -qE '/(\.env|config\.toml)$'`,而 `grep -q` 命中即退出、上游 `printf` 收到 SIGPIPE 以 141 退出,`set -o pipefail` 让整条管道返回 141——`if` 只把 0 当成功,于是**「命中」被判成「未命中」**。触发条件是归档列表超过管道缓冲区(Linux 上 64 KiB):实测真实 v1.4.0 产物的列表已是 75–79 KB,也就是这条检查自发布起就一直失效。两个方向都与事实相反——含 `.env` 的包被放行发布(凭证泄漏),而 `tiktoken_cache` 检查会对正常包误报缺失。改用 `grep ... >/dev/null` 的 here-string(grep 读完整个输入,不产生 SIGPIPE)。既有 8 条工作流用例的合成归档只有 1–2 个条目、列表远小于缓冲区,因此完全测不到这条路径;新增两条 6000 条目的用例(一个含 `.env`、一个干净)把两个方向都钉住。 +- **修复 `get_relative_path` 对所有取值套 `safe_repr` 导致相对路径功能失效**:上一轮为「兜底分支自己会抛」引入的修复把 `safe_repr` 用在了**参与计算**的值上,而 `safe_repr` 是给错误消息用的——它加一对引号并在 80 字符处截断。带引号的前缀永远匹配不到 `ROOT_DIR`,`os.path.relpath` 于是原样返回绝对路径(外加引号),超长路径还会丢掉文件名;真实知识库 39 个文档 100% 命中,三个调用点(召回测试、聊天、建库脚本)一起受影响。现改为 `_path_text`:只在 `str()` 真的失败时才降级到 `safe_repr`,正常路径走原样的 `str()`。同时把回归断言从「返回非空字符串」(原样返回的实现也能通过)换成「结果是相对形态且不含 `ROOT_DIR`」,并补一条超长路径不被截断、不加引号的用例。 +- **修复 Excel 日志写入成功却打不开**:xlsx 是 XML,**两处**各自拒绝不同的码位——`openpyxl` 的 `check_string()` 在 `append()` 期拒绝 C0 控制符(已处理),而 XML 1.0 不允许的码位在 `save()` 期被 `xml.etree` 写成非法字符引用:**写入成功、读回失败**,`load_workbook` 抛 `ParseError`,而 `log_results` 的 `except Exception` 把它吞成一行日志——用户看到「召回测试完成」而文件打不开,而这份日志是召回测试的**唯一产出**。第 2 类漏掉的正是孤立代理项(`U+D800`–`U+DFFF`)与 `U+FFFE`/`U+FFFF`:前者经 legacy pickle 路径可达(pickle 是二进制格式、不受 UTF-8 约束),后者是**合法 UTF-8 码位**(`read_text(encoding="utf-8")` 与 `BasicCleaner` 的 `\s+` 折叠都放行)。现把两类一并补进替换表,并断言 `load_workbook` 能读回(只断言 `save()` 不抛是假绿——原实现正是「写得进、读不出」),另加一条反向守卫确认合法码位(emoji/CJK/`U+D7FF`/`U+E000` 边界)逐字保留。 +- **修复嵌入 provider key 打错字抛裸 `KeyError`**:`default_embedding_provider` 没有任何 validator(`Settings` 里只是普通 `str`),而 `EmbeddingService.embedding_model_detail` 与 `embed_knowledge_base.py` 的展示都直接下标。实测 `DEFAULT_EMBEDDING_PROVIDER=gogle` 时脚本以裸 traceback 退出、消息里连可选值都没有,而同一份配置走 `ModelProviderFactory` 得到的是带可选值列表的 `ValueError`——两条路径的报错契约本该一致,否则调用方按 `ValueError` 写的错误分类与脱敏接不住 `KeyError`。调用点在检索与建库的主路径上(`embed_query` 的 Google 分支、`knowledge_build_service` 写清单)。现两处共用 `_require_model_detail`。 +- **修复 `_resolve_version` 漏接 `TypeError`**:`except (OSError, KeyError, tomllib.TOMLDecodeError)` 少了 `TypeError`,而 `["project"]`/`["version"]` 在形态不对时抛的正是它(`project = "x"` → `string indices must be integers`;`project = 5` → `'int' object is not subscriptable`),不是 `KeyError`。本函数在**模块导入期**执行,漏出的异常让进程以裸 traceback 退出——而两级兜底存在的意义正是「读不到就降级」,与已修的 `VERSION.txt` 解码失败同一失效模式。现补上 `TypeError` 与 `ValueError`,并按四种畸形 `pyproject.toml` 形态各加一条子进程用例。 +- **修复基础清洗器哨兵长度的平方复杂度**:`while placeholder in content: placeholder += "\x00"` 每次 `in` 都是一次全串扫描、每次 `+=` 又让哨兵长一位,于是 N 个 NUL 要扫 N 次——实测 20 万个 NUL 耗时 19.8 秒、100 万按平方外推约 1300 秒,而旧的字面量哨兵是 O(1)。触发输入很现实:零填充的 md(写入中断/预分配/误拷的二进制残片)或 Windows 用户存成 UTF-16LE 的纯 ASCII 文档,`read_text(encoding="utf-8")` 对两者**都不报错**,因此 `Pipeline._process_file` 的解码失败分支拦不住,用户只看到「向量化处理」卡死数分钟且无任何提示。现改为「一次扫描取一个内容里不存在的非空白字符」。 +- **修复清洗器哨兵在边界上的歧义(同一处修复自身引入的缺陷)**:把哨兵写成「最长 NUL 游程 + 1」这种多字符形态看似更省事,实则在边界上歧义——内容 `"a\x00\x00\x00\n\nb"` 换出 4 个 NUL 后与原有 3 个 NUL 连成 7 个,`str.replace` 取最左匹配、只吃掉前 4 个,剩下 3 个 NUL 被留在双换行**之后**,实测得到 `'a\n\n\x00\x00\x00b'`:用户内容被静默改写。单个且内容中不存在的字符不可能有这种歧义。已在 2 万条随机输入上与修复前的实现做差分(含 NUL 游程、Unicode 空白、控制字符),结果零差异。 +- **修复 `RecursiveTextSplitter` 的 `mode` 静默回退与尺寸参数无类型守卫**:`mode` 原先只判 `== "token"`,任何拼错/大小写不符/非字符串的取值都**静默退回 char 模式**——实测 `"Token"`/`"tokens"`/`None`/`["token"]`/`123` 全部得到 char 分片,于是 `chunk_size=1500` 意味着 1500 **字符**而不是 1500 token(中文下每 token 约一字),分片规模与配置意图不符且无任何提示,违反 AGENTS.md「不用静默回退或占位结果掩盖失败」。尺寸参数则只做比较、不做类型检查:`child_chunk_size='300'` 构造成功、到 `split()` 才抛 `TypeError: '>=' not supported between instances of 'int' and 'str'`,而本模块的契约是 `ValueError`。现两者都在构造期校验,另修掉 `parent_chunk_size=0` 时 `or` 兜底与 `is not None` 比较造成的「校验的值和用的值不是同一个」(实测报出 `parent=0` 而真正生效的是 1500)。 +- **修复分隔符表无长度上界导致 `RecursionError`**:LangChain 的 `_split_text` 按分隔符表**递归**、每层消耗一项,实测 1500 项抛 `RecursionError: maximum recursion depth exceeded`——它不是 `ValueError`,穿透调用方的错误分类与脱敏。表来自用户可写的 `kb_splitter_separators`(TOML 数组),`Settings` 的 validator 只做 `str -> list` 拆分、没有长度上界。现加 64 项上界(真实配置是个位数,默认只有 `["###"]`)。 +- **修复 `SessionConfig` 的字段名校验漏出 `TypeError` 与位数上限错误**:`key not in self.to_dict()` 对不可哈希的 key 抛 `TypeError: unhashable type`,而消息里的 `{key!r}` 对位数超限的整数抛 `ValueError: Exceeds the limit`——两条都与「字段名写错了」无关,而同文件的 `_validate_fusion_strategy` 早已认领同一类线索,只有字段名这一处漏了。`set_many` 同样(且只修 `__setitem__` 会留下另一个入口)。现两处共用 `_require_known_field`,未知的**字符串**字段仍照常报 `KeyError`。 +- **修复组合校验读取属性直写值时抛 `OverflowError`**:`SessionConfig` 是公开类,普通属性没有 `__setattr__` 守卫,嵌入方(或携带超大整数的 pickle 快照)可以直接 `cfg.vector_weight = 10**400`。随后任一次 `set_many`/`__setitem__` 都会读到该兄弟字段并运算:`float(10**400)` 抛 `OverflowError`(`ArithmeticError` 子类,**不是** `ValueError`)、`int(inf)` 同样、`int(nan)` 抛 `ValueError`——前两条会穿透只接 `ValueError` 的调用方。同文件注释曾断言「到这一步必是有限浮点、不会再抛 `OverflowError`」,那个前提对属性直写不成立。现经 `_require_finite_number`/`_require_int_value` 收敛,校验与运算用同一个值。 +- **修复候选规模过大的报错消息自己会抛**:`_reject_oversized_candidate_pool` 的消息插值 `{candidates}`(= `top_k * multiplier`),而 `top_k` 被直写成位数超限整数时,它在**正要报「候选规模过大」**的那一步抛 `ValueError: Exceeds the limit`——类型对但内容与字段毫无关系。同文件的 `_require_bounded_int` 早已用 `safe_repr`,只有这里漏了。 +- **修复快照清单的写入侧仍用 `str()`/`int()`**:`from_mapping`(读取侧)早已按「pickle 快照可携带位数超限整数」修过,但 `to_toml`(写入侧)写的是裸 `str(value)` 与 `int(value)`——同一条缺陷只修了一个方向。实测文本字段是 `10**4301` 时 `write_manifest` 抛「整数转字符串超限」,与「快照写入失败」毫无关系;计数字段是 `inf` 时 `int()` 抛 `OverflowError`(`ArithmeticError` 子类,不是 `ValueError`)。现两处都经与读取侧同源的收敛,并给计数加 64 位上界(TOML 能表示任意精度整数,但这个量级的计数没有任何现实意义)。 +- **修复 `/config` 应用失败终止整个聊天会话**:内层 `try` 只接 `(KeyboardInterrupt, EOFError)`,而 `SessionConfig` 的校验器会拒绝非法取值(`ValueError`)。配置编辑器的动态上界与 `SessionConfig` 的守卫是两套独立实现,任一侧演化就会命中这条路径——实测编辑器返回「非法字段 + 非法值」时异常穿透到 `finally`,用户被丢回主菜单并**丢失会话**。同文件的 `chat_async` 对异常是恢复的(打印后继续下一轮),这里与之对齐:报错(经 `safe_exception_text` 脱敏)、保留原配置、回到提示符。诊断日志经 `getattr` 取,避免日志取不到时把恢复路径变成新的失败点。 +- **修复召回测试日志在同一秒内互相覆盖**:文件名的时间戳只到**秒**(`recall_test_log_%Y%m%d_%H%M%S.xlsx`),同一秒内构造的第二个 `ExcelLogger` 会拿到同一个路径并覆盖前一个文件——实测两个实例的 `filepath` 完全相同。而 Excel 日志是召回测试的**唯一产出**,覆盖等于悄悄丢掉一次测试的结果。可达路径是 `main.py` 的菜单循环(从召回测试返回主菜单后再次进入)或嵌入方在同一秒内构造两个 logger。现用 `O_CREAT | O_EXCL` 独占创建,冲突时加 `_1`/`_2` 后缀(第一个仍是原文件名,保持既有形态不变)。判据用 `O_EXCL` 而不是 `os.path.exists`:后者是「先查后写」,两个进程同时构造时会双双认为路径可用、然后互相覆盖。构造中途失败时回收已创建的占位文件,否则目录里会留下一个 0 字节的 `.xlsx`,看起来像一次「没有结果的召回测试」。 +- **修复分片器的日志口径**:标准分支记的是 `len(langchain_chunks)`(**过滤前**的数量),一份被整段判空的文档会显示「生成 8 个块」而实际贡献 0 个——日志反过来掩盖了问题。实测 `"a" + "\t"*5 + "b"` 在 `chunk_size=1` 下过滤前 7 个、实际收录 2 个。现改为记录**实际收录**的块数,空文档单独记成「清洗后为空,跳过」而不是「生成 0 个块」。「原文非空却产出 0 块」经笛卡尔扫描(16 种输入 × 5 组分隔符 × 3 组尺寸 × 2 种结构)未复现,故**不**为它写告警分支——写不到的死代码只会让人误以为有防护。 + +- **修复 `redact_sensitive_text` 的非字符串分支自己会抛**:该函数第一个分支是 `if not isinstance(value, str): value = str(value)`——明确声明支持非字符串,而 `str(10**4301)` 撞上 CPython 的整数位数上限抛 `ValueError`。同文件的 `safe_exception_text` 早就为同一问题用 `safe_repr` 兜底(其 docstring 还专门解释了 pickle 为什么能带进这种值),只有这里漏了。现内联同一判据(位数超限时用 `bit_length()` 给出量级);**不**导入 `config.safe_repr`,因为 `config` 反过来从本模块导入 `redact_sensitive_text`,跨模块引用会形成循环导入。 + +- **修复空的路径配置静默解析成「当前工作目录」**:`resolve_path` 的 docstring 承诺「将相对路径解析为绝对路径」,但解析**之前**有一个 `if not v: return v` 提前返回,而 `Path("")` 在求值期等价于 `Path(".")`。两个后果:其一,**pickle 的信任根被扩到 CWD**——`FaissStore.load_snapshot` 拿 `snapshot_root` 当来源信任根(`AGENTS.md` 要求快照只从本项目生成的文件加载),实测 `SNAPSHOT_ROOT=""` 时信任根落在仓库根目录,在其中放一个带 `ACTIVE_SNAPSHOT` 标记的目录即可让 `load_snapshot` 接受并反序列化它的 `chunks.pkl`,边界等于不存在;其二,**行为随 CWD 漂移**——五个路径字段都原样返回,同一份配置在仓库内与仓库外运行指向不同目录(实测 CWD=/tmp/xxx 时五者全部解析到 /tmp/xxx),而默认值(如 `data/kb`)走 `ROOT_DIR` 分支、不随 CWD 变。现显式拒绝空值与纯空白(`Path(" ")` 会解析成一个名为空格的**真实目录**),并给出「删除该配置项回落默认值」的可执行指引。`cache_path` 清理侧的 `if not cache_dir` 守卫是删除前的最后一道防线,不是「空值合法」的依据。经 `.env` 与 `config.toml` 两条来源实测可达。 +- **修复清洗器复杂度用例的间歇误报**:该用例用 2k 个 NUL 做基准、断言 100 倍规模的耗时比低于 600,而 2k 的耗时约 0.1ms,已接近 `time.perf_counter()` 的可靠量级——实测同一台机器上 8 次采样出现 967 与 598 两个越界值,稳态比值则在 50~75 之间波动。现基准改用 20k(约 0.7ms)、规模放大改为 10 倍、每档取多次采样的最小值(逼近无干扰耗时,而非把 GC 与调度抖动算进去),阈值取 60:线性实现实测比值 7.8~11.5,平方实现实测 700,两侧各有 5 倍以上余量。定向变异(把哨兵改回 `while placeholder in content`)后该用例无法通过。 + +- **修复空的 Base URL 静默变成相对路径**:`openai_compatible.__init__` 用 `getattr(settings, "_base_url")` 取值,随后只判 `if provider != "openai" and not self._base_url`——**openai 被显式排除在外**(它允许回落 SDK 默认地址)。但字段默认值非空、`.env` 里又有 `OPENAI_API_BASE`,于是「显式置空」这条路径无人把守:实测 `OPENAI_API_BASE=""` 时 `self._base_url` 是空串而非 `None`,判 `is None` 的回落分支不触发,`openai.OpenAI(base_url="")` 得到 `URL('')`,请求 URL 变成相对路径 `chat/completions`(没有主机名),失败信息与「地址配错」毫无关系;`" "` 更会被 SDK 编码成 `%20%20%20/`。现拒绝字符串形态的空值,同时保留 `None` 的原有语义(未配置 → 用 SDK 默认地址,实测为 `https://api.openai.com/v1/`),并为两条方向各补一条断言。错误消息按取值**实际来自哪个字段**命名:openai 渠道的现行字段是旧名 `openai_api_base`,只报 `openai_base_url` 会把用户指向一个不存在的配置项。 + +- **修复 rerank 缓存键在集合与自引用容器上失效**:`_stable_fingerprint` 的 docstring 承诺「递归到叶子」与「绝不抛异常」,实测两条都只在 dict/list/tuple 上成立。其一,集合走 `repr()` 兜底、**没有递归**,而集合含位数超限的整数时 `repr()` 抛 `ValueError`,整个集合塌成 ``——实测 `ModelDetail(options={"x": {10**4301}})` 与 `{"x": {10**4302}}` 都通过 `validate_secret_free_options`(它只查敏感键名、不做递归类型校验),随后两者得到同一个缓存键,两份不同的 rerank 配置静默复用同一个 provider 实例:错误配置的请求打到错误的端点上,且没有任何报错,比崩溃更糟。其二,自引用容器让它无限递归抛 `RecursionError`,而 `RecursionError` 继承 `RuntimeError`、不在调用方 `except (TypeError, ValueError)` 的范围内,会穿透成未脱敏的 traceback。现集合分支一并递归(元素按各自渲染结果排序——集合无序且 `repr` 顺序受哈希随机化影响,不排序会让**同一个**取值在两次调用间产生不同指纹、缓存永远命中不了),并沿当前路径记录容器 `id` 以截断环(用「路径上的 id 集合」而非全局去重:同一对象在兄弟位置出现两次是正常取值,只有回到祖先才是环)。经 50 次重复调用验证同值同指纹,且跨进程哈希种子一致。 + +- **修复检索方法的 `top_k` 无校验且两处判据不一致**:`semantic_search` 与 `keyword_search` 同名参数、同一份上界来源,此前判据完全不同,同一取值给出三种收场。其一,`top_k=0`/负数:前者触发 FAISS 内建 `assert k > 0`,抛的是**没有消息的** `builtins.AssertionError`(实测 `str(e)` 为空串,栈里只有 C 层,用户既不知道哪个参数错了也不知道怎么改;`python -O` 下断言被剥掉、行为还会变成未定义);后者走 `if len(results) >= top_k: break`,而循环体先 `append` 再判长度,于是 **`top_k=0` 返回 1 条**——调用方要求「不要结果」却拿到一条且无任何提示。其二,非整数:FAISS 报 `TypeError: an integer is required`、后者在 `int >= str` 处报 `TypeError: '>=' not supported`,两者都是裸 `TypeError`,按 `except ValueError` 写的调用方接不住。其三,过大:FAISS 不报错也不截断,直接按 k 分配结果数组——实测 `top_k=10**9` 挂起(>30s)、`10**8` 耗时 15.3s。配置层的 `_MAX_RETRIEVAL_TOP_K` 拦不住这三个方向:它只作用在 `Settings.chat_top_k` 与 `SessionConfig.top_k` 上,而这两个方法是 `VectorStoreBase` 的公开方法(库调用方与测试直接调用),参数一路透传到 FAISS。现抽出 `_require_top_k` 作为唯一判据、两处共用,`bool` 显式排除(它是 `int` 子类,`True` 会被当成 1 放行),并补一条「`top_k` 大于文档数仍合法」的反向守卫防止上界写成「不得超过命中数」。回归用例的夹具先自检「确实存在正分文档」——语料全为 0 分时 `keyword_search` 会在最高分处提前返回空列表,把 `top_k` 的问题整个遮住。 + +- **修复父块内容把非字符串原样写进文档正文**:`_promote_parent_context` 把 `resolver(parent_id)` 直接赋给 `promoted_doc["page_content"]`,只判了真值(`if parent_content:`)、没判类型,而 `page_content: str` 是 `Document` 的契约(`src/models/document.py`),`FaissStore._validate_loaded_document` 在快照载入期也按这条校验。可达性:`parent_resolver` 取自 `getattr(self.vector_store, "resolve_parent_content", None)`,而 `VectorStoreBase` 正是为替换实现而存在的——签名声明 `-> str | None`,第三方实现返回别的形态不会被任何地方拦下。实测返回列表让 `page_content` 变成 `['a', 'b']`、返回整数变成 `123`:展示层 `document_content` 把它收敛成 `"N/A"`(用户看到「无内容」而不是报错),而 prompt 构造对正文做 `.lower()` 时抛 `AttributeError`,检索整体不可用,报错现场离真正的原因很远。现经 `_key_text` 收敛;**不**用裸 `str()`——位数超限的整数(pickle 是二进制格式、不受十进制位数限制,畸形快照能带进来)会让它抛 `ValueError: Exceeds the limit`,而本函数的职责是「把父块内容放进正文」,不该在正要成功时崩。 + +- **修复协议名归一化在形态异常的取值上抛裸的整数转换错误**:五个归一化点都直接写 `str(value).strip().lower().replace("-", "_")`,而 `10**4301` 撞上 CPython 的整数位数上限,抛的是 `ValueError: Exceeds the limit (4300 digits) for integer string conversion`——既没有「哪个参数错了」,也没有「可选值是什么」,用户拿到的是与协议诊断毫无关系的整数转换错误。其中 `protocol_status`/`capability_report` 是公开的诊断入口(配置界面与排障工具会调),参数来自调用方而非 `Settings`——`ModelDetail.protocol` 有 validator,但这两个入口收的是**裸取值**,没有任何校验;`openai_compatible._normalize_protocol` 与 `VolcengineProvider.__init__` 经公开构造器可达。现统一显式拒绝非字符串并给出参数名与可选值;`_responses_provider_variant` 例外——它只做 Ark/OpenAI 的**分流**,非字符串一律返回 `"openai"` 而不抛错,因为它抛在 `_normalize_responses_native_item` 的入口,会把本该给出的「字段类型不对」消息换成一条与协议无关的整数转换错误。 + +- **修复 Anthropic 流式拒答文本在超大整数上崩溃**:`_stream_event` 的两处拒答分支写的是 `refusal if isinstance(refusal, str) else str(refusal or "")`,非字符串走裸 `str()`。而 `refusal` 来自服务端返回的 JSON(`content_block` 或 `delta` 里的字段),形态不受本地控制——JSON 里一个超大整数字面量(`10**4301`)会让 `str()` 撞上 CPython 的整数位数上限,抛 `ValueError: Exceeds the limit`。该异常发生在**流式解析**过程中(`_iterate_stream_events` 的生成器里):用户已经看到部分输出,随后拿到一条与「服务端拒答」毫无关系的整数转换错误;而同一函数对 `42` 正常转成 `"42"`、对 `None` 给出 `""`,只有位数超限的那一个炸——收场方式取决于取值的**大小**而不是形态。现抽出 `_refusal_text`,非字符串给 `safe_repr` 的可读描述而不是空串(空串会让调用方误以为「没有拒答」),两处共用同一判据。 + +- **修复 Anthropic 拒答详情(`stop_details`)在超大整数上崩溃,且错误掩盖安全拦截信号**:`_extract_result` 与 `_stream_event` 的 `message_delta` 分支各有一处`str(explanation or category or "<默认文案>")`,非字符串走裸 `str()`。`stop_details` 是 Anthropic 的拒答详情对象,`explanation`/`category` 都来自服务端 JSON、形态不受本地控制——JSON 里一个超大整数字面量(`10**4301`)就让 `str()` 撞上 CPython 的整数位数上限,抛 `ValueError: Exceeds the limit`。后果比单纯的崩溃更糟:`stop_reason == "refusal"` 是**安全拦截**的信号,此处抛异常会把一次本该被显式上报的拒答变成一条与安全策略无关的整数转换错误——用户看到的是「程序出错了」,而不是「请求被安全策略拒绝」。现新增 `_refusal_detail`(与上一轮的 `_refusal_text` 同一判据,非字符串给 `safe_repr` 描述),两处共用。空值语义有意与 `_refusal_text` 不同:`explanation`/`category` 可以合法缺失,`None` 必须返回空串以继续向后回落,否则缺失的 `explanation` 会渲染成 `"None"` 并挡住 `category`;「两者皆缺仍给默认文案」这条由上层保留。 + +- **修复 `finish_reason` 归一化在超大整数上抛裸的整数转换错误**:`GoogleProvider._normalize_finish_reason` 的 `str(raw)` 与 `AnthropicProvider._stream_event` 的 `str(reason)` 都直接转换服务端返回的停止原因。该字段来自响应 JSON、形态不受本地控制,`10**4301` 撞上 CPython 的整数位数上限抛 `ValueError: Exceeds the limit`——而两处都在**响应解析**里,用户已经拿到部分输出后才看到一条与「停止原因」毫无关系的整数转换错误。现非字符串走 `safe_repr`(给出量级描述,仍可区分不同取值)。修 Google 时发现一处交互缺陷:紧随其后的 `if "." in text: text.rsplit(".", 1)[-1]` 是给 `"FinishReason.MAX_TOKENS"` 这类带模块前缀的枚举名准备的,而 `safe_repr` 的截断标记恰好也是 `...`——对它套这条逻辑会把整段数字切光、只剩空串。现该逻辑只对字符串形态生效。 + +- **修复 `extra_headers` 的取值校验与 embedding 配置取值的校验(第三轮对抗审查,逐条实测复现)**: + + - **Anthropic 的 `extra_headers` 只校验容器、不校验取值**:`validate_secret_free_request_overrides` 确认整体是 Mapping 后原样保留,实测 `CompletionRequest(extra_headers={"anthropic-beta": 42})` 构造成功。非字符串值的两种收场都不在渠道的错误契约内——交给 httpx 时抛 `TypeError: Header value must be str or bytes, not `(实测 `httpx.Headers({"x-trace": None})` 同一条),而本模块对 `anthropic-beta` 做 `.split(",")` 时抛 `AttributeError: 'int' object has no attribute 'split'`(`list`/`dict` 同理,`bytes` 则抛 `TypeError: a bytes-like object is required`)。现新增 `_require_string_header_values`:非字符串给带头名的 `ValueError`,`None` **剔除**(剔除后与「该头未设置」完全等价,符合调用方写 `extra_headers={"x": None}` 时的意图)。校验放在 `_user_profile_fields` 的两条分支**之前**——`user=None` 时该函数原本直接返回原样的 `extra_headers`,而这是最常见的调用形态,比下面的合并分支更容易走到。 + - **`anthropic-beta` 头值写 `None` 时抛裸 `AttributeError`**:合并分支对它做 `.split(",")`,实测 `_user_profile_fields("u1", {"anthropic-beta": None}, None, beta=False)` 抛 `AttributeError: 'NoneType' object has no attribute 'split'`。现于合并前剔除 `None` 值,语义与「该头未设置」等价。合并同时改为写在副本上——原实现就地修改调用方的 `request.extra_headers`。 + - **beta 计数入口丢弃了校验后的头**:`beta_count_tokens`/`async_beta_count_tokens` 把 `_user_profile_fields` 的返回值丢进 `_`,转头写回原始的 `request.extra_headers`——被剔除的 `None` 值于是原样发给 httpx。同文件 `beta=False` 的计数入口用的正是 `profile_headers`,两处现对齐。 + - **`EmbeddingService.embedding_model_detail` 只判键在不在、不判值可用**:`{"local-hash": None}` 的键确实存在,`None` 被原样返回,两个调用点(`embed_query` 的 Google 分支、`knowledge_build_service` 写清单)紧接着读 `.provider`/`.model_name`,在那里抛裸 `AttributeError`。同一份配置走 `ModelProviderFactory.get_embedding_provider` 得到的是带字段名的 `ValueError`——两条路径的报错契约本该一致,否则调用方按 `ValueError` 写的错误分类与日志脱敏接不住 `AttributeError`。现复用 `require_detail_attributes`(判据是属性存在性而非 `isinstance(..., ModelDetail)`,理由见其 docstring——使用方只要求取值提供这几个属性,仓库测试就用 `SimpleNamespace` 构造等价配置)。可达路径里最晚暴露的是**零分块**的 markdown:它跳过 embedding 调用、直接走到写清单那一步读 `model_name`。 + +- **补齐 Anthropic `user`/`betas` 的取值校验(第四轮对抗审查)**。`CompletionRequest` 是普通 dataclass,`__post_init__` 只校验 `extra_headers`/`extra_query`/`extra_body`,声明为 `str | None` 与 `Sequence[str] | None` 的这两个字段不受任何约束。它们都进 HTTP 请求(`user` 转成 `user_profile_id` 头、`betas` 拼成 `anthropic-beta` 头),走真实 SDK(`httpx2.MockTransport` 拦网)实测修复前的收场: + + - `invoke(user=5)`/`beta_create(user=5)` → `AttributeError: 'int' object has no attribute 'encode'`(`['a']` 与 `b"x"` 同理,后者是 httpx 对 `bytes` 头值走另一条分支)。`AttributeError` 不落本渠道的 `except ValueError`/`except RuntimeError` 分类。七个入口(`invoke`/`count_tokens`/`beta_create`/`beta_stream`/`beta_count_tokens` 及三个异步对应项)实测全部如此。 + - `beta_create(betas=5)` → 裸 `TypeError: 'int' object is not iterable`。 + - `beta_create(betas=[1, 2])` 发出 `anthropic-beta: 1,2`;`betas=b"x"` 发出 `120`(`list(b"x")` 得 `[120]`);`betas={"a": 1}` 静默取**键**发出 `a`;`betas="s"` 被 `list()` 拆成 `['s']`。服务端收到不存在的 beta 名——要么报一个与调用方写法毫无关系的错,要么静默忽略掉本想启用的特性。 + - 现新增 `_require_profile_id` 与 `_require_beta_tokens`:前者只接受 `str | None`;后者把 `str`/`bytes`/`Mapping` 三类「可迭代但不是字符串列表」的形态显式拒绝(`list()` 对它们不报错,只会静默给出错误结果),并逐个校验元素类型。 + - **`betas` 的校验必须放在 `_build_message_params` 的 `beta_values` 构造处**,不能只放在 `_user_profile_fields` 里:那个函数仅在 `user is not None` 时被调用,而 `betas` 走 `beta_values` 直接进 params——只加在前者时 `beta_create(prompt="hi", betas=5)` 仍抛裸 `TypeError`(这正是首版实现漏掉的路径,回归用例 `test_anthropic_betas_validated_without_user` 专门盯它)。 + +- **补齐工具定义、消息列表、rerank 与 embedding 入口的入参形态校验(第五轮对抗审查)**。四组缺陷同一类:判据只覆盖了「可迭代但元素非法」,漏了「不可迭代」那一半,或者把形态判据放在了读取形态的代码**之后**。 + + - **四个 provider 的 `_convert_tools` 对不可迭代的 `tools` 抛裸 `TypeError`**:`google`/`anthropic`/`volcengine`/`openai_compatible` 都写 `for index, tool in enumerate(tools)`,而同函数对 `"s"`/`{"a": 1}`/`b"x"` 这类可迭代但元素非法的形态早已给带序号的 `ValueError`——只有标量漏了。实测 `google.invoke(tools=5)` 与 `openai.complete(tools=5)`(responses 协议)抛 `TypeError: 'int' object is not iterable`。现四处统一补容器判据。 + - **`extract_system_prompt` 的形态判据缺失**:它是 12 处调用的共享 helper,且在多数 provider 里**先于** `normalize_messages`(后者有完整判据)被调用,因此非法取值在这里就炸。实测 `messages=5` 抛裸 `TypeError: 'int' object is not iterable`,`messages="s"` 抛裸 `AttributeError: 'str' object has no attribute 'get'`——字符串是合法的 `Sequence`,只判可迭代拦不住。现判据与 `normalize_messages` 逐字对齐(含 `bytes`/`bytearray` 的显式排除),条目非 Mapping 时给带下标的 `ValueError` 而不是静默跳过——跳过会让一条写错的消息**悄悄消失**,system 提示随之丢失。 + - **两个 rerank provider 把 `len(documents)` 写在参数校验之前**:`jina` 与 `siliconflow_rerank` 的同步/异步四个入口,日志行 `文档数: {len(documents)}` 都在 `_validate_inputs` 之前求值。实测 `documents=5` 抛裸 `TypeError: object of type 'int' has no len()`,而紧随其后的校验对同一取值给的是带指引的 `ValueError`。现把校验提到日志之前(`_prepare_payload` 里那次保留——它是请求构造路径的守卫)。 + - **六个 embedding 实现都不判 `texts` 的形态**:`TextEmbeddingModel` 的全部公开入口此前没有一处校验,而 `texts` 是它们唯一的必填入参。`texts=5` 在四处抛裸 `TypeError: object of type 'int' has no len()`、在 `local_hash` 抛 `TypeError: 'int' object is not iterable`;`texts="abc"` 更隐蔽——字符串是合法的 `Sequence`,被**静默**当成三条单字符文本向量化(`local_hash` 实测返回 3 条),调用方以为传了一条文本、实际入库三条垃圾向量。现新增 `require_text_list`(`src/providers/__base__/model_provider.py`),接入 `local_hash`/`google`/`openai_compatible`/`volcengine` 的同步与异步共 8 个入口。空列表**合法**——语义是「没有文档要向量化」,与 rerank 的「必须非空」有意不同。 + + +- **补齐 ETL 三个阶段的入参形态校验(第六轮对抗审查)**。``Pipeline.process`` → ``Cleaner.clean`` → ``Splitter.split`` 是建库主路径上相邻的三步(``KnowledgeBuildService._process_file`` 对知识库里的每个文件各调一次),三处此前都不判入参形态,实测: + + - ``Pipeline.process(5)`` → 裸 ``AttributeError: 'int' object has no attribute 'metadata'``(方法第一行就读 ``document.metadata``)。``"abc"``/``[5]``/``None`` 同理。``process`` 是 ``_process_file`` 的直接下游,异常落在 ``KnowledgeBuildService.build`` 按 ``ValueError`` 建立的分类之外。 + - ``BasicCleaner.clean(5)`` → 裸 ``TypeError: 'int' object is not iterable``;``clean("abc")``/``clean([5])``/``clean([None])`` → 裸 ``AttributeError: 'str' object has no attribute 'content'``。 + - ``RecursiveTextSplitter.split(5)`` → 裸 ``TypeError: object of type 'int' has no len()``(日志行里的 ``len(documents)``);``split("abc")``/``split([5])`` → 裸 ``AttributeError: 'str' object has no attribute 'metadata'``。``split_hierarchical`` 是公开方法且同样暴露:``split_hierarchical(5)`` → ``TypeError: 'int' object is not iterable``。 + + 三处的 ``"abc"`` 尤其要紧:字符串是合法的 ``Sequence``,只判「可迭代」拦不住,它会被当成一串字符逐字处理。现三处共用同一判据(容器必须是 ``Sequence`` 且显式排除 ``str``/``bytes``/``bytearray``,条目必须是 ``Document`` 实例并带下标),分片器把判据抽成 ``_require_documents`` 供 ``split`` 与 ``split_hierarchical`` 共用——只守卫 ``split`` 会留下「直接调 ``split_hierarchical``」的口子。条目判据用 ``Document`` 实例而非鸭子类型:三个阶段后续都直接读它的 ``content``/``metadata``,而 ``Document`` 是 pydantic 模型(构造期已校验 ``content: str``),要求实例化才能进入能让错误定位到构造处而不是处理处。 + + +- **补齐配置写入入口的容器形态校验**:`SessionConfig.set_many` 与 `Chatbot.chat_config` setter 的标注是映射,但运行时不受约束,非映射取值一路走到 `.items()`/`set_many` 的迭代才炸。实测 `cfg.set_many(5)` 与 `bot.chat_config = 5` 都得到裸 `TypeError: 'int' object is not iterable`,而 `cfg.set_many("abc")` 更糟——字符串是合法的可迭代对象,它被拆成一串单字符字段名,抛出 `KeyError: "SessionConfig 没有字段 'a'。"`(`bot.chat_config = "abc"` 同理)。同一个「配置写错」的收场不该取决于写错的是标量还是映射:非法**字段名**早已给带指引的 `ValueError`,现在非法**容器**也是。两处都在任何写入之前判形态(`str`/`bytes`/`bytearray` 显式排除),`chat_config` setter 的既有原子性契约(`deepcopy` 副本上写入)保持不变。 + +- **补齐 `retrieval_method` 的取值收敛**:该字段声明为 `RetrievalMethod`,但 `SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫,嵌入方(或携带异常取值的 pickle 快照)可以直写成任何形态。三个取用点此前各自假设它是枚举成员: + + - **`RetrievalService.retrieve` 静默走错检索路径**(最要紧的一条):`retrieve` 用 `==` 判分支,而它是该函数里**唯一没有形态收敛**的字段——同一函数里 `top_k`、候选倍率、`score_threshold`、`rerank_enabled`、`hybrid_fusion_strategy` 都有对应的 `_coerce_*` 助手。实测 `cfg.retrieval_method = "hybrid"`/`None`/`5` 全部静默落进 `else` 分支,**退化成纯关键词检索**(记录调用可见只走了 `keyword_search`);`"语义"` 同理。用户看到的是「混合检索配好了但只召回关键词命中」,而不是一条配置错误。`"hybrid"` 这条尤其容易踩:它是 `"混合检索"` 的英文写法。 + - **`display_chat_config` 在启动横幅处终止会话**:该函数读 `.value`,而它在 `start_chat_session_async` 里于任何提示符**之前**被调用。实测直写 `5`/`None` 抛裸 `AttributeError: 'int' object has no attribute 'value'`——用户刚看到「知识快照加载成功」,会话随即终止。 + - **`config_menu.edit_retrieval_params` 读同一个 `.value`**,菜单根本渲染不出来。 + + 现新增共享助手 `require_retrieval_method`(`src/utils/config.py`,`RetrievalMethod` 的归属地),三个取用点与 `SessionConfig._validate_retrieval_method` 共用它——配置边界与运行期读取侧从此只有一处判据、一种消息。判据按**属性存在性**而不是具体类型(与同文件 `require_detail_attributes` 同一约定:使用方只要求取值提供 `.value`,任何具备它的对象都能工作,仓库既有测试就用 `SimpleNamespace(value="混合检索")` 作替身)。接受枚举成员与它的字符串取值(`config.toml` 的 `chat_retrieval_method` 走的就是后者);**不做**大小写/别名归一——静默接受 `"hybrid"` 等于把「配置写错了」变成「检索行为悄悄变了」。 + +- **补齐 `chat_temperature` 的取值收敛**:该字段有**完整**的校验器(`Settings.validate_chat_temperature` 与 `SessionConfig._validate_chat_temperature` 各一份,判据一致:拒 `bool`、非数值、非有限、负数、超出双精度的整数),但两个**读取点**都直读原属性,而 `SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫: + + - **`ChatService.generate_reply` 把非法取值静默送进请求**:`session_config.chat_temperature` 直接进 `CompletionRequest`,再原样成为 SDK 的 kwargs。实测 `"abc"`/`None`/`[0.5]`/`True`/`-1` 全部**静默通过**——而它们正是 `session_config["chat_temperature"] = ...` 会明确拒绝的取值。同一个字段「写得进就合法、直写就非法」两种口径;非法值要到服务端才暴露,用户看到的是接口返回的参数错误,而不是一条本地配置错误。 + - **`Chatbot.chat_async` 的兜底分支**(`chat_service` 缺失时)读 `self.session_config["chat_temperature"]`,同一个值、同一后果。 + + 现新增共享助手 `require_chat_temperature`(`src/utils/config.py`),两个读取点与 `SessionConfig._validate_chat_temperature` 共用它——与上一轮的 `require_retrieval_method` 同一模式。 + +- **修正 `_should_apply_score_threshold` 兜底分支的判据方向**:该方法的 docstring 写着「`fusion_strategy` 由调用方传入**已收敛**的取值:`retrieve` 已经校验过,这里再读一次原属性会绕过那道校验」,但它的**兜底分支**恰恰就在读原属性。判据是 `strategy == "rrf"`,而 `"RRF"`/`" rrf "`/`"Rrf"` 走的是 RRF 融合却不等于这个字面量——阈值过滤被错误地套用在排名倒数的量纲上(结果被静默清空),正是 `_coerce_fusion_strategy` 的 docstring 记录过的那个缺陷,只是留在了库调用方这条路径上。实测修复前 `hybrid_fusion_strategy="RRF"` 返回 `True`。同一处还把 `retrieval_method` 改为经 `require_retrieval_method` 收敛——与字面量比较对未知取值不会报错,而是静默判定「不是混合检索」。生产路径(`retrieve`)传的是收敛后的值,因此这条只在库调用方直接调本方法时可达,属契约一致性修复。 + +- **补齐模型配置展示路径的两类收敛**:`ModelDetail.provider` 声明为 `str`,但 `SessionConfig.llm_configurations`/`rerank_configurations` 的 `_VALIDATORS` 明确不登记(「按原样写入,由使用方负责其内部结构」),嵌入方可以往映射里放任意取值。`display_chat_config` 在 `start_chat_session_async` 里于任何提示符**之前**被调用,于是以下每一条都让会话在「知识快照加载成功」之后立刻终止: + + - **非字符串 `provider` 抛裸异常**:`display_chat_config` 拿它拼 `f"{provider.lower()}_api_key"`。实测 `provider=["openai"]` 更早一步——它在 `active_providers = {…provider}` 这个**集合**里就抛 `TypeError: unhashable type: 'list'`;`provider=None`/`5` 则抛 `AttributeError: 'NoneType' object has no attribute 'lower'`。两处判据(集合成员与 `.lower()`)都要收敛。现抽出共享助手 `require_provider_name`,`RetrievalService._get_rerank_provider` 里原本内联的同一判据改为复用它——两处不再各写一份。判据只要求「是字符串」,**不**查 `_provider_map` 白名单:白名单是工厂的职责,在展示层提前套它会让「配置里有个尚未支持的 provider」从「启动时列出配置」变成「启动时崩溃」。 + - **同一张表的裸 f-string 插值**:`model_name`、`active_llm_configuration`、`active_rerank_configuration` 是位数超限整数时抛 `ValueError: Exceeds the limit`。而**同一个函数**的 `top_k`/`score_threshold`/权重早已走 `_render_value`,注释还明确写着「本函数在任何提示符之前被调用,会话在启动横幅这一步就终止」——判据只做了一部分单元格。现四处统一经 `_render_value`(降级成量级描述,正常取值显示形态不变)。 + - 顺带把该函数里五处 `logger.debug(f"…{取值}")` 改成惰性 `%s` 参数:f-string 在 DEBUG 关闭时也**已经求值**,裸插值对同一取值照样抛。 + +- **补齐配置编辑器动态上界的规模字段收敛**:`config_menu._top_k_ceiling` 与 `_multiplier_ceiling` 读取另一个可直写的规模字段来算动态上界。修复前实测「`retrieval_candidate_multiplier` 被直写成 `"abc"`、用户在菜单里选择『调整 Top K』」时,`max()` 抛裸 `TypeError: '>' not supported between instances of 'str' and 'int'`;`top_k` 被直写成 `None` 时对称复现。配置编辑器在聊天主循环的 `try` 之外,内层只接 `KeyboardInterrupt`/`EOFError`,裸 `TypeError` 会以 traceback 终止整个会话。`retrieve` 对同一字段早有收敛判据,现将它上移为 `require_scale_field`(`src/utils/config.py`),服务层与 UI 共用;UI 不反向依赖服务层私有函数。非正整数继续按既有读取语义收敛为 1,`bool`/非整数/超上界取值给出带字段名的 `ValueError`。 + +- **补齐 `markup_text` 的模板形态校验**:该函数对**值**参数早已用 `_render_value` 完整收敛(位数超限的整数降级成量级描述),对占位符数量不符与 NUL 字符也都抛 `ValueError`;唯独**模板**参数没有形态判据,而函数体第一行就对它做成员测试(`if _MARKUP_PLACEHOLDER in template`)。实测修复前非字符串模板抛裸 `TypeError`,消息还随形态各不相同——`5`/`None` 是 `argument of type 'int' is not iterable`,`list`/`tuple`/`dict` 是 `expected string or bytes-like object`,`bytes` 是 `a bytes-like object is required`。三种消息都不指向「模板写错了」,也不是 `ValueError`:同一个「入参形态非法」的处置,同一个函数里两种收场。现统一收敛成带取值的 `ValueError`。 + + 可达性说明:44 个调用点里 43 个模板是字符串字面量,剩下 1 个(`scripts/embed_knowledge_base.py` 的 `mask_api_key(api_key_value)`)两个分支都返回字面量,因此本仓自带流程走不到——修的是**公开函数**的契约一致性。 + +- **补齐 Google 与 Ark 响应顶层容器的形态守卫**:两个 provider 的响应容器此前只写 `field(x, "candidates", []) or []` / `field(x, "output", []) or []`——那只挡住 `None` 与空值,挡不住其它形态。**可达性两者不同**,取决于各 SDK 的响应构造方式(详见第十四轮条目里的对照表):Ark 走 `construct_type`,**不执行 pydantic 校验**,因此线上可达;Google 走 `model_validate`,畸形响应在 SDK 层就被拒,因此那里的守卫只在测试替身下可达。原文把两者一并称为「实测走真实入口」是不准确的——测试打桩了 `_get_client`,恰好绕过了校验所在的那一层。 + + - Google `candidates=5` → 裸 `TypeError: 'int' object is not iterable`;`candidates={"0": {}}` → 更晚一步,在 `candidates[0]` 处抛裸 `KeyError: 0`。本文件有 **6 处**直接迭代 `candidates`(`_response_text`、`_extract_refusal`、`_extract_result` 两处、`_stream_events_from_chunk`、`_stream_chunk_has_terminal_event`),现全部经新增的 `_require_candidates` 收敛。 + - Ark Responses `output=5` → 裸 `TypeError`;Ark Chat `message.tool_calls=5` → 同一个裸 `TypeError`。现经新增的 `_require_ark_list` 收敛(6 处调用点)。 + + 这些异常都不是 `RuntimeError`——而两个 provider 的错误分类(以及调用方的 `except`)按 `RuntimeError` 写。同一个形态在 `openai_compatible._raise_for_chat_response_error` 对 `choices` 早已有显式守卫,三个 provider 的收场必须一致。`candidates` / `output` 缺失(`None`)的既有语义保持不变:流式路径的正常中间态依赖它。 + +- **补齐 OpenAI 兼容渠道响应容器的形态守卫,并厘清同类守卫在三个 SDK 上的可达性差异**:`OpenAICompatibleProvider` 有 **8 处**直接迭代响应容器(`output`、`message.tool_calls`、流式 `delta.tool_calls`),此前每处都只写 `field(x, ..., []) or []`——那只挡住 `None` 与空值。实测走真实入口:Chat 路径 `message.tool_calls=5` 与 Responses 路径 `output=5` 都抛裸 `TypeError: 'int' object is not iterable`,而调用方的错误分类按 `RuntimeError` 写。现新增 `_require_response_list`,判据与同文件 `_raise_for_chat_response_error` 对 `choices` 的守卫一致,8 处全部接入。这一条影响 `qwen`/`siliconflow`/`ollama`/`lm-studio`/`deepseek`/`grok` 等 7 个薄子类共用的路径。 + + **同时厘清一个此前被我说错的事实**:同一类守卫在三个 SDK 上**可达性不同**,因为响应构造方式不同—— + + | SDK | 响应构造 | 是否校验 | + |---|---|---| + | `google-genai` | `_from_response` → `cls.model_validate(response)` | **校验**,畸形容器在 SDK 层就被拒 | + | `openai` | `_process_response_data` → `construct_type`(`_strict_response_validation` 实测默认 `False`) | **不校验** | + | `volcenginesdkarkruntime` | 同样只有 `construct_type` | **不校验** | + + 因此上一轮(第十三轮)提交里的 Google 守卫**只在测试替身下可达**——那批用例打桩了 `_get_client`,恰好绕过了校验所在的那一层,而原文把 Google 与 Ark 一并称为「实测走真实入口」是不准确的。Ark 的那批是线上可达的。Google 守卫本身保留(契约一致性有价值,且它暴露了一个不真实的既有夹具),但定位已在代码注释、测试 docstring 与本条目里改正;另加两条**前提断言**(`test_google_sdk_validates_response_containers_so_the_guard_is_not_reachable_live` 与 `test_openai_sdk_does_not_validate_response_containers_by_default`)把这个差异固定在测试里——某个 SDK 版本改了构造方式就会变红。 + +- **补齐 `rerank_enabled` 读取侧的判据(此前只有写入侧)**:`SessionConfig._validate_rerank_enabled` 只认 `bool`(`tests/runtime/test_contracts.py` 里就有 `("rerank_enabled", "yes")` 这一行),但那只管**写入**。`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫,直写 `"false"`/`"no"`/`0` 之后,UI 的 **5 个读取点**走的是真值判断,与服务层的 `_coerce_rerank_enabled` 分成两套判据: + + - `display_chat_config`(`chat_table` 的状态格、Rerank 模型段落、API Key 段落的三处分支):实测直写 `"false"` 时配置表显示 **[bold green]「启用」** 并渲染出激活的 Rerank 模型,而 `RetrievalService.retrieve` 对同一个值直接抛 `ValueError: rerank_enabled 必须是布尔值`。把 rerank 写成 `"false"` 的人看到的是「已启用」,且界面不提示任何错误——**与配置意图相反**。 + - `config_menu` 的菜单项与 `questionary.confirm(default=...)`:同一取值下菜单显示「当前: 启用」,且 `confirm` 在用户直接回车时把 `default` **原样**返回(`prompt_toolkit` 的 `set_answer` 分支),于是回车得到字符串 `"false"` 并被写回配置——界面显示「是」,写进去的是字符串,服务层再报错。 + + 现新增共享助手 `require_rerank_enabled`(`src/utils/config.py`),与 `_validate_rerank_enabled`/`_coerce_rerank_enabled` 共用同一判据(只认 `bool`,**不**接受 `0`/`1`/`"yes"`——真值语义正是这条缺陷的根因)。服务层的 `_coerce_rerank_enabled` 改为复用它,两处不再各写一份消息。 + + **两处读取点的处置不同,这是有意的**:`display_chat_config` 与同一张表的 `retrieval_method` 一致,**直接拒绝**(它本就在启动横幅处对非法取值报错);`config_menu` 则降级——菜单由 `launch_config_editor` 经 `asyncio.to_thread` 调用,调用点只接 `(KeyboardInterrupt, EOFError)`,抛 `ValueError` 会终止整个会话,而菜单是用户唯一还能改配置的入口(`display_chat_config` 在上游已经拒绝)。因此菜单里非法取值显示「未知」、`confirm` 的默认项传 `None`(questionary 的合法默认,不预选),用户仍能进菜单把它改回布尔。 + +- **修正 `top_k` 在同一个函数里被重读**:`RetrievalService.retrieve` 开头就把 `session_config.top_k` 收敛进局部变量(`require_scale_field`),但两处下游**重新读原属性**: + + - `retrieve` 出口的切片 `ranked_results[: max(1, session_config.top_k)]`; + - `_rerank_if_needed` 的 `max(1, min(session_config.top_k, ...))`——该方法的 docstring 明确写着「供库调用方直接调本方法」,而库调用方**不经过** `retrieve` 开头那道收敛。 + + 该字段可被直写(`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫)。静态非法值会被开头那道收敛拦下,但 `retrieve` 是 async、中间有多处 `await`——**检索飞行中**改写属性就绕过了它。实测两条路径都抛裸 `TypeError: '>' not supported between instances of 'str' and 'int'`(库调用方路径是 `'<'`),而调用方的错误分类按 `ValueError` 写。 + + 修法与同函数里 `rerank_enabled` 的既有处置一致:出口切片改用**已收敛的局部变量**;`_rerank_if_needed` 新增 `top_k` 形参,由 `retrieve` 传入收敛后的值,`None` 时自行经 `require_scale_field` 收敛(供库调用方直接调用的场景)。两处都只保留一份判据。 + +- **补齐 `display_utils._require_model_detail` 的不可哈希键守卫**:该函数的 docstring 明确以「同一个 key 走 `ModelProviderFactory._get_model_detail` 得到 `ValueError`,错误分类本就该一致」为理由建立契约,但工厂侧有一条它没有的守卫——**成员判断前先收敛键形态**。`key not in mapping` 对**不可哈希**的取值抛裸 `TypeError: unhashable type: 'list'`,而本函数在 `start_chat_session_async` 里于任何提示符**之前**被调用:实测直写 `active_rerank_configuration = ["siliconflow"]` 时,用户刚看完启动横幅就收到裸 traceback,会话随即终止。可达路径与其它 `active_*` 字段同源(`Settings` 与 `SessionConfig` 都没有为它们登记 validator,普通属性也没有 `__setattr__` 守卫)。 + + 守卫**只收不可哈希这一类,不要求 key 是字符串**。初版写成「必须是字符串」,被既有用例 `test_display_chat_config_renders_values_beyond_the_conversion_limit` 挡下:该用例明确要求位数超限的**整数键**可渲染(可哈希的非字符串键在映射里能正常取到值,那是显示层的既有契约)。工厂侧要求字符串是因为它要拿 key 去建 provider,两处的接受面本就不同;一致的是**异常分类**。 + +- **补齐退出钩子的系统目录清单**:`cleanup_temp_files` 是 `atexit` 钩子,删除前用 `_reject_unsafe_cache_dir` 判断 `cache_path` 是否可安全 `rmtree`。清单原先只有 `/etc`/`/usr`/`/bin`/`/sbin`/`/var` 与三个 macOS 目录,**漏掉了同样属于系统级、且删除后果不可逆的一批**。实测以下路径全部被放行,退出钩子会把它们整个删掉: + + - `/tmp`(在 macOS 上解析成 `/private/tmp`)——整个系统临时目录; + - `/Users`——macOS 上**全部用户家目录的父目录**,`rmtree` 等于抹掉机器上每个账户的家目录;`/home` 在 Linux 上同理; + - `/opt`——本模块自己的 docstring 就把「安装在 `/opt`」列为场景,而它的父目录同样不该被删; + - `/dev`(设备节点)、`/proc`、`/sys`(内核虚拟文件系统)等。 + + 触发条件只是一个配置项:`config.toml` 里写 `cache_path = "/tmp"`(或 `/Users`/`/opt`)。判据补为**相等**,因此只挡系统根本身,卷内/目录内的正常缓存位置不受影响——`/tmp/myapp/cache`、`/opt/myapp/cache`、`/Volumes/MyDrive/cache`、`/Users/someone/Library/Caches/app` 全部继续放行(本函数从不过度拦截用户有意指定的缓存目录,见下方「目录名不是 `.cache` 时只警告不阻断」的既有取舍)。 + +- **补齐嵌套响应容器的形态守卫**:第十三/十四轮把**顶层**容器收敛了(Google 的 `candidates`、Ark 的 `output`/`tool_calls`、OpenAI 兼容的 `output`/`tool_calls`/`choices`),但**嵌套一层**的容器仍是 `field(x, ..., []) or []`——只挡 `None` 与空值,挡不住其它形态。实测全部抛裸 `TypeError: 'int' object is not iterable`,而各 provider 的错误分类按 `RuntimeError` 写。这是「判据只做了一半」的又一实例:同一个形态在顶层被拒、在下一层被放行。 + + - **Google**(5 处 `candidates[].content.parts`、4 处 Embedding `embeddings`):新增 `_require_parts` 与 `_require_embeddings`。可达性同 `_require_candidates`——google-genai 走 `model_validate`,畸形容器在 SDK 层就被拒,因此守的是替身场景与契约一致性。 + - **Ark**(3 处 `output[].content`、2 处 tokenization `data`):新增 `_require_ark_content`,`data` 复用既有的 `_require_ark_list`。**线上可达**(Ark SDK 走 `construct_type`,不校验)。 + - **Anthropic**(顶层 `content`):**线上可达**——实测 `anthropic` SDK 的 `_strict_response_validation` 默认 `False`,与 openai/Ark 同类。 + - **OpenAI 兼容**(3 处 `output[].content`,以及 Responses 路径的 `choices`):`choices` 这条尤其要紧——**Responses 路径没有别处替它把关**:`_raise_for_chat_response_error`(含 choices 形态守卫)只被 Chat 路径调用,而 `_invoke_responses` 走的是 `_raise_for_response_error`,后者只查 `error` 与 `status`。实测 Responses 响应带 `choices=5` 时抛裸 `TypeError`。 + + 共 19 处读取点接入守卫;`None` 与空值的既有语义保持不变(`None` 继续表示「该字段不存在」,正常中间态依赖它)。 + +### 测试 + +- 新增第十九轮的回归用例(20 个用例;回退源码后其中 21 个参数化实例变红):Google 嵌套 `parts`/`embeddings`、Ark 嵌套 `content`/tokenization `data`、OpenAI 兼容 Responses `choices` 与嵌套 `content`、Anthropic 顶层 `content` 六类读取点各对 `int`/`mapping`/`str` 三种非法形态的拒绝;以及三条「不误伤」反向守卫——Google `parts`、Ark 嵌套 `content`、Anthropic `content` 的正常响应照旧解析。 + +- 新增第十八轮的回归用例(20 个用例;回退源码后其中 16 个变红):16 个系统级根(`/tmp`/`/private/tmp`/`/Users`/`/home`/`/opt`/`/dev`/`/proc`/`/sys`/`/boot`/`/run`/`/srv`/`/mnt`/`/media`/`/root`/`/Volumes`/`/private`)必须拒绝;以及 4 个「不误伤」反向守卫——系统根**内部**的缓存目录(`/tmp/myapp/cache`/`/opt/myapp/cache`/`/Volumes/MyDrive/cache`/`/Users/someone/Library/Caches/app`)照常放行。 + +- 新增第十七轮的回归用例(12 个用例;回退源码后其中 4 个变红):三类不可哈希 key(`[1]`/`{"a": 1}`/`{1, 2}`)必须收敛成带指引的 `ValueError`;同一个 key 在展示层与工厂侧必须给出同一种异常分类;以及四组「不误伤」反向守卫——可哈希的缺失键(`"missing"`/`5`/`None`/超大整数)仍是原来的「未找到key」消息、位数超限的整数键仍能取到值、既有的渲染用例保持绿。 + +- 新增第十六轮的回归用例(5 个用例,回退源码后全部变红):库调用方直调 `_rerank_if_needed` 时四类非法 `top_k`(`"abc"`/`None`/`[1]`/`True`)必须收敛成带指引的 `ValueError`;以及检索飞行中把 `top_k` 直写成 `"abc"` 后,出口切片仍须正常返回(用已收敛的局部变量)。 + +- 新增第十五轮的回归用例(10 个用例;回退源码后其中 9 个变红,1 个「不误伤」反向守卫保持绿):六个非布尔取值(`"false"`/`"no"`/`"true"`/`0`/`1`/`None`)下 UI 与服务层必须给出同一种收场;配置表不得把 `"false"` 显示成「启用」(原始症状);菜单的标签与 `confirm` 默认项对非法取值降级为「未知」/`None`;以及真布尔取值照旧渲染「启用」/「禁用」的反向守卫。 + +- 新增第十四轮的回归用例(8 个用例;回退源码后其中 5 个变红):OpenAI 兼容渠道 Responses `output` 对三类非序列取值的拒绝、Chat `message.tool_calls` 的拒绝、流式 `delta.tool_calls` 的拒绝(走同一守卫)、正常响应(含工具调用)照旧解析;以及两条**可达性前提断言**——google-genai 在 SDK 层就拒绝畸形容器、openai 默认不校验。后两条在回退源码后仍为绿(它们断言的是 SDK 行为而非本仓行为),这正是它们的作用:把「哪个守卫线上可达」这件事从口头结论变成会随依赖升级而失效的测试。 + +- 新增第十三轮的回归用例(18 个用例;回退源码后其中 7 个变红,11 个「不误伤」反向守卫保持绿):Google `complete` 对三类非序列 `candidates`(`int`/`mapping`/`str`)的拒绝、流式两个读取点用同一守卫、`candidates=None` 的既有语义不变、正常响应照旧解析;Ark 两条协议分别对非序列 `output` 与 `tool_calls` 的拒绝,以及正常响应(含工具调用)照旧解析。用例走 `complete` 入口而不是直接调私有解析函数——「守卫加在哪个函数上」与「路径是否经过它」是两回事。但要注意**可达性两半不同**:Ark 的用例线上可达,Google 的打桩了 `_get_client`、绕过了 SDK 的 `model_validate`,因此只在替身场景下成立(第十四轮已把这条前提写成断言)。 + + 同时修正 `tests/providers/test_google_genai.py` 的一处夹具:它用未指定 `candidates` 的裸 `MagicMock`,而真 SDK 的字段类型是 `list[Candidate] | None`。裸 `MagicMock` 的属性是一个可迭代的空 mock,旧代码只做 `or []` 与迭代,这种不真实形态恰好能通过;形态守卫会把它拒掉。夹具已写成真实形状(显式 `candidates = []`)。 + +- 新增第十二轮的回归用例(12 个用例;回退源码后其中 7 个变红,5 个「不误伤」反向守卫保持绿):`markup_text` 对七类非字符串模板(`int`/`None`/`list`/`tuple`/`dict`/`bytes`/位数超限整数)的拒绝、四类合法模板(含零占位符与多占位符)照旧渲染,以及「形态守卫不得盖掉既有占位符判据」——模板是合法字符串但数量不符时仍须给出原来那条消息。 + +- 新增第十一轮的回归用例(16 个用例;回退源码后其中 11 个变红,5 个「不误伤」反向守卫保持绿):两个动态上界助手对字符串/`None`/列表/`bool`/位数超限整数的拒绝、两种方向的合法上界照旧工作、真实 `edit_retrieval_params` 入口在坏倍率下给出可读错误而不是终止,以及 `require_scale_field` 与服务层既有的 `max(1, ...)` 读取语义对齐。 + +- 新增第十轮的回归用例(15 个用例;回退源码后其中 9 个变红,6 个「不误伤」反向守卫保持绿):`display_chat_config` 对四类非字符串 provider(`list`/`dict`/`None`/`int`)的拒绝、Rerank 侧同一判据、三类位数超限取值(`model_name`/两个 `active_*_configuration`)**必须可渲染**而不是终止会话、合法配置照旧渲染,以及共享助手 `require_provider_name` 接受任意字符串(含尚未支持的 provider)并拒绝其余形态。位数超限那两条特意把激活名与配置键一并设成同一取值——它们同源,只设激活名会先撞上「未找到key」那条判据,测不到渲染路径。 + +- 新增第九轮的回归用例(18 个用例;回退源码后其中 11 个变红,7 个「不误伤」反向守卫保持绿):`generate_reply` 对六类不可用 `chat_temperature`(`str`/`None`/`list`/`bool`/负数/位数超限整数)的拒绝**且断言被拒前未发起请求**(只断言「抛了 ValueError」会让「先发请求再校验」的实现也通过)、五条合法取值照旧传给模型并收敛成 `float`、配置边界与运行期读取侧对同一取值给出同一种收场(这条盯「只修一边」);以及 RRF 融合在任何大小写/空白形态下都跳过阈值过滤、`weighted` 与非混合检索仍套用阈值、兜底分支对无法识别的策略报错而不是静默当成「非 RRF」。 + +- 新增第八轮的回归用例(19 个用例;回退源码后其中 12 个变红,7 个「不误伤」反向守卫保持绿):`retrieve` 对六类不可用 `retrieval_method`(英文别名/缩写/`None`/`int`/`list`/位数超限整数)的拒绝**且断言被拒前未发起任何一路检索**(只断言「抛了 ValueError」会让「先检索再校验」的实现也通过)、`display_chat_config` 对四类的拒绝、六条合法取值(枚举成员与字符串取值 × 三种模式)照旧走对分支、合法取值照旧渲染、配置边界与运行期读取侧对同一取值给出同一种收场(这条盯「只修一边」),以及共享助手对鸭子类型替身的接受与对取值形态不对时的拒绝。 + +- 新增第七轮的回归用例(14 个用例;回退源码后其中 10 个变红,4 个「不误伤」反向守卫保持绿):`SessionConfig.set_many` 对五类非映射容器(`int`/`None`/`str`/`bytes`/`list`)的拒绝、`Chatbot.chat_config` setter 对同样五类的拒绝、两条合法路径照旧工作(映射与 `SessionConfig` 实例),以及被拒时状态不被改了一半。 + +- 新增第六轮的回归用例(22 个用例;回退源码后其中 18 个变红,4 个「不误伤」反向守卫保持绿):``Pipeline.process`` 对四类非法入参的拒绝、``BasicCleaner.clean`` 对四类非法容器与三类非法条目的拒绝、``RecursiveTextSplitter.split`` 的同两组、``split_hierarchical`` 与 ``split`` 共用同一判据(这条专门盯「只守卫一个入口」),以及三个阶段合法路径照旧工作(含空列表与清洗结果)。 + + +- 新增第五轮的回归用例(9 个测试函数,覆盖 24 条 provider×方向路径;回退源码后其中 8 个变红,1 个「不误伤」反向守卫保持绿):四个 provider 的工具转换器对不可迭代取值的拒绝与对 `None`/空列表/合法工具的接受、`extract_system_prompt` 对四类非法容器与两类非法条目的拒绝、两个 rerank provider 同步与异步入口对非序列 `documents` 的拒绝、四个 embedding provider 同步与异步入口对四类非法 `texts` 的拒绝(含 `"abc"` 这条静默拆分的)、以及 `require_text_list` 报错消息带下标。 + + +- 新增 Anthropic `user`/`betas` 取值的回归用例(14 个;回退源码后其中 12 个变红):`user` 的四类非字符串形态、`betas` 的四类非列表形态(`int`/`bytes`/裸字符串/`Mapping`)、`betas` 元素的三类非字符串形态,以及三条「不误伤」的反向守卫(合法 `user` 与 `betas` 仍合并进 `anthropic-beta`、`betas` 单独出现时原样进 params 不注入 profile 头)。另有 `test_anthropic_betas_validated_without_user` 专门盯住「校验只加在 `_user_profile_fields` 里」这条漏网路径——`betas` 在 `user=None` 时不经过那个函数。测试的捕获 helper 同时装上 `create` 与 `count_tokens`,因为 `betas` 只被前者转发(`beta_count_tokens` 的白名单放行它但函数体不读)。 + + + +- 新增第三轮修复的回归用例(`tests/test_boundary_hardening_regressions.py` 第六节,16 个用例;回退源码后其中 13 个变红):`extra_headers` 的四类非字符串取值(`int`/`bytes`/`list`/`dict`)与 `None` 值剔除、`user` 已设置而 `extra_headers` 未提供、beta 计数入口同步与异步两条路径都用校验后的头、合法头(含既有 `anthropic-beta` 值)照旧保留并合并;以及 `embedding_model_detail` 对五类不可用取值(`None`/字符串/整数/缺 `model_name`/缺 `provider`)的拒绝与两条不误伤的接受路径,另有一条覆盖**零分块**建库路径(它跳过 embedding 调用、最晚暴露)。三条未变红的是「收紧不误伤」的反向守卫。beta 计数入口的用例整体替换 `_get_client`/`_get_aclient`(客户端是懒加载的,构造期 `_client` 为 `None`),沿用仓库既有协议适配测试的注入方式。 + +- 新增本轮全部修复的回归用例,逐项经红绿验证(回退源码后对应用例变红):发布工作流两条 6000 条目的大归档用例(分别盯住「凭证检查漏报」与「`tiktoken_cache` 误报」两个方向)、`get_relative_path` 的相对形态与超长路径不被截断、Excel 日志五类 XML 敌对码位的 `load_workbook` 回读(外加一条合法码位不被误伤的反向守卫与一条区分「XML 规范化 `\r`」和「被替换成 `\ufffd`」的用例)、嵌入 provider key 缺失时的 `ValueError`、四种畸形 `pyproject.toml` 形态的子进程降级、清洗器哨兵的一次扫描(断言复杂度量级而非绝对耗时)与边界无歧义、分片器的 `mode`/尺寸参数/分隔符长度三类构造期拒绝、`SessionConfig` 的字段名与属性直写兄弟值、清单写入侧的文本与计数字段、以及 `/config` 被拒后仍在会话内(含一条断言被拒消息经脱敏)。 +- 修复四处「修复没有测试覆盖」的假绿:对本轮九项修复做定向变异(逐项把源码改回修复前的形态,再跑对应测试),其中四项回退后测试**仍然全绿**——BM25 的「所有 token 都为空」守卫、`_parent_key` 的 `str()` 替换、`parents.pkl` 错误消息的 `safe_repr`、以及 `retrieval_candidate_multiplier` 的 `inf` 分支。前三项现补上直接触发该分支的用例(`[[]]`/`[[], []]`/混合形态、`pickle` 往返的 `parent_id = 10**4301`、`parents.pkl` 写入 `{10**4301: [1, 2]}`)。第四项暴露的是**两处独立防线**:`isfinite` 守卫(接住 `config.toml` 的 `inf`)与 `int()` 的 `OverflowError` 兜底(接住 `Decimal('Infinity')` 这类非 `float` 的非有限值),只变异其中一处时另一处仍然生效、测试自然全绿——现为两条路径各补一条用例并分别变异验证。回退后这四处全部变红。 + +- 修复 `test_dotenv_is_resolved_from_root_dir_not_cwd` 的假绿:原实现直接改写 `Settings.model_config`,断言比较的是测试自己注入的值,把 `env_file` 回退成相对路径后依然通过。现改用子进程伪造冻结环境,让 `Settings` 在类定义期走真实求值路径,回退修复时该测试会失败。 +- 修复发布脚本测试对构建机缓存与网络的隐式依赖:`prepare_runtime_layout` 与 `stage_bundle` 会触发 BPE 预置,其源路径取决于宿主机 `$TMPDIR` 或联网。现把预置从布局准备中拆出,并为需要它的用例提供自给夹具,使 `tests/test_release_scripts.py` 在干净 `TMPDIR` 且断网时仍全绿。 +- 新增 BPE 预置的正反用例:哈希正确时按 `tiktoken` 的查找键名落盘,内容损坏时拒绝打包。 +- 新增版本一致性与日志降级用例:产物自报版本必须与 `pyproject.toml` 一致(构建脚本据此断言),降级 warning 只发一次。 +- 新增冻结环境的分词缓存用例:包内缓存存在时 `TIKTOKEN_CACHE_DIR` 必须被指向它——即使用户环境里已有一个空串值(`tiktoken` 把空串视为禁用缓存),以及缺失时必须有警告。两条都走子进程伪造 `sys.frozen`,因为该分支在 import 期只求值一次,`monkeypatch` 触达不到。 +- 新增发布工作流的 shell 逻辑回归测试(`tests/test_release_workflow.py`):把步骤的 `run` 脚本从 YAML 里取出来,用合成归档在本地真跑一遍,覆盖凭证文件、缺失 `tiktoken_cache`、空归档集、`download-artifact` 的嵌套布局、zip 分支,以及校验和的落盘位置与「用户视角 `shasum -c` 能通过」。旧实现的单层 glob 会在这组用例下全部变红。 +- 新增退出清理的安全边界用例:`cache_path` 指向应用根目录(`.`、`./`、`data/..`、`..`)或应用运行时目录(`data`、`knowledge_base`、`data/logs`、`data/kb`)时必须拒绝删除,且端到端断言 `cleanup_temp_files()` 不会真的删掉 `.env` 与源文档;同时断言普通缓存目录(`.cache`、`data/cache`)不被误拒。 +- 新增日志清理的作用域用例:只删本应用命名的日志,`nginx_access_*.log`、`postgres_*.log`、`my_app_log_*.log` 等一律保留,未过期的本应用日志保留,非 `.log` 文件不参与。 +- 新增终端展示的凭证边界用例:`display_chat_config` 渲染出的文本不得含 API Key 或 Base URL 中内嵌的凭证,同时主机名必须保留(脱敏不能把 URL 整个抹掉)。 +- 新增活动快照标记文件的取值用例:`../` 逃逸、绝对路径、含分隔符与 `..` 的取值一律拒绝,本应用自己生成的 id 形态照常接受。 +- 新增快照 id 逃逸用例:`../outside`、`../../etc`、绝对路径、`a/../..`、`..` 在四个拼路径的方法上逐一断言拒绝,并端到端断言快照根目录之外的目录未被删除;同时断言生成 id 的正常流程照常走通。 +- 新增混合合并的键碰撞用例:前 64 字符相同的两篇文档必须各占一条,同一篇文档在两路结果里出现时仍须合并计分。 +- 新增权重边界的构造器用例:`nan`/`inf`/`-inf`/负数/大于 1 在 `SessionConfig(...)` 与 `HybridReranker` 上逐一断言拒绝,合法权重照常接受。 +- 新增构造器与 `__setitem__` 的一致性用例:对 11 组非法取值断言两条入口都拒绝,合法取值在两条入口上得到相同的配置。这类「只修了一条入口」的问题此前已发生过一次(`top_k` 上界),逐字段比对能防止后续新增字段时再漏一侧。 +- 新增候选规模上界的用例:`Settings` 与 `SessionConfig` 都必须拒绝「两个字段各自合法、乘积越界」的组合,恰好等于上界的组合必须接受;并断言配置编辑器给出的上界配上另一字段的当前值一定不会被 `SessionConfig` 拒绝(这类不一致会以裸 traceback 终止会话)。 +- 新增配置批量更新的原子性用例:任一字段非法时整批都不生效(原配置逐字段不变),合法批量更新整体生效。 +- 新增索引文件行数的用例:`semantic.index` / `lexical.index` 与 `chunks.pkl` 条数不一致时必须拒绝,一致时通过。同时修掉两处快照夹具:它们用占位字节写 `semantic.index`,而真实加载路径会 `faiss.read_index` 它——这类夹具构造出的快照本来就不可能被加载,等于绕过了被测行为。 +- 修掉向量有限性用例泄漏的 `RuntimeWarning`:`np.array(..., dtype=np.float32)` 对超出 float32 范围的值只发 warning 后静默溢出,那条用例正是要覆盖这个行为,现在显式断言该 warning 而不是让它混进测试输出——泄漏的 warning 会掩盖真正的新警告。 + +- 新增基础清洗器的哨兵碰撞用例:正文里出现占位串字面量时必须原样保留,整段由该串构成的文档不得被抹成空串。 +- 新增 `upsert_embeddings` 的失败原子性用例:维度不一致被拒后 `documents` 与 `embeddings` 行数必须仍然一致、`save_snapshot` 不会落盘不自洽状态;此前该方法没有任何直接测试覆盖。 + +- 新增富文本标记边界的回归用例:`markup_text` 对 10 类标记形态(`[/]`、`[/color]`、`[/if]`、`[b]…[/b]`、markdown 链接、`[url=…]`、反斜杠游程、尾部反斜杠)断言原样保留,同时断言模板自身的样式仍生效、值继承所在位置的样式、多占位符按序落位、模板与实参数量不符时显式报错。聊天、召回测试与 `main.py` 顶层错误出口各自新增端到端用例:文档正文/用户查询/异常消息含 `[/]` 时必须照常渲染(修复前这些用例全部变红)。红绿验证方式为把调用点回退成 f-string 拼接,三个路径各自复现 `MarkupError`。 +- 新增 `parents.pkl` 的校验用例:无法解析、顶层非映射(`None`/`int`/list/str)、父条目既非 dict 也非 str 四类必须拒绝,正常的 dict 与字符串形态照常通过。同时修掉 `tests/retrieval/vdb/test_factory.py` 里用空字节写 `parents.pkl` 的夹具——这类占位文件构造出的快照本来就不可能被加载,等于绕过了被测行为(与上一轮 `semantic.index` 的夹具同一问题)。 + +- 新增 Excel 日志的类型用例:以 `=` 开头的内容、查询与来源必须按文本落盘(断言 `data_type` 而不是只看 `value`——`load_workbook` 读回公式时 `value` 仍是原字符串,只看值无法区分),并断言内容逐字保留、不加前缀。修复前该用例在 `data_type` 上变红。 + +- 新增 manifest 的 TOML 往返用例:10 类取值(反斜杠、双引号、单引号、换行、制表符、控制字符、方括号、井号、等号、中文)在 `embedding_model` 上必须原样读回,并逐字段断言每个字符串字段都做了转义(不能只修被报告的那一个);同时断言计数字段仍是 TOML 整数而非带引号的字符串。此前 `to_toml` 没有任何直接测试覆盖——两个夹具只是调用它来搭快照,值都是安全的常量。 + +- 新增 `config.toml` 语法错的用例:必须转成带 `配置解析失败` 前缀的 `ValueError`,且消息保留行号。第二条断言同时查前缀与行号——tomllib 的原始消息里本来就有 `line 2`,只查行号的话未包装的裸异常也能通过,测试会因为错误的原因变绿。 + +- 新增 embedding 维度的上界用例:`local_hash` 在构造期拒绝超界 `model_name`(断言异常发生在构造而不是 embed——只在 embed 里判的话向量已经建好了),上界以内含边界照常可用,无数字后缀时仍回落默认维度;`EmbeddingService._as_float32_matrix` 另按同一常量断言 65537/10⁶/10⁸ 被拒、1/256/3072/65536 被接受。 + +- 新增文档契约的载入期用例:`page_content` 为 `None`/整数/列表、`metadata` 为列表/字符串/整数、条目本身不是映射共 7 类必须拒绝,合规文档照常载入且 `metadata` 缺省仍补成空 dict。同时新增展示层与 prompt 构造的形态用例:三个提取助手逐形态断言退回默认值,两个展示函数与 `_build_prompt` 对 9 类异常条目必须照常完成,并断言非字符串内容**不会**以 `内容: None` 的形式进入 prompt。`RetrievalService` 另断言 store 返回 `None`/映射/字符串列表/含 `None` 的列表时给出可读的 `TypeError`,而 `metadata` 非映射时检索照常完成。 + +- 新增 Rerank 入参形态的用例:内容为 `None`/列表/整数时必须收敛成空串,正常内容原样传入。 + +- 新增轮转备份清理的用例:三个直接构造的备份名断言被清理、未过期的备份断言保留,另有一条**走真实 `RotatingFileHandler`** 让 handler 自己轮转出备份、再断言清理规则覆盖真实产生的文件名——直接写 `.log.1` 这样的名字等于假设了备份形态,而这条用例断言的是 handler 的实际行为(若 `doRollover` 改变命名,它会失败)。变异验证:把模式退回 `$` 结尾后这两条变红。 +- 新增 PEM 脱敏的用例:六类私钥形态(RSA/通用/OpenSSH/EC/被截断无 END/嵌在异常消息里)断言正文不出现在脱敏结果里,并断言替换标记确实出现(只查正文不在的话,一个把整段文本删空的实现也能通过);另有一条走真实链路——把 PEM 当 options 键名让 `validate_secret_free_options` 拒绝,再经 `safe_exception_text` 断言展示给用户的消息里不含正文。反向守卫覆盖证书、公钥与普通文本,防止把 `CERTIFICATE`/`PUBLIC KEY` 一起脱敏掉(它们不是凭证,抹掉会让用户失去上下文)。变异验证:去掉 PEM 规则后这 7 条全部变红。 +- 新增 `HIDDEN_IMPORTS` 的一致性守卫(`tests/test_release_scripts.py`):正向断言注册表里每个 provider 模块都在清单里,反向断言清单里没有已不在注册表中的模块名(只查一个方向的话,注册表删掉一个 provider 后旧名字会一直留着,而 PyInstaller 对不存在的 hidden import 只警告——清单会慢慢失真)。第三条用例校验前两条的**前提**仍然成立:逐个用 `grep` 确认这些模块确实没有静态导入路径,前提不成立时那两条守的是一个不存在的问题。另新增自检守卫的用例:注入一个 `ImportError` 后必须报成带 `HIDDEN_IMPORTS` 指引的 `RuntimeError`,而不是让 `ImportError` 穿出去。变异验证:从清单里删掉一个模块、以及往清单里加一个不存在的模块,两条守卫各自变红。 +- 新增 `save_snapshot` 与 token 计数的用例:`metadata=None` 的条目必须能存快照,且断言与「metadata 缺键」这条既有路径**同值**(而不是断言某个具体数字——`document_count` 的口径是「distinct source」,没有 source 的条目会以 `None` 占一个桶;要紧的是两条路径得到同一结果)。`require_token_count` 对 11 类畸形取值(缺字段、非数值字符串、浮点字符串、位数超限的数字串、浮点数、`inf`、`nan`、列表、映射、布尔值、负数)断言报成带 provider 与字段名的 `RuntimeError`,并单列一条断言它**永不**抛出 `RuntimeError` 以外的类型——`int(float("inf"))` 抛的是 `OverflowError`,既不是 `ValueError` 也不是 `TypeError`,任何按后者写的处理都接不住。`Anthropic options.max_tokens` 对 `inf`/`-inf`/`nan`/`1e400` 断言得到「必须是正整数」消息,其余非法取值与合法取值一并覆盖。回退四处修复后这 20 条全部变红。 +- 新增快照元数据字段取值的用例:`manifest.toml` 的 `chunk_count` 写成非数值字符串/位数超限的数字串/整个字段缺失,以及 `stats.json` 的 `chunk_count` 写成非数值字符串/浮点字符串/列表/位数超限的数字串,四类 `stats.json` 形态用参数化覆盖。每条都同时断言「有重建指引」与「保留了原始异常类型」——只查指引的话,一个把任意异常都包成同一句话的实现也能通过;只查类型的话,未包装的裸异常也能通过。另断言合法 manifest 照常通过(收紧不误伤)。回退两处修复后这 7 条全部变红。另新增整数解析判据的用例(`tests/test_config.py`):`parse_decimal_int` 对超位数限制的十进制串、前导零超限串的拒绝,对合法字面量/非字面量/非字符串的归类,外加「本身永不抛异常」的兜底断言;`Settings` 侧超大整数字面量报成非 TOML 的带指引 `ValueError`。 + ## [1.4.0] - 2026-09-20 ### Provider 与协议 @@ -70,6 +1068,19 @@ ### 审查修复 +- **补回 `GoogleProvider.embed_query`/`aembed_query` 丢失的空结果兜底**:基类 `TextEmbeddingModel.embed_query` 先判 `if not vectors` 再取 `[0]`,空结果得到带指引的 `ValueError("Embedding provider 返回空结果。")`;`GoogleProvider` 为设 `task_type` 覆写了这两个方法,却把那条兜底一并丢掉,直接下标取 `[0]`——provider 返回空列表时抛裸 `IndexError: list index out of range`。空返回不是假想输入:`embed_documents` 的条数校验按 `len(texts)` 做,传空 `texts` 时 `len(values) == len(texts) == 0` 成立、方法正常返回 `[]`;网关截断响应或返回空 `embeddings` 字段也走到同一形态。`IndexError` 不是 `ValueError`/`RuntimeError`,会穿透调用方按这两类做的错误分类与日志脱敏,消息也与「provider 返回了什么」毫无关系。现按基类逐字对齐补回,`task_type` 覆写的理由保持不变。 +- **补齐父块索引写入侧的顶层形态守卫**:`register_parent_documents` 直接写 `for ... in parent_documents.items()`,非映射传进来时抛裸 `AttributeError: 'int' object has no attribute 'items'`;而载入侧的 `_normalize_documents` 对同一形态给的是带指引的 `ValueError: 知识快照不自洽:父块索引的顶层不是映射(int)…`。两者都是 `VectorStoreBase` 的公开契约方法,而 `_normalize_documents` 的注释本就写明「直接调它们的嵌入方不经过那道校验」——同一取舍只在一侧执行了。本仓自带流程到不了写入侧这条分支(`knowledge_build_service` 传的是 `dict`,且前面已用 `isinstance(..., dict)` 过滤),因此这是一致性修复而非崩溃修复。 +- **补齐向量载入侧的非有限值校验**:写入侧 `EmbeddingService._as_float32_matrix` 早就拒绝 NaN/inf 向量,理由也写在注释里(FAISS 把 NaN 向量照单收下,`ntotal` 正常增加,既不报错也不跳过),但两个载入入口 `FaissStore.load`(legacy pkl)与 `load_snapshot` 共用的 `_validate_embeddings_shape` 只判 `ndim`——同一份内容**写入侧拒绝、载入侧放行**。实测载入后 `semantic_search` 对任意查询都只返回好文档、坏文档的下标干脆不出现,用户看到的是「搜到了」而不是「这份快照坏了」;而 `upsert_embeddings` 是 `VectorStoreBase` 的公开契约方法,直接调它的嵌入方绕开 `EmbeddingService`,坏向量还会被 `save_snapshot` 落盘、由 `validate_snapshot_dir` 放行成活动快照。判据落在 `astype(np.float32)` **之后**:`1e300` 在 float64 里是有限的,cast 成 float32 才溢出成 `inf`——只对原数组调 `isfinite` 会让载入侧在这一点上再次与写入侧分叉。cast 只是校验用副本,失败时 store 仍保持原状。 +- **补齐配置编辑器模型菜单的容器形态校验**:`edit_model_params` 原先直接写 `list(chat_config["llm_configurations"].keys())`,取值不是映射时抛裸 `AttributeError: 'int' object has no attribute 'keys'`——不是 `ValueError`,会以裸 traceback 终止会话(主循环只捕获 `KeyboardInterrupt`/`EOFError`)。现经 `_configuration_keys` 收敛,判据与 `display_utils._require_model_detail` 一致。**只判容器、不判取值**:`display_chat_config` 在上游读值时会直接拒绝形态坏掉的配置项,菜单是用户唯一还能把激活项切走的入口,按取值判会让配置写坏后彻底没有修复路径——这条取舍由 `test_edit_model_params_lists_unusable_details_without_reading_them` 锁住。 +- **补齐配置映射「取值」与「容器」两处形态校验**(同一缺陷类,`llm_configurations`/`embedding_configurations`/`rerank_configurations` 三个角色同源): + - **取值不可用**:`key in mapping` 只证明**键**在,不证明**值**可用。`{"k": None}` 的键确实存在,于是 `ModelProviderFactory._get_model_detail` 不会走「未找到key」那条分支,而是把 `None` 原样返回给调用方;三个入口紧接着都读 `config.provider`,在那里抛裸 `AttributeError: 'NoneType' object has no attribute 'provider'`——它不是 `ValueError`,穿透 `Chatbot.reload_llm` 的 `except ValueError` 分类。取值来源是 `SessionConfig.llm_configurations` 等可直写映射(`_VALIDATORS` 明确不登记这几个字段)。现新增 `require_detail_attributes` 统一收敛,三处调用点(工厂、`RetrievalService._get_rerank_provider`、`display_utils._require_model_detail`)共用。**判据用属性存在性而不是 `isinstance(detail, ModelDetail)`**:使用方只要求取值提供这几个属性,按具体类型收窄会把仓库里用 `SimpleNamespace` 构造的等价配置一并拒掉(实测一次按类型收窄让 17 个既有用例变红)。 + - **容器不可迭代**:`_get_model_detail` 直接写 `provider_key not in configurations`。取值不可迭代时抛裸 `TypeError: argument of type 'int' is not iterable`;是字符串时走**子串**判定(`"k" in "k"` 为真),随后 `"k"["k"]` 抛 `TypeError: string indices must be integers`。两者都不是 `ValueError`。取值经 `Chatbot.reload_llm` 从 `session_config.llm_configurations` 原样传到这里。 +- **修复 `RetrievalService._get_rerank_provider` 对 rerank 缓存键两个分量的形态假设**: + - `provider`/`model_name` 此前只经 `getattr(..., "")` 取值,有默认值但**没有类型判据**。`ModelDetail` 是 pydantic 模型、**构造**时会拒绝非字符串,但普通属性没有赋值守卫(`validate_assignment` 未开)——实测 `detail.provider = ["p"]` 与 `detail.model_name = ["m"]` 都直接成功。两者都进 `cache_key` 元组,在 `self._rerank_providers.get(...)` 处抛裸 `TypeError: unhashable type: 'list'`。 + - 属性被 `del` 掉时(实测 `del detail.provider` 成功)`getattr` 兜出的空串会把「属性没了」伪装成「provider 名为空」,一路带到工厂再抛 `AttributeError`。 + - 新增 `_is_hashable`:判据用**可哈希**而不是 `isinstance(..., str)`,非字符串但可哈希的取值(整数、元组)本来就能正常工作,按类型收窄会误伤;位数超限的整数是可哈希的,不必在此拦截。 +- **修复注释里的 `type:` 被 mypy 当成类型注释**:`# type: 'list'``;` 这类行在 Python 解析器看来是普通注释(`ast.parse`/`ruff`/`compileall` 全部通过、测试全绿),但 mypy 会把紧跟缩进后的 `type:` 识别为**类型注释**(PEP 484 风格)并去解析其后的内容,于是报 `Invalid syntax` 且**阻断整个项目的类型检查**(`errors prevented further checking`)。本次是写注释时踩到的,改动只是把该行拆成两句。记录在此是因为该类行的失败模式很隐蔽:语法错误只在 mypy 出现,其余工具全绿。 + - 恢复 `src/etl/splitters/recursive_text_splitter.py` 的 Dify 移植声明头。该文件在引入 tiktoken 与层级分片的重写中被误删头部,此后 `sync dify rag hierarchical retrieval` 又向这个已无头的文件移植了父子分片逻辑,导致文件含 Dify 衍生代码却没有版权与许可证声明,与 `AGENTS.md` 的合规要求及 Apache 2.0 第 4(b) 条不符。 - Responses 错误出口补齐脱敏:`_raise_for_response_error` 此前把服务端消息直接拼进异常,而同一适配器的 Chat Completions 出口已经过 `redact_sensitive_text`。服务端错误常回显请求头或 URL,异常文本会进日志与终端,是凭证最容易泄漏的出口,现两个出口行为一致。 - 修复召回测试 Excel 记录的落盘位置:`ExcelLogger` 硬编码相对路径 `data/logs`,而文本日志使用经 `ROOT_DIR` 解析的 `settings.log_path`。相对路径基于 CWD,从仓库外运行程序时两者会分叉,Excel 记录落到当前工作目录。现默认复用 `settings.log_path`。 @@ -104,7 +1115,7 @@ - 新增 12 例布尔短路测试,覆盖 `log_retention_days`、`kb_chunk_size`、`kb_chunk_overlap`、`chat_top_k`、`retrieval_candidate_multiplier`、`chat_vector_weight`、`chat_score_threshold`、`chat_temperature` 等字段拒绝 `True`。 - 修复分片在「分隔符耗尽」时完全失效:LangChain 的 `RecursiveCharacterTextSplitter._split_text` 走到 `if not new_separators: final_chunks.append(s)` 是原样追加、不再按 `chunk_size` 切,而默认配置 `kb_splitter_separators = ["###"]` 只有一项匹配项、`new_separators` 直接为空,于是**任何不含 `###` 的文档整篇变成一个块**。实测 `chunk_size=1500` 时 3600 token 的纯列表文本产出单个 3599 token 块;对真实知识库 39 个文件扫描,34 个产出超限块(子块上限 300,实测最坏 4595)。现让分隔符表统一以 `""` 收尾,兜底只在前面所有分隔符都不匹配时生效——含 `###` 的文档切分结果不变(实测 3 块/最大 252 字符)。 -- 修复分片开头标点被直接删除:`_strip_leading_punctuation` 用 `re.sub(r"^[\s.。]+", "", text)` 删除开头连续的空白与句号。当分隔符本身就是句号时,`keep_separator=True` 会把上一句的句号留在下一个分片开头,于是那个句号被永久丢弃(实测 843 字符分成 15 块后只剩 829,丢失 1.66%);整篇以句号开头的输入更会被判空并 `continue`,产出 0 个块。现改为替换为等长空格,句中信息一字不丢。 +- 修复分片开头标点被直接删除:`_strip_leading_punctuation` 用 `re.sub(r"^[\s.。]+", "", text)` 删除开头连续的空白与句号。当分隔符本身就是句号时,`keep_separator=True` 会把上一句的句号留在下一个分片开头,于是那个句号被永久丢弃;整篇以句号开头的输入更会被判空并 `continue`,产出 0 个块。现改为**只**匹配空白(`re.sub(r"^\s+", ...)`),标点原样保留。**此条此前是假的**:上一版写的是「替换为等长空格」,被替换的字符集恰好等于紧随其后的 `strip()` 会删掉的字符集,替换出来的空格立刻又被删掉,与原始实现逐字符等价(实测 3 万条随机输入零差异,`"。" * 200` 依然产出 0 块,`separators=["。"]` 下依然丢 2.9% 的字符)。当时配套的测试也绕开了被测字符(`if char == "。": continue`),因此对缺陷零区分度。现断言改为「分片按序拼接等于原文」。 - 给知识快照的 pickle 加载补上来源边界:快照内的 `chunks.pkl`/`parents.pkl`/`lexical.index` 都是 pickle,反序列化即执行代码,而 `AGENTS.md` 要求「只从本项目生成的本地快照或明确受信的 legacy 文件加载」——此前代码不做任何来源校验,`load_snapshot(dir)` 接受任意目录、`load(path)` 接受任意路径、`FaissStore(file_path=...)` 还会隐式加载。现新增 `resolve_within()`/`ensure_trusted_source()`(两侧先 `resolve()` 再比归属,符号链接逃逸会被拦下;信任根为空时一律拒绝),并要求 `load_snapshot` 满足三件事:目录在信任根内、目录本身不是符号链接、`ACTIVE_SNAPSHOT` 标记指向的就是该目录。第三条是关键——仅「在 root 之下」不足以证明目录可用,未激活或半成品目录同样在 root 之下。 - 快照 manifest 补上 schema 版本准入:`load_manifest` 此前只 `str(data["schema_version"])` 解析、不比对,未知版本被静默接受并照着当前代码解释,而 v1→v2 调整过分块与 embedding 的落盘结构,这样会得到错误结果而不是报错。现版本不符即拒绝,消息给出快照版本与当前版本。版本判断放在 `load_manifest` 而非 `from_mapping`,后者保持纯解析。 - 快照完整性校验补上数据源自洽:`validate_snapshot_dir` 此前只查文件**存在性**。实测 `documents=3` 而 `embeddings=(2,4)`(`faiss_index.ntotal=2`)的 store 能正常落盘、通过校验、加载后零报错,之后 `semantic_search` 拿 `indices` 索引 `self.documents` 才越界。现在校验 chunks / embeddings / `stats.json` 三者行数一致,并把截断文件的解析失败(原先裸 `EOFError`)归类为「快照不自洽」。 diff --git a/README.md b/README.md index abdd0d4..f3933eb 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ │ ├── services/ # 应用服务层:知识库构建、检索、聊天 │ ├── ui/ # 交互界面:动态配置菜单与 Rich 渲染 │ └── utils/ # 基础设施:强类型配置 (Pydantic) 与日志系统 -├── scripts/ # 工具脚本:知识库构建、发布打包与说明提取 +├── scripts/ # 工具脚本:知识库构建、发布打包、说明提取与变异审计(scripts/audit/) ├── tests/ # 验证矩阵:覆盖核心组件的单元测试 └── data/ # 持久化层:向量索引文件与审计日志 ``` @@ -145,7 +145,31 @@ uv run python scripts/build_binary_release.py --target macos-x64 --validate 打包脚本不执行跨平台交叉编译,`--target` 必须与当前主机或 CI runner 的系统和 CPU 架构一致;例如 Intel macOS 使用 `macos-x64`,Apple Silicon macOS 使用 `macos-arm64`。不匹配时脚本会在清理产物前显式失败,避免生成错误架构的发布包。 发布工作流定义在 [`.github/workflows/release.yml`](./.github/workflows/release.yml),发布说明会从 [CHANGELOG.md](./CHANGELOG.md) 的对应版本节自动提取。 -二进制包内默认只包含空的 `knowledge_base/` 占位目录,使用时请自行放入 Markdown 源文档后再构建知识快照。 +二进制包解压后的目录结构如下,`PyRAG-Kit/` 子目录是程序自己的根目录(可执行文件所在位置), +所有相对路径(`knowledge_base/`、`data/`、`config.toml`、`.env`)都以它为准: + +``` +PyRAG-Kit-<版本>-<目标>/ +├── README.md # 本文件 +├── LICENSE / DIFY_LICENSE / licenses/ +└── PyRAG-Kit/ # 程序根目录 + ├── PyRAG-Kit # 可执行文件 + ├── _internal/ # 运行时依赖 + ├── config.toml.example # 配置模板(复制为同目录下的 config.toml) + ├── .env.example # 密钥模板(复制为同目录下的 .env) + ├── knowledge_base/ # 放置 Markdown 源文档 + ├── tiktoken_cache/ # 预置的分词编码,供离线首次运行使用 + └── data/ # 运行产物(快照、日志) +``` + +因此在二进制包中,`knowledge_base/` 指的是 `PyRAG-Kit/knowledge_base/`, +配置模板也在 `PyRAG-Kit/` 目录内,复制时请一并进入该目录: + +```bash +cd PyRAG-Kit +cp config.toml.example config.toml +cp .env.example .env +``` --- diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md index bd8b69b..d894ca0 100644 --- a/docs/user_guide/configuration.md +++ b/docs/user_guide/configuration.md @@ -73,6 +73,10 @@ OPENAI_API_BASE="https://api.openai.com/v1" DEFAULT_LLM_PROVIDER="openai" ``` +注意:`.env` 的 `DEFAULT_LLM_PROVIDER` 会覆盖 `config.toml` 的零凭证默认值, +未配置好对应渠道前照抄会让全部模型配置因密钥为空串在运行期失败—— +请先在 `config.toml` 中完成渠道定义再启用。 + `.env` 中的 `OPENAI_API_BASE` 会覆盖 `config.toml` 的 `openai_api_base`,适合把流量指向代理、中转或自建网关。 注意 `provider = "openai"` 的条目都会走这个 Base URL,因此指向网关时,`model_name` 必须是该网关实际提供的模型 ID。 diff --git a/docs/user_guide/llm-providers.md b/docs/user_guide/llm-providers.md index 8dad468..abc6c00 100644 --- a/docs/user_guide/llm-providers.md +++ b/docs/user_guide/llm-providers.md @@ -171,6 +171,11 @@ OPENAI_API_BASE="https://api.openai.com/v1" DEFAULT_LLM_PROVIDER="openai" ``` +注意:`.env` 的 `DEFAULT_LLM_PROVIDER` 会覆盖 `config.toml` 的零凭证默认值, +若尚未配置好对应渠道的模型定义与密钥,14 条模型配置会在运行期因密钥为空串 +而失败(`.env.example` 因此默认注释掉这一行)。照抄本示例前请先完成 +`config.toml` 中的 OpenAI 兼容渠道定义。 + 并在 `config.toml` 中保留对应模型定义: ```toml diff --git a/main.py b/main.py index f7d3b5a..c55d721 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import sys from contextlib import redirect_stderr, redirect_stdout from io import StringIO +from pathlib import Path import pyfiglet from prompt_toolkit import prompt @@ -12,6 +13,35 @@ os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1") +# 冻结运行时把 tiktoken 的缓存目录指向包内预置的 BPE 文件:tiktoken 在缓存 +# 未命中时会联网拉取编码文件(tiktoken/load.py),离线环境下首次构建知识库 +# 必然失败。构建脚本已把 cl100k_base 放进 ``/tiktoken_cache``。 +# +# 这里用赋值而非 ``setdefault``:tiktoken 的 ``read_file_cached`` 只看 +# ``TIKTOKEN_CACHE_DIR`` **是否存在**,不存在「先查该目录、再回落别处」的 +# 语义。用 setdefault 的话,环境里一旦已有该变量(CI、容器、用户显式设置), +# 包内预置的 BPE 就被完全旁路,离线运行退回联网并失败——预置形同虚设。 +# +# 覆盖是安全的:全项目只有 ``cl100k_base`` 一种编码(见 +# ``RecursiveTextSplitter.__init__`` 的默认值,无配置入口),而包内文件 +# 就是它的 BPE;命中缓存时 tiktoken 只读不写,因此只读安装同样可用。 +# 目录缺失时保持不设置,让 tiktoken 走自己的默认路径而不是被锁死在空目录; +# 但这是降级而非正常状态——包内文件是发布包唯一的离线兜底,缺了它就只能 +# 联网,而 tiktoken 的 ``requests.get(blobpath)`` **没有超时**(见 +# ``tiktoken/load.py`` 的 ``read_file``),网络黑洞下会长时间卡住而不是 +# 快速失败。因此这里显式告知,避免用户面对一个没有线索的挂起。 +if getattr(sys, "frozen", False): + _bundled_tiktoken_cache = Path(sys.executable).resolve().parent / "tiktoken_cache" + if _bundled_tiktoken_cache.is_dir(): + os.environ["TIKTOKEN_CACHE_DIR"] = str(_bundled_tiktoken_cache) + else: + print( + f"警告: 未找到包内的分词编码缓存 {_bundled_tiktoken_cache}," + "首次分片将尝试联网获取 cl100k_base;" + "若本机无法访问外网,请重新解压完整的发布包。", + file=sys.stderr, + ) + # --- 绝对导入 --- # 导入 cleanup 模块以注册 atexit 钩子 import src.utils.cleanup @@ -20,14 +50,70 @@ import src.utils.log_manager # 导入UI工具 -from src.ui.display_utils import CONSOLE_WIDTH +from src.models.document import Document +from src.ui.display_utils import CONSOLE_WIDTH, markup_text # 从新的配置模块导入 settings 实例 from src.utils.config import get_settings, resolve_app_root from src.utils.security import safe_exception_text console = Console() -VERSION = "1.4.0" # 程序版本 + + +def _resolve_version() -> str: + """返回程序版本号,优先取构建期写入的 ``VERSION.txt``。 + + 版本号有两处消费方:发布包的**文件名**来自 ``pyproject.toml``(构建脚本 + 读取),而横幅与自检输出来自本模块。两处各写一份时,只改一处会让包名与 + 自报版本不一致,而 ``--validate`` 只 grep ``smoke test ok``、不带版本号, + 构建照样通过。构建脚本因此把版本写进 ``VERSION.txt``,此处优先读取, + 使产物自报的版本与包名同源;源码运行时退回读 ``pyproject.toml``。 + """ + # 用 resolve_app_root() 而不是 __file__ 的父目录:冻结后 main.py 位于 + # ``_internal/``,而 VERSION.txt 与 pyproject.toml 都在应用根目录 + # (可执行文件旁),按 __file__ 找会两者皆空、静默退回 "0.0.0"。 + app_root = resolve_app_root() + + bundled = app_root / "VERSION.txt" + if bundled.is_file(): + # 读取必须兜住:本函数在**模块导入期**执行,早于 ``main()`` 的任何 + # ``try``,异常会直接终止进程。实测 VERSION.txt 编码损坏(例如被某个 + # 编辑器写成 UTF-16 或被截断)时 ``read_text`` 抛 ``UnicodeDecodeError``, + # 程序连横幅都打不出来就退出,exit code 1、消息是裸 traceback。 + # + # 这与本函数的设计意图相反:下面两级兜底(pyproject.toml → "0.0.0") + # 存在的意义正是「读不到就降级」,而读不到的原因不止「文件不存在」。 + try: + text = bundled.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + text = "" + if text: + return text + + pyproject = app_root / "pyproject.toml" + if pyproject.is_file(): + import tomllib + + try: + with pyproject.open("rb") as handle: + version = tomllib.load(handle)["project"]["version"] + except (OSError, KeyError, TypeError, ValueError, tomllib.TOMLDecodeError): + # ``TypeError`` 与 ``ValueError`` 都要接:``["project"]`` 与 + # ``["version"]`` 在下标形态不对时抛 ``TypeError`` + # (``project = "x"`` -> ``string indices must be integers``、 + # ``project = 5`` -> ``'int' object is not subscriptable``), + # 不是 ``KeyError``。本函数在**模块导入期**执行,漏出的异常会让 + # 进程以裸 traceback 退出,而两级兜底存在的意义正是「读不到就降级」。 + # ``ValueError`` 覆盖 ``tomllib`` 对超长整数等取值的拒绝。 + pass + else: + if isinstance(version, str) and version: + return version + + return "0.0.0" # 兜底:仅在既无 VERSION.txt 也无 pyproject.toml 时出现 + + +VERSION = _resolve_version() # ================================================================= @@ -141,9 +227,14 @@ def initialize_dependencies(): src.utils.log_manager.cleanup_old_logs() # 2. 使用从 settings 实例获取的缓存目录 - cache_dir = get_settings().cache_path - if not os.path.exists(cache_dir): - os.makedirs(cache_dir) + # + # 该目录是 ``jieba`` 的临时目录,属于可选优化:不可写时回落到系统临时 + # 目录即可,不该让程序无法启动。默认的 ``cache_path``(``.cache``)解析 + # 到应用根目录之下,安装在 ``/opt``、``/Applications``、 + # ``C:\Program Files`` 这类只读位置时 ``makedirs`` 会抛 + # ``PermissionError``——而本函数在 ``main()`` 的 ``try`` 之外,崩溃表现为 + # 裸 traceback。与 ``log_manager`` 的日志降级同理。 + cache_dir = _prepare_cache_dir(get_settings().cache_path) # 2. 预热jieba并完全抑制其所有启动日志 with redirect_stdout(StringIO()), redirect_stderr(StringIO()): @@ -157,15 +248,121 @@ def initialize_dependencies(): console.print("[dim]依赖项初始化完成。[/dim]") +def _prepare_cache_dir(configured_path: str) -> Path: + """返回可写的缓存目录,不可写时回落到系统临时目录。 + + 只影响 ``jieba`` 的临时文件位置;``settings.cache_path`` 本身不改写, + 以免影响退出时 ``cleanup_temp_files()`` 对用户配置路径的判断。 + """ + import tempfile + + candidate = Path(configured_path) + try: + candidate.mkdir(parents=True, exist_ok=True) + if os.access(candidate, os.W_OK): + return candidate + reason = "目录不可写" + except OSError as exc: + reason = str(exc) + + fallback = Path(tempfile.gettempdir()) / "pyrag-kit-cache" + try: + fallback.mkdir(parents=True, exist_ok=True) + except OSError as exc: + # 与全仓「面向用户的异常输出一律经 safe_exception_text」一致(脱敏 + + # 240 截断),不直接把异常对象交给 markup_text。 + console.print( + markup_text( + "[yellow]缓存目录不可用,且无法创建临时目录:{}[/yellow]", + safe_exception_text(exc), + ) + ) + else: + console.print( + markup_text( + "[yellow]缓存目录不可用({}:{}),本次运行改用临时目录 {}。[/yellow]", + configured_path, + reason, + fallback, + ) + ) + return fallback + + def run_smoke_test() -> int: - """执行非交互式启动自检。""" + """执行非交互式启动自检。 + + 自检必须真正触达发布包的关键路径,否则「构建通过、包却是坏的」会一路 + 漏到用户手里:PyInstaller 的静态分析看不见 ``pkgutil.iter_modules`` + 对 ``tiktoken_ext`` 命名空间包的扫描,漏收该插件时 + ``get_encoding("cl100k_base")`` 直到用户点击「知识库文档向量化」才会 + 抛 ``Unknown encoding``,而只打印路径的自检会照常通过。这里替换成 + 真实调用分片器的编码加载,把该类缺陷拦在构建阶段。 + + 同时执行 ``initialize_dependencies()``:它建缓存目录、预热 jieba,且 + 位于 ``main()`` 的 ``try`` 之外。先前只打印路径的自检走不到那里,于是 + 「只读安装下 ``cache_path`` 不可写导致启动崩溃」在自检里完全不可见—— + 验证入口绕开了故障路径,得到的是假通过。 + """ settings = get_settings() - console.print(f"PyRAG-Kit {VERSION} smoke test ok") - console.print(f"app_root={resolve_app_root()}") - console.print(f"log_path={settings.log_path}") + _assert_splitter_encoding_available() + _assert_provider_modules_importable() + initialize_dependencies() + # 路径与版本都可能含方括号(``log_path`` 是用户可配置项,安装目录名也 + # 由用户决定),与上面几处同理:走标记解析会被改写或抛错。 + console.print(markup_text("PyRAG-Kit {} smoke test ok", VERSION)) + console.print(markup_text("app_root={}", resolve_app_root())) + console.print(markup_text("log_path={}", settings.log_path)) return 0 +def _assert_provider_modules_importable() -> None: + """确认每个已注册的 provider 模块都能导入。 + + 十三个 provider 模块**没有任何静态导入路径**——它们只经 + ``importlib.import_module(provider_info["module"])`` 按运行时字符串加载 + (``src/providers/factory.py``)。PyInstaller 的静态分析看不见这一层, + 因此 ``scripts/build_binary_release.py`` 的 ``HIDDEN_IMPORTS`` 是它们在 + 冻结包里能否被找到的唯一依据。 + + 构建脚本的清单与实际注册表分处两个文件,各自演化;此前没有任何守卫把两者 + 绑在一起,也没有任何构建期检查会碰这些模块——自检只走分片器与日志路径, + 不实例化任何 provider。漏收一个模块的后果是:PyInstaller 不报错、 + ``--validate`` 通过,用户在那个渠道上才拿到 ``ModuleNotFoundError``。 + 这与 ``tiktoken_ext.openai_public`` 当初漏收是同一个失效模式。 + + 这里逐个 import(只导入模块,不实例化 provider,因此不需要任何凭证)。 + """ + import importlib + + from src.providers.factory import ModelProviderFactory + + for provider_name, info in sorted(ModelProviderFactory._provider_map.items()): + module_name = info["module"] + try: + importlib.import_module(module_name) + except ImportError as exc: + raise RuntimeError( + f"provider 模块无法导入: {provider_name} -> {module_name}。" + " 若这是在发布包自检中出现的,请检查构建脚本的 HIDDEN_IMPORTS " + "是否覆盖了该模块(PyInstaller 的静态分析看不见 importlib 加载)。" + ) from exc + + +def _assert_splitter_encoding_available() -> None: + """确认分片器依赖的 tiktoken 编码在本次运行环境中确实可用。 + + 走 ``RecursiveTextSplitter`` 的真实入口,而不是直接调 ``tiktoken``, + 这样插件发现、编码加载与分片器的构造顺序都会被覆盖到。 + """ + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + splitter = RecursiveTextSplitter() + chunks = splitter.split([Document(content="smoke 自检文本。" * 20)]) + if not chunks: + raise RuntimeError("分片器自检未产出任何分片。") + + def run_cli(argv: list[str] | None = None) -> int: """解析启动参数并执行对应入口。 @@ -179,7 +376,7 @@ def run_cli(argv: list[str] | None = None) -> int: return run_smoke_test() main() except Exception as exc: # noqa: BLE001 - top-level CLI boundary reports failures - console.print(f"[bold red]启动失败:[/bold red] {safe_exception_text(exc)}") + console.print(markup_text("[bold red]启动失败:[/bold red] {}", safe_exception_text(exc))) return 1 return 0 @@ -201,17 +398,27 @@ def main(): width=CONSOLE_WIDTH, ) ) - # 延迟加载和执行 + # 延迟加载和执行。 + # ``SystemExit`` 不能终止整个程序:``embed_knowledge_base`` 在用户 + # 取消确认时 ``sys.exit(0)``,而这里的 ``except Exception`` 接不住 + # 它(``SystemExit`` 继承 ``BaseException``)。捕获后回到主菜单, + # 让用户能继续别的选项——「取消」不等于「退出程序」。 from scripts.embed_knowledge_base import main as run_embedding_process - run_embedding_process() - console.print( - Panel( - "[bold green]向量化处理完成。[/bold green]", - border_style="green", - width=CONSOLE_WIDTH, + try: + run_embedding_process() + except SystemExit as exc: + if exc.code not in (0, None): + raise + console.print("[bold yellow]操作已取消。[/bold yellow]") + else: + console.print( + Panel( + "[bold green]向量化处理完成。[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) ) - ) elif choice == "2": console.print( Panel( @@ -259,7 +466,11 @@ def main(): console.print("\n[bold yellow]检测到中断信号,正在退出程序... 再见![/bold yellow]") sys.exit(0) except Exception as e: # noqa: BLE001 - top-level CLI boundary reports failures - console.print(f"\n[bold red]程序运行期间发生错误:[/bold red] {safe_exception_text(e)}") + console.print( + markup_text( + "\n[bold red]程序运行期间发生错误:[/bold red] {}", safe_exception_text(e) + ) + ) console.print("[bold red]请检查错误信息并重试。[/bold red]") diff --git a/scripts/audit/README.md b/scripts/audit/README.md new file mode 100644 index 0000000..4105d37 --- /dev/null +++ b/scripts/audit/README.md @@ -0,0 +1,60 @@ +# 变异审计工具 + +`scripts/audit/` 把一次性变异扫描收口成可复现的三步工具链: + +```bash +# 1. 枚举候选(路径相对仓库根,列是 UTF-8 字节偏移) +uv run python scripts/audit/list_sites.py --kinds eqneq src/ > /tmp/eqneq.txt + +# 2. 扫描:无 selector 时对每个变异跑全量套件,-x 让被杀变异快速结束 +# 启动时会先跑一次**未变异**基线套件;基线不绿(有预先存在的失败)会中止整轮, +# 否则红套件会把真存活点误判成 KILLED(假绿)。加 --no-baseline 跳过此门。 +uv run python scripts/audit/verify_sites.py \ + --worktrees /path/to/verify-wt,/path/to/verify2-wt \ + /tmp/eqneq.txt + +# 3. 定点红验证/回归比对 +# target|line|col|kind|file::test_name|KILLED +uv run python scripts/audit/verify_sites.py \ + --worktrees /path/to/verify-wt \ + scripts/audit/regressions/green38.txt +``` + +## 站点文件与判定 + +站点行格式为 `target|line|col|kind[|selector[|expected]]`。支持的 kind: + +- `drop`:删除 `not`; +- `notin`:`not in` ↔ `in`; +- `isnot`:`is not` ↔ `is`; +- `eqneq`:`==` ↔ `!=`; +- `cmpdir`:`<` ↔ `>`、`<=` ↔ `>=`(方向反转); +- `boolc`:`True` ↔ `False`(布尔字面量互换); +- `boolop`:`and` ↔ `or`(只翻每个 BoolOp 组的第一个运算符;站点键取第一个值 + 的结尾以区分链式 `a and b or c`,与 `arith` 同理。运算符夹缝用 tokenize 精确定位, + 注释里出现 `and`/`or` 文本不会被误翻)。判据陷阱:多项 `or` 链 + `A or B or C` 里若靠后的项(如 `not is_double_convertible(x)`)已完整覆盖 + `A`/`B` 为真的所有情形,翻 `A or B` 不会改变结果——这是等价变异,不是缺口。 + 存活点必须用**红验证**(注入变异 → 候选测试确实变红)确认,仅凭"某输入下 + 前两项会分叉"的推演会误报;`isinstance(x, bool) or not isinstance(x, (int, float))` + 这类两条件**互斥**(bool 是 int 子类)的 `or`,翻成 `and` 后恒假,看似真缺口, + 实际常被同一 `or` 链后项或下游校验兜住。 +- `arith`:`+` ↔ `-`、`*` ↔ `/`(加减互换、乘除互换;只收数值二元运算, + 字符串字面量参与的拼接不算位点,站点键取左操作数结尾以区分链式表达式)。 + AST 无法静态区分类型,因此 `Path / x` 这类**路径拼接除法**也会进位点——其 + 变异体(`Path * str`)在运行期抛 `TypeError` 而被杀,只让 KILLED 计数虚高, + 不产生假结论。 + +`expected` 可为 `KILLED`、`SURVIVED`、`MUTFAIL` 或 `COLLECT_ERROR`。验证器会在启动时同步每个已注册且干净的 worktree 到主仓 HEAD;先跑未变异**基线套件**,不绿即中止(`--no-baseline` 可跳过,自担风险);pytest 输出里出现 `No module named pytest`/`INTERNALERROR` 等基础设施故障标记时归为非判定(退出码 1 的「环境不可用」与「测试失败」同码);选择器预检按 AST 收集函数名(顶格 def、类方法、async def 都识别,旧 `^def` 正则只认顶格);pytest 退出码 4/5 不会被当成红测;源码行号漂移只在窗口内找到唯一最近节点时自动修正,否则明确报 `MUTFAIL`。每条变异都在 `finally` 中还原,结束时再次检查 worktree 清洁。 + +**读源码文本的测试不参与判定**:套件里有一批测试断言的是「源码本身长什么样」(夹具坐标核对、重复装饰符扫描、`importlib.import_module` 存在性等)。变异期间源码处于变异态,这类测试只要被扫到就必红,测的却不是被测行为——把它们算进「有没有测试失败」会让假 KILLED。实测 green38 夹具里 4 个 SURVIVED 位点落在 `test_green38_fixture_sites_resolve_at_head` 引用的节点上,因此全部被误报 KILLED。这些测试只在**未变异**的基线上跑(普通 pytest 与 `--selftest` 均不受影响)。 + +排除清单 `verify_sites._SOURCE_INSPECTING_TESTS` 的**完整性**由静态探测器把关:`detect_source_inspecting_tests()` 按「锚定仓库源码路径(字面量 `src`/`scripts`/`main.py`,或模块级 `__file__` 派生的全局)+ 读文件/解析/导入动作」两个条件**同时成立**来判定,`tests/scripts/test_audit_tooling.py::test_source_inspecting_registry_is_complete` 断言「探测器发现的一切都已登记」。这条反向核对堵的是历史盲区:清单只登记当下已知的自指测试,新增一个读源码的测试不会自己进清单,而 `--deselect` 对漏登记/写错的名字**静默通过**,假 KILLED 便静默复现。`--selftest` 里也含同样的核对与一组正反例。 + +位点不明确或基础设施出错时不会自动「猜成 KILLED」。先运行自检: + +```bash +uv run python scripts/audit/verify_sites.py --selftest +``` + +`regressions/green38.txt` 是已人工复核的验收夹具,不接入默认 pytest;它用于验证工具链本身的漂移、选择器、退出码和回归比对保护仍有效。**坐标已基线化到某个 HEAD**:源码继续演化会让坐标失效(报 MUTFAIL)——`tests/scripts/test_audit_tooling.py::test_green38_fixture_sites_resolve_at_head` 会把失效的位点列出来,届时按当轮源码重新定位或删除对应行。 diff --git a/scripts/audit/list_sites.py b/scripts/audit/list_sites.py new file mode 100644 index 0000000..5b41b41 --- /dev/null +++ b/scripts/audit/list_sites.py @@ -0,0 +1,66 @@ +"""枚举源码里的变异候选位点,输出 verify_sites.py 可消费的站点文件。 + +用法:: + + python list_sites.py [--root .] [--kinds drop,notin,isnot,eqneq] [路径 ...] + +输出每行 ``target|line|col|kind``:``target`` 相对 ``--root``(默认当前 +目录),``col`` 为 CPython AST 的 ``col_offset``(UTF-8 字节偏移)。 +路径参数缺省为 ``src``;``.py`` 文件与目录都可传入。 +""" + +import argparse +import ast +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from mutate import KINDS, iter_sites + + +def iter_python_files(root: pathlib.Path, paths: list[str]): + for raw in paths: + path = (root / raw).resolve() + if path.is_file() and path.suffix == ".py": + yield path + elif path.is_dir(): + yield from sorted(path.rglob("*.py")) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="枚举变异候选位点") + parser.add_argument("--root", default=".", help="输出路径的相对根(默认当前目录)") + parser.add_argument( + "--kinds", + default=",".join(KINDS), + help=f"逗号分隔的 kind 列表(默认全部:{','.join(KINDS)})", + ) + parser.add_argument("paths", nargs="*", default=["src"], help="要扫描的文件或目录(默认 src)") + args = parser.parse_args(argv[1:]) + + kinds = [k.strip() for k in args.kinds.split(",") if k.strip()] + unknown = sorted(set(kinds).difference(KINDS)) + if unknown: + print(f"未知 kind: {', '.join(unknown)};可用: {', '.join(KINDS)}", file=sys.stderr) + return 2 + + root = pathlib.Path(args.root).resolve() + rows: list[tuple[str, int, int, str]] = [] + for path in iter_python_files(root, args.paths): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError as exc: + print(f"跳过无法解析的文件 {path}: {exc}", file=sys.stderr) + continue + target = path.relative_to(root).as_posix() + for kind in kinds: + for lineno, col, _ in iter_sites(tree, kind): + rows.append((target, lineno, col, kind)) + for target, lineno, col, kind in sorted(rows): + print(f"{target}|{lineno}|{col}|{kind}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/audit/mutate.py b/scripts/audit/mutate.py new file mode 100644 index 0000000..3bd4e51 --- /dev/null +++ b/scripts/audit/mutate.py @@ -0,0 +1,287 @@ +"""守卫变异器:对 ``(行号, 列偏移)`` 唯一确定的 AST 节点施加语义相邻变异。 + +支持的 kind: + +* ``drop``:``not X`` → ``X``,删除 ``not`` 关键字本身(只删三个字符, + 不替换整个节点——``not (a and b)`` 的操作数跨度不含括号,整段替换会 + 产出语法错误)。 +* ``notin``:``x not in y`` → ``x in y``,只替换运算符区段。 +* ``isnot``:``x is not y`` → ``x is y``,只替换运算符区段。 +* ``eqneq``:``x == y`` → ``x != y``(反之亦然),只替换运算符区段; + 多操作数比较只翻转第一个 ``Eq``/``NotEq``。 +* ``cmpdir``:``x < y`` → ``x > y``、``x <= y`` → ``x >= y``(反之亦然), + 方向反转;多操作数比较只翻转第一个 ``Lt``/``LtE``/``Gt``/``GtE``。 +* ``boolc``:``True`` → ``False``(反之亦然),只替换布尔字面量本身;``1``/ + ``0``/``"True"`` 等取值不同型,不是本 kind 的位点。 +* ``boolop``:``a and b`` ↔ ``a or b``,只翻转**第一个**运算符;短路语义变了, + 但两侧取值表达式不变。站点键取**第一个值的结尾**——链式 ``a and b or c`` + 的两层 ``BoolOp`` 起点 ``col_offset`` 都指向 ``a``,用起点会碰撞而整片漏扫。 +* ``arith``:``x + y`` ↔ ``x - y``、``x * y`` ↔ ``x / y``(加减互换、乘除互换)。 + 只收**数值**二元运算:任一操作数是字符串字面量的 ``BinOp``(``"a" + "b"`` + 这类拼接)不是算术,其变异体在运行期抛 ``TypeError`` 而非改变计算,故排除。 + +坐标约定:``line`` 为 1 起始行号,``col`` 为 CPython AST 的 ``col_offset`` +(**UTF-8 字节**偏移)。旧版 ``mut_cmp.py`` 把 ``col_offset`` 当字符偏移 +用,含中文的行会错位——本模块统一走 ``_char_offset`` 换算。 + +用法:: + + python mutate.py <相对路径> <行号> <列偏移> + +退出码:0=已施加;2=未找到位点;3=变异体语法错误。 +""" + +import ast +import io +import pathlib +import re +import sys +import tokenize + +KINDS = ("drop", "notin", "isnot", "eqneq", "cmpdir", "boolc", "boolop", "arith") + +_OP_KINDS = { + "notin": (ast.NotIn,), + "isnot": (ast.IsNot,), + "eqneq": (ast.Eq, ast.NotEq), + "cmpdir": (ast.Lt, ast.LtE, ast.Gt, ast.GtE), +} + +# ``arith`` 的 BinOp kind 与区段正则。加减互换、乘除互换各归一组;``%``、 +# ``//``、``**`` 不在面内——它们的语义相邻变异(如 ``//``↔``/``)是另一组 +# 方向,且现存位点极少(``//`` 9 处、``%`` 6 处),收益不抵扫描成本。 +_ARITH_GROUP = { + "addsub": (ast.Add, ast.Sub), + "muldiv": (ast.Mult, ast.Div), +} +_ARITH_OPS = tuple({op for ops in _ARITH_GROUP.values() for op in ops}) +_ARITH_PATTERN = { + ast.Add: (r"\+", "-"), + ast.Sub: (r"-", "+"), + ast.Mult: (r"\*", "/"), + ast.Div: (r"(? int: + """把 ``(行号, UTF-8 字节列)`` 换算成全文**字符**偏移。""" + line_start = sum(len(x) for x in lines[: lineno - 1]) + line_text = lines[lineno - 1] + prefix_bytes = line_text.encode("utf-8")[:byte_col] + return line_start + len(prefix_bytes.decode("utf-8")) + + +def _is_arith_site(node: ast.BinOp) -> bool: + """``arith`` 的位点判据:组内运算符,且不是字符串字面量参与的表达式。""" + if not isinstance(node.op, _ARITH_OPS): + return False + for operand in (node.left, node.right): + if isinstance(operand, ast.Constant) and isinstance(operand.value, str): + return False + return True + + +def iter_sites(tree: ast.AST, kind: str): + """产出 ``(lineno, col_offset, node)``:全树中所有该 kind 的位点。""" + if kind == "drop": + for node in ast.walk(tree): + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + yield node.lineno, node.col_offset, node + return + if kind == "boolc": + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, bool): + yield node.lineno, node.col_offset, node + return + if kind == "boolop": + # 站点键取**第一个值的结尾**(同 ``arith`` 用左操作数结尾的理由): + # ``a and b or c`` 里两层 ``BoolOp`` 的起点 ``col_offset`` 都指向 ``a``。 + # 实测 src/ 735 个 BoolOp 位点该键零碰撞;即便未来碰撞,``find_site`` + # 也会因命中 2 个节点而抛 SiteNotFound(MUTFAIL),不会静默错位。 + for node in ast.walk(tree): + if isinstance(node, ast.BoolOp): + yield node.values[0].end_lineno, node.values[0].end_col_offset, node + return + if kind == "arith": + # 站点键取**左操作数的结尾**而不是 BinOp 的起点:链式表达式 + # ``a + b + c`` 会被解析成两层 BinOp,两层起点的 ``col_offset`` 都指向 + # 第一个 ``a``,用起点会让 ``find_site`` 命中两个节点而判 MUTFAIL、把 + # 链式位点整片漏掉。左操作数各自的结尾互不相同(左子树占据不相交的 + # 源码区间),因此是唯一键;``verify_sites`` 只把它当不透明坐标使用。 + for node in ast.walk(tree): + if isinstance(node, ast.BinOp) and _is_arith_site(node): + yield node.left.end_lineno, node.left.end_col_offset, node + return + wanted = _OP_KINDS[kind] + for node in ast.walk(tree): + if isinstance(node, ast.Compare) and any(isinstance(op, wanted) for op in node.ops): + yield node.lineno, node.col_offset, node + + +def find_site(tree: ast.AST, line: int, col: int, kind: str) -> ast.AST: + """精确定位唯一节点;未命中或重复命中抛 ``SiteNotFound``。""" + matches = [ + node + for lineno, col_offset, node in iter_sites(tree, kind) + if (lineno, col_offset) == (line, col) + ] + if len(matches) != 1: + raise SiteNotFound(f"{kind} 位点在 {line}:{col} 找到 {len(matches)} 个,期望 1") + return matches[0] + + +def find_nearest_site( + tree: ast.AST, line: int, col: int, kind: str, window: int +) -> tuple[int, int] | None: + """漂移定位:``±window`` 行内找同 kind 节点,**唯一最近**才返回坐标。 + + 平局时用列偏移消解:同距候选中若恰有一个 ``col_offset`` 与原列一致, + 取它(行号漂移而列未变是最常见的形态)。仍无法唯一确定则返回 + ``None``——宁可 MUTFAIL 也不能把变异施加到别的守卫上。 + """ + candidates = sorted( + (lineno, col_offset) + for lineno, col_offset, _ in iter_sites(tree, kind) + if abs(lineno - line) <= window + ) + if not candidates: + return None + best_distance = min(abs(lineno - line) for lineno, _ in candidates) + nearest = [(lineno, c) for lineno, c in candidates if abs(lineno - line) == best_distance] + if len(nearest) == 1: + return nearest[0] + col_matches = [(lineno, c) for lineno, c in nearest if c == col] + if len(col_matches) == 1: + return col_matches[0] + return None + + +def mutate_source(src: str, node: ast.AST, kind: str) -> str: + """返回对 ``node`` 施加 ``kind`` 变异后的全文(不写盘、不校验)。""" + lines = src.splitlines(keepends=True) + if kind == "boolc": + # ``Constant`` 带完整位置信息,直接按字面量跨度替换(这不是运算符 + # 节点,不需要区段正则)。字面量本身必须真的是 ``True``/``False``—— + # 否则是 ``1``/``0``/``"True"`` 之类取了别名的取值,不是本 kind 的位点。 + start = _char_offset(lines, node.lineno, node.col_offset) + end = _char_offset(lines, node.end_lineno, node.end_col_offset) + literal = src[start:end] + if literal not in ("True", "False"): + raise SiteNotFound(f"boolc 定位不符: {literal!r}") + replacement = "False" if literal == "True" else "True" + return src[:start] + replacement + src[end:] + + if kind == "drop": + start = _char_offset(lines, node.lineno, node.col_offset) + if src[start : start + 3] != "not": + raise SiteNotFound(f"drop 定位不符: {src[start : start + 3]!r}") + end = start + 3 + # 后面若是空格则一并删掉一个,避免留下双空格。 + if end < len(src) and src[end] == " ": + end += 1 + return src[:start] + src[end:] + + if kind not in _OP_KINDS and kind != "arith" and kind != "boolop": + raise ValueError(f"未知 kind: {kind}") + if kind == "boolop": + # 第一个值与第二个值之间的区段即运算符夹缝(可能跨行、夹注释与括号)。 + # 不能用正则找关键字——注释里出现 ``and``/``or`` 文本会先命中(实测 + # 形态:``a # keep and note\\n and b``),故用 tokenize 精确取第一个 + # AND/OR 的 NAME 令牌(注释是 COMMENT、字符串是 STRING,不会误配)。 + prev, nxt = node.values[0], node.values[1] + seg_start = _char_offset(lines, prev.end_lineno, prev.end_col_offset) + seg_end = _char_offset(lines, nxt.lineno, nxt.col_offset) + target = None + for token in tokenize.generate_tokens(io.StringIO(src).readline): + if token.type != tokenize.NAME or token.string not in ("and", "or"): + continue + start = _char_offset(lines, token.start[0], token.start[1]) + if seg_start <= start < seg_end: + target = (start, _char_offset(lines, token.end[0], token.end[1]), token.string) + break + if target is None: + raise SiteNotFound(f"boolop 区段内未找到 and/or: {src[seg_start:seg_end]!r}") + start, end, keyword = target + return src[:start] + ("or" if keyword == "and" else "and") + src[end:] + if kind == "arith": + # 运算符节点(ast.Add 等)不带位置信息,与 ``cmpdir`` 同构:先切出 + # 操作数之间的区段,再在区段内做带边界的正则替换。 + prev, nxt = node.left, node.right + seg_start = _char_offset(lines, prev.end_lineno, prev.end_col_offset) + seg_end = _char_offset(lines, nxt.lineno, nxt.col_offset) + segment = src[seg_start:seg_end] + pattern, replacement = _ARITH_PATTERN[type(node.op)] + new_segment, count = re.subn(pattern, replacement, segment, count=1) + if count != 1: + raise SiteNotFound(f"运算符区段内未找到 {pattern!r}: {segment!r}") + return src[:seg_start] + new_segment + src[seg_end:] + wanted = _OP_KINDS[kind] + index = next(i for i, op in enumerate(node.ops) if isinstance(op, wanted)) + prev = node.left if index == 0 else node.comparators[index - 1] + nxt = node.comparators[index] + seg_start = _char_offset(lines, prev.end_lineno, prev.end_col_offset) + seg_end = _char_offset(lines, nxt.lineno, nxt.col_offset) + segment = src[seg_start:seg_end] + if kind == "notin": + pattern, replacement = r"\bnot\s+in\b", "in" + elif kind == "isnot": + pattern, replacement = r"\bis\s+not\b", "is" + elif kind == "eqneq": + pattern, replacement = r"==|!=", "!=" if isinstance(node.ops[index], ast.Eq) else "==" + else: + # 运算符节点(ast.Lt 等)不带位置信息,只能靠区段正则;按最长优先 + # 排列,避免 ``<`` 先于 ``<=`` 命中而在 ``<=`` 里切出 ``> =``。 + pattern = r"<=|>=|<|>" + + def replacement(match: re.Match[str]) -> str: + return {"<": ">", "<=": ">=", ">": "<", ">=": "<="}[match.group(0)] + + new_segment, count = re.subn(pattern, replacement, segment, count=1) + if count != 1: + raise SiteNotFound(f"运算符区段内未找到 {pattern!r}: {segment!r}") + return src[:seg_start] + new_segment + src[seg_end:] + + +def apply_mutation(path: str | pathlib.Path, line: int, col: int, kind: str) -> str: + """定位、变异、语法校验并写盘;返回 kind。""" + file_path = pathlib.Path(path) + src = file_path.read_text(encoding="utf-8") + tree = ast.parse(src) + node = find_site(tree, line, col, kind) + new_src = mutate_source(src, node, kind) + # 变异必须产出合法 Python:语法错误会让 pytest 在收集期就失败, + # 被验证器记成「已杀死」——那是假阴性,会把真缺口藏起来。 + try: + compile(new_src, str(file_path), "exec") + except SyntaxError as exc: + raise MutantSyntaxError(str(exc)) from exc + file_path.write_text(new_src, encoding="utf-8") + return kind + + +def main(argv: list[str]) -> int: + if len(argv) != 5: + print(f"用法: {argv[0]} <相对路径> <行号> <列偏移> ", file=sys.stderr) + return 2 + path, line, col, kind = argv[1], int(argv[2]), int(argv[3]), argv[4] + try: + print(apply_mutation(path, line, col, kind)) + except SiteNotFound as exc: + print(f"SITE-NOT-FOUND {exc}", file=sys.stderr) + return 2 + except MutantSyntaxError as exc: + print(f"MUTANT-SYNTAX {exc}", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/audit/regressions/green38.txt b/scripts/audit/regressions/green38.txt new file mode 100644 index 0000000..01cb21d --- /dev/null +++ b/scripts/audit/regressions/green38.txt @@ -0,0 +1,65 @@ +# 否定变异扫描的 38 个存活位点(坐标已重新基线化到当前 HEAD)。 +# +# 历史教训:夹具最初记录在扫描当轮的源码位置上,随后源码持续演化(行号漂移、 +# 守卫随修复移动、死代码删除整段移位),到 HEAD 时 10 个位点已无法定位、 +# 10 个需要漂移才能命中——夹具静默失效却仍被按「字段可解析」放行。本版把 +# 每个坐标在 HEAD 上用 mutate.find_site 精确校验过(exact=38,无漂移)。 +# +# 源码再演化时本文件会再次失效:跑一次下面的命令,凡报 MUTFAIL 的行就是 +# 坐标漂移/守卫被移除,需按当轮源码重新定位或删除该行。 +# +# 用法(需要 1 个以上已注册且干净的 git worktree): +# uv run python scripts/audit/verify_sites.py --worktrees /path/wt scripts/audit/regressions/green38.txt +# +# 期望判定来自逐点人工复核(含本轮重新分诊,重跑时剔除自指元测试): +# 35 处 KILLED(红验证过的测试,含 google.py:571 与 openai_compatible.py:1391 +# 两处后补的 killer 选择器)、4 处 SURVIVED(确证的等价变异)。 +# +# 本轮分诊的两个教训(假判定点都出在这里): +# 1. 元测试 test_green38_fixture_sites_resolve_at_head 读工作区源码 AST——变异 +# 点落在它引用的节点上时它必红,导致 4 个 SURVIVED 全部假报 KILLED。它已随 +# 其余读源码的测试一起被 run_pytest 从判定中 --deselect(只在未变异源码上 +# 跑);因此 SURVIVED 现在是「剔除自指元测试后的全量套件存活」。 +# 2. 定点选择器与全量套件的结论可能相反(此前 openai_compatible.py:1153 记了 +# 一个能杀死它的全量 killer 却在定点下存活——实测换 +# test_chat_request_forwards_extra_headers 即稳定杀死)。换 killer 必须在 +# **定点模式**下重验,不能只看全量扫描的红。 +# SURVIVED 行验证无 selector 时的全量套件判定(同样剔除自指元测试)。 +src/providers/anthropic.py|1615|31|notin|tests/providers/test_guard_coverage_regressions.py::test_anthropic_async_stream_emits_completed_tool_call|KILLED +src/providers/anthropic.py|281|12|drop|tests/providers/test_guard_coverage_regressions.py::test_anthropic_supports_sampling_option_tracks_model_generation|KILLED +src/providers/anthropic.py|684|19|drop|tests/providers/test_guard_coverage_regressions.py::test_anthropic_tool_content_list_is_preserved|KILLED +src/providers/anthropic.py|841|71|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_beta_only_fields_rejected_without_beta|KILLED +src/providers/anthropic.py|972|19|isnot||SURVIVED +src/providers/anthropic.py|981|19|isnot|tests/providers/test_guard_coverage_regressions.py::test_anthropic_duplicate_sampling_field_with_extra_body|KILLED +src/ui/config_menu.py|249|15|isnot|tests/ui/test_config_menu.py::test_edit_retrieval_params_writes_rerank_toggle|KILLED +src/ui/config_menu.py|473|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED +src/retrieval_test/excel_logger.py|346|84|isnot|tests/retrieval_test/test_retrieval_cli.py::test_excel_log_drops_none_scores_from_all_scores_column|KILLED +src/providers/factory.py|356|16|isnot||SURVIVED +src/providers/google.py|1798|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED +src/providers/google.py|1800|61|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_accepts_valid_name|KILLED +src/providers/google.py|2576|19|drop|tests/providers/test_guard_coverage_regressions.py::test_google_stream_refusal_part_uses_text_fallback|KILLED +src/providers/google.py|2612|37|drop|tests/providers/test_guard_coverage_regressions.py::test_google_stream_candidate_refusal_emitted_once|KILLED +src/providers/google.py|306|19|notin|tests/providers/test_google_genai.py::test_google_provider_loads_credentials_file_and_forwards_it|KILLED +src/providers/google.py|3687|11|isnot|tests/providers/test_guard_coverage_regressions.py::test_google_async_validate_tuning_reward_forwards_composite_config|KILLED +src/providers/google.py|577|15|drop|tests/providers/test_guard_coverage_regressions.py::test_google_validate_http_options_keeps_blank_query_values|KILLED +src/providers/__base__/model_provider.py|1204|11|notin||SURVIVED +src/providers/__base__/model_provider.py|338|7|isnot||SURVIVED +src/providers/openai_compatible.py|1153|11|isnot|tests/providers/test_guard_coverage_regressions.py::test_chat_request_forwards_extra_headers|KILLED +src/providers/openai_compatible.py|1391|15|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_responses_none_max_output_tokens_not_forwarded|KILLED +src/providers/openai_compatible.py|1505|15|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_responses_official_endpoint_rejects_configured_extra_body_stop|KILLED +src/providers/openai_compatible.py|1587|28|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_responses_configured_top_logprobs_used_as_fallback|KILLED +src/providers/openai_compatible.py|2460|27|notin|tests/providers/test_guard_coverage_regressions.py::test_openai_responses_sync_emits_completed_before_later_events|KILLED +src/providers/openai_compatible.py|2516|31|notin|tests/providers/test_protocol_adapters.py::test_openai_responses_async_stream_deduplicates_tool_completion_events|KILLED +src/providers/openai_compatible.py|2550|27|notin|tests/providers/test_guard_coverage_regressions.py::test_openai_responses_async_emits_completed_before_later_events|KILLED +src/providers/openai_compatible.py|4828|19|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_async_update_vector_store_file_drops_none_extras|KILLED +src/providers/openai_compatible.py|5292|15|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_async_compact_responses_forwards_only_given_fields|KILLED +src/providers/openai_compatible.py|590|33|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_response_retrieve_kwargs_drops_none_values|KILLED +src/providers/openai_compatible.py|996|58|drop|tests/providers/test_guard_coverage_regressions.py::test_openai_compatible_merge_resource_kwargs_keeps_none_by_default|KILLED +src/providers/openai_compatible.py|996|75|isnot|tests/providers/test_guard_coverage_regressions.py::test_openai_compatible_merge_resource_kwargs_omits_none_when_requested|KILLED +src/utils/security.py|531|11|isnot|tests/providers/test_security_boundaries.py::test_secret_free_options_keeps_self_copied_sdk_model|KILLED +src/utils/security.py|712|49|drop|tests/providers/test_security_boundaries.py::test_secret_free_options_nested_non_string_key_reports_path|KILLED +src/providers/volcengine.py|1428|38|isnot|tests/providers/test_guard_coverage_regressions.py::test_ark_responses_native_kwargs_validate_extra_body_secrets|KILLED +src/providers/volcengine.py|1841|31|notin|tests/providers/test_guard_coverage_regressions.py::test_ark_responses_sync_deduplicates_tool_call_delivered_by_finish|KILLED +src/providers/volcengine.py|1876|27|notin|tests/providers/test_guard_coverage_regressions.py::test_ark_responses_sync_emits_completed_before_later_events|KILLED +src/providers/volcengine.py|1941|31|notin|tests/providers/test_guard_coverage_regressions.py::test_ark_responses_async_deduplicates_tool_call_delivered_by_finish|KILLED +src/providers/volcengine.py|1975|27|notin|tests/providers/test_guard_coverage_regressions.py::test_ark_responses_async_emits_completed_before_later_events|KILLED diff --git a/scripts/audit/verify_sites.py b/scripts/audit/verify_sites.py new file mode 100644 index 0000000..db1dfa0 --- /dev/null +++ b/scripts/audit/verify_sites.py @@ -0,0 +1,698 @@ +"""变异验证驱动:注入变异 → 跑 pytest → 按退出码分类判定。 + +站点文件每行一条,``#`` 注释与空行忽略,字段以 ``|`` 分隔:: + + target|line|col|kind[|selector[|expected]] + +* ``target``:相对仓库根的源码路径。 +* ``selector``:``文件::测试名``,缺省时跑全量套件。 +* ``expected``:``KILLED``/``SURVIVED``/``MUTFAIL``/``COLLECT_ERROR``, + 提供后进入**回归比对模式**:判定与 expected 不符即失败。 + +判定(只有 pytest 退出码 0/1 是判定,其余一律非判定):: + + pytest 0 → SURVIVED(变异存活,覆盖缺口) + pytest 1 → KILLED(变异被测试杀死) + pytest 4/5 → COLLECT_ERROR(选择器失效等,必须人工看) + pytest 2/3 → ERROR + 超时 → TIMEOUT + 位点未找到且漂移定位失败 → MUTFAIL + +防呆保证(每条对应一次实测翻车): + +1. 启动时校验每个 worktree 已注册且干净,并 ``checkout --detach`` 到主仓 + HEAD——陈旧提交/残留变异会让整轮判定失真。 +2. **基线对照**:先跑一次未变异的 pytest。基线不绿(有预先存在的失败)时, + 任何位点都不得进 KILLED/SURVIVED 判定——否则红套件会把真存活点判成 + KILLED(假绿),把真缺口藏起来。 +3. 位点未命中时在 ``--drift-window`` 行内做**唯一最近**漂移定位,命中则 + 在备注里打印 ``DRIFTED``;平局或无匹配 → MUTFAIL,绝不施加到别的守卫。 +4. 选择器先在 worktree 内预检文件与测试名存在——pytest 对不存在的测试名 + 返回退出码 4,历史上被 ``returncode != 0`` 误判成「已杀死」。 +5. 每站点 try/finally 还原源码;结束复查全部 worktree 清洁。 +6. 无 selector 的扫描默认 ``pytest -q -x``(被杀变异秒退);全量套件是 + 唯一判据,没有「子集先行」的两级语义。 + +用法:: + + python verify_sites.py --worktrees /path/wt1,/path/wt2 sites.txt + python verify_sites.py --selftest +""" + +import argparse +import ast +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile +import threading + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from mutate import ( + KINDS, + MutantSyntaxError, + SiteNotFound, + apply_mutation, + find_nearest_site, + find_site, +) + +PYTEST_TIMEOUT = 600 +DEFAULT_REPO = pathlib.Path(__file__).resolve().parents[2] + +VERDICT_KILLED = "KILLED" +VERDICT_SURVIVED = "SURVIVED" +VERDICT_MUTFAIL = "MUTFAIL" +VERDICT_COLLECT_ERROR = "COLLECT_ERROR" +VERDICT_ERROR = "ERROR" +VERDICT_TIMEOUT = "TIMEOUT" + +#: 可以写进站点文件 expected 列的判定;ERROR/TIMEOUT 是基础设施故障, +#: 不允许作为期望值。 +EXPECTED_VERDICTS = (VERDICT_KILLED, VERDICT_SURVIVED, VERDICT_MUTFAIL, VERDICT_COLLECT_ERROR) + +#: 读**工作区源码文本**(而非导入后行为)的测试节点。变异扫描期间源码处于变异 +#: 态,这类测试会因为「源码长什么样变了」而红,与被测行为无关——把它们算进 +#: 「有没有测试失败」会让假 KILLED。实测:green38 夹具里 4 个 SURVIVED 位点 +#: (``anthropic.py:972``、``factory.py:356``、``model_provider.py:1202``/ +#: ``336``)落在 ``test_green38_fixture_sites_resolve_at_head`` 引用的节点上, +#: 该元测试在变异下必红,于是 4 个存活点全部被误报 KILLED;去掉它后全量 4420 +#: passed(真·存活)。逐个剔除前要**实测**(同 deselect 跑一次,只剩该测试红才算 +#: 收进来):盲目的 ``--deselect`` 对不存在的 node id 是静默通过的,不构成保护。 +_SOURCE_INSPECTING_TESTS = ( + # 夹具坐标与源码 AST 的逐位点比对:变异点任一落进夹具引用的节点即必红。 + "tests/scripts/test_audit_tooling.py::test_green38_fixture_sites_resolve_at_head", + # 夹具文件本身(scripts/ 下的 .txt)在变异期间不变,这条其实不会变红——但 + # 判定标准是「读工作区文件而非导入后行为」,登记它无害;真正的保护是反向 + # 核对「探测器发现的一切都已登记」,见 test_source_inspecting_registry_is_complete。 + "tests/scripts/test_audit_tooling.py::test_green38_fixture_parses_with_expected_verdicts", + # 读全仓源码做 AST 级断言(重复装饰符、markup 入口、raw %-logging、 + # provider 的 importlib 隐藏导入、chat 的恢复分支形态):断言的是源码文本 + # 本身,任何变异都可能让它们变红,与变异点的实际被测行为无关。 + "tests/test_boundary_hardening_regressions.py::test_no_duplicate_decorators_in_source_tree", + "tests/test_boundary_hardening_regressions.py::test_config_branch_missing_update_method_raises_a_caught_type", + "tests/test_display_utils.py::test_rich_markup_entry_points_never_receive_raw_variables", + "tests/test_display_utils.py::test_untrusted_free_argument_allowlist_has_no_stale_entries", + "tests/test_release_scripts.py::test_registered_provider_modules_have_no_static_import_path", + "tests/providers/test_security_boundaries.py::test_user_facing_error_paths_do_not_print_raw_exceptions", + # 守卫自身:它读仓库源码(测试文件)来核对这份清单,因此也落在探测器里。 + # 纳入清单使「探测器发现的 ⊆ 清单」这一不变量闭合;它在普通 CI 运行里照常 + # 执行(扫描期间被 deselect 不影响它把关——静态不变量归 pytest 管,不归扫描)。 + "tests/scripts/test_audit_tooling.py::test_source_inspecting_tests_are_excluded_from_verdicts", +) + +#: 「读文件/解析/导入」的动作名(按属性名匹配)。与锚点同时出现才算自指测试。 +_SOURCE_READ_ATTRS = frozenset( + {"read_text", "read_bytes", "getsource", "getsourcefile", "glob", "rglob", "open"} +) +#: 同上,但按完整点号名匹配(``provider.parse`` 这类 SDK 方法不算读源码)。 +_SOURCE_READ_DOTTED = frozenset( + { + "ast.parse", + "importlib.import_module", + "tokenize.open", + "importlib.util.spec_from_file_location", + } +) +#: 指向仓库源码的字符串字面量;``Path("src").rglob(...)`` 与 ``"src/x.py"`` 都要覆盖。 +_SOURCE_PATH_LITERALS = ("src", "scripts", "main.py") + + +def _dotted_name(node: ast.AST) -> str: + """把 ``a.b.c`` 形态的表达式还原成点号名;其余返回空串。""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + base = _dotted_name(node.value) + return f"{base}.{node.attr}" if base else node.attr + return "" + + +def _mentions_source_path(node: ast.AST) -> bool: + """表达式里是否出现仓库源码路径(``__file__`` 派生或源码字面量)。""" + for inner in ast.walk(node): + if isinstance(inner, ast.Name) and inner.id == "__file__": + return True + if isinstance(inner, ast.Constant) and isinstance(inner.value, str): + value = inner.value + if value in _SOURCE_PATH_LITERALS or value.startswith(("src/", "scripts/")): + return True + return False + + +def _strip_docstrings(tree: ast.AST) -> None: + """原地去掉模块/类/函数的首条字符串——否则文档里提到 ``read_text`` 会误判。""" + for node in ast.walk(tree): + if not isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + body = node.body + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + node.body = body[1:] + + +def detect_source_inspecting_tests(root: pathlib.Path) -> list[str]: + """扫描 ``root/tests`` 下所有测试函数,返回「读仓库源码文本」的节点 id。 + + 判定要**锚点与读取动作同时出现**:只看 ``read_text`` 会把读临时目录产物 + (快照 pkl、tar 包)的正当行为测试也扫进来,那些测试正是变异的合法判据。 + 锚点有两种:字面量 ``src``/``scripts``/``main.py``,或模块级由 ``__file__`` + 派生的全局(如 ``REPO_ROOT = Path(__file__).resolve().parents[2]``)。 + + 这是 ``_SOURCE_INSPECTING_TESTS`` 的**完整性**保证:清单只登记了当下已知的 + 自指测试,新增一个读源码的测试不会自己进清单,``--deselect`` 对漏登记的 + 名字静默通过——假 KILLED 就会静默复现。由守卫测试断言 + 「本函数发现的一切 ⊆ 清单」来堵这个口子。 + """ + tests_dir = root / "tests" + if not tests_dir.is_dir(): + return [] + found: list[str] = [] + for path in sorted(tests_dir.rglob("*.py")): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, OSError): + continue + _strip_docstrings(tree) + anchor_globals = set() + for stmt in tree.body: + targets: list[ast.expr] = [] + if isinstance(stmt, ast.Assign): + targets = list(stmt.targets) + elif isinstance(stmt, ast.AnnAssign): + targets = [stmt.target] + if targets and stmt.value is not None and _mentions_source_path(stmt.value): + anchor_globals.update(t.id for t in targets if isinstance(t, ast.Name)) + relative = path.relative_to(root).as_posix() + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not node.name.startswith("test_"): + continue + anchored = reads = False + for inner in ast.walk(node): + if isinstance(inner, ast.Name) and inner.id in anchor_globals: + anchored = True + elif isinstance(inner, ast.Constant) and isinstance(inner.value, str): + value = inner.value + if value in _SOURCE_PATH_LITERALS or value.startswith(("src/", "scripts/")): + anchored = True + elif isinstance(inner, ast.Call): + func = inner.func + by_attr = isinstance(func, ast.Attribute) and func.attr in _SOURCE_READ_ATTRS + if by_attr or _dotted_name(func) in _SOURCE_READ_DOTTED: + reads = True + if anchored and reads: + found.append(f"{relative}::{node.name}") + return found + + +def classify_pytest_exit(returncode: int) -> str: + """把 pytest 退出码映射成判定;只有 0/1 是判定。""" + if returncode == 0: + return VERDICT_SURVIVED + if returncode == 1: + return VERDICT_KILLED + if returncode in (4, 5): + return VERDICT_COLLECT_ERROR + return VERDICT_ERROR + + +# pytest 因「解释器/依赖不可用」而非「测试失败」退出时,返回码往往是 1(与 +# KILLED 同码),只有输出能区分。没有这层嗅探时,``--python`` 指到一个没装 +# pytest 的解释器会让**整轮**位点全部误报 KILLED。 +_PYTEST_UNAVAILABLE_MARKERS = ( + "No module named pytest", + "No module named 'pytest'", + "ERROR: usage:", + "INTERNALERROR", + "attempted relative import", +) + + +def pytest_output_is_infrastructure_failure(output: str) -> bool: + """pytest 输出是否表明「基础设施不可用」而非「测试失败」。 + + 这类收场与「变异被杀死」退出码相同(1),必须先排除,否则整轮判定失真。 + """ + return any(marker in output for marker in _PYTEST_UNAVAILABLE_MARKERS) + + +def check_selector(worktree: pathlib.Path, selector: str) -> str | None: + """在 worktree 内预检 ``文件::测试名``;返回错误原因或 ``None``。""" + if "::" not in selector: + return f"选择器缺少 ``::``: {selector!r}" + file_part, _, test_name = selector.partition("::") + path = worktree / file_part + if not path.is_file(): + return f"选择器文件不存在: {file_part}" + source = path.read_text(encoding="utf-8") + # 不能用 ``^def name(``:该正则只匹配**顶格**函数,类内方法、任何缩进 def、 + # async def 全被误判「测试不存在」→ 假 COLLECT_ERROR,而 pytest 本可收集。 + # 按 AST 遍历 ``FunctionDef``/``AsyncFunctionDef`` 取名字,与 pytest 的 + # 实际收集方式一致。 + try: + tree = ast.parse(source) + except SyntaxError as exc: + return f"选择器文件语法错误: {file_part}: {exc}" + test_names = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + if test_name not in test_names: + return f"选择器测试不存在: {file_part} 中未找到 def {test_name}(" + return None + + +def parse_sites(text: str) -> list[dict]: + """解析站点文件;格式错误直接抛 ``ValueError``。""" + sites = [] + for number, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + fields = line.split("|") + if len(fields) not in (4, 5, 6): + raise ValueError(f"第 {number} 行字段数应为 4/5/6,实际 {len(fields)}: {raw!r}") + target, lineno, col, kind = fields[0], int(fields[1]), int(fields[2]), fields[3] + if not target: + raise ValueError(f"第 {number} 行 target 为空: {raw!r}") + if lineno < 1: + raise ValueError(f"第 {number} 行行号必须 ≥1,实际 {lineno}: {raw!r}") + if col < 0: + raise ValueError(f"第 {number} 行列偏移必须 ≥0,实际 {col}: {raw!r}") + if kind not in KINDS: + raise ValueError(f"第 {number} 行未知 kind {kind!r};可用: {', '.join(KINDS)}") + selector = fields[4] if len(fields) >= 5 else "" + expected = fields[5] if len(fields) == 6 else "" + if expected and expected not in EXPECTED_VERDICTS: + raise ValueError( + f"第 {number} 行未知 expected {expected!r};可用: {', '.join(EXPECTED_VERDICTS)}" + ) + sites.append( + { + "target": target, + "line": lineno, + "col": col, + "kind": kind, + "selector": selector, + "expected": expected, + } + ) + return sites + + +def prepare_worktree(repo: pathlib.Path, name: str, head: str) -> pathlib.Path: + """校验 worktree 已注册、不是主仓本身且干净,然后 detach 到 ``head``。""" + listing = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=repo, + capture_output=True, + text=True, + check=True, + ).stdout + registered = [ + pathlib.Path(line[len("worktree ") :]).resolve() + for line in listing.splitlines() + if line.startswith("worktree ") + ] + worktree = pathlib.Path(name).resolve() + if worktree not in registered: + raise RuntimeError(f"worktree 未注册于 {repo}: {name}") + if worktree == registered[0]: + raise RuntimeError(f"不能把主仓本身当 worktree: {name}") + dirty = subprocess.run( + ["git", "status", "--short"], cwd=worktree, capture_output=True, text=True, check=True + ).stdout.strip() + if dirty: + raise RuntimeError(f"worktree 不干净,先处理残留改动:\n{dirty}") + subprocess.run( + ["git", "checkout", "--detach", head], + cwd=worktree, + capture_output=True, + text=True, + check=True, + ) + return worktree + + +def run_pytest( + worktree: pathlib.Path, selector: str, python: str, first_failure: bool +) -> tuple[int, str]: + """跑 pytest;返回 ``(退出码, 输出末行)``。超时抛 ``subprocess.TimeoutExpired``。 + + 字节码缓存隔离:``==``↔``!=`` 这类变异不改变文件长度,若还原与再变异发生在 + 与上一次编译**同一秒**内,CPython 的 (mtime, size) 校验会把上一轮的 + ``__pycache__`` 当成有效缓存——pytest 实际执行的是上一轮的字节码,判定与 + 当前位点无关。每次运行把 pyc 写到独立前缀目录并清除,强制按源码重新编译。 + + **自指测试必须排除**:套件里有读**工作区源码文本**的元测试(校验夹具坐标 + 是否命中当前源码)。扫描时工作区正处于变异态,变异点一旦落在元测试引用的 + 节点上,元测试必红——它测的不是被测行为,而是「源码本身长什么样」。把它算进 + 「有没有测试失败」的判据,会让**任何**指向夹具所引用节点的存活变异被误报 + KILLED(实测 green38 夹具里 4 个 SURVIVED 位点因此全部假报 KILLED)。 + 这些测试对工具自身仍有价值,但只在**未变异**的源码上跑,见 + ``_SOURCE_INSPECTING_TESTS``。 + """ + args = [python, "-m", "pytest", "-q", "-p", "no:cacheprovider"] + if first_failure: + args.append("-x") + for node in _SOURCE_INSPECTING_TESTS: + args += ["--deselect", node] + if selector: + args.append(selector) + pycache_prefix = worktree / ".pycache-audit" + shutil.rmtree(pycache_prefix, ignore_errors=True) + env = {**os.environ, "PYTHONPYCACHEPREFIX": str(pycache_prefix)} + try: + proc = subprocess.run( + args, + cwd=worktree, + capture_output=True, + text=True, + errors="replace", + timeout=PYTEST_TIMEOUT, + check=False, + env=env, + ) + finally: + shutil.rmtree(pycache_prefix, ignore_errors=True) + lines = (proc.stdout + proc.stderr).strip().splitlines() + output = proc.stdout + proc.stderr + if pytest_output_is_infrastructure_failure(output): + # 退出码 1 在这里不代表「变异被杀死」——解释器/依赖不可用也是 1。 + # 返回一个非判定码(2)让上层归 ENVIRONMENT 而非 KILLED。 + return 2, (lines[-1] if lines else "pytest 基础设施不可用") + return proc.returncode, lines[-1] if lines else "" + + +def verify_site( + worktree: pathlib.Path, + site: dict, + python: str, + drift_window: int, + first_failure: bool, +) -> tuple[str, str]: + """在单个 worktree 上验证一个位点;返回 ``(判定, 备注)``。""" + target = site["target"] + if site["selector"]: + selector_error = check_selector(worktree, site["selector"]) + if selector_error: + return VERDICT_COLLECT_ERROR, selector_error + path = worktree / target + note = "" + try: + subprocess.run(["git", "checkout", "--", target], cwd=worktree, check=True) + try: + apply_mutation(path, site["line"], site["col"], site["kind"]) + except SiteNotFound as exc: + tree = ast.parse(path.read_text(encoding="utf-8")) + drifted = find_nearest_site(tree, site["line"], site["col"], site["kind"], drift_window) + if drifted is None: + return VERDICT_MUTFAIL, f"{exc};±{drift_window} 行内无唯一最近同 kind 节点" + note = f"DRIFTED {site['line']}:{site['col']}→{drifted[0]}:{drifted[1]}" + site["line"], site["col"] = drifted + apply_mutation(path, drifted[0], drifted[1], site["kind"]) + try: + returncode, tail = run_pytest(worktree, site["selector"], python, first_failure) + except subprocess.TimeoutExpired: + return VERDICT_TIMEOUT, f"pytest 超过 {PYTEST_TIMEOUT}s" + verdict = classify_pytest_exit(returncode) + detail = " | ".join(part for part in (note, tail) if part) + return verdict, detail + except (SiteNotFound, MutantSyntaxError) as exc: + return VERDICT_MUTFAIL, str(exc) + finally: + subprocess.run(["git", "checkout", "--", target], cwd=worktree, check=True) + + +def _catch(fn): + try: + fn() + except Exception as exc: # noqa: BLE001 + return exc + return None + + +def _probe_selector_ast() -> str | None: + """把一段含类方法与 async def 的源码写进临时文件,验证 ``check_selector`` + 的 AST 版本能同时识别这三类声明(旧的正则只认顶格 def)。""" + source = ( + "def test_plain():\n" + " pass\n" + "class TestX:\n" + " def test_method_inside_class(self):\n" + " pass\n" + "async def test_async_top_level():\n" + " pass\n" + ) + worktree = pathlib.Path(tempfile.mkdtemp()) + (worktree / "t.py").write_text(source, encoding="utf-8") + try: + return check_selector(worktree, "t.py::test_method_inside_class") or ( + check_selector(worktree, "t.py::test_async_top_level") + or check_selector(worktree, "t.py::test_plain") + ) + finally: + shutil.rmtree(worktree, ignore_errors=True) + + +def selftest() -> int: + """不触碰 worktree 的防呆自检。""" + failures = [] + + def check(name: str, condition: bool, detail: str = "") -> None: + if condition: + print(f" ok {name}") + else: + failures.append(name) + print(f" FAIL {name} {detail}") + + print("退出码分类:") + check("pytest 0 → SURVIVED", classify_pytest_exit(0) == VERDICT_SURVIVED) + check("pytest 1 → KILLED", classify_pytest_exit(1) == VERDICT_KILLED) + check("pytest 4 → COLLECT_ERROR", classify_pytest_exit(4) == VERDICT_COLLECT_ERROR) + check("pytest 5 → COLLECT_ERROR", classify_pytest_exit(5) == VERDICT_COLLECT_ERROR) + check("pytest 2 → ERROR", classify_pytest_exit(2) == VERDICT_ERROR) + check("pytest 3 → ERROR", classify_pytest_exit(3) == VERDICT_ERROR) + + print("选择器预检:") + good = ( + "tests/providers/test_security_boundaries.py" + "::test_secret_free_options_nested_non_string_key_reports_path" + ) + check("有效选择器通过", check_selector(DEFAULT_REPO, good) is None) + check("缺 :: 被拒", check_selector(DEFAULT_REPO, "tests/test_cleanup.py") is not None) + check("缺文件被拒", check_selector(DEFAULT_REPO, "tests/nope.py::test_x") is not None) + check( + "缺测试名被拒", + check_selector(DEFAULT_REPO, "tests/test_cleanup.py::test_no_such_test") is not None, + ) + # 旧实现用 ``^def name(`` 正则,只认顶格函数——类方法与 async def 被误判 + # 「测试不存在」。AST 遍历必须能识别这两类。 + check( + "类方法/缩进声明被识别", + _probe_selector_ast() is None, + ) + + print("站点文件解析:") + sites = parse_sites( + "# 注释\n\nsrc/a.py|10|4|drop\nsrc/b.py|20|8|eqneq|tests/t.py::test_x|KILLED\n" + ) + check("注释与空行被忽略", len(sites) == 2) + check( + "四字段行无 selector/expected", + sites[0]["selector"] == "" and sites[0]["expected"] == "", + ) + check( + "六字段行解析完整", + sites[1]["expected"] == "KILLED" and sites[1]["selector"] == "tests/t.py::test_x", + ) + check( + "未知 kind 被拒", isinstance(_catch(lambda: parse_sites("src/a.py|10|4|bogus")), ValueError) + ) + check( + "未知 expected 被拒", + isinstance(_catch(lambda: parse_sites("src/a.py|10|4|drop||NOPE")), ValueError), + ) + + print("漂移定位:") + tree = ast.parse("x = 1\nif a == b:\n pass\nif c != d:\n pass\n") + check("精确命中", find_site(tree, 2, 3, "eqneq") is not None) + check("未命中抛错", isinstance(_catch(lambda: find_site(tree, 99, 3, "eqneq")), SiteNotFound)) + check("唯一最近漂移", find_nearest_site(tree, 1, 3, "eqneq", 10) == (2, 3)) + check("平局返回 None", find_nearest_site(tree, 3, 3, "eqneq", 10) is None) + check("窗口外返回 None", find_nearest_site(tree, 1, 3, "eqneq", 0) is None) + tree_tie = ast.parse("x = 1\nif a == b:\n pass\n if c == d:\n pass\n") + check("同距同列消解平局", find_nearest_site(tree_tie, 3, 3, "eqneq", 10) == (2, 3)) + check("同距异列返回 None", find_nearest_site(tree_tie, 3, 9, "eqneq", 10) is None) + + print("自指测试探测器:") + detected = set(detect_source_inspecting_tests(DEFAULT_REPO)) + registered = set(_SOURCE_INSPECTING_TESTS) + # 探测器必须真能命中——一个恒返回空集的探测器会让下面的「⊆」检查变成空转。 + check( + "探测器在真实 tests/ 上非空", + len(detected) >= 5, + f"只发现 {len(detected)} 个,锚点/读取动作匹配可能已失效", + ) + check( + "探测器发现的都已在清单里", + detected <= registered, + f"未登记(新增自指测试会让假 KILLED 复现): {sorted(detected - registered)}", + ) + probe_root = pathlib.Path(tempfile.mkdtemp()) + try: + (probe_root / "tests").mkdir() + (probe_root / "tests" / "test_probe.py").write_text( + "import ast\n" + "import pathlib\n" + "\n" + "REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]\n" + "\n" + "\n" + "def test_reads_source():\n" + ' """文档里写 read_text 不算读取动作。"""\n' + " return ast.parse((REPO_ROOT / 'src' / 'a.py').read_text())\n" + "\n" + "\n" + "def test_reads_only_temp_artifacts(tmp_path):\n" + " return (tmp_path / 'chunks.pkl').read_bytes()\n", + encoding="utf-8", + ) + probe = set(detect_source_inspecting_tests(probe_root)) + check("命中读源码的测试", "tests/test_probe.py::test_reads_source" in probe) + check( + "放过只读临时产物的测试", + "tests/test_probe.py::test_reads_only_temp_artifacts" not in probe, + ) + finally: + shutil.rmtree(probe_root, ignore_errors=True) + + print(f"\nselftest 失败: {len(failures)}") + return 1 if failures else 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="变异验证驱动") + parser.add_argument("sites", nargs="?", help="站点文件路径") + parser.add_argument("--repo", default=str(DEFAULT_REPO), help="主仓路径(默认脚本所在仓库)") + parser.add_argument("--worktrees", default="", help="逗号分隔的 worktree 路径(并行分片)") + parser.add_argument("--python", default=sys.executable, help="跑 pytest 的解释器") + parser.add_argument("--drift-window", type=int, default=3, help="漂移定位的最大行距") + parser.add_argument("--no-first-failure", action="store_true", help="扫描时不加 -x") + parser.add_argument( + "--no-baseline", + action="store_true", + help="跳过基线对照(自担风险:红套件会把存活点误判成 KILLED)", + ) + parser.add_argument("--selftest", action="store_true", help="运行防呆自检后退出") + args = parser.parse_args(argv[1:]) + + if args.selftest: + return selftest() + if not args.sites: + parser.error("缺少站点文件(或用 --selftest)") + + repo = pathlib.Path(args.repo).resolve() + sites = parse_sites(pathlib.Path(args.sites).read_text(encoding="utf-8")) + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True + ).stdout.strip() + worktrees = [ + prepare_worktree(repo, name.strip(), head) + for name in args.worktrees.split(",") + if name.strip() + ] + if not worktrees: + raise RuntimeError("至少需要一个 --worktrees") + + # 基线对照:先跑一次**未变异**的套件。基线不绿时任何位点都不能进 + # KILLED/SURVIVED 判定——红套件会把真存活点判成 KILLED(假绿), + # 把真缺口藏起来。这是与「退出码分类」同等重要的前置门。 + if not args.no_baseline: + print("基线对照中(未变异全量套件)...", flush=True) + for worktree in worktrees: + try: + rc, tail = run_pytest(worktree, "", args.python, first_failure=False) + except subprocess.TimeoutExpired: + raise RuntimeError(f"基线 pytest 超时 {PYTEST_TIMEOUT}s:{worktree}") from None + if rc != 0: + raise RuntimeError( + f"基线套件不绿(退出码 {rc})于 {worktree}:{tail}\n" + " 预先存在的失败会让红套件把存活点误判成 KILLED。" + " 请先在干净 HEAD 上跑全量套件并修复,或加 --no-baseline 跳过(自担风险)。" + ) + print("基线绿。", flush=True) + + lock = threading.Lock() + results: list[tuple[dict, str, str]] = [] + problems: list[str] = [] + + def worker(worktree: pathlib.Path, jobs: list[dict]) -> None: + for site in jobs: + tag = f"{site['target']}:{site['line']}:{site['col']}:{site['kind']}" + try: + verdict, note = verify_site( + worktree, site, args.python, args.drift_window, not args.no_first_failure + ) + except Exception as exc: # noqa: BLE001 + verdict, note = VERDICT_ERROR, f"{type(exc).__name__}: {exc}" + with lock: + results.append((site, verdict, note)) + line = f"RESULT {verdict} {tag}" + if site["selector"]: + line += f" selector={site['selector']}" + if note: + line += f" | {note}" + print(line, flush=True) + if site["expected"]: + if verdict != site["expected"]: + problems.append(f"{tag}: expected {site['expected']},实际 {verdict}") + elif verdict not in (VERDICT_KILLED, VERDICT_SURVIVED): + problems.append(f"{tag}: 非判定结果 {verdict} | {note}") + + buckets: list[list[dict]] = [[] for _ in worktrees] + for index, site in enumerate(sites): + buckets[index % len(worktrees)].append(site) + threads = [ + threading.Thread(target=worker, args=(worktree, jobs), daemon=True) + for worktree, jobs in zip(worktrees, buckets) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + for worktree in worktrees: + dirty = subprocess.run( + ["git", "status", "--short"], cwd=worktree, capture_output=True, text=True, check=True + ).stdout.strip() + if dirty: + problems.append(f"worktree 残留改动 {worktree}:\n{dirty}") + + counts: dict[str, int] = {} + for _, verdict, _ in results: + counts[verdict] = counts.get(verdict, 0) + 1 + print( + f"\n汇总: {len(results)} 个位点 — " + + ", ".join(f"{key}={value}" for key, value in sorted(counts.items())) + ) + if problems: + print(f"\n需要处理 {len(problems)} 条:") + for item in problems: + print(f" {item}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/build_binary_release.py b/scripts/build_binary_release.py index 74f0f19..3caabf5 100755 --- a/scripts/build_binary_release.py +++ b/scripts/build_binary_release.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import hashlib import os import platform import shutil @@ -17,6 +18,8 @@ DIST_ROOT = PROJECT_ROOT / "dist" ARTIFACT_ROOT = PROJECT_ROOT / "release_artifacts" APP_NAME = "PyRAG-Kit" +TIKTOKEN_CACHE_DIRNAME = "tiktoken_cache" +CACHE_DIRNAME = ".cache" SPEC_PATH = PROJECT_ROOT / f"{APP_NAME}.spec" PACKAGE_FILES = [ "README.md", @@ -25,10 +28,20 @@ # Dify 衍生代码遵循修改后的 Apache 2.0,该许可证第 4(a) 条要求随分发 # 提供许可证副本;DIFY_LICENSE 只是引用它的摘要,不能替代全文。 "licenses/APACHE-2.0.txt", +] +# 必须与 app 读取配置的位置一致:``config.toml`` 与 ``.env`` 都由 +# ``ROOT_DIR`` 解析,冻结时即 ``bundle_root / APP_NAME``。放在包根会被静默 +# 忽略,用户复制示例后仍拿不到预期配置。 +APP_ROOT_FILES = [ "config.toml.example", ".env.example", ] HIDDEN_IMPORTS = [ + # tiktoken 用 pkgutil.iter_modules 在运行时扫描 tiktoken_ext 命名空间包来 + # 发现编码插件(tiktoken/registry.py:19-33),PyInstaller 的静态分析看不到 + # 这一步。漏掉它的后果是冻结包内 pkgutil.iter_modules 返回空列表, + # get_encoding("cl100k_base") 抛 "Unknown encoding",ETL 分片第一步即失败。 + "tiktoken_ext.openai_public", "src.providers.google", "src.providers.openai", "src.providers.anthropic", @@ -174,15 +187,36 @@ def stage_bundle(target: str, version: str) -> Path: # copy2 不会自动创建父目录。 destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(PROJECT_ROOT / relative_file, destination) + for relative_file in APP_ROOT_FILES: + destination = app_target / relative_file + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(PROJECT_ROOT / relative_file, destination) prepare_runtime_layout(bundle_root) + # BPE 预置单独一步:它需要联网(缓存缺失时),与「建目录」是两件事, + # 拆开后布局逻辑的单元测试不必依赖网络或宿主缓存。 + stage_tiktoken_cache(app_target) + # 把版本写进包内:``main.py`` 优先读它,使产物自报的版本与包名 + # (来自 pyproject.toml)同源,消除「改了 pyproject 忘了改 main.py」 + # 导致包名与横幅不一致、而 --validate 照常通过的漂移。 + (app_target / "VERSION.txt").write_text(version + "\n", encoding="utf-8") return bundle_root def prepare_runtime_layout(bundle_root: Path) -> None: - knowledge_base_dir = bundle_root / "knowledge_base" - data_kb_dir = bundle_root / "data" / "kb" - data_logs_dir = bundle_root / "data" / "logs" + """在冻结应用自己的根目录下准备可写运行时目录。 + + ``resolve_app_root()`` 在冻结时返回可执行文件所在目录 + (``src/utils/config.py``),也就是 ``bundle_root / APP_NAME``,而所有 + 默认相对路径(``knowledge_base``、``data/kb``、``data/logs``)都经 + ``resolve_path`` 解析到该目录之下。把这些目录建在 ``bundle_root`` 上会 + 留下一个应用永远不会读的死目录,用户按 README 把文档放进去只会拿到 + ``FileNotFoundError``。此处统一以 ``APP_NAME`` 子目录为根。 + """ + app_root = bundle_root / APP_NAME + knowledge_base_dir = app_root / "knowledge_base" + data_kb_dir = app_root / "data" / "kb" + data_logs_dir = app_root / "data" / "logs" knowledge_base_dir.mkdir(parents=True, exist_ok=True) data_kb_dir.mkdir(parents=True, exist_ok=True) @@ -195,6 +229,97 @@ def prepare_runtime_layout(bundle_root: Path) -> None: ) +# cl100k_base 的 BPE blob 与其 sha256。哈希取自 tiktoken 的 +# ``tiktoken_ext/openai_public.py``(``cl100k_base`` 构造器里的 +# ``expected_hash``),构建期用它校验预置进包内的文件——发布包里的缓存是 +# 只读的,用户离线时无法触发 tiktoken 自身的重取与校验,坏文件只会在 +# 分片时表现为难以归因的解析错误。 +TIKTOKEN_BLOB_URL = "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken" +TIKTOKEN_BLOB_SHA256 = "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7" + + +def stage_tiktoken_cache(app_root: Path) -> None: + """把 cl100k_base 的 BPE 文件预置进包内,供离线首次运行使用。 + + ``tiktoken`` 在缓存未命中时会 ``requests.get`` 远程 blob + (``tiktoken/load.py``),因此未预置的包在离线环境首次构建知识库必然 + 失败。键名是 ``sha1(blobpath)``,与 ``read_file_cached`` 的查找逻辑一致; + 运行时由 ``main.py`` 把 ``TIKTOKEN_CACHE_DIR`` 指向该目录。 + + 缓存缺失时**由本函数自己联网获取**,而不是要求构建者先手工跑一次知识库 + 构建:CI runner 是全新机器,``$TMPDIR`` 里没有任何缓存,把「开发者本机 + 恰好构建过一次」当成构建前提会让五个发布目标全部失败。构建机本来就在 + 联网拉取 PyInstaller 依赖,多取一个文件不改变信任模型;取回后按 + ``TIKTOKEN_BLOB_SHA256`` 校验,坏数据不会进包。 + """ + cache_dir = app_root / TIKTOKEN_CACHE_DIRNAME + cache_dir.mkdir(parents=True, exist_ok=True) + destination = cache_dir / _tiktoken_cache_key() + + source = Path(tiktoken_cache_lookup_path(_tiktoken_cache_key())) + if source.is_file(): + payload = source.read_bytes() + else: + payload = _download_tiktoken_blob() + + actual = hashlib.sha256(payload).hexdigest() + if actual != TIKTOKEN_BLOB_SHA256: + raise RuntimeError( + f"cl100k_base 的 BPE 文件校验失败:期望 sha256 {TIKTOKEN_BLOB_SHA256}," + f"实际 {actual}。\n" + "该文件可能已损坏或过期;请清理缓存后重试," + "若 tiktoken 升级导致 blob 变更,需同步更新 TIKTOKEN_BLOB_SHA256。" + ) + destination.write_bytes(payload) + + +def _tiktoken_cache_key() -> str: + """返回 tiktoken 为 cl100k_base 使用的缓存键(``sha1(blobpath)``)。""" + # 非安全用途:这只是 tiktoken 自己的缓存文件名算法,必须与之逐字一致。 + return hashlib.sha1(TIKTOKEN_BLOB_URL.encode(), usedforsecurity=False).hexdigest() + + +def _download_tiktoken_blob() -> bytes: + """联网取回 cl100k_base 的 BPE 数据。 + + 失败时换成带上下文的错误:裸 ``URLError`` 只说明某次连接失败,看不出 + 「这是发布包的离线兜底依赖」,也无从知道该往哪个方向修。 + """ + import urllib.error + import urllib.request + + try: + with urllib.request.urlopen(TIKTOKEN_BLOB_URL, timeout=60) as response: # nosec B310 + return response.read() + except (urllib.error.URLError, OSError) as exc: + raise RuntimeError( + f"无法获取 cl100k_base 的 BPE 数据: {exc}\n" + f"来源: {TIKTOKEN_BLOB_URL}\n" + "该文件是发布包离线运行的分词兜底依赖,构建期必须取得。" + "若构建环境无外网,请预先把缓存放到 " + "``$TIKTOKEN_CACHE_DIR`` 或 ``$TMPDIR/data-gym-cache`` 下," + "键名为 sha1(blob 路径)。" + ) from exc + + +def tiktoken_cache_lookup_path(cache_key: str) -> str: + """复刻 tiktoken 的缓存查找顺序,返回当前主机的缓存文件路径。 + + 逐字对齐 ``tiktoken.load.read_file_cached``:变量是否**存在**决定是否 + 采用(空串表示禁用缓存,tiktoken 会直接走网络,此处返回空串让调用方 + 据此判断),而不是按真值判断。 + """ + for variable in ("TIKTOKEN_CACHE_DIR", "DATA_GYM_CACHE_DIR"): + if variable in os.environ: + override = os.environ[variable] + if override == "": + return "" + return str(Path(override) / cache_key) + import tempfile + + return str(Path(tempfile.gettempdir()) / "data-gym-cache" / cache_key) + + def find_dangling_symlinks(bundle_root: Path) -> list[Path]: """列出 bundle 内目标不存在的符号链接。 @@ -251,6 +376,15 @@ def validate_bundle(bundle_root: Path) -> None: if "smoke test ok" not in result.stdout: raise RuntimeError("发布包自检输出不符合预期。") + # 自检输出必须带上与包名一致的版本号:只 grep "smoke test ok" 的话, + # 产物横幅写着旧版本、文件名却是新版本也能通过。 + expected_version = load_version() + if f"PyRAG-Kit {expected_version} smoke test ok" not in result.stdout: + raise RuntimeError( + f"发布包自报版本与 pyproject.toml 的 {expected_version} 不一致。\n" + f"自检输出:\n{result.stdout}" + ) + def main() -> None: args = parse_args() @@ -260,15 +394,39 @@ def main() -> None: clean_output_dirs() run_pyinstaller() bundle_root = stage_bundle(args.target, version) - archive_path = archive_bundle(bundle_root, args.target) - if SPEC_PATH.exists(): - SPEC_PATH.unlink() + # 先校验再归档:反过来的话,校验失败时归档已经落盘,且内容是校验前的 + # 状态——「校验通过」与「归档内容」之间没有任何约束关系。 if args.validate: validate_bundle(bundle_root) + # 自检会真实初始化日志与缓存目录,在 bundle 内留下空日志与 .cache。 + # 它们是构建期残留,必须在校验之后、归档之前清掉,否则会被打进发布包 + # (并且暴露构建日期)。 + remove_runtime_residue(bundle_root) + + archive_path = archive_bundle(bundle_root, args.target) + if SPEC_PATH.exists(): + SPEC_PATH.unlink() print(archive_path) +def remove_runtime_residue(bundle_root: Path) -> None: + """删除自检在 bundle 内产生的运行时残留。 + + 只删运行产物(日志、缓存),保留 ``prepare_runtime_layout`` 建的占位 + 目录本身——用户解压后应当看到空的 ``knowledge_base/`` 与 ``data/``。 + """ + app_root = bundle_root / APP_NAME + logs_dir = app_root / "data" / "logs" + if logs_dir.is_dir(): + for entry in logs_dir.iterdir(): + if entry.is_file(): + entry.unlink() + cache_dir = app_root / CACHE_DIRNAME + if cache_dir.is_dir(): + shutil.rmtree(cache_dir) + + if __name__ == "__main__": main() diff --git a/scripts/embed_knowledge_base.py b/scripts/embed_knowledge_base.py index 85e62e8..7a53e4c 100644 --- a/scripts/embed_knowledge_base.py +++ b/scripts/embed_knowledge_base.py @@ -6,6 +6,7 @@ from rich import box from rich.console import Console from rich.table import Table +from rich.text import Text PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: @@ -16,9 +17,15 @@ from src.runtime.contracts import build_run_config from src.services.embedding_service import EmbeddingService from src.services.knowledge_build_service import KnowledgeBuildService -from src.ui.display_utils import CONSOLE_WIDTH, get_relative_path +from src.ui.display_utils import ( + CONSOLE_WIDTH, + _require_model_detail, + get_relative_path, + markup_text, +) from src.utils.config import get_settings from src.utils.log_manager import get_module_logger +from src.utils.security import safe_exception_text logger = get_module_logger(__name__) @@ -45,43 +52,74 @@ def mask_api_key(key: str | None) -> str: table.add_row("[bold green]知识库配置[/bold green]", "") table.add_row( "知识库目录", - f"[bold cyan]{get_relative_path(str(run_config.knowledge_base_path))}[/bold cyan]", + markup_text( + "[bold cyan]{}[/bold cyan]", + get_relative_path(str(run_config.knowledge_base_path)), + ), ) table.add_row( - "快照根目录", f"[bold cyan]{get_relative_path(str(run_config.snapshot_root))}[/bold cyan]" + "快照根目录", + markup_text("[bold cyan]{}[/bold cyan]", get_relative_path(str(run_config.snapshot_root))), ) table.add_row( "旧版 pkl 路径", - f"[bold cyan]{get_relative_path(str(run_config.legacy_pkl_path))}[/bold cyan]", + markup_text( + "[bold cyan]{}[/bold cyan]", + get_relative_path(str(run_config.legacy_pkl_path)), + ), ) - table.add_row("索引模式", f"[bold magenta]{splitter_structure_mode}[/bold magenta]") - table.add_row("文本切分块大小", f"[bold magenta]{run_config.kb_chunk_size}[/bold magenta]") - table.add_row("切分重叠量", f"[bold magenta]{run_config.kb_chunk_overlap}[/bold magenta]") - table.add_row("子分段块大小", f"[bold magenta]{run_config.kb_child_chunk_size}[/bold magenta]") table.add_row( - "子分段重叠量", f"[bold magenta]{run_config.kb_child_chunk_overlap}[/bold magenta]" + "索引模式", markup_text("[bold magenta]{}[/bold magenta]", splitter_structure_mode) ) table.add_row( - "嵌入批大小", f"[bold magenta]{run_config.kb_embedding_batch_size}[/bold magenta]" + "文本切分块大小", + markup_text("[bold magenta]{}[/bold magenta]", run_config.kb_chunk_size), + ) + table.add_row( + "切分重叠量", markup_text("[bold magenta]{}[/bold magenta]", run_config.kb_chunk_overlap) + ) + table.add_row( + "子分段块大小", + markup_text("[bold magenta]{}[/bold magenta]", run_config.kb_child_chunk_size), + ) + table.add_row( + "子分段重叠量", + markup_text("[bold magenta]{}[/bold magenta]", run_config.kb_child_chunk_overlap), + ) + table.add_row( + "嵌入批大小", + markup_text("[bold magenta]{}[/bold magenta]", run_config.kb_embedding_batch_size), ) embedding_key = run_config.default_embedding_provider - embedding_detail = run_config.embedding_configurations[embedding_key] + # 走共用助手而不是直接下标:``default_embedding_provider`` 没有 validator, + # 打错字时裸下标抛 ``KeyError``,而 ``main()`` 只接 ``KeyboardInterrupt`` + # ——用户看到 traceback,报错里连可选值都没有。chat 路径已改用同一契约。 + embedding_detail = _require_model_detail( + run_config.embedding_configurations, embedding_key, "Embedding" + ) provider = embedding_detail.provider table.add_section() table.add_row("[bold green]模型与 API 配置[/bold green]", "") table.add_row( - "激活嵌入提供商", f"[bold green]{embedding_key}[/bold green] ([dim]{provider}[/dim])" + "激活嵌入提供商", + markup_text("[bold green]{}[/bold green] ([dim]{}[/dim])", embedding_key, provider), ) table.add_row( - "模型名称", f"[bold bright_white]{embedding_detail.model_name}[/bold bright_white]" + "模型名称", + markup_text("[bold bright_white]{}[/bold bright_white]", embedding_detail.model_name), ) if provider == "local-hash": - table.add_row("API Key", "[dim]本地模型,无需设置[/dim]") + table.add_row("API Key", markup_text("[dim]本地模型,无需设置[/dim]")) else: + # 行标签由 ``provider`` 拼出,而该值来自 ``config.toml``,本函数在 + # **任何校验之前**运行(``main_async`` 先调它、再建服务),因此标签里的 + # ``[/]`` 会让表格渲染抛 ``MarkupError``,把「确认配置」这一步变成崩溃。 key_field_name = f"{provider.lower()}_api_key" api_key_value = getattr(settings, key_field_name, None) - table.add_row(f"API Key ({key_field_name.upper()})", mask_api_key(api_key_value)) + table.add_row( + Text(f"API Key ({key_field_name.upper()})"), markup_text(mask_api_key(api_key_value)) + ) console.print(table) console.print("[yellow]配置信息来源于 config.toml、.env 或环境变量。[/yellow]") @@ -106,12 +144,28 @@ async def main_async(splitter_structure_mode: str): embedding_service=embedding_service, snapshot_repository=snapshot_repository, ) - result = await build_service.build(splitter_structure_mode) + result = None + try: + result = await build_service.build(splitter_structure_mode) + finally: + # 构建成功与失败(建库失败是常态路径)都要释放 provider:``build`` 内 + # ``embed_in_batches`` 懒加载出 provider(openai/google/ark 等渠道各持有 + # httpx AsyncClient 连接池),而菜单循环里每选一次「向量化处理」都新建 + # 一次 ``EmbeddingService``——不释放则连接池随菜单循环逐次累积。失败 + # 收场只记日志,不顶替构建本身的异常。判据与 + # ``retrieval_test.core._aclose_quietly`` 一致。 + try: + await embedding_service.aclose() + except Exception as exc: # noqa: BLE001 - teardown 不得顶替主流程异常 + logger.warning("释放 Embedding 服务失败: %s", safe_exception_text(exc)) console.print( - f"\n[bold green]知识库构建完成。活动快照: [cyan]{result['snapshot_id']}[/cyan][/bold green]" + markup_text( + "\n[bold green]知识库构建完成。活动快照: [cyan]{}[/cyan][/bold green]", + result["snapshot_id"], + ) ) - console.print(f"[green]快照目录: [cyan]{result['snapshot_dir']}[/cyan][/green]") + console.print(markup_text("[green]快照目录: [cyan]{}[/cyan][/green]", result["snapshot_dir"])) logger.info("知识库快照构建完成: %s", result) diff --git a/src/chat/core.py b/src/chat/core.py index 907fd12..9fa5e24 100644 --- a/src/chat/core.py +++ b/src/chat/core.py @@ -1,7 +1,7 @@ import asyncio import inspect import os -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from copy import deepcopy from typing import Any @@ -29,11 +29,20 @@ from ..services.embedding_service import EmbeddingService from ..services.retrieval_service import RetrievalService from ..ui.config_menu import launch_config_editor -from ..ui.display_utils import display_chat_config, get_relative_path -from ..utils.config import get_settings -from ..utils.log_manager import get_chat_logger +from ..ui.display_utils import ( + display_chat_config, + document_content, + document_score, + document_source, + get_relative_path, + markup_text, +) +from ..utils.config import get_settings, require_chat_temperature, safe_repr +from ..utils.log_manager import get_chat_logger, get_module_logger from ..utils.security import safe_exception_text +logger = get_module_logger(__name__) + class Chatbot: def __init__(self, console: Console): @@ -58,10 +67,37 @@ def chat_config(self, value: SessionConfig | dict[str, Any]): if isinstance(value, SessionConfig): self.session_config = value return + # 形态先判:标注是 ``SessionConfig | dict``,但运行时不受约束。非 Mapping + # 取值在此抛裸 ``TypeError: 'int' object is not iterable``(下面两条路径 + # 的 ``.items()``/``set_many`` 迭代),而非法**字段名**给的是带指引的 + # ``ValueError``。同一个「配置写错」的收场不该取决于它是标量还是映射。 + if not isinstance(value, Mapping): + raise ValueError( + f"chat_config 必须是 SessionConfig 或字段映射,但得到 {safe_repr(value)}。" + ) if not hasattr(self, "session_config"): self.session_config = build_session_config(get_settings()) - for key, item in value.items(): - self.session_config[key] = item + # 先在副本上逐个写入,全部通过后再整体替换。 + # + # 直接循环写 ``self.session_config`` 不是原子的:任一字段被拒时, + # 前面的字段已经落进去了——实测 ``{"top_k": 42, "retrieval_method": + # "不存在"}`` 会留下「top_k 已改成 42、检索模式还是旧的」这种改了一半 + # 的状态,而调用方只看到一个异常,以为整批都没生效。 + # + # 用 ``deepcopy`` 而不是 ``SessionConfig(**to_dict())``:``session_config`` + # 的类型是运行时可替换的(嵌入方可以传入自己的映射实现),``__setitem__`` + # 是唯一被要求的接口,重建具体类型会把这类实现挡在外面。 + candidate = deepcopy(self.session_config) + # 有 ``set_many`` 就用它:逐个 ``__setitem__`` 会拿**旧**的兄弟字段值 + # 做组合校验,于是「top_k × multiplier」这类上界约束会因为中间态瞬时 + # 越界而拒绝一个合法终态,且结论随 dict 键顺序变化。 + set_many = getattr(candidate, "set_many", None) + if callable(set_many): + set_many(value) + else: + for key, item in value.items(): + candidate[key] = item + self.session_config = candidate def _initialize_vector_store(self): self.vector_store = VectorStoreFactory.get_default_vector_store() @@ -74,7 +110,9 @@ def _initialize_vector_store(self): # 「无相关文档」,把构建缺失误诊成检索质量差。 chunk_count = len(getattr(self.vector_store, "documents", []) or []) if chunk_count: - self.console.print(f"[green]知识快照加载成功,共 {chunk_count} 个分块。[/green]") + self.console.print( + markup_text("[green]知识快照加载成功,共 {} 个分块。[/green]", chunk_count) + ) else: self.console.print( "[bold yellow]未找到可用的知识快照,检索将始终返回空结果。[/bold yellow]" @@ -104,7 +142,9 @@ def reload_llm(self) -> bool: except Exception as exc: # noqa: BLE001 - reload boundary reports provider failures if new_model is not None and new_model is not self.llm_model: self._dispose_model(new_model) - self.console.print(f"[bold red]重载 LLM 出错: {safe_exception_text(exc)}[/bold red]") + self.console.print( + markup_text("[bold red]重载 LLM 出错: {}[/bold red]", safe_exception_text(exc)) + ) return False def _dispose_model(self, model: LargeLanguageModel) -> None: @@ -231,7 +271,9 @@ async def apply_config_update_async( await self._adispose_model(new_model) if previous_config is not None: self.session_config = previous_config - self.console.print(f"[bold red]重载 LLM 出错: {safe_exception_text(exc)}[/bold red]") + self.console.print( + markup_text("[bold red]重载 LLM 出错: {}[/bold red]", safe_exception_text(exc)) + ) self.console.print("[bold yellow]LLM 切换失败,已保留当前模型配置。[/bold yellow]") async def _identify_intent_async(self, user_query: str) -> str: @@ -255,7 +297,7 @@ async def chat_async(self, user_input: str) -> AsyncGenerator[str, None]: try: intent = await self._identify_intent_async(user_input) retrieved_docs = await self._retrieve_knowledge_async(intent) - self.console.print(f" [bold]意图:[/bold] [yellow]{intent}[/yellow]") + self.console.print(markup_text(" [bold]意图:[/bold] [yellow]{}[/yellow]", intent)) self._display_retrieved_docs(retrieved_docs) chat_service = getattr(self, "chat_service", None) @@ -269,15 +311,22 @@ async def chat_async(self, user_input: str) -> AsyncGenerator[str, None]: full_response += chunk yield chunk elif (llm_model := getattr(self, "llm_model", None)) is not None: - prompt = ( - f"你是一个智能客服。用户问题: {user_input}\n" - f"意图: {intent}\n" - f"知识: {retrieved_docs}\n回答要简洁。" - ) + # 与 ``ChatService.generate_reply`` 走**同一条** prompt 构造: + # 这里原先自己 f-string 整份 ``retrieved_docs``,于是 + # ``page_content=None`` 会把字面量 ``None`` 当成一条真实知识喂给 + # 模型,``score=10**4301`` 则抛裸 ``ValueError: Exceeds the + # limit`` 让整次问答失败。``_build_prompt`` 已经用 + # ``display_utils`` 的助手把每个字段收敛好(第三方 store 不做 + # 载入期归一化),复用它即可,不必再维护第二份模板。 + prompt = ChatService._build_prompt(user_input, intent, retrieved_docs) invoke_kwargs: dict[str, Any] = {"prompt": prompt, "stream": True} supports_sampling = getattr(llm_model, "supports_sampling_option", None) if callable(supports_sampling) and supports_sampling("temperature"): - invoke_kwargs["temperature"] = self.session_config["chat_temperature"] + # 与 ``ChatService.generate_reply`` 同判据:这里读的是可直写的 + # 字段,非法取值同样会静默进请求 kwargs。 + invoke_kwargs["temperature"] = require_chat_temperature( + self.session_config["chat_temperature"] + ) async for chunk in llm_model.ainvoke(**invoke_kwargs): full_response += chunk yield chunk @@ -296,13 +345,26 @@ async def chat_async(self, user_input: str) -> AsyncGenerator[str, None]: "聊天请求处理失败: %s", error_text, ) - self.console.print(f"[red]LLM 异步生成出错: {safe_exception_text(exc)}[/red]") + self.console.print( + markup_text("[red]LLM 异步生成出错: {}[/red]", safe_exception_text(exc)) + ) # 已经输出部分内容时不要追加道歉文本,避免用户收到混合响应; # 具体异常已通过日志和控制台显式记录。 if not full_response: yield "抱歉,处理请求时遇到错误。" def _display_retrieved_docs(self, docs: list[dict[str, Any]]): + # 形态先收敛,与召回测试侧的 ``display_results`` 同判据——两者消费的是 + # 检索管线输出的同一种值,而那一侧已有这条守卫,这一侧此前漏了。实测 + # 非列表取值两种收场都不在契约内:``"abc"`` 是合法可迭代对象,逐**字符** + # 迭代后每个字符经 ``document_*`` 助手收敛成占位符,渲染出一张 3 行来源与 + # 预览都是空串的伪结果表格(用户看到「检索详情」里有 3 条命中,而实际返回 + # 的是字符串);``5``/``True`` 抛裸 + # ``TypeError: 'int' object is not iterable``,而本方法在 + # ``chat_async`` 的 ``try`` 内,异常会被兜成「LLM 异步生成出错」——一次 + # 成功检索的展示失败被显示成模型故障。 + if not isinstance(docs, list): + docs = [] if not docs: self.console.print("[yellow]无相关文档。[/yellow]") return @@ -312,9 +374,12 @@ def _display_retrieved_docs(self, docs: list[dict[str, Any]]): table.add_column("预览", style="white") table.add_column("得分", style="bold") for doc in docs: - preview = doc.get("page_content", "")[:60].replace("\n", " ") + "..." - source = get_relative_path(doc.get("metadata", {}).get("source", "N/A")) - table.add_row(source, preview, f"{doc.get('score', 0):.4f}") + # 字段形态由 document_* 助手收敛(检索结果可以来自第三方 store, + # 不经 FaissStore 的载入期归一化);预览与来源还可能含字面量 + # ``[/]`` 之类的标记形态,因此用 Text 包装后再交给 add_row。 + preview = document_content(doc, "")[:60].replace("\n", " ") + "..." + source = get_relative_path(document_source(doc)) + table.add_row(Text(source), Text(preview), f"{document_score(doc):.4f}") self.console.print(table) @@ -325,10 +390,39 @@ async def start_chat_session_async(): session = PromptSession() if bot.llm_model: - display_chat_config(console, bot.chat_config) - console.print( - f"客服已就绪 ([bold green]{bot.chat_config['active_llm_configuration']}[/bold green])" - ) + # 启动横幅的回显失败不该以「程序级错误」呈现——``chat_rerank_enabled`` + # 启用而 rerank 段为空/键不对应时 ``display_chat_config`` 抛 + # ``ValueError``(实测),裸抛会把一个**可修复的配置问题**顶成 + # 「程序运行期间发生错误」再打回主菜单。配置已生效,只报原因即可。 + try: + display_chat_config(console, bot.chat_config) + except (ValueError, KeyError) as exc: + console.print( + markup_text( + "[bold yellow]配置显示失败(会话仍将启动): {}[/bold yellow]", + safe_exception_text(exc), + ) + ) + try: + console.print( + markup_text( + "客服已就绪 ([bold green]{}[/bold green])", + bot.chat_config["active_llm_configuration"], + ) + ) + except (ValueError, KeyError, TypeError) as exc: + # 「就绪」行同样只读配置展示:替身 bot 的 ``chat_config=None`` + # 形态在下标处抛 **TypeError**(``'NoneType' object is not + # subscriptable``,实测穿透 ``(ValueError, KeyError)``)、缺键 + # 形态抛 ``KeyError``——都会顶替上一段「回显失败只报黄字」的 + # 恢复契约,同一契约覆盖到最后一行。(真实 ``SessionConfig`` + # 经 pydantic 校验不可达这些形态,仅替身可达。) + console.print( + markup_text( + "[bold yellow]配置读取失败(会话仍将启动): {}[/bold yellow]", + safe_exception_text(exc), + ) + ) while True: try: @@ -344,23 +438,114 @@ async def start_chat_session_async(): if isinstance(bot.chat_config, SessionConfig) else dict(bot.chat_config) ) - llm_needs_reload, updated_config = await asyncio.to_thread( - launch_config_editor, - editable_config, - ) - # 真实 Chatbot 使用异步重载以正确释放 SDK 连接池;保留 - # 同步兼容入口,便于嵌入方或旧版替身实现配置编辑。 - apply_async = getattr(bot, "apply_config_update_async", None) - if callable(apply_async): - result = apply_async(updated_config, llm_needs_reload) - if inspect.isawaitable(result): - await result - else: - apply_sync = getattr(bot, "apply_config_update", None) - if not callable(apply_sync): - raise AttributeError("Chatbot 缺少配置更新方法。") - apply_sync(updated_config, llm_needs_reload) - display_chat_config(console, bot.chat_config) + # 配置应用失败**不能**终止整个会话。``SessionConfig`` 的 + # 校验器会拒绝非法取值(``ValueError``),而编辑器的动态 + # 上界与它是两套独立实现,任一侧演化就会命中这条路径—— + # 实测编辑器返回「非法字段 + 非法值」时,异常穿透内层只接 + # ``(KeyboardInterrupt, EOFError)`` 的 ``try``,用户被丢回 + # 主菜单并丢失会话。同文件的 ``chat_async`` 对异常是恢复的 + # (打印后继续下一轮),这里与之对齐:报错、保留原配置、 + # 回到提示符。 + # + # 编辑器调用与配置回显都在 ``try`` **之内**:``launch_config_editor`` + # 自身会抛 ``ValueError``(配置段不是映射、``questionary`` + # 拿到空候选),``display_chat_config`` 也会对一份自洽性 + # 有问题的配置抛 ``ValueError``(如 rerank 段为空表时找不到 + # provider key)——两处都是同类可恢复的配置错误,漏接即 + # 终止会话,与本块开头声明的契约矛盾(实测复现)。 + try: + llm_needs_reload, updated_config = await asyncio.to_thread( + launch_config_editor, + editable_config, + ) + # 真实 Chatbot 使用异步重载以正确释放 SDK 连接池;保留 + # 同步兼容入口,便于嵌入方或旧版替身实现配置编辑。 + apply_async = getattr(bot, "apply_config_update_async", None) + if callable(apply_async): + result = apply_async(updated_config, llm_needs_reload) + if inspect.isawaitable(result): + await result + else: + apply_sync = getattr(bot, "apply_config_update", None) + if not callable(apply_sync): + # 抛 ``ValueError`` 而**不是** ``AttributeError``: + # 下面这个 ``except`` 只接 ``(ValueError, KeyError)``, + # 而 ``AttributeError`` 同样不落在更内层的 + # ``except (KeyboardInterrupt, EOFError)`` 里——实测 + # 会一路穿透到 ``finally``,**终止整个会话**。这与 + # 本块开头那段注释的意图(「配置应用失败不能终止整个 + # 会话…报错、保留原配置、回到提示符」)直接矛盾:同一 + # 个恢复路径对别的失败都生效,唯独这条替身缺方法的 + # 情况会把用户丢出会话。 + # + # 语义上这也确实是一条配置错误(嵌入方传入的 bot + # 实现缺少约定接口),与同 ``except`` 里的 + # ``SessionConfig`` 校验失败同类。 + raise ValueError("Chatbot 缺少配置更新方法。") + apply_sync(updated_config, llm_needs_reload) + except EOFError as exc: + # EOF(Ctrl+D)已由 ``launch_config_editor`` 收敛为 + # 「按原样保存返回」,此处是替身实现的编辑器仍抛 + # EOFError 的兜底:与 ``KeyboardInterrupt`` 同语义 + # (用户中断输入),不应走到下面的通用恢复分支。 + raise KeyboardInterrupt from exc + except (ValueError, KeyError) as exc: + # 经 ``getattr`` 取 logger:``bot`` 的类型是运行时 + # 可替换的(嵌入方可以传入自己的实现),同文件其它 + # 位置也用 ``getattr`` 探测 ``apply_config_update``/ + # ``aclose``。诊断日志取不到时不能反过来把恢复路径 + # 变成新的失败点。 + bot_logger = getattr(bot, "logger", None) + if bot_logger is not None: + bot_logger.warning( + "配置应用失败,保留原配置: %s", safe_exception_text(exc) + ) + console.print( + markup_text( + "[bold yellow]配置未生效: {}[/bold yellow] 已保留当前配置。", + safe_exception_text(exc), + ) + ) + continue + except Exception as exc: # noqa: BLE001 + # 编辑器线程内的**意外**异常(questionary/prompt_toolkit + # 在异常态下可能抛非 ``(ValueError, KeyError)`` 类型) + # 原先直接穿透——内层 ``except (KeyboardInterrupt, + # EOFError)`` 不接,会话被静默终止且无任何提示(实测 + # 复现),与「配置应用失败不能终止整个会话」契约矛盾。 + # 兜底报告后回到提示符,与通用恢复分支同语义。注意 + # 必须排在 ``(ValueError, KeyError)`` 之后:该分支 + # 报「配置未生效」、本分支报「配置编辑器异常」,是 + # 两条不同的用户可见消息,反序会把前者整个遮蔽。 + bot_logger = getattr(bot, "logger", None) + if bot_logger is not None: + bot_logger.warning( + "配置编辑器异常,保留原配置: %s", safe_exception_text(exc) + ) + # 配置回显同样收在恢复 ``try`` 内:``display_chat_config`` + # 会读取刚保存的 rerank 段,取值不合洽时抛 ``ValueError`` + # (实测 rerank 段为空表即触发)——若不接,配置已生效、 + # 会话却被终止,用户丢失全部对话。回显失败只报告,不撤销 + # 已生效的配置。 + try: + display_chat_config(console, bot.chat_config) + except (ValueError, KeyError) as exc: + # 经 ``getattr`` 取 logger:``bot`` 的类型是运行时 + # 可替换的(嵌入方可以传入自己的实现),同文件其它 + # 位置也用 ``getattr`` 探测 ``apply_config_update``/ + # ``aclose``。诊断日志取不到时不能反过来把恢复路径 + # 变成新的失败点。 + bot_logger = getattr(bot, "logger", None) + if bot_logger is not None: + bot_logger.warning( + "配置回显失败(配置已生效): %s", safe_exception_text(exc) + ) + console.print( + markup_text( + "[bold yellow]配置已生效,但回显失败: {}[/bold yellow]", + safe_exception_text(exc), + ) + ) continue response_panel = Panel("", title="客服", border_style="green") @@ -380,7 +565,17 @@ async def start_chat_session_async(): finally: close = getattr(bot, "aclose", None) if close is not None: - await close() + # 与同文件对 ``apply_config_update`` 的探测同一契约:``bot`` 的 + # ``aclose`` 是运行时可替换的,替身实现抛异常时裸 ``await`` 会 + # 顶替正常收尾(``/quit`` 后用户看到的是替身的错误而非再见), + # 也会顶替在途异常(实测 ExplodingBot 复现)。释放失败只记录, + # 不改写本函数的真实退出原因。 + try: + result = close() + if inspect.isawaitable(result): + await result + except Exception as exc: # noqa: BLE001 - teardown 不得顶替主流程 + logger.warning("释放聊天会话资源失败: %s", safe_exception_text(exc)) def start_chat_session(): diff --git a/src/etl/cleaners/basic_cleaner.py b/src/etl/cleaners/basic_cleaner.py index bd61126..13728b2 100644 --- a/src/etl/cleaners/basic_cleaner.py +++ b/src/etl/cleaners/basic_cleaner.py @@ -3,11 +3,17 @@ # 遵循修改后的 Apache License 2.0 许可证。详情请参阅项目根目录下的 DIFY_LICENSE 文件。 import re +from collections.abc import Sequence from src.models.document import Document +from src.utils.config import safe_repr from .base import BaseCleaner +# 与第 3 步折叠空白用的是同一条判据(``\s`` 的 Unicode 语义)。哨兵候选必须 +# 用它筛掉,否则哨兵会被 ``re.sub(r"\s+", " ")`` 一起折叠掉、双换行无法还原。 +_WHITESPACE_RE = re.compile(r"\s") + class BasicCleaner(BaseCleaner): """ @@ -26,23 +32,75 @@ def clean(self, documents: list[Document], **kwargs) -> list[Document]: Returns: List[Document]: 清洗后的文档对象列表。 """ + # 形态校验先做:``documents=5`` 抛裸 + # ``TypeError: 'int' object is not iterable``,``"abc"``/``[5]``/ + # ``[None]`` 抛裸 ``AttributeError: 'str' object has no attribute + # 'content'``(下面的 ``doc.content``)——两者都不在调用方按 + # ``ValueError`` 建立的分类内。判据与同一层的 + # ``RecursiveTextSplitter._require_documents`` 一致(两者在 + # ``Pipeline.process`` 里前后相邻调用,同一份 ``Document`` 列表)。 + if isinstance(documents, (str, bytes, bytearray)) or not isinstance(documents, Sequence): + raise ValueError(f"清洗器 documents 必须是文档列表,但得到 {safe_repr(documents)}。") cleaned_documents = [] - for doc in documents: + for index, doc in enumerate(documents): + if not isinstance(doc, Document): + raise ValueError( + f"清洗器 documents[{index}] 必须是 Document,但得到 {safe_repr(doc)}。" + ) # 直接访问 Document 对象的属性 content = doc.content # 1. 将所有回车换行符统一为单个换行符 content = content.replace("\r\n", "\n") - # 2. 临时替换双换行符,以防止它们在后续的空白字符处理中被破坏 - double_newline_placeholder = "__DOUBLE_NEWLINE_PLACEHOLDER__" - content = content.replace("\n\n", double_newline_placeholder) + # 2. 临时替换双换行符,以防止它们在后续的空白字符处理中被破坏。 + # + # 哨兵按「内容里不存在的字符」现算,而不是用固定的可读字面量: + # 原实现硬编码 ``"__DOUBLE_NEWLINE_PLACEHOLDER__"``,它与用户内容 + # 碰撞——文档里出现同样的字符串就会被改写成双换行,而整段由它 + # 构成的段落会被抹成空串(实测 ``"__DOUBLE_NEWLINE_PLACEHOLDER__" + # * 2`` 清洗后得到空串)。内容是用户数据,不该被实现细节改写。 + # + # 哨兵取**单个**「内容里没有、且不是空白」的字符,有三条硬要求: + # + # a. 不在内容里。否则替换回双换行时会把用户自己的字符也改掉。 + # b. 不是空白字符。第 3 步的 ``re.sub(r"\s+", " ")`` 会把它一起 + # 折叠掉,哨兵丢失、双换行无法还原。 + # c. **单字符**,且不与内容的同类字符相邻。用「NUL 游程 + 1」这种 + # 多字符哨兵看似更省事,实则在边界上歧义:内容 + # ``"a\x00\x00\x00\n\nb"`` 换出 4 个 NUL 后与原有 3 个 NUL + # 连成 7 个,``str.replace`` 取最左匹配、只吃掉前 4 个,剩下 3 个 + # NUL 被留在了双换行**之后**——实测得到 + # ``'a\n\n\x00\x00\x00b'``,用户内容被静默改写。单个且内容中 + # 不存在的字符不可能有这种歧义。 + # + # 复杂度:``set(content)`` 一次扫描(O(n)),随后的候选扫描只在 + # 「缺失的非空白字符」上迭代,正常文本第一个候选就命中(NUL)。 + # 旧写法 ``while placeholder in content: placeholder += "\x00"`` 每次 + # ``in`` 都是一次全串扫描、每次 ``+=`` 又让哨兵长一位,于是 N 个 NUL + # 要扫 N 次——实测 20 万个 NUL 耗时 19.8 秒,100 万 NUL 按平方外推约 + # 1300 秒。触发输入很现实:零填充的 md(写入中断/预分配/误拷的 + # 二进制残片)或 Windows 用户存成 UTF-16LE 的纯 ASCII 文档, + # ``read_text(encoding="utf-8")`` 对两者**都不报错**,因此 + # ``Pipeline._process_file`` 的解码失败分支拦不住,用户只看到 + # 「向量化处理」卡死数分钟且无任何提示。 + present = set(content) + placeholder = "\x00" + for code in range(0x110000): + if 0xD800 <= code <= 0xDFFF: + continue # 孤立代理项,不能出现在合法文本里 + candidate = chr(code) + if candidate in present or _WHITESPACE_RE.match(candidate): + continue + placeholder = candidate + break + content = content.replace("\n\n", placeholder) # 3. 将所有连续的空白字符(包括单个换行符、制表符、多个空格)替换为单个空格 content = re.sub(r"\s+", " ", content) # 4. 将占位符替换回双换行符 - content = content.replace(double_newline_placeholder, "\n\n") + content = content.replace(placeholder, "\n\n") # 5. 移除字符串首尾的空白字符 content = content.strip() diff --git a/src/etl/pipeline.py b/src/etl/pipeline.py index a3f9f3e..a0ddf1a 100644 --- a/src/etl/pipeline.py +++ b/src/etl/pipeline.py @@ -4,6 +4,7 @@ import time from pathlib import Path +from typing import Any from src.etl.cleaners.base import BaseCleaner from src.etl.cleaners.basic_cleaner import BasicCleaner @@ -12,11 +13,44 @@ from src.etl.splitters.base import BaseSplitter from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter from src.models.document import Document +from src.utils.config import safe_repr from src.utils.log_manager import get_module_logger logger = get_module_logger(__name__) +def _require_chunk_list(chunks: Any, stage: str) -> list[Document]: + """确认流水线某阶段返回的是「文档列表」。 + + 与 ``RetrievalService._require_document_list`` 同一判据、同一收场:三个阶段 + 都是 ``BaseExtractor``/``BaseCleaner``/``BaseSplitter`` 的实现,抽象基类 + 正是为替换实现而存在的,而 ``Pipeline.__init__`` 接受任意实现。实测 + ``split`` 返回 ``None`` 时,下面那条日志里的 ``len(final_chunks)`` 抛裸 + ``TypeError: object of type 'NoneType' has no len()``——报错现场在日志行, + 消息里既没有阶段名也没有「是谁违反了契约」。 + + 这里抛 ``TypeError`` 而不是 ``ValueError``,理由与 + ``_require_document_list`` 逐字相同:这是**接口契约**问题,不是数据内容 + 问题。入参 ``document`` 形态错误(调用方给了非 ``Document``)才是数据问题, + 那一处用 ``ValueError``。 + + 不静默丢弃不合规条目:丢弃会让「实现错了」表现为「分片变少了」。 + """ + if not isinstance(chunks, list): + raise TypeError( + f"{stage}返回的必须是文档列表,但得到 {type(chunks).__name__}。" + " 请检查 BaseExtractor/BaseCleaner/BaseSplitter 实现是否遵守契约。" + ) + for position, chunk in enumerate(chunks): + if not isinstance(chunk, Document): + raise TypeError( + f"{stage}返回的第 {position} 项不是 Document" + f"({type(chunk).__name__})。" + " 请检查 BaseExtractor/BaseCleaner/BaseSplitter 实现是否遵守契约。" + ) + return chunks + + class Pipeline: """ 文档处理流水线。 @@ -59,24 +93,42 @@ def process(self, document: Document) -> list[Document]: """ 处理单个文档,并监控每一步的耗时 (CSE Sensor)。 """ - source = document.metadata.get("source", "未知来源") + # 形态校验先做:``process`` 的第一行就读 ``document.metadata``,此前 + # ``5``/``"abc"``/``[5]``/``None`` 全部抛裸 + # ``AttributeError: ... object has no attribute 'metadata'``——不在 + # ``KnowledgeBuildService.build`` 按 ``ValueError`` 建立的分类内,而 + # 那是建库主路径(``_process_file`` 对知识库里的每个文件调一次)。 + # 判据与 ``RecursiveTextSplitter._require_documents`` 一致:要求 + # ``Document`` 实例而不是鸭子类型,本方法后续每一步都读它的 + # ``content``/``metadata``。 + if not isinstance(document, Document): + raise ValueError(f"Pipeline 处理的文档必须是 Document,但得到 {safe_repr(document)}。") + # ``metadata`` 声明为 ``dict[str, Any]``,取值不受控;而下面每一行日志都 + # 把它插进 f-string。``str(10**4301)`` 撞 CPython 的整数位数上限,抛 + # ``ValueError: Exceeds the limit (4300 digits)``——一条**日志**让建库 + # 主路径崩掉,而 ``KnowledgeBuildService._process_file`` 的 ``except`` + # 只按 ``ValueError`` 分类,收场是「建库失败:整数转换超限」,与真实 + # 原因(来源字段形态异常)毫无关系。只在非字符串时兜底:正常路径的 + # 输出逐字不变(``safe_repr`` 会给字符串加引号),异常形态退回量级描述。 + source_value = document.metadata.get("source", "未知来源") + source = source_value if isinstance(source_value, str) else safe_repr(source_value) logger.info(f"开始处理文档: {source}") start_total = time.perf_counter() # 抽取 start_step = time.perf_counter() - extracted_docs = self.extractor.extract(document) + extracted_docs = _require_chunk_list(self.extractor.extract(document), "抽取阶段") logger.info(f"抽取完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s") # 清洗 start_step = time.perf_counter() - cleaned_docs = self.cleaner.clean(extracted_docs) + cleaned_docs = _require_chunk_list(self.cleaner.clean(extracted_docs), "清洗阶段") logger.info(f"清洗完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s") # 分割 start_step = time.perf_counter() - final_chunks = self.splitter.split(cleaned_docs) + final_chunks = _require_chunk_list(self.splitter.split(cleaned_docs), "分割阶段") logger.info( f"分割完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s, 分片数: {len(final_chunks)}" ) diff --git a/src/etl/splitters/recursive_text_splitter.py b/src/etl/splitters/recursive_text_splitter.py index e699f3d..b4c0ab3 100644 --- a/src/etl/splitters/recursive_text_splitter.py +++ b/src/etl/splitters/recursive_text_splitter.py @@ -4,13 +4,14 @@ import re import uuid -from collections.abc import Callable +from collections.abc import Callable, Sequence +from typing import Any import tiktoken from langchain_text_splitters import RecursiveCharacterTextSplitter from src.models.document import Document -from src.utils.config import get_settings +from src.utils.config import get_settings, safe_repr from src.utils.log_manager import get_module_logger from .base import BaseSplitter @@ -18,6 +19,23 @@ logger = get_module_logger(__name__) +def _safe_source(document: Any) -> str: + """取出文档来源用于**日志插值**,形态异常时退回可安全渲染的文本。 + + ``Document.metadata`` 声明为 ``dict[str, Any]``,取值不受控;而本模块的日志 + 行把它插进 f-string/``%s``。``str(10**4301)`` 撞 CPython 的整数位数上限, + 抛 ``ValueError: Exceeds the limit (4300 digits)``——一条 debug 日志让 + ``split``/``split_hierarchical`` 崩掉,报出的是整数转换错误,与「来源字段 + 形态异常」毫无关系。 + + 与 ``ui.display_utils.document_source`` 同一判据(那里供展示层用),本模块 + 不能反向依赖 ``ui``,故按同一规则在 ETL 侧取一个最小实现:字符串原样返回 + (正常路径输出逐字不变),其余形态走 ``safe_repr``。 + """ + value = document.metadata.get("source", "未知来源") + return value if isinstance(value, str) else safe_repr(value) + + class RecursiveTextSplitter(BaseSplitter): """ 递归文本分割器。 @@ -42,14 +60,39 @@ def __init__( mode (str): 分割模式,可选 "char" 或 "token"。 encoding_name (str): tiktoken 编码名称。 """ - if structure_mode not in {"standard", "hierarchical"}: - raise ValueError(f"不支持的结构模式: {structure_mode}") + # 两条守卫缺一不可: + # * 集合成员判断对不可哈希的值抛 ``TypeError: unhashable type``——调用方 + # 可以传列表/字典,异常类型与「模式不支持」无关。 + # * 消息里插值 ``{structure_mode}`` 自己会抛:``str(10**4301)`` 撞上 + # CPython 的整数位数上限,用户看到 ``Exceeds the limit (4300 digits)``。 + if not isinstance(structure_mode, str) or structure_mode not in { + "standard", + "hierarchical", + }: + raise ValueError(f"不支持的结构模式: {safe_repr(structure_mode)}") + # ``mode`` 原先完全没有校验,直接赋值后在 ``_get_length_function`` 里只判 + # ``== "token"``:任何拼错/大小写不符/非字符串的取值都**静默退回 char + # 模式**。实测 ``"Token"``/``"tokens"``/``None``/``["token"]`` 全部得到 + # char 分片——``chunk_size=1500`` 于是意味着 1500 **字符**而不是 1500 + # token,中文下每 token 约一字,分片规模与配置意图不符且无任何提示。 + # 这违反 AGENTS.md「不用静默回退或占位结果掩盖失败」。 + if not isinstance(mode, str) or mode not in {"token", "char"}: + raise ValueError(f"不支持的分割模式: {safe_repr(mode)},只接受 'token' 或 'char'。") self.mode = mode self.structure_mode = structure_mode - self.parent_chunk_size = parent_chunk_size - self.parent_chunk_overlap = parent_chunk_overlap - self.child_chunk_size = child_chunk_size - self.child_chunk_overlap = child_chunk_overlap + # 尺寸参数的形态守卫。原先只做比较(``_validate_hierarchical_sizes``), + # 于是 ``child_chunk_size='300'`` 构造成功、到 split 时才抛 + # ``TypeError: '>=' not supported between instances of 'int' and 'str'``; + # ``child_chunk_overlap=['x']`` 同理。契约是同文件的 ``ValueError``, + # 而 ``TypeError`` 不是——调用方的错误分类接不住它。 + self.parent_chunk_size = self._require_optional_size(parent_chunk_size, "parent_chunk_size") + self.parent_chunk_overlap = self._require_optional_size( + parent_chunk_overlap, "parent_chunk_overlap", minimum=0 + ) + self.child_chunk_size = self._require_optional_size(child_chunk_size, "child_chunk_size") + self.child_chunk_overlap = self._require_optional_size( + child_chunk_overlap, "child_chunk_overlap", minimum=0 + ) self.parent_documents: dict[str, dict[str, object]] = {} self._encoder = tiktoken.get_encoding(encoding_name) logger.info( @@ -60,12 +103,64 @@ def __init__( ) self._init_splitter() + @staticmethod + def _require_optional_size(value: Any, label: str, *, minimum: int = 1) -> int | None: + """把可选的尺寸参数收敛成 ``int``;非法形态抛 ``ValueError``。 + + ``None`` 表示「用 settings 的值」,照常放行。``bool`` 是 ``int`` 子类, + ``True`` 会静默变成 ``1``——与 ``_coerce_number`` 同源的判据。 + """ + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} 必须是整数,但得到 {safe_repr(value)}。") + if value < minimum: + # 与上面那条同为「值本身非法」,消息里的取值同样要经 ``safe_repr``。 + # 裸 ``{value}`` 在**负的**超大整数上抛 + # ``ValueError: Exceeds the limit``:能走到这个分支的正是 + # ``value < minimum`` 成立的取值,而位数超限的整数里只有负的才满足 + # 它(``10**4301 >= 1`` 不成立,直接返回)。实测 + # ``parent_chunk_size=-(10**4301)`` 时用户看到的是整数转换错误,而不是 + # 「必须大于等于 1」。 + raise ValueError(f"{label} 必须大于等于 {minimum},但得到 {safe_repr(value)}。") + return value + def _get_length_function(self) -> Callable[[str], int]: """根据模式返回长度计算函数。""" if self.mode == "token": return lambda x: len(self._encoder.encode(x)) return len + # 分隔符表的长度上界。LangChain 的 ``RecursiveCharacterTextSplitter._split_text`` + # 按分隔符表**递归**:每一层为「剩余分隔符」再开一次调用。实测 1500 个分隔符 + # 抛 ``RecursionError: maximum recursion depth exceeded``——它不是 + # ``ValueError``,穿透调用方的错误分类与脱敏。表来自用户可写的 + # ``kb_splitter_separators``(TOML 数组),``Settings`` 的 validator 只做 + # ``str -> list`` 拆分、没有长度上界。 + # + # 上界取 64:真实配置是个位数(默认只有 ``["###"]``),而 LangChain 每层只 + # 消耗一个分隔符,64 层远在递归上限之内,同时挡住「配置写错成超长数组」。 + _MAX_SEPARATORS = 64 + + @staticmethod + def _require_separators(separators: Any) -> list[str]: + """校验分隔符表的形态与长度,返回原列表。 + + ``None`` 交给调用方按 settings 兜底;这里只管非 ``None`` 的取值。 + """ + if not isinstance(separators, list): + raise ValueError(f"分隔符表必须是列表,但得到 {safe_repr(separators)}。") + for item in separators: + if not isinstance(item, str): + raise ValueError(f"分隔符表的每一项都必须是字符串,但得到 {safe_repr(item)}。") + if len(separators) > RecursiveTextSplitter._MAX_SEPARATORS: + raise ValueError( + f"分隔符表最多 {RecursiveTextSplitter._MAX_SEPARATORS} 项," + f"但得到 {len(separators)} 项。分隔符表按项递归切分,过长的表会" + "在切分时抛 RecursionError。" + ) + return separators + @staticmethod def _with_fallback_separator(separators: list[str]) -> list[str]: """确保分隔符表以 ``""`` 结尾,让 ``chunk_size`` 在退化输入下仍然生效。 @@ -92,21 +187,151 @@ def _init_splitter(self): """根据当前配置初始化内部分割器。""" current_settings = get_settings() self.text_splitter = RecursiveCharacterTextSplitter( - chunk_size=self.parent_chunk_size or current_settings.kb_chunk_size, + # ``is not None`` 而不是 ``or``:``or`` 把 ``0`` 当假值兜底成 settings + # 的值(实际用 1500),而 ``_validate_hierarchical_sizes`` 用的是 + # ``is not None``、会保留 0 参与比较——于是「校验的值」与「用的值」 + # 不是同一个,实测 ``parent_chunk_size=0, child_chunk_size=300`` 报出 + # 「parent=0」,而真正生效的是 1500。上面的形态守卫现在会先拒掉 0, + # 这里同步对齐语义,避免两处判据再次分叉。 + chunk_size=self.parent_chunk_size + if self.parent_chunk_size is not None + else current_settings.kb_chunk_size, chunk_overlap=self.parent_chunk_overlap if self.parent_chunk_overlap is not None else current_settings.kb_chunk_overlap, - separators=self._with_fallback_separator(current_settings.kb_splitter_separators), + separators=self._with_fallback_separator( + self._require_separators(current_settings.kb_splitter_separators) + ), length_function=self._get_length_function(), is_separator_regex=False, ) logger.info( "文本分割器已就绪: chunk_size=%s, mode=%s, structure=%s", - self.parent_chunk_size or current_settings.kb_chunk_size, + self.parent_chunk_size + if self.parent_chunk_size is not None + else current_settings.kb_chunk_size, self.mode, self.structure_mode, ) + def _parent_sizes(self) -> tuple[int, int]: + """返回当前生效的父分片 ``(size, overlap)``。""" + settings = get_settings() + size = ( + self.parent_chunk_size if self.parent_chunk_size is not None else settings.kb_chunk_size + ) + overlap = ( + self.parent_chunk_overlap + if self.parent_chunk_overlap is not None + else settings.kb_chunk_overlap + ) + return size, overlap + + def _validate_parent_sizes(self) -> None: + """校验父分片器的 ``overlap < size`` 与 ``overlap <= size / 2``。 + + 为什么必须在这里判:``RecursiveTextSplitter`` 是公开类,调用方可以直接 + 构造它而不经过 ``Settings``,而 LangChain 的 + ``RecursiveCharacterTextSplitter`` 只在 ``overlap > size`` 时报错, + **相等时静默接受**(实测 size=100/ov=100 通过、ov=101 抛 ValueError)—— + 每个分片只前进 1 个字符。 + + 比例上界(``overlap <= size / 2``)拦的是「接近但不相等」这一类, + 它同样静默:实测一篇 15000 字符的文档在 size=1500 时,overlap=150(10%) + 产出 14 个分片(1.1x),而 overlap=1490(99%)产出 1651 个(137.6x)、 + overlap=1499 产出 13751 个(1145.9x)——全部进入 embedding 与 FAISS + 写入。判据与 ``Settings.validate_overlap_smaller_than_size`` 同源,两个 + 入口必须一致(``Settings`` 拦配置来源、这里拦直接构造)。 + + ``_validate_hierarchical_sizes`` 对**子**分片早有同一判据(注释写明「相等 + 会让子分片器无法推进」),父分片这条同一语义的判据此前缺失;而父分片器在 + standard 与 hierarchical 两种模式下都会构造(见 ``_init_splitter``), + 因此这条校验不能只挂在层级分支上。 + """ + size, overlap = self._parent_sizes() + if overlap >= size: + raise ValueError( + "kb_chunk_overlap 必须小于 kb_chunk_size," + f"但得到 overlap={safe_repr(overlap)}、size={safe_repr(size)}。" + " 相等会让父分片器几乎无法推进,产出成百上千倍的冗余分片。" + ) + if overlap * 2 > size: + raise ValueError( + "kb_chunk_overlap 不能超过 kb_chunk_size 的一半," + f"但得到 overlap={safe_repr(overlap)}、size={safe_repr(size)}。" + " 重叠比例过高会让父分片器几乎无法推进,产出成百上千倍的冗余分片。" + ) + + def _validate_hierarchical_sizes(self) -> None: + """层级模式下校验子分片与父分片的尺寸关系。 + + 这两条约束原先写在 ``Settings`` 的 model_validator 里,但**模式不在 + Settings 里**——它是分片时的运行时参数(``build(splitter_structure_mode)`` + 或 CLI 的 ``--mode``)。无条件校验的后果是一组「standard 下完全合法」的 + 取值连配置都加载不了:实测 ``kb_chunk_size=100, kb_child_chunk_size=300`` + 被 ``ValidationError`` 拒绝,而 standard 模式只调用普通分片器 + (``_split_standard_documents``),子分片器的参数根本不参与。 + + 放在这里判定同时保证了「校验的正是真正会用到的参数」:层级分支一定会 + 构造子分片器,非层级分支一定不会。 + + * ``child_size >= chunk_size``:层级结构静默退化为一层。实测 + ``kb_chunk_size=300, kb_child_chunk_size=1500`` 被接受后父块数从 2 涨到 + 10(每个父块只产出 1 个子块);相等同理(parent=child=300 时不同父块数 + == 分块数,15/15)。 + * ``child_overlap >= child_size``:子分片器无法推进(得到空分片或死循环)。 + """ + self._validate_parent_sizes() + settings = get_settings() + child_size = ( + self.child_chunk_size + if self.child_chunk_size is not None + else settings.kb_child_chunk_size + ) + child_overlap = ( + self.child_chunk_overlap + if self.child_chunk_overlap is not None + else settings.kb_child_chunk_overlap + ) + parent_size = self._parent_sizes()[0] + if child_overlap >= child_size: + raise ValueError( + "kb_child_chunk_overlap 必须小于 kb_child_chunk_size," + f"但得到 overlap={safe_repr(child_overlap)}、size={safe_repr(child_size)}。" + " 相等会让子分片器无法推进。" + ) + # 比例上界:与父分片的 ``_validate_parent_sizes`` 同判据、同理由。 + # 这两条判据此前只落在**父**侧,而子分片器同样产出分片(每个父块都要 + # 过它一次),同一个静默放大在子侧完全存在。实测父=1500/150、子=300 时 + # 一篇 15000 字符的文档: + # + # =========== ========== ========== + # 子 overlap 子块数 放大倍数 + # =========== ========== ========== + # 30 (10%) 66 1.0x + # 150 (50%) 99 1.5x + # 270 (90%) 451 6.8x + # 299 (99.7%) 13211 200.2x + # =========== ========== ========== + # + # 全部进入 embedding 与 FAISS 写入。``child_overlap >= child_size`` 那条 + # 拦不住它们——299 < 300 成立、直接通过。配置侧同样放行: + # ``Settings.validate_overlap_smaller_than_size`` 只判 ``kb_chunk_overlap`` + # 与 ``kb_chunk_size``,子分片的两条关系按注释所述有意留给本函数(模式 + # 是运行时参数),因此这里是唯一的判据位置。 + if child_overlap * 2 > child_size: + raise ValueError( + "kb_child_chunk_overlap 不能超过 kb_child_chunk_size 的一半," + f"但得到 overlap={safe_repr(child_overlap)}、size={safe_repr(child_size)}。" + " 重叠比例过高会让子分片器几乎无法推进,产出成百上千倍的冗余分片。" + ) + if child_size >= parent_size: + raise ValueError( + "kb_child_chunk_size 必须小于 kb_chunk_size(子分片不得大于或等于父分片)," + f"但得到 child={safe_repr(child_size)}、parent={safe_repr(parent_size)}。" + " 否则层级结构静默退化为一层。" + ) + def _build_child_splitter(self) -> RecursiveCharacterTextSplitter: """构建子分片器。""" current_settings = get_settings() @@ -119,7 +344,9 @@ def _build_child_splitter(self) -> RecursiveCharacterTextSplitter: if self.child_chunk_overlap is not None else current_settings.kb_child_chunk_overlap ), - separators=self._with_fallback_separator(current_settings.kb_splitter_separators), + separators=self._with_fallback_separator( + self._require_separators(current_settings.kb_splitter_separators) + ), length_function=self._get_length_function(), is_separator_regex=False, ) @@ -136,21 +363,34 @@ def _strip_leading_punctuation(text: str) -> str: 829 字符,丢失 1.66%。最坏情况整篇都是句号分隔符时,全部分片都可能被判为空 并 ``continue`` 掉,产出 0 个块。 - 现在只规范化边界空白:把开头的句号替换为等长空格再 strip,句中信息 - 一字不改,也不会再产生「内容全被删掉因此该块被跳过」的路径。 + 现在只规范化边界空白:把开头的**空白**替换为等长空格再 strip,标点一字 + 不动。句中信息一字不改,也不会再产生「内容全被删掉因此该块被跳过」的路径。 + + 上一版写的是 ``re.sub(r"^[\s.。]+", lambda m: " " * len(m.group()), text)`` + ——看似「替换而不是删除」,实则与旧实现**逐字符等价**:被替换的字符集 + (空白 + ``.`` + ``。``)恰好等于随后 ``strip()`` 会删掉的字符集,替换成 + 空格后立刻又被 strip 掉。实测 3 万条随机输入零差异,``"。" * 200`` 依然 + 产出 0 块、``separators=["。"]`` 下依然丢 2.9% 的字符。因此那条 CHANGELOG + 声称的「句中信息一字不丢」当时并不成立。 + + 现在**只**匹配空白。句号属于正文——当分隔符本身就是 ``。`` 时, + ``keep_separator=True`` 会把上一句的句号留在下一个分片开头,删掉它 + 就是丢掉用户内容;而 ``。`` 本身是有意义的分隔信息,保留它才能让分片 + 拼接回原文。 """ - text = re.sub(r"^[\s.。]+", lambda match: " " * len(match.group()), text) + text = re.sub(r"^\s+", lambda match: " " * len(match.group()), text) return text.strip() def _split_standard_documents(self, documents: list[Document]) -> list[Document]: """标准单层分片。""" all_chunks: list[Document] = [] for doc in documents: - logger.debug(f"正在分割文档: {doc.metadata.get('source', '未知来源')}") + logger.debug(f"正在分割文档: {_safe_source(doc)}") langchain_chunks = self.text_splitter.create_documents( [doc.content], metadatas=[doc.metadata] ) + chunks_before = len(all_chunks) for i, chunk in enumerate(langchain_chunks): chunk_content = self._strip_leading_punctuation(chunk.page_content) if not chunk_content: @@ -164,21 +404,30 @@ def _split_standard_documents(self, documents: list[Document]) -> list[Document] chunk_metadata["token_count"] = len(self._encoder.encode(chunk_content)) all_chunks.append(Document(content=chunk_content, metadata=chunk_metadata)) - logger.debug( - "文档 '%s' 分割完成,生成 %s 个块。", - doc.metadata.get("source", "未知来源"), - len(langchain_chunks), - ) + # 日志必须报**实际收录**的块数:原先写的是 ``len(langchain_chunks)`` + # (过滤前的数量),一份被整段判空的文档会显示「生成 8 个块」而实际 + # 贡献 0 个——日志反过来掩盖了问题。 + self._report_document_chunks(doc, len(all_chunks) - chunks_before) return all_chunks def split_hierarchical(self, documents: list[Document]) -> list[Document]: """层级分片:先切父块,再切子块。""" + # 两个守卫都必须在这里,不能只挂在 ``split`` 的层级分支上: + # ``split_hierarchical`` 是**公开方法**(同 ``_require_documents`` 的 + # 理由),直接调它时 ``split`` 的校验全部被绕过。实测 + # ``child_overlap == child_size`` 时 ``split`` 抛带指引的 ``ValueError``, + # 而这里照常返回——1000 字符的文档产出 694 个分片(子分片器几乎不前进), + # 全部进入 embedding 与 FAISS 写入且无任何提示;``child_size >= + # parent_size`` 则让层级结构静默退化为一层。同一个非法配置在两条入口上 + # 的收场必须一致。 + self._require_documents(documents) + self._validate_hierarchical_sizes() all_chunks: list[Document] = [] child_splitter = self._build_child_splitter() self.parent_documents = {} for doc in documents: - logger.debug(f"正在层级分割文档: {doc.metadata.get('source', '未知来源')}") + logger.debug(f"正在层级分割文档: {_safe_source(doc)}") parent_documents = self.text_splitter.create_documents( [doc.content], metadatas=[doc.metadata] ) @@ -217,18 +466,67 @@ def split_hierarchical(self, documents: list[Document]) -> list[Document]: all_chunks.append(Document(content=child_content, metadata=child_metadata)) doc_chunk_count += 1 - logger.debug( - "文档 '%s' 层级分割完成,生成 %s 个块。", - doc.metadata.get("source", "未知来源"), - doc_chunk_count, - ) + self._report_document_chunks(doc, doc_chunk_count, hierarchical=True) return all_chunks + @staticmethod + def _report_document_chunks( + doc: Document, produced: int, *, hierarchical: bool = False + ) -> None: + r"""记录单篇文档**实际收录**的块数。 + + 原先标准分支记的是 ``len(langchain_chunks)``——过滤**前**的数量。一份 + 被整段判空的文档会显示「生成 8 个块」而实际贡献 0 个,日志反过来掩盖了 + 问题。 + + ``produced == 0`` 只可能出现在原文(按 ``\s`` 语义)为空时:过滤条件是 + 「该块去掉首尾空白后为空」,若所有块都被滤掉,则所有块都是空白,即原文 + 只有空白。实测对 16 种输入 × 5 组分隔符 × 3 组尺寸 × 2 种结构组合做笛卡尔 + 扫描,没有出现「原文有非空白内容却产出 0 块」的情形——因此这里不为它写 + 警告分支(写不到的死代码只会让人误以为有防护)。 + """ + source = _safe_source(doc) + if produced: + stage = "层级分割" if hierarchical else "分割" + logger.debug("文档 '%s' %s完成,生成 %s 个块。", source, stage, produced) + else: + logger.debug("文档 '%s' 清洗后为空,跳过。", source) + + @staticmethod + def _require_documents(documents: Any) -> None: + """校验分片入参是 ``Document`` 列表。 + + 本类此前对七类非法输入全部抛裸异常:``documents=5`` 抛 + ``TypeError: object of type 'int' has no len()``(``split`` 里的日志行) + 或 ``TypeError: 'int' object is not iterable``(``split_hierarchical`` + 的循环),``"abc"``/``[5]``/``[None]`` 抛 + ``AttributeError: 'str' object has no attribute 'metadata'``(两个分支 + 里的 ``doc.metadata``)。两者都不在调用方按 ``ValueError`` 建立的分类内。 + + 两个公开入口(``split`` 与 ``split_hierarchical``)共用这一处判据: + 只守卫 ``split`` 会留下「直接调 ``split_hierarchical``」的口子,而它是 + 公开方法。 + + 条目判据用 ``Document`` 实例而不是鸭子类型:两个分支都直接读 + ``doc.content`` 与 ``doc.metadata``,而 ``Document`` 是 pydantic 模型 + (构造期已校验 ``content: str``),要求实例化才能进入这里,能让错误 + 定位到构造处而不是分片处。 + """ + if isinstance(documents, (str, bytes, bytearray)) or not isinstance(documents, Sequence): + raise ValueError(f"分片器 documents 必须是文档列表,但得到 {safe_repr(documents)}。") + for index, document in enumerate(documents): + if not isinstance(document, Document): + raise ValueError( + f"分片器 documents[{index}] 必须是 Document,但得到 {safe_repr(document)}。" + ) + def split(self, documents: list[Document], **kwargs) -> list[Document]: """ 将文档列表中的文本内容分割成更小的块。 """ + # 形态校验必须先于日志里的 ``len(documents)``。 + self._require_documents(documents) logger.info(f"开始分割 {len(documents)} 个文档。") # 实时同步最新配置 @@ -236,8 +534,12 @@ def split(self, documents: list[Document], **kwargs) -> list[Document]: self.parent_documents = {} if self.structure_mode == "hierarchical": + self._validate_hierarchical_sizes() all_chunks = self.split_hierarchical(documents) else: + # 父分片器的 ``overlap < size`` 与结构模式无关:standard 分支同样会 + # 构造并使用它,这条校验不能只挂在层级分支上。 + self._validate_parent_sizes() all_chunks = self._split_standard_documents(documents) logger.info( diff --git a/src/models/document.py b/src/models/document.py index 73d77e5..d29bf59 100644 --- a/src/models/document.py +++ b/src/models/document.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, Field +from src.utils.config import safe_repr + class Document(BaseModel): """ @@ -17,8 +19,26 @@ class Document(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) def __str__(self): - """返回文档内容的字符串表示,方便调试。""" - return f"Document(content='{self.content[:50]}...', metadata={self.metadata})" + """返回文档内容的字符串表示,方便调试。 + + ``metadata`` 声明为 ``dict[str, Any]``,取值不受控;``repr`` 里一个位数 + 超过 ``sys.get_int_max_str_digits()``(默认 4300)的整数就让整条 f-string + 抛 ``ValueError: Exceeds the limit``。这比「显示不好看」严重:``__repr__`` + 是 pytest 失败报告、日志与调试器的**兜底渲染**,它一抛,报错现场就什么都 + 看不到(实测 pytest 把失败对象显示成 + ``<[ValueError(...) raised in repr()] Document object at 0x...>``)。 + + 与 ``ui.display_utils._render_value``/``_path_text`` 同一取舍:只在渲染 + **真的失败**时才降级到 ``safe_repr``,正常路径逐字不变(``safe_repr`` 会在 + 80 字符处截断,真实分片 metadata 的 ``repr`` 长约 127 字符,无条件套用会 + 改变所有正常文档的调试输出)。 + """ + try: + return f"Document(content='{self.content[:50]}...', metadata={self.metadata})" + except (ValueError, RuntimeError, RecursionError): + return ( + f"Document(content='{self.content[:50]}...', metadata={safe_repr(self.metadata)})" + ) def __repr__(self): """返回文档的官方字符串表示。""" diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index b1b7c0e..84c302f 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -5,6 +5,7 @@ import json import math import struct +import sys from abc import ABC, abstractmethod from collections.abc import ( AsyncGenerator, @@ -31,6 +32,9 @@ wait_exponential, ) +from src.utils.config import is_double_convertible, parse_decimal_int, safe_repr +from src.utils.log_manager import get_module_logger +from src.utils.security import safe_exception_text from src.utils.security import ( validate_secret_free_options as _validate_secret_free_options, ) @@ -280,6 +284,30 @@ class StreamEvent: raw: Any = None +def hashable_key_part(value: Any) -> Any: + """把要参与累加器键的值收敛成可哈希形态。 + + 为什么需要它:``accumulator.setdefault(("index", value), ...)`` 对不可哈希的 + ``value`` 抛 ``TypeError: unhashable type``。这些值来自不可信的兼容网关 SSE + JSON——``openai`` SDK 的流式解析走 ``construct_type``,**跳过已声明字段的 + Literal/int 校验**,实测 ``{"tool_calls": [{"index": [1, 2]}]}`` 会原样产出 + ``ChoiceDeltaToolCall(index=[1, 2])``(声明类型是 ``int``)。调用方按 + ``ValueError`` / ``RuntimeError`` 分类错误,于是「网关片段形态异常」变成裸 + ``TypeError``,日志脱敏与用户提示都接不住。 + + 收敛而不是抛错,是因为键只用于**归并同一调用的分片**:不可哈希的形态本来就 + 无法充当稳定标识,退回 ``("position", ...)`` 与「没有 id/index」走同一条 + 分支,语义一致。可哈希的取值原样返回,行为不变。 + """ + if value is None: + return None + try: + hash(value) + except TypeError: + return None + return value + + def merge_tool_call_fragment( accumulator: dict[Any, dict[str, Any]], tool_call: Mapping[str, Any], @@ -290,8 +318,8 @@ def merge_tool_call_fragment( 供应商通常只在第一个片段发送 id/name,后续片段只发送 index 和 partial arguments。统一在适配层保留这些字段,避免调用方自行拼接。 """ - index = tool_call.get("index", fallback_index) - call_id = tool_call.get("id") or tool_call.get("call_id") + index = hashable_key_part(tool_call.get("index", fallback_index)) + call_id = hashable_key_part(tool_call.get("id") or tool_call.get("call_id")) if index is not None: key: Any = ("index", index) elif call_id: @@ -464,7 +492,27 @@ def normalize_messages( ``require_tool_call_ids`` 只应由确实要求 assistant 历史工具调用带 ``id`` 的协议(Chat Completions)开启;其它协议继续接受 Gemini 这类无 id 的历史。 + + ``prompt`` 与 ``system_prompt`` 的形态必须在这里收敛,且必须与 ``messages`` + 分支同判据:本函数是四个 provider 公共 ``invoke`` 的**统一请求边界** + (``google.py``/``anthropic.py``/``openai_compatible.py``/``volcengine.py`` + 都调它),而 ``messages`` 那条分支对非序列、非 ``Mapping`` 条目都给 + ``ValueError``,``prompt``/``system_prompt`` 这一半此前**完全没有**判据: + + * ``prompt=5``/``["a"]`` 静默生成 ``{"role": "user", "content": 5}``——非字符串 + 字面量被当成真实用户输入发给模型(与 ``ChatService._build_prompt`` 钉住的 + ``用户问题: None`` 是同一形态); + * ``prompt=10**4301`` 在 ``str(prompt).strip()`` 处抛 ``ValueError: Exceeds + the limit``,消息与「请求内容写错了」无关; + * ``system_prompt`` 同理:真值非字符串直接进 ``content``。 + + ``None`` 是合法取值(``prompt=None`` 走「必须提供 prompt 或 messages」分支, + ``system_prompt=None`` 表示不带系统提示),只拒绝非字符串的**真值**形态。 """ + if prompt is not None and not isinstance(prompt, str): + raise ValueError(f"prompt 必须是字符串,但得到 {safe_repr(prompt)}。") + if system_prompt is not None and not isinstance(system_prompt, str): + raise ValueError(f"system_prompt 必须是字符串,但得到 {safe_repr(system_prompt)}。") if messages is not None: if isinstance(messages, (str, bytes, bytearray)) or not isinstance(messages, Sequence): raise ValueError("messages 必须是消息对象序列。") @@ -517,8 +565,18 @@ def _responses_has_content(value: Any) -> bool: def _responses_provider_variant(provider: str) -> str: - """规范化 Responses 目标 SDK 变体。""" - normalized = str(provider).strip().lower().replace("-", "_") + """规范化 Responses 目标 SDK 变体。 + + 非字符串收敛成 ``"openai"`` 而不是抛错:本函数只做**分流**(Ark 还是 + OpenAI 变体),调用方在两条路径上都会继续做各自的取值校验。此前写 + ``str(provider)``,``10**4301`` 撞上 CPython 的整数位数上限抛 + ``ValueError: Exceeds the limit``——一个纯粹的分流判断不该产生与协议无关的 + 整数转换错误,而且它抛在 ``_normalize_responses_native_item`` 的入口,会让 + 本该给出的「字段类型不对」消息变成这条。 + """ + if not isinstance(provider, str): + return "openai" + normalized = provider.strip().lower().replace("-", "_") return "ark" if normalized in {"ark", "volcengine", "volc_engine"} else "openai" @@ -690,10 +748,12 @@ def _responses_content_part( raise ValueError(f"{location}.file_id 必须是字符串。") if not video_url and not video_file_id: raise ValueError(f"{location} 缺少 video_url 或 file_id。") + # 先判可转换性:``math.isfinite(float(x))`` 对超出双精度范围的整数 + # 自己就抛 ``OverflowError``(**不是** ``ValueError``)。 if fps is not None and ( isinstance(fps, bool) or not isinstance(fps, (int, float)) - or not math.isfinite(float(fps)) + or not is_double_convertible(fps) ): raise ValueError(f"{location}.fps 必须是有限数字。") converted_video: dict[str, Any] = {"type": "input_video"} @@ -782,7 +842,7 @@ def _responses_content_part( allowed_details = {"auto", "low", "high"} if variant == "openai": allowed_details.add("original") - if source["detail"] not in allowed_details: + if not isinstance(source["detail"], str) or source["detail"] not in allowed_details: allowed = "、".join(sorted(allowed_details)) raise ValueError(f"{location}.detail 必须是 {allowed}。") converted_input_image: dict[str, Any] = {"type": "input_image", "detail": source["detail"]} @@ -827,7 +887,12 @@ def _responses_content_part( raise ValueError(f"{location}.{key} 必须是字符串。") converted = {"type": "input_file"} if source.get("detail") is not None: - if variant != "openai" or source["detail"] not in {"auto", "low", "high"}: + detail_value = source["detail"] + if ( + variant != "openai" + or not isinstance(detail_value, str) + or detail_value not in {"auto", "low", "high"} + ): raise ValueError(f"{location}.detail 不受 {variant.title()} Responses SDK 支持。") converted["detail"] = source["detail"] for key in ("file_id", "file_data", "file_url", "filename"): @@ -847,7 +912,12 @@ def _responses_content_part( raise ValueError(f"{location} 缺少 file_id、file_data 或 file_url。") converted = {"type": "input_file"} if source.get("detail") is not None: - if variant != "openai" or source["detail"] not in {"auto", "low", "high"}: + detail_value = source["detail"] + if ( + variant != "openai" + or not isinstance(detail_value, str) + or detail_value not in {"auto", "low", "high"} + ): raise ValueError(f"{location}.detail 不受 {variant.title()} Responses SDK 支持。") converted["detail"] = source["detail"] for key in ("file_id", "file_data", "file_url", "filename"): @@ -1187,14 +1257,31 @@ def usage_key(prefix: str) -> str: def extract_system_prompt(messages: Sequence[Mapping[str, Any]] | None) -> str | None: - """提取消息列表中的 system 内容,供无 system role 的 SDK 使用。""" - if not messages: + """提取消息列表中的 system 内容,供无 system role 的 SDK 使用。 + + 容器与条目形态都要先收敛:本函数在多数 provider 里**先于** + ``normalize_messages`` 被调用(后者有完整的 ``ValueError`` 判据),因此 + 非法取值在这里就炸,收场由本函数决定。实测 ``messages=5`` 抛裸 + ``TypeError: 'int' object is not iterable``(列表推导的迭代)、 + ``messages="s"`` 抛裸 ``AttributeError: 'str' object has no attribute 'get'`` + (``"s".get`` 不存在,且字符串是合法的 ``Sequence``,只判可迭代拦不住)。 + 两者都不落调用方按 ``ValueError`` 建立的分类。 + + 判据与 ``normalize_messages`` 逐字对齐(含 ``bytes``/``bytearray`` 的 + 显式排除——它们也是 ``Sequence``,迭代得到的是整数,``.get`` 不存在)。 + 条目非 Mapping 时同样给 ``ValueError`` 而不是跳过:静默跳过会让一条写错的 + 消息**悄悄消失**,system 提示随之丢失,比报错更难查。 + """ + if messages is None: return None - values = [ - content_to_text(message.get("content", "")) - for message in messages - if message.get("role") == "system" - ] + if isinstance(messages, (str, bytes, bytearray)) or not isinstance(messages, Sequence): + raise ValueError("messages 必须是消息对象序列。") + values = [] + for index, message in enumerate(messages): + if not isinstance(message, Mapping): + raise ValueError(f"messages[{index}] 必须是消息对象,但得到 {safe_repr(message)}。") + if message.get("role") == "system": + values.append(content_to_text(message.get("content", ""))) value = "\n\n".join(item for item in values if item) return value or None @@ -1224,21 +1311,55 @@ def normalize_tool_arguments(value: Any) -> str: def validate_complete_tool_call(tool_call: Mapping[str, Any], provider: str) -> dict[str, Any]: - """校验流式工具调用在发送 completed 事件前已经完整。""" + """校验流式工具调用在发送 completed 事件前已经完整。 + + 判据是**黑名单**:只有 ``custom_tool_call`` 接受任意文本(Responses 的 + custom tool 契约里 ``input`` 就是 ``str``,无 JSON 约束),其余任何 ``type`` + 都按函数调用要求完整 JSON。 + + 这一点必须说清楚,因为两条判据很容易被写成相反的方向——「必须有非空 + arguments」用 ``type != "custom_tool_call"``(黑名单),「必须校验 JSON」却 + 写成 ``type in {"function", "function_call"}``(白名单)。这样任何不在白名单 + 里的取值(网关改写过的 ``type``、大写形式、空串)同时满足「必须有参数」与 + 「不必校验参数」,半截 JSON 就被当作完整调用交付。而 ``type`` 是不可信输入: + SDK 流式解析走 ``construct_type``(``_process_response_data`` 在非严格校验下 + 不校验已声明字段),``tool_calls[].type`` 写成 ``["function"]`` 也能到达这里。 + + 因此这里只做一次类型收敛(非字符串无法与 ``custom_tool_call`` 相等,按函数 + 调用处理),不再做集合成员测试——成员测试对 ``list``/``dict`` 会抛 + ``TypeError: unhashable type``,那是崩溃而不是校验。 + """ name = tool_call.get("name") if not isinstance(name, str) or not name.strip(): raise RuntimeError(f"{provider} 工具调用缺少 name,流提前结束。") - tool_type = tool_call.get("type", "function_call") + raw_type = tool_call.get("type") + is_custom_tool = isinstance(raw_type, str) and raw_type == "custom_tool_call" arguments = tool_call.get("arguments") # Responses custom tools intentionally accept arbitrary text, including an - # empty input. Function calls still require a complete JSON arguments value. - if tool_type != "custom_tool_call" and (arguments is None or arguments == ""): + # empty input. Every other call requires a complete JSON arguments value. + if not is_custom_tool and (arguments is None or arguments == ""): raise RuntimeError(f"{provider} 工具调用 {name} 缺少完整 arguments,流提前结束。") - if tool_type == "function_call" and isinstance(arguments, str): + if not is_custom_tool and isinstance(arguments, str): try: json.loads(arguments) except json.JSONDecodeError as exc: raise RuntimeError(f"{provider} 工具调用 {name} 的 arguments 不是完整 JSON。") from exc + except RecursionError as exc: + # 嵌套深度超过解释器栈:JSON 可能结构完整,但本进程无法校验它。 + # ``RecursionError`` 是 ``RuntimeError`` 的子类,类型上虽落在调用方 + # 的分类内,消息却只有解释器文案,没有工具调用名。 + raise RuntimeError( + f"{provider} 工具调用 {name} 的 arguments 嵌套过深,无法校验完整性。" + ) from exc + except ValueError as exc: + # 除 ``JSONDecodeError``(``ValueError`` 子类,上面已捕获)外, + # ``json.loads`` 还会因整数位数超过 ``sys.get_int_max_str_digits()`` + # 抛 ``ValueError``。它不是 ``RuntimeError``,会穿透调用方按 + # ``RuntimeError`` 做的错误分类,消息更是与「哪个工具调用」无关。 + # 与全项目同一判据:整数转换问题不冒名成协议错误。 + raise RuntimeError( + f"{provider} 工具调用 {name} 的 arguments 含超长整数,无法校验完整性。" + ) from exc return dict(tool_call) @@ -1247,7 +1368,29 @@ def normalize_embedding_vector(value: Any) -> list[float]: if hasattr(value, "tolist"): value = value.tolist() if isinstance(value, (list, tuple)): - return [float(item) for item in value] + # ``float(item)`` 对超出双精度范围的整数抛 ``OverflowError``——它不是 + # ``ValueError``,会穿透 ``EmbeddingService`` 的 ``except ValueError`` + # 与 provider 的错误分类逻辑。SDK 返回的 JSON 里出现一个 401 位整数 + # 就足以走到这里,因此逐项先判可转换性。 + # + # 只收敛 ``OverflowError``,不改变既有契约:``inf``/``nan`` 在此仍照旧 + # 转成 ``inf``/``nan`` 交给下游——``EmbeddingService`` 会显式拒绝非有限 + # 向量(``np.isfinite`` 检查),而 base64 分支(``struct.unpack``)本来 + # 就可能产出 ``inf``。在这里另加一道有限性拒绝会让两个分支的契约不一致。 + converted: list[float] = [] + for index, item in enumerate(value): + # 只拦「超出双精度范围的 int」——它是 ``OverflowError`` 的唯一来源。 + # ``inf``/``nan`` 照旧放行:base64 分支(``struct.unpack``)本来就 + # 可能产出它们,有限性由下游 ``EmbeddingService`` 统一拒绝,在这里 + # 另加一道会让两个分支的契约不一致。 + if ( + isinstance(item, int) + and not isinstance(item, bool) + and not is_double_convertible(item) + ): + raise ValueError(f"Embedding 向量第 {index} 项超出双精度范围: {safe_repr(item)}。") + converted.append(float(item)) + return converted if isinstance(value, (bytes, bytearray, memoryview)): encoded = bytes(value) elif isinstance(value, str): @@ -1271,6 +1414,167 @@ def field(value: Any, name: str, default: Any = None) -> Any: return getattr(value, name, default) +def enum_field(value: Any, name: str) -> str: + """读取**用于集合成员判断**的枚举字段,非字符串一律收敛成 ``""``。 + + 为什么需要它:``field(x, "type") in {"a", "b"}`` 对 ``list``/``dict`` 抛 + ``TypeError: unhashable type``。这些值来自网关 JSON(openai SDK 对 SSE + chunk 的未知顶层字段用 ``extra="allow"`` 原样保留,实测一个 + ``{"type": ["error"]}`` 就能构造出来),而调用方按 ``ValueError`` / + ``RuntimeError`` 分类错误——于是「网关返回了形态异常的事件」变成裸 + ``TypeError``,日志脱敏与错误分类都接不住。 + + 收敛成 ``""`` 而不是抛错,是因为这些站点的语义是「**不是**我认识的事件就 + 跳过」:未知类型本来就走 else 分支,空串落在同一个分支上,行为不变。 + """ + candidate = field(value, name, "") + return candidate if isinstance(candidate, str) else "" + + +def require_token_count(value: Any, provider: str, field_name: str) -> int: + """把 SDK 返回的 token 计数收敛成非负整数。 + + 八个 provider 方法(Anthropic 同步/异步 × 普通/Beta、Google 同步/异步、 + OpenAI Responses 同步/异步)此前都写成「判 ``None`` 后直接 ``int(value)``」。 + 那只挡住了缺字段:SDK 或兼容网关返回 ``"abc"``、``1.5``、``[1]`` 或位数超限 + 的数字串时,``int()`` 抛的是裸 ``ValueError``/``TypeError``/位数超限错误, + 与「token 计数」这件事无关,调用方也拿不到是哪个字段出的问题。 + + 非负整数是这份数据的真实契约:token 计数不可能为负,也不可能带小数。 + """ + if value is None: + raise RuntimeError(f"{provider} token count 响应缺少 {field_name}。") + if isinstance(value, bool): + raise RuntimeError( + f"{provider} token count 响应的 {field_name} 不是整数: {safe_repr(value)}。" + ) + if isinstance(value, str): + parsed = parse_decimal_int(value) + if parsed is None: + raise RuntimeError( + f"{provider} token count 响应的 {field_name} 不是整数: {safe_repr(value)}。" + ) + numeric = parsed + elif isinstance(value, (int, float)): + if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()): + raise RuntimeError( + f"{provider} token count 响应的 {field_name} 不是整数: {safe_repr(value)}。" + ) + numeric = int(value) + else: + raise RuntimeError( + f"{provider} token count 响应的 {field_name} 不是整数({type(value).__name__})。" + ) + if numeric < 0: + raise RuntimeError( + f"{provider} token count 响应的 {field_name} 不能为负: {safe_repr(numeric)}。" + ) + return numeric + + +# 客户端级重试次数的上界。 +# +# SDK 的重试循环是 ``range(max_retries + 1)``(openai SDK 的 +# ``_request``;Ark SDK 是等价的递归 ``_retry_request``),因此这个值直接决定 +# 一次请求在故障时最多发出多少次。给它上界有两个理由:``config.toml`` 的 TOML +# 整数**无上界**,不设界时 ``max_retries = 10**400`` 会被原样接受并存进 SDK +# 客户端(实测 ``client.max_retries`` 确实是那个 401 位整数),网络故障下等价于 +# 无限重试;而一个合理的重试上限本来就是配置项该有的约束,不是可以取到天文 +# 数字的量。 +_MAX_CLIENT_RETRIES = 100 + +# 客户端级请求超时(秒)的上界。 +# +# 与 ``max_retries`` 同属「透传给 SDK、SDK 自己不校验」的一类,但失效方式不同: +# 超时值本身再大也只是「等到天荒地老」,真正的崩点在**派生值**上。Google 的 +# ``_build_http_options`` 把秒换算成毫秒(``int(timeout * 1000)``)交给 +# ``types.HttpOptions``,而 ``int()`` 对超过 ``DBL_MAX`` 的浮点抛 +# ``OverflowError``——它是 ``ArithmeticError`` 的子类,穿透项目全部以 +# ``ValueError`` 为契约的 handler(``get_settings``、chat 主循环、questionary +# validator),以裸 traceback 终止会话。实测 ``config.toml`` 写 +# ``timeout = 1e306`` 即可:配置加载成功、provider 构造成功,首次提问才炸。 +# +# 这个上界取一天:超时是「多久算失败」的判定,没有任何真实场景需要等更久, +# 而它同时把换算后的毫秒值(86400000)约束在安全整数区间内。 +_MAX_CLIENT_TIMEOUT_SECONDS = 86_400.0 + + +def normalize_client_timeout(value: Any, provider: str, field_name: str) -> float: + """把客户端级 ``timeout`` 收敛成 ``0`` 到 ``_MAX_CLIENT_TIMEOUT_SECONDS`` 的秒数。 + + 必须在构造期校验,而不是交给 SDK:SDK 对 ``timeout`` 不做任何检查,非法 + 取值要到发请求时才以形态各异的错误暴露。 + + * **非正数**(``0``/``-1``):httpx 会立刻超时,请求形同未发。 + * **超大值**:不只是「等得久」。凡是在该值上再做算术的下游都会炸—— + Google SDK 的毫秒换算抛 ``OverflowError``,而 ``OverflowError`` 不是 + ``ValueError``,穿透项目所有错误边界。实测 ``1e306`` 即触发。 + * **非数字**(``"abc"``/``True``):SDK 原样存进客户端,直到比较超时时 + 才抛 ``TypeError``,报错点离配置处很远。 + + 只接受 ``int``/``float``,不接受字符串字面量:超时是数值型配置,三个渠道 + (Google、Anthropic、OpenAI 兼容)原本就拒绝 ``"30"``,这条契约由 + ``test_google_request_timeout_must_be_finite_and_positive`` 固定。 + """ + # ``is_double_convertible`` 是这里唯一正确的守卫:它先判类型,再对 ``int`` + # 判绝对值上界、对 ``float`` 判有限性。直接写 ``float(value)`` 会引入同一个 + # 失效模式——``float(10**400)`` 抛 ``OverflowError``(**不是** + # ``ValueError``),本函数就成了「正要拒绝非法值时改抛别的异常」。它同时 + # 挡住 ``bool``(``int`` 子类,``True`` 会静默变成 1.0)与 ``str``。 + if not is_double_convertible(value): + raise ValueError(f"{provider} {field_name} 必须是正数。") + numeric = float(value) + if numeric <= 0: + raise ValueError(f"{provider} {field_name} 必须是正数。") + if numeric > _MAX_CLIENT_TIMEOUT_SECONDS: + raise ValueError( + f"{provider} {field_name} 不能超过 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 秒," + f"但得到 {safe_repr(value)}。" + ) + return numeric + + +def normalize_client_retries(value: Any, provider: str) -> int: + """把客户端级 ``max_retries`` 收敛成 ``0`` 到 ``_MAX_CLIENT_RETRIES`` 的整数。 + + 为什么必须在构造期校验,而不是交给 SDK:SDK 对 ``max_retries`` 不做任何 + 检查,取值直接决定重试循环的次数,于是每一类非法取值都表现为远处的怪错误。 + + * **负数**:``range(max_retries + 1)`` 一次都不执行,请求根本没发出去, + 最终抛的是 SDK 内部的 ``AssertionError: could not resolve response + (should never happen)``——实测 ``max_retries=-1`` 时 HTTP 请求数为 **0**, + 用户看到的是「断言失败」,与「重试次数配错了」毫无关联。 + * **超大值**:等价于无上限重试。 + * **非整数**(``"abc"``/``1.5``/``True``):SDK 把它们原样存进 + ``self.max_retries``,直到真正重试时才在 ``max_retries - retries_taken`` + 上抛 ``TypeError``,报错点离配置处很远。 + + 字符串按十进制字面量接受(``"3"``),因为 ``.env``/TOML 来源本来就以字符串 + 到达;``bool`` 必须显式拒绝——它是 ``int`` 的子类,``True`` 会静默变成 ``1``。 + """ + if isinstance(value, bool): + raise ValueError(f"{provider} options.max_retries 必须是整数,不能是布尔值。") + if isinstance(value, str): + parsed = parse_decimal_int(value) + if parsed is None: + raise ValueError(f"{provider} options.max_retries 必须是整数。") + numeric = parsed + elif isinstance(value, int): + numeric = value + elif isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + raise ValueError(f"{provider} options.max_retries 必须是整数。") + numeric = int(value) + else: + raise ValueError(f"{provider} options.max_retries 必须是整数。") + if not 0 <= numeric <= _MAX_CLIENT_RETRIES: + raise ValueError( + f"{provider} options.max_retries 必须在 0 到 {_MAX_CLIENT_RETRIES} 之间," + f"但得到 {safe_repr(value)}。" + ) + return numeric + + def is_retryable_error(error: BaseException) -> bool: """只对连接、限流和服务端错误重试,避免参数/认证错误重复发送请求。""" if isinstance(error, (TimeoutError, ConnectionError)): @@ -1285,9 +1589,14 @@ def is_retryable_error(error: BaseException) -> bool: continue if isinstance(status, int): return status == 429 or status >= 500 - if isinstance(status, str) and status.isdigit(): - numeric_status = int(status) - return numeric_status == 429 or numeric_status >= 500 + # 解析走 ``parse_decimal_int``:``int()`` 会抛错的情形有两类——非十进制 + # 数字字符(``isdigit()`` 为真但 ``int()`` 抛错的 ``²`` ``①`` ``፩`` …) + # 与位数超过 ``sys.get_int_max_str_digits()``。任一类漏掉都会让「这个错误 + # 要不要重试」的判断本身崩掉,把可重试的 5xx 变成一条解析异常。 + if isinstance(status, str): + numeric_status = parse_decimal_int(status) + if numeric_status is not None: + return numeric_status == 429 or numeric_status >= 500 name = error.__class__.__name__.lower() return any( token in name @@ -1358,17 +1667,33 @@ async def iterate_async(value: Any) -> AsyncGenerator[Any, None]: return raise TypeError("SDK 流式响应既不是异步迭代器也不是同步迭代器。") finally: - close = getattr(value, "aclose", None) - if close is not None: - result = close() - if inspect.isawaitable(result): - await result - else: - close = getattr(value, "close", None) + # 关闭异常**不能**顶替 ``try`` 里正在上抛的那个:``finally`` 的异常 + # 会替换掉它,而调用方按异常类型分类——``retry_async_stream`` 用 + # ``is_retryable_error`` 的类名判据决定是否重试。实测「流中途断开」 + # (``ConnectionError``,可重试)被「关闭也失败」(``RuntimeError``, + # 不可重试)顶替后,一次可重试的连接错误只尝试了 1 次就放弃。 + # + # ``sys.exc_info()`` 必须在**进入 except 之前**取:一旦进到内层 + # ``except``,它返回的就是关闭异常本身,判据失效。 + in_flight = sys.exc_info()[0] is not None + try: + close = getattr(value, "aclose", None) if close is not None: result = close() if inspect.isawaitable(result): await result + else: + close = getattr(value, "close", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + except Exception: # 清理失败不得顶替主流程异常 + if not in_flight: + raise + + +logger = get_module_logger(__name__) T = TypeVar("T") @@ -1387,6 +1712,13 @@ def raise_for_stream_error_event(event: Any, provider: str) -> None: """将协议层 error 事件转换为带状态码的异常。""" event_type = field(event, "type", "") embedded_error = field(event, "error") + # ``event_type`` 来自网关 JSON,形态不受控:``type`` 写成 list/dict 时 + # ``not in {...}`` 抛 ``TypeError: unhashable type``,而本函数的调用方 + # 按 ``ValueError``/``RuntimeError`` 分类错误。先判类型,与 + # ``_normalize_responses_native_item`` 的 ``isinstance(item_type, str)`` + # 同一写法。 + if not isinstance(event_type, str): + event_type = "" if event_type not in {"error", "response.error"} and embedded_error is None: return error = embedded_error or event @@ -1414,7 +1746,15 @@ def raise_for_stream_error_event(event: Any, provider: str) -> None: if not message: pending.extend(value for value in values if value is not None) if not message: - message = field(error, "code") or f"{provider} 流返回错误" + # ``code`` 来自网关 JSON,形态不受控:位数超限的整数让 f-string 抛 + # ``ValueError: Exceeds the limit``,正要报出流错误的那一步变成整数转换 + # 错误,而调用方按 ``StreamEventError`` 分类。判据与上面提取 ``message`` + # 的路径一致(字符串原样,其余走 ``safe_repr``)。 + code = field(error, "code") + if code: + message = code if isinstance(code, str) else safe_repr(code) + else: + message = f"{provider} 流返回错误" status: int | None = None for value in ( field(error, "status_code"), @@ -1427,9 +1767,13 @@ def raise_for_stream_error_event(event: Any, provider: str) -> None: if isinstance(value, int): status = value break - if isinstance(value, str) and value.isdigit(): - status = int(value) - break + # 同 ``is_retryable_error``:``int()`` 解析不了的值必须跳过,而不是让 + # 流错误的提取本身崩掉。 + if isinstance(value, str): + parsed_status = parse_decimal_int(value) + if parsed_status is not None: + status = parsed_status + break raise StreamEventError(f"{provider} 流返回错误: {message}", status) @@ -1466,30 +1810,87 @@ def retry_sync_stream( ) -> Generator[Any, None, None]: """只重试同步流在首个事件前的建立阶段,避免重复已输出内容。""" - def open_and_peek() -> tuple[Any, Any]: - iterator = iter(factory()) + def open_and_peek() -> tuple[Any, Any, Any]: + response = factory() + iterator = iter(response) try: first = next(iterator) if first is not _STREAM_END and first_event_validator is not None: first_event_validator(first) - return iterator, first + return response, iterator, first except StopIteration: - return iterator, _STREAM_END + return response, iterator, _STREAM_END except BaseException: + # 关闭异常**不能**顶替这里正在上抛的原始异常:``retry_sync_call`` + # 按 ``is_retryable_error`` 的类名判据决定要不要重试,替身/自定义 + # 迭代器的 ``close`` 抛错会让可重试的 429 被不可重试的关闭异常 + # 顶替(实测只尝试 1 次),或反向让不可重试错误被重发 3 次。 + # + # 底层 response 也要在这里补关(与消费阶段 ``finally`` 同判据): + # openai/anthropic 的 ``Stream.__iter__`` 是生成器函数,包装形态下 + # ``iterator.close()`` 只终止包装层——validator 拒绝(正是 429 重试 + # 路径)时每次重试泄漏一条连接(实测 3 次 = 3 个未关闭 response)。 close = getattr(iterator, "close", None) if close is not None: - close() + try: + close() + except Exception as close_exc: # noqa: BLE001 + # 清理失败不得顶替原始异常——吞掉但留一条调试日志。 + logger.debug("建立阶段清理流失败: %s", safe_exception_text(close_exc)) + if response is not iterator: + response_close = getattr(response, "close", None) + if response_close is not None: + try: + response_close() + except Exception as close_exc: # noqa: BLE001 + logger.debug("建立阶段清理响应失败: %s", safe_exception_text(close_exc)) raise - iterator, first = retry_sync_call(open_and_peek) + response, iterator, first = retry_sync_call(open_and_peek) try: if first is not _STREAM_END: yield first yield from iterator finally: - close = getattr(iterator, "close", None) - if close is not None: - close() + # 关闭异常**不能**顶替消费阶段正在上抛的那个:``finally`` 的异常会替换 + # 掉它,而调用方按异常类型分类(``is_retryable_error`` 的类名判据决定 + # 要不要重试)。实测同步流「中途断开」(``ConnectionError``,可重试) + # 被「关闭也失败」(``RuntimeError``,不可重试)顶替后只尝试 1 次。 + # + # ``sys.exc_info()`` 必须在 ``finally`` 的**第一条语句**取、且早于 + # 内层 ``except``:放到 ``try`` 之前取会恒为 ``None``(那时还没有异常), + # 进到内层 ``except`` 之后取则返回关闭异常本身。两种都会让判据失效。 + in_flight = sys.exc_info()[0] is not None + # ``iter()`` 拿到的常常只是**包装生成器**:openai/anthropic 的 + # ``Stream.__iter__`` 是生成器函数(``for item in self._iterator: + # yield item``),close 它只终止包装层,``Stream.close()``(即 + # ``response.close()``)从不被调——消费方提前退出或首帧校验失败时 + # 连接释放退化为 GC 时机(实测复现,async 侧经 ``iterate_async`` + # 包装则确定性关闭)。这里对底层响应补一刀;``__iter__`` 返回 + # self 的形态两者同一对象,跳过重复关闭。 + # + # 补刀必须放在**外层 finally**(嵌套 try/finally):iterator.close + # 抛错且无在途异常时按契约 re-raise——裸的顺序结构在 re-raise 时 + # 会跳过补刀(实测 log 只有 ``iclose``),response 泄漏。嵌套 + # finally 保证补刀无条件执行,iterator 的清理异常仍按原语义上抛。 + try: + try: + close = getattr(iterator, "close", None) + if close is not None: + close() + except Exception as close_exc: + logger.debug("消费阶段清理流失败: %s", safe_exception_text(close_exc)) + if not in_flight: + raise + finally: + if response is not iterator: + response_close = getattr(response, "close", None) + if response_close is not None: + try: + response_close() + except Exception: # 清理失败不得顶替主流程异常 + if not in_flight: + raise async def retry_async_stream( @@ -1518,17 +1919,25 @@ async def open_and_peek() -> tuple[Any, Any]: raise iterator, first = await retry_async_call(open_and_peek) + # 与 ``retry_sync_stream`` 同一契约:关闭异常不得顶替消费阶段的异常。 try: if first is not _STREAM_END: yield first async for item in iterator: yield item finally: - close = getattr(iterator, "aclose", None) - if close is not None: - result = close() - if inspect.isawaitable(result): - await result + # ``sys.exc_info()`` 取在 ``finally`` 第一条语句,理由见 + # ``retry_sync_stream`` 的同名位置。 + in_flight = sys.exc_info()[0] is not None + try: + close = getattr(iterator, "aclose", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + except Exception: # 清理失败不得顶替主流程异常 + if not in_flight: + raise @contextmanager @@ -1540,9 +1949,15 @@ def enter() -> tuple[Any, Any]: try: return manager, manager.__enter__() except BaseException: + # 清理异常不得顶替 ``__enter__`` 的原始异常——与 ``open_and_peek`` + # 同一契约(实测:可重试 429 被 close 的 ``Boom`` 顶替后重试链 + # 被斩断,只尝试 1 次)。 close = getattr(manager, "close", None) if close is not None: - close() + try: + close() + except Exception as close_exc: # noqa: BLE001 + logger.debug("建立阶段清理 manager 失败: %s", safe_exception_text(close_exc)) raise manager, response = retry_sync_call(enter) @@ -1564,11 +1979,17 @@ async def enter() -> tuple[Any, Any]: try: return manager, await manager.__aenter__() except BaseException: + # 与 sync 侧同一契约:清理异常不得顶替原始异常(``iterate_async`` + # 包装的 in-flight 守卫在替身形态下不生效,显式吞错对齐——实测 + # aclose 抛错顶替可重试 429 后只尝试 1 次)。 close = getattr(manager, "aclose", None) if close is not None: - result = close() - if inspect.isawaitable(result): - await result + try: + result = close() + if inspect.isawaitable(result): + await result + except Exception as close_exc: # noqa: BLE001 + logger.debug("建立阶段清理 manager 失败: %s", safe_exception_text(close_exc)) raise manager, response = await retry_async_call(enter) @@ -1926,6 +2347,37 @@ async def aclose(self) -> None: return +def require_text_list(texts: Any, provider: str) -> list[str]: + """校验向量化入参是**字符串列表**,返回原对象。 + + ``texts`` 是 ``TextEmbeddingModel`` 全部公开入口的入参,此前六个实现 + (``local_hash``/``openai_compatible``/``google``/``volcengine`` 各两个 + 方向)没有一处判它的形态。三种收场都不在调用方的错误分类内: + + * ``texts=5`` —— 六个实现里有四处抛裸 + ``TypeError: object of type 'int' has no len()``(日志里的 + ``len(texts)`` 或 ``asyncio.to_thread`` 之前的迭代),``local_hash`` 抛 + ``TypeError: 'int' object is not iterable``; + * ``texts="abc"`` —— 字符串是合法的 ``Sequence``,被**静默**当成三条单字符 + 文本向量化(``local_hash`` 实测返回 3 条),而不是报错; + * ``texts=[b"x"]`` —— 元素非字符串时由各家 SDK 分别报错,形态各异。 + + 判据与 ``normalize_messages`` 对齐:显式排除 ``str``/``bytes``/ + ``bytearray``(它们是 ``Sequence``,迭代得到的是字符或整数)。空列表**合法** + ——``embed_documents([])`` 的语义是「没有文档要向量化」,各家返回空列表。 + """ + if isinstance(texts, (str, bytes, bytearray)) or not isinstance(texts, Sequence): + raise ValueError( + f"{provider} Embedding texts 必须是字符串列表,但得到 {safe_repr(texts)}。" + ) + for index, text in enumerate(texts): + if not isinstance(text, str): + raise ValueError( + f"{provider} Embedding texts[{index}] 必须是字符串,但得到 {safe_repr(text)}。" + ) + return list(texts) + + class TextEmbeddingModel(ABC): """文本向量化模型抽象基类""" diff --git a/src/providers/anthropic.py b/src/providers/anthropic.py index 41c5d6f..973eee1 100644 --- a/src/providers/anthropic.py +++ b/src/providers/anthropic.py @@ -5,7 +5,7 @@ import re import time from collections.abc import AsyncGenerator, Generator, Mapping -from typing import Any +from typing import Any, get_args, get_type_hints import anthropic @@ -17,26 +17,38 @@ close_resource_sync, coerce_completion_request, content_to_text, + enum_field, extract_system_prompt, field, iterate_async, merge_tool_call_fragment, + normalize_client_retries, + normalize_client_timeout, normalize_messages, normalize_usage, raise_for_stream_error_event, reject_unsupported_kwargs, + require_token_count, retry_async_call, retry_async_stream_context, retry_sync_call, retry_sync_stream_context, + validate_complete_tool_call, validate_secret_free_options, validate_secret_free_request_overrides, ) from src.providers.resources import AnthropicResources, AsyncAnthropicResources -from src.utils.config import get_settings +from src.utils.config import ( + get_settings, + is_double_convertible, + require_model_name, + safe_repr, + to_finite_float, +) from src.utils.log_manager import get_module_logger from src.utils.security import ( redact_sensitive_text, + safe_exception_text, validate_secret_free_payload, validate_secret_free_resource_kwargs, ) @@ -44,6 +56,70 @@ logger = get_module_logger(__name__) +def _safe_type_hint(model: Any, field_name: str) -> Any: + """读取模型字段的类型注解,读不到时返回 ``None``。""" + try: + return get_type_hints(model).get(field_name) + except Exception: # noqa: BLE001 - 注解解析失败只影响该成员的推导 + logger.debug("无法解析 %s 的类型注解,跳过。", getattr(model, "__name__", model)) + return None + + +def _server_tool_types() -> frozenset[str]: + """从 SDK 的 ``ToolUnionParam`` 联合类型推导「非普通函数工具」的 ``type`` 取值。 + + 为什么要推导而不是写死白名单:``_convert_tools`` 原先只认 ``server_`` 前缀与 + 三个 2025-01-24 时代的字面量(``computer_``/``bash_``/``text_editor_``)。 + 而 SDK 里真实存在的服务端工具类型有二十种(``web_search_20250305``、 + ``code_execution_20250522``、``memory_20250818``、``web_fetch_20250910``、 + ``browser_toolset_20260801`` …),它们既不匹配那个前缀、也不在那三个字面量里, + 于是被降级成空 schema 的普通函数工具——``max_uses`` 等专属字段被丢弃,服务端 + 收到的是自定义函数工具而不是服务端检索工具,用户看到 web_search 静默失效。 + 实测发出的请求体从 ``{'type': 'web_search_20250305', 'name': 'web_search', + 'max_uses': 5}`` 变成 ``{'name': 'web_search', 'description': '', + 'input_schema': {...}}``。 + + 推导口径:联合类型里除 ``ToolParam``(普通函数工具,其 ``type`` 是 + ``Literal['custom']``)之外,每个成员都是某个服务端工具的专用参数模型, + 取其 ``type`` 的字面量取值。SDK 升级新增工具时集合自动跟随,不需要改这里的 + 代码——写死白名单的失效模式正是「SDK 加了新工具、本地静默降级」。 + + 推导失败(SDK 结构变化)时退回 ``server_`` 前缀判据:宁可少认几个工具,也不能 + 让普通函数工具被当成服务端工具原样透传。 + """ + members = get_args(getattr(anthropic.types, "ToolUnionParam", None) or ()) + function_tool = getattr(anthropic.types, "ToolParam", None) + types: set[str] = set() + for member in members: + if member is function_tool: + continue + # 单个成员解析失败不影响其余成员:``get_type_hints`` 对无法解析的注解会抛 + # 任意异常,而一个成员的模型结构变化不该让整张表退化成空集。 + hint = _safe_type_hint(member, "type") + for literal in get_args(hint) if hint is not None else (): + if isinstance(literal, str): + types.add(literal) + return frozenset(types) + + +# 历史上被硬编码认可、但已从当前 SDK 联合类型中移除的服务端工具字面量。 +# +# 推导集合跟随 SDK,而 SDK 会**移除**旧版本的工具模型(实测 ``computer_20250124`` +# 已被 ``computer_toolset_20260801`` 取代、不再出现在 ``ToolUnionParam`` 里)。 +# 只取推导集合会让这些仍在服务端生效的旧取值从「原样透传」退回「降级成空 schema +# 的函数工具」——比修复前更糟。与推导集合取并集。 +_LEGACY_SERVER_TOOL_TYPES = frozenset( + { + "computer_20250124", + "bash_20250124", + "text_editor_20250124", + } +) + +# 服务端工具(非普通函数 schema)的 ``type`` 取值集合,启动时从 SDK 推导一次。 +_SERVER_TOOL_TYPES = _server_tool_types() | _LEGACY_SERVER_TOOL_TYPES + + class AnthropicProvider(LargeLanguageModel): """ Anthropic模型提供商,处理Claude系列模型。 @@ -96,8 +172,18 @@ class AnthropicProvider(LargeLanguageModel): ) def __init__(self, model_name: str, options: dict[str, Any] | None = None): - self._model_name = model_name + # ``AnthropicProvider`` 是公开类,嵌入方可以绕过工厂直接构造。工厂路径已用 + # ``require_model_name`` 收敛(``cc10585``),这里同样要判:非字符串取值 + # 会在下面的 ``logger.info`` 插值处抛裸 ``ValueError: Exceeds the limit`` + # (位数超限的整数),或静默原样发给 SDK(列表/``None``)。 + self._model_name = require_model_name(model_name) self._options = validate_secret_free_options(options, "Anthropic") + # 客户端构造参数在配置边界就校验。``timeout``/``max_retries`` 会被原样 + # 透传给 ``anthropic.Anthropic(...)``,而 SDK 不校验:实测 + # ``max_retries=-1`` 让 SDK 的重试循环 ``range(max_retries + 1)`` 一次都不 + # 执行,HTTP 请求数为 **0**,最终抛 ``RuntimeError: could not resolve + # response (should never happen)``;``10**400`` 则等价于无限重试。 + self._normalized_client_options = self._normalize_client_options(self._options) settings = get_settings() self._api_key = settings.anthropic_api_key @@ -117,26 +203,43 @@ def resources(self) -> AnthropicResources: def async_resources(self) -> AsyncAnthropicResources: return AsyncAnthropicResources(self) + @classmethod + def _normalize_client_options(cls, options: Mapping[str, Any]) -> dict[str, Any]: + """校验并规范化客户端构造参数(``timeout``/``max_retries``)。 + + 判据与 OpenAI 兼容渠道、Ark 渠道一致,三个渠道共用 + ``normalize_client_retries`` 与 ``is_double_convertible``,不再各写一份。 + """ + normalized: dict[str, Any] = {} + if "timeout" in options: + timeout = options["timeout"] + if hasattr(timeout, "connect"): + # httpx.Timeout:SDK 接受,且无法用 TOML 表达,原样保留。 + normalized["timeout"] = timeout + else: + normalized["timeout"] = normalize_client_timeout( + timeout, "Anthropic", "options.timeout" + ) + if "max_retries" in options: + normalized["max_retries"] = normalize_client_retries( + options["max_retries"], "Anthropic" + ) + return normalized + def _get_client(self) -> anthropic.Anthropic: if self._client is None: validate_secret_free_options(self._options, "Anthropic") - client_options = { - key: value - for key, value in self._options.items() - if key in {"timeout", "max_retries"} - } - self._client = anthropic.Anthropic(api_key=self._api_key, **client_options) + self._client = anthropic.Anthropic( + api_key=self._api_key, **self._normalized_client_options + ) return self._client def _get_aclient(self) -> anthropic.AsyncAnthropic: if self._aclient is None: validate_secret_free_options(self._options, "Anthropic") - client_options = { - key: value - for key, value in self._options.items() - if key in {"timeout", "max_retries"} - } - self._aclient = anthropic.AsyncAnthropic(api_key=self._api_key, **client_options) + self._aclient = anthropic.AsyncAnthropic( + api_key=self._api_key, **self._normalized_client_options + ) return self._aclient @staticmethod @@ -189,10 +292,11 @@ def _normalize_sampling_value(self, name: str, value: Any) -> Any: if getattr(self, "_options", {}).get("allow_deprecated_sampling") is True: return value if name == "temperature": - try: - numeric = float(value) - except (TypeError, ValueError) as exc: - raise ValueError("Anthropic temperature 必须是数字。") from exc + # ``float(10**400)`` 抛 ``OverflowError``——它不是 ``ValueError``, + # 因此不能只 catch ``(TypeError, ValueError)``。 + numeric = to_finite_float(value) + if numeric is None: + raise ValueError("Anthropic temperature 必须是数字。") if numeric in {0.7, 1.0}: # 0.7 is the application's historical default; 1.0 is the # backwards-compatible server value. Omitting either avoids a @@ -200,10 +304,9 @@ def _normalize_sampling_value(self, name: str, value: Any) -> Any: return None raise ValueError("当前 Claude 模型不支持该 temperature;请省略该字段或使用 1.0。") if name == "top_p": - try: - numeric = float(value) - except (TypeError, ValueError) as exc: - raise ValueError("Anthropic top_p 必须是数字。") from exc + numeric = to_finite_float(value) + if numeric is None: + raise ValueError("Anthropic top_p 必须是数字。") if numeric >= 0.99: return None raise ValueError( @@ -211,15 +314,51 @@ def _normalize_sampling_value(self, name: str, value: Any) -> Any: ) raise ValueError("当前 Claude 模型不支持 top_k;请移除该字段。") + @staticmethod + def _require_positive_max_tokens(value: Any, field_name: str) -> int: + """把 ``max_tokens`` 收敛成正整数,非法取值给出带来源的 ``ValueError``。 + + 两个入口共用:``options.max_tokens``(经 ``_default_max_tokens``)与 + 请求级 ``max_tokens``。后者此前**完全无校验**,取值原样进 params,由 SDK + 深处的 ``_calculate_nonstreaming_timeout`` 执行 + ``maximum_time * max_tokens / 128_000`` 时才炸:实测 ``10**400`` 抛 + ``OverflowError: integer division result too large for a float``(它是 + ``ArithmeticError`` 子类,既不落 ``except ValueError`` 也不落 + ``except RuntimeError``)、``"abc"`` 与 ``[1]`` 抛 ``TypeError``,而 + ``1.5``/``-5``/``True`` 静默通过、原样发给服务端。 + + 判据用 ``is_double_convertible`` 而不是 ``math.isfinite``:后者对超出 + 双精度范围的整数自己就抛 ``OverflowError``(实测 ``math.isfinite(10**400)`` + 抛错),拿它当守卫等于没有守卫。 + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"Anthropic {field_name} 必须是正整数。") + if not is_double_convertible(value): + raise ValueError(f"Anthropic {field_name} 必须是正整数。") + number = int(value) + if number <= 0 or number != value: + raise ValueError(f"Anthropic {field_name} 必须是正整数。") + return number + def _default_max_tokens(self) -> int: configured = getattr(self, "_options", {}).get("max_tokens") if configured is not None: if isinstance(configured, bool) or not isinstance(configured, (int, float)): raise ValueError("Anthropic options.max_tokens 必须是正整数。") - configured_int = int(configured) - if configured_int <= 0 or configured_int != configured: - raise ValueError("Anthropic options.max_tokens 必须是正整数。") - return configured_int + # 余下判据(有限性、整数性、正数)在 ``_require_positive_max_tokens`` + # 里,请求级 ``max_tokens`` 走同一个 helper——同一个量在两个入口的 + # 判据必须一致。 + # 有限性必须先判,再 ``int()``:``inf`` 让 ``int()`` 抛 + # ``OverflowError: cannot convert float infinity to integer``, + # ``nan`` 抛 ``ValueError: cannot convert float NaN to integer``—— + # 两者都**不是**本方法承诺的「必须是正整数」消息,而且 + # ``OverflowError`` 连 ``except ValueError`` 都接不住。实测 + # ``options.max_tokens = inf`` 正是这种收场,而同为非法取值的 + # ``1.5``/``0``/``-1`` 都能得到带指引的消息。 + # 判据用 ``is_double_convertible`` 而不是 ``math.isfinite``:后者对 + # 超出双精度范围的整数自己就抛 ``OverflowError``(实测 + # ``math.isfinite(10**400)`` 抛错),拿它当守卫等于没有守卫。 + return self._require_positive_max_tokens(configured, "options.max_tokens") model = str(getattr(self, "_model_name", "")).lower() if "claude-3-opus" in model or "claude-3-haiku" in model: return 4096 @@ -230,7 +369,104 @@ def _default_max_tokens(self) -> int: return 8192 @staticmethod + def _require_string_header_values( + extra_headers: Mapping[str, Any] | None, + ) -> dict[str, Any] | None: + """校验 ``extra_headers`` 的**取值**都是字符串,返回浅拷贝。 + + 为什么值也要判:``validate_secret_free_request_overrides`` 只校验 + ``extra_headers`` 整体是 Mapping,不校验值——实测 + ``CompletionRequest(extra_headers={"anthropic-beta": 42})`` 构造成功并原样 + 保留。非字符串值的两种收场都不在本渠道的错误契约内: + + * 交给 httpx 时抛 ``TypeError: Header value must be str or bytes``; + * 本模块对 ``anthropic-beta`` 做 ``.split(",")`` 时抛 + ``AttributeError: 'int' object has no attribute 'split'``。 + + HTTP 头的值本来就该是字符串,非字符串一律给出带头名的 ``ValueError``。 + ``None`` **剔除**而不是放行,且必须是**在合并之前**剔除:合并分支对 + ``anthropic-beta`` 做 ``.split(",")``,``None`` 在那里抛 + ``AttributeError: 'NoneType' object has no attribute 'split'``(实测 + ``_user_profile_fields("u1", {"anthropic-beta": None}, None, beta=False)``); + 交给 httpx 时则抛 ``TypeError: Header value must be str or bytes``。两者 + 都不在本渠道的错误契约内。剔除后它与「该头未设置」完全等价,符合调用方 + 写 ``extra_headers={"x": None}`` 时的意图。 + + 只收 ``str`` 不收 ``bytes``:``bytes`` 虽是 httpx 接受的形态,但本模块对 + ``anthropic-beta`` 做的是 ``.split(",")``(``str`` 分隔符),对 bytes 抛 + ``TypeError: a bytes-like object is required``——放行它只是把问题挪个地方。 + """ + if extra_headers is None: + return None + headers = dict(extra_headers) + for name, value in headers.items(): + if value is not None and not isinstance(value, str): + raise ValueError( + f"Anthropic extra_headers[{safe_repr(name)}] 必须是字符串," + f"但得到 {safe_repr(value)}。" + ) + return {name: value for name, value in headers.items() if value is not None} + + @staticmethod + def _require_profile_id(user: Any) -> str | None: + """校验 ``user``(转成 ``user_profile_id``)是字符串。 + + 声明类型是 ``str | None``,但 ``CompletionRequest`` 是普通 dataclass、 + ``__post_init__`` 不校验它——实测 ``beta_create(prompt="hi", user=5)`` + 构造成功。SDK 把这个值当 HTTP 头的取值,非字符串抛 + ``AttributeError: 'int' object has no attribute 'encode'``(``bytes`` + 抛 ``'bytes' object has no attribute 'encode'``,因为 httpx 对 ``bytes`` + 头值走的是另一条分支)。``AttributeError`` 不落本渠道的 + ``except ValueError``/``except RuntimeError`` 分类。 + + 走真实 SDK(``httpx2.MockTransport`` 拦网)实测:``invoke``/ + ``beta_create``/``async_beta_create`` 三个入口对 ``5``/``["a"]``/ + ``b"x"`` 都抛该 ``AttributeError``。 + """ + if user is None or isinstance(user, str): + return user + raise ValueError(f"Anthropic user 必须是字符串,但得到 {safe_repr(user)}。") + + @staticmethod + def _require_beta_tokens(betas: Any) -> list[str] | None: + """把 ``betas`` 收敛成字符串列表;非法形态给带字段名的 ``ValueError``。 + + ``CompletionRequest.betas`` 声明为 ``Sequence[str] | None``,但同样不经 + 校验。三种形态的收场都不在渠道契约内(走真实 SDK 实测): + + * ``5`` 抛裸 ``TypeError: 'int' object is not iterable``(``list(5)``); + * ``b"x"`` 被 ``list()`` 拆成 ``[120]``——整数进了 HTTP 头; + * ``{"a": 1}`` 静默取**键**得 ``["a"]``; + * ``["a", 1]`` 的元素原样进请求体。 + + 后三类更隐蔽:SDK 把它们直接拼进 ``anthropic-beta`` 头(实测 + ``betas=[1, 2]`` 发出 ``anthropic-beta: 1,2``、``betas=b"x"`` 发出 + ``120``),服务端收到一个不存在的 beta 名——要么报一个与调用方写法 + 毫无关系的错,要么静默忽略掉本想启用的特性。字符串是唯一的合法元素 + 形态,与 ``_require_string_header_values`` 同一判据。 + """ + if betas is None: + return None + # 字符串与 bytes 要**先**拒:两者都是可迭代的,``list("ab")`` 会得到 + # ``["a", "b"]``(把单个 beta 名拆成两个),``list(b"x")`` 得到 + # ``[120]``(整数进 HTTP 头)。``Mapping`` 同样可迭代,但迭代的是**键** + # ——``{"a": 1}`` 静默变成 ``["a"]``,值被丢弃而调用方无从察觉。 + if isinstance(betas, (str, bytes, Mapping)): + raise ValueError(f"Anthropic betas 必须是字符串列表,但得到 {safe_repr(betas)}。") + try: + values = list(betas) + except TypeError as exc: + raise ValueError( + f"Anthropic betas 必须是字符串列表,但得到 {safe_repr(betas)}。" + ) from exc + for value in values: + if not isinstance(value, str): + raise ValueError(f"Anthropic betas 的元素必须是字符串,但得到 {safe_repr(value)}。") + return values + + @classmethod def _user_profile_fields( + cls, user: str | None, extra_headers: Mapping[str, str] | None = None, betas: Any = None, @@ -238,45 +474,62 @@ def _user_profile_fields( beta: bool, ) -> tuple[dict[str, str] | None, Any]: """补齐 user profile 所需的 SDK beta 资源声明。""" + # 头值校验放在**两条分支之前**:``user=None`` 时这里直接返回原样的 + # ``extra_headers``,非字符串值会一路交给 httpx,在那里抛 + # ``TypeError: Header value must be str or bytes``——既不落 + # ``except ValueError`` 也不落 ``except RuntimeError``。而 ``user=None`` + # 恰恰是最常见的调用形态,这条路径比下面的合并分支更容易走到。 + headers = cls._require_string_header_values(extra_headers) + user = cls._require_profile_id(user) if user is None: - return dict(extra_headers) if extra_headers is not None else None, betas + return headers, betas header = "user-profiles-2026-08-18" if beta: - if isinstance(betas, str): - values = [betas] - else: - values = list(betas or []) + values = cls._require_beta_tokens(betas) or [] if header not in values: values.append(header) - return dict(extra_headers) if extra_headers is not None else None, values - headers = dict(extra_headers or {}) - existing = headers.get("anthropic-beta", "") + # 返回**校验过**的副本(``headers`` 可能是 ``None``,此时语义就是 + # 「没有额外头」),不能用原始的 ``extra_headers``——那会绕过上面刚做的 + # 取值校验。 + return headers, values + # ``headers`` 可能是 ``None``(调用方没传 extra_headers),而 + # ``_require_string_header_values`` 对 ``None`` 输入原样返回 ``None``。 + # 合并必须建立在副本上:直接写 ``headers`` 会在 ``None`` 上抛裸 + # ``TypeError``,而写在原字典上会让调用方的 ``request.extra_headers`` + # 被就地修改。 + merged = dict(headers or {}) + existing = merged.get("anthropic-beta") or "" values = [item.strip() for item in existing.split(",") if item.strip()] if header not in values: values.append(header) - headers["anthropic-beta"] = ",".join(values) - return headers, betas + merged["anthropic-beta"] = ",".join(values) + return merged, betas @staticmethod def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: if not tools: return None + # 容器形态先判:``enumerate(tools)`` 对不可迭代的取值抛裸 + # ``TypeError: 'int' object is not iterable``,而同函数对可迭代但元素 + # 非法的形态给的是带序号的 ``ValueError``——同一判据的两半必须都在。 + # 实测 ``_build_message_params(prompt="hi", tools=5)`` 抛该 ``TypeError``。 + if not isinstance(tools, (list, tuple)): + raise ValueError(f"Anthropic 工具定义必须是列表,但得到 {safe_repr(tools)}。") converted: list[dict[str, Any]] = [] for index, tool in enumerate(tools): if not isinstance(tool, Mapping): raise ValueError(f"Anthropic 工具定义[{index}] 必须是对象。") tool_type = tool.get("type") - # Anthropic 的 server_* 工具不是 function schema,原样传递其 - # 专属字段,避免统一 OpenAI schema 破坏服务端工具配置。 + # Anthropic 的服务端工具不是 function schema,原样传递其专属字段, + # 避免统一 OpenAI schema 破坏服务端工具配置。判据取自 SDK 联合类型 + # (见 ``_SERVER_TOOL_TYPES``),而不是写死的白名单——后者在 SDK + # 新增工具时会静默降级。``custom`` 也是 ``ToolParam`` 自己声明的 + # 合法取值(``Literal['custom']``),同样原样透传。 if isinstance(tool_type, str) and ( - tool_type.startswith("server_") - or tool_type - in { - "computer_20250124", - "bash_20250124", - "text_editor_20250124", - } + tool_type in _SERVER_TOOL_TYPES + or tool_type == "custom" + or tool_type.startswith("server_") ): converted.append(dict(tool)) continue @@ -323,6 +576,8 @@ def _convert_tool_choice(tool_choice: Any) -> Any: raise ValueError("Anthropic tool_choice 必须是字符串或对象。") choice = dict(tool_choice) choice_type = choice.get("type") + if not isinstance(choice_type, str): + choice_type = None if choice_type in {"auto", "any", "none"}: return choice if choice_type == "tool": @@ -356,6 +611,8 @@ def _convert_content(content: Any) -> Any: converted.append(part) continue part_type = part.get("type") + if not isinstance(part_type, str): + part_type = None if part_type in {"text", "input_text"}: converted.append({"type": "text", "text": part.get("text", "")}) elif part_type in {"image_url", "input_image"}: @@ -492,15 +749,29 @@ def flush_tool_results() -> None: return converted @staticmethod - def _response_format_config(response_format: dict[str, Any] | None) -> dict[str, Any] | None: + def _response_format_config(response_format: Any) -> dict[str, Any] | None: + """把统一的 ``response_format`` 转成 Anthropic 的 ``output_config.format``。 + + 容器形态必须在这里判:``options.response_format`` 那一侧已有守卫(见 + ``_build_message_params``),但**请求参数**来源(``request.response_format``) + 直接进本函数,非 Mapping 时 ``.get()`` 抛裸 ``AttributeError``。同一语义的 + 两个来源,守卫只盖住了一处;``google.py`` 的 ``_response_format_values`` + 对同一判据给的是显式 ``ValueError``。 + """ if not response_format: return None + if not isinstance(response_format, Mapping): + raise ValueError( + f"Anthropic response_format 必须是对象,但得到 {safe_repr(response_format)}。" + ) if response_format.get("type") == "json_schema": schema = response_format.get("json_schema", response_format) return {"type": "json_schema", "schema": schema.get("schema", schema)} if response_format.get("type") == "json_object": return {"type": "json_schema", "schema": {"type": "object"}} - return response_format + # ``dict(...)`` 而不是原样返回:入参经上面的守卫收窄成 ``Mapping``,而本函数 + # 的契约是返回可自由改写的 ``dict``(调用方会把它嵌进 params)。 + return dict(response_format) def _build_message_params( self, @@ -552,7 +823,12 @@ def _build_message_params( "context_management": context_management, "mcp_servers": mcp_servers, "speed": speed, - "betas": betas, + # ``betas`` 必须在这里校验,不能只在 ``_user_profile_fields`` 里判: + # 那个函数仅在 ``user is not None`` 时被调用,而 ``betas`` 走 + # ``beta_values`` 直接进 params(见下方 ``if beta:`` 的 update)。 + # 实测 ``beta_create(prompt="hi", betas=5)`` 在加这条之前抛裸 + # ``TypeError``、``betas=[1, 2]`` 把 ``1,2`` 拼进 anthropic-beta 头。 + "betas": self._require_beta_tokens(betas), "diagnostics": diagnostics, "fallback_credit_token": fallback_credit_token, "fallbacks": fallbacks, @@ -568,7 +844,11 @@ def _build_message_params( normalized = normalize_messages(prompt, system_prompt, messages) params: dict[str, Any] = { "model": self._model_name, - "max_tokens": max_tokens if max_tokens is not None else self._default_max_tokens(), + "max_tokens": ( + self._require_positive_max_tokens(max_tokens, "请求级 max_tokens") + if max_tokens is not None + else self._default_max_tokens() + ), "messages": self._convert_messages(normalized), } effective_system = extract_system_prompt(messages) or system_prompt @@ -766,17 +1046,33 @@ def _build_message_params( @classmethod def _extract_result(cls, response: Any) -> CompletionResult: + # ``content`` 是 Anthropic 响应的顶层块容器。此前写 + # ``field(response, "content", []) or []`` + # ——那只挡住 ``None`` 与空值。实测 ``content=5`` 抛裸 + # ``TypeError: 'int' object is not iterable``,而本 provider 的错误分类 + # 按 ``RuntimeError`` 写(同一文件对拒答等情形用的就是它)。 + # + # **本条线上可达**:anthropic SDK 的 ``_base_client`` 在 + # ``_strict_response_validation`` 为假时走 ``construct_type``——该标志 + # 实测默认 ``False``,与 openai/Ark 同类,不执行 pydantic 校验。 blocks = field(response, "content", []) or [] + if not isinstance(blocks, (list, tuple)): + raise RuntimeError(f"Anthropic 响应的 content 必须是列表,但得到 {safe_repr(blocks)}。") + blocks = list(blocks) # ``field`` 只在键不存在时返回默认值;键存在而值为 None 时仍返回 None, # 而 SDK 的部分块(如 thinking / redacted_thinking)确实会给出 None。 - # 直接 join 会抛 ``TypeError: expected str instance, NoneType found``。 + # 直接 join 会抛 ``TypeError: expected str instance, NoneType found``; + # **真值非字符串**(``5``/``10**4301``)同样会抛——``or ""`` 只挡住 + # ``None`` 与空串。统一走 ``_block_text``(与 ``_refusal_text`` 同判据)。 text = "".join( - field(block, "text", "") or "" for block in blocks if field(block, "type") == "text" + cls._block_text(field(block, "text", "")) + for block in blocks + if field(block, "type") == "text" ) reasoning = "".join( - field(block, "thinking", "") or "" + cls._block_text(field(block, "thinking", "")) for block in blocks - if field(block, "type") in {"thinking", "redacted_thinking"} + if enum_field(block, "type") in {"thinking", "redacted_thinking"} ) tool_calls = [ { @@ -789,9 +1085,20 @@ def _extract_result(cls, response: Any) -> CompletionResult: if field(block, "type") == "tool_use" ] usage_dict = normalize_usage(field(response, "usage")) + # 取值优先级必须与流式路径(``_stream_event`` 的 content_block_start 与 + # content_block_delta 两个分支)**逐字一致**:三处都是 + # ``refusal or text or reason``。此前这里漏了 ``refusal`` 键,于是同一份 + # 响应在两条路径上给出不同结果——实测 content 为 + # ``[{"type": "refusal", "refusal": "被拒绝"}]`` 时非流式得到 + # ``refusal=None``、``text=''``、``finish_reason='end_turn'``,``invoke()`` + # 输出空列表:一次**安全拦截**变成「空成功」,而流式路径正确抛出 + # ``RuntimeError: Anthropic 请求被拒绝``。``refusal`` 是 SDK 的 + # ``RefusalBlock`` 的正式字段名(``text``/``reason`` 只是兼容形态)。 refusal = next( ( - field(block, "reason", "") or field(block, "text", "") + cls._refusal_text( + field(block, "refusal") or field(block, "text") or field(block, "reason") + ) for block in blocks if field(block, "type") == "refusal" ), @@ -802,11 +1109,20 @@ def _extract_result(cls, response: Any) -> CompletionResult: stop_details = field(response, "stop_details") or field( response, "refusal_stop_details" ) - details_type = field(stop_details, "type") + details_type = enum_field(stop_details, "type") if stop_reason == "refusal" or details_type == "refusal": explanation = field(stop_details, "explanation") category = field(stop_details, "category") - refusal = str(explanation or category or "Anthropic 请求被安全策略拒绝") + # 两处拒答详情同理(见 ``_refusal_text``):``explanation`` 与 + # ``category`` 都来自服务端 JSON,裸 ``str()`` 会在超大整数上抛 + # ``ValueError: Exceeds the limit``。后果比崩溃更糟——这是**安全 + # 拦截**的信号,抛异常会让一次本该显式上报的拒答变成一条与安全 + # 策略无关的整数转换错误。取值为空时仍给默认文案。 + refusal = ( + cls._refusal_detail(explanation) + or cls._refusal_detail(category) + or "Anthropic 请求被安全策略拒绝" + ) return CompletionResult( text=text, tool_calls=tool_calls, @@ -866,13 +1182,13 @@ def _iter_invoke_stream_text(cls, response: Any) -> Generator[str, None, None]: for text in response.text_stream: cls._raise_for_stream_snapshot_refusal(response) if text: - yield text + yield cls._block_text(text) cls._raise_for_stream_snapshot_refusal(response) return for text in response.text_stream: if text: - yield text + yield cls._block_text(text) @classmethod async def _aiter_invoke_stream_text(cls, response: Any) -> AsyncGenerator[str, None]: @@ -901,17 +1217,72 @@ async def _aiter_invoke_stream_text(cls, response: Any) -> AsyncGenerator[str, N async for text in iterate_async(response.text_stream): cls._raise_for_stream_snapshot_refusal(response) if text: - yield text + yield cls._block_text(text) cls._raise_for_stream_snapshot_refusal(response) return async for text in iterate_async(response.text_stream): if text: - yield text + yield cls._block_text(text) + + @staticmethod + def _refusal_detail(value: Any) -> str: + """收敛 ``stop_details`` 里的拒答详情取值;空值返回空串。 + + 与 ``_refusal_text`` 同一判据,差别只在**空值语义**:``stop_details`` 的 + ``explanation``/``category`` 可以合法缺失(两者都没有时调用方会给默认 + 文案),所以 ``None`` 返回空串而不是「非字符串就给描述」——否则一个缺失 + 的 ``explanation`` 会渲染成 ``"None"`` 并挡住后面的 ``category``。 + """ + if value is None: + return "" + if isinstance(value, str): + return value + return safe_repr(value) + + @staticmethod + def _block_text(value: Any) -> str: + """把响应块里的文本字段(``text``/``thinking``/``refusal``)收敛成字符串。 + + 这些字段都来自服务端 JSON,形态不受本地控制(SDK 在 + ``_strict_response_validation=False`` 时走 ``construct_type``,不执行 + pydantic 校验)。裸 ``"".join(... or "")`` 只挡住 ``None`` 与空串—— + **真值非字符串**(``5``/``{"a": 1}``/``10**4301``)原样进 ``join``,抛裸 + ``TypeError: sequence item 0: expected str instance, int found``,而本 + provider 的错误分类按 ``RuntimeError`` 写。 + + 非字符串给 ``safe_repr`` 的可读描述而不是空串:空串会让调用方以为「这块 + 没有内容」。判据与 ``_refusal_text`` 逐字相同,两者共用本实现。 + """ + if isinstance(value, str): + return value + if value is None: + return "" + return safe_repr(value) + + @staticmethod + def _refusal_text(value: Any) -> str: + """把服务端返回的拒答取值收敛成字符串。 + + ``refusal`` 来自服务端 JSON(``content_block`` 或 ``delta`` 里的字段), + 形态不受本地控制。原写法是 + ``value if isinstance(value, str) else str(value or "")``——非字符串走裸 + ``str()``,而 JSON 里一个超大整数字面量(``10**4301``)会让它撞上 CPython + 的整数位数上限,抛 ``ValueError: Exceeds the limit``。 + + 这个异常发生在**流式解析**过程中(``_iterate_stream_events`` 的生成器 + 里):用户已经看到部分输出,随后拿到一条与「服务端拒答」毫无关系的整数 + 转换错误;而同一函数对 ``42`` 正常转成 ``"42"``、对 ``None`` 给出 ``""``, + 只有位数超限的那一个炸——收场方式取决于取值的**大小**而不是形态。 + + 非字符串一律给可读描述而不是空串:空串会让调用方以为「没有拒答」。 + 收敛判据与 ``_block_text`` 相同,委托给它。 + """ + return AnthropicProvider._block_text(value) @classmethod def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEvent | None: - event_type = field(event, "type", "") + event_type = enum_field(event, "type") if event_type == "message_start": message = field(event, "message") message_id = field(message, "id") or response_id @@ -919,12 +1290,12 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven return StreamEvent(type="start", usage=usage, response_id=message_id, raw=event) if event_type == "content_block_start": block = field(event, "content_block") - block_type = field(block, "type") + block_type = enum_field(block, "type") if block_type == "refusal" or field(block, "refusal"): refusal = field(block, "refusal") or field(block, "text") or field(block, "reason") return StreamEvent( type="refusal_delta", - refusal=refusal if isinstance(refusal, str) else str(refusal or ""), + refusal=cls._refusal_text(refusal), response_id=response_id, raw=event, ) @@ -937,7 +1308,11 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven else "" ) else: - initial_arguments = str(initial_input) if initial_input else "" + # 非映射的 ``input`` 走 ``_block_text`` 而不是裸 ``str()``: + # 位数超限的整数让 ``str()`` 抛 ``ValueError: Exceeds the + # limit``,而 ``arguments`` 的契约是字符串(非流式路径经 + # ``normalize_tool_arguments`` 收敛)。 + initial_arguments = cls._block_text(initial_input) return StreamEvent( type="tool_call_delta", tool_call={ @@ -960,18 +1335,23 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven return None if event_type == "content_block_delta": delta = field(event, "delta") - delta_type = field(delta, "type") + delta_type = enum_field(delta, "type") if delta_type == "text_delta": return StreamEvent( type="text_delta", - text=field(delta, "text", "") or "", + # ``or ""`` 只挡住 ``None`` 与空串;真值非字符串(``5``/ + # ``{"a": 1}``)原样进 ``StreamEvent.text``(声明为 ``str``), + # 而消费方 ``_iter_invoke_stream_text`` 直接 ``yield`` 它—— + # ``Generator[str]`` 的契约被打破,调用方拼接时抛裸 + # ``TypeError``。走 ``_block_text``(与 ``_refusal_text`` 同判据)。 + text=cls._block_text(field(delta, "text", "")), response_id=response_id, raw=event, ) if delta_type == "thinking_delta": return StreamEvent( type="reasoning_delta", - reasoning=field(delta, "thinking", "") or "", + reasoning=cls._block_text(field(delta, "thinking", "")), response_id=response_id, raw=event, ) @@ -979,7 +1359,7 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven refusal = field(delta, "refusal") or field(delta, "text") or field(delta, "reason") return StreamEvent( type="refusal_delta", - refusal=refusal if isinstance(refusal, str) else str(refusal or ""), + refusal=cls._refusal_text(refusal), response_id=response_id, raw=event, ) @@ -988,7 +1368,10 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven type="tool_call_delta", tool_call={ "index": field(event, "index"), - "arguments": field(delta, "partial_json", "") or "", + # 同上:``arguments`` 的契约是字符串(非流式路径经 + # ``normalize_tool_arguments`` 收敛),非字符串原样透传会让 + # 两条路径给出不同类型。字符串片段原样透传,拼接语义不变。 + "arguments": cls._block_text(field(delta, "partial_json", "")), }, response_id=response_id, raw=event, @@ -999,11 +1382,20 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven delta = field(event, "delta") reason = field(delta, "stop_reason") stop_details = field(delta, "stop_details") or field(delta, "refusal_stop_details") - details_type = field(stop_details, "type") + details_type = enum_field(stop_details, "type") if reason == "refusal" or details_type == "refusal": explanation = field(stop_details, "explanation") category = field(stop_details, "category") - refusal = str(explanation or category or "Anthropic 请求被安全策略拒绝") + # 两处拒答详情同理(见 ``_refusal_text``):``explanation`` 与 + # ``category`` 都来自服务端 JSON,裸 ``str()`` 会在超大整数上抛 + # ``ValueError: Exceeds the limit``。后果比崩溃更糟——这是**安全 + # 拦截**的信号,抛异常会让一次本该显式上报的拒答变成一条与安全 + # 策略无关的整数转换错误。取值为空时仍给默认文案。 + refusal = ( + cls._refusal_detail(explanation) + or cls._refusal_detail(category) + or "Anthropic 请求被安全策略拒绝" + ) return StreamEvent( type="refusal_delta", refusal=refusal, @@ -1014,7 +1406,7 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven return StreamEvent( type="finish", usage=usage, - finish_reason=str(reason), + finish_reason=cls._refusal_text(reason), response_id=response_id, raw=event, ) @@ -1128,6 +1520,12 @@ def stream_events( elif converted.type == "finish": for key, tool_call in tool_calls.items(): if key not in completed_tool_calls: + # 与 openai_compatible / google / volcengine 的 + # 交付前校验保持一致:Anthropic 的流式片段同样不带 + # ``type``(由 merge_tool_call_fragment 补成 + # ``function``),若不在这里校验,半截 JSON 会被 + # 当作完整工具调用交给调用方。 + validate_complete_tool_call(tool_call, "Anthropic Messages") completed_tool_calls.add(key) yield StreamEvent( type="tool_call_completed", @@ -1215,6 +1613,12 @@ async def astream_events( elif converted.type == "finish": for key, tool_call in tool_calls.items(): if key not in completed_tool_calls: + # 与 openai_compatible / google / volcengine 的 + # 交付前校验保持一致:Anthropic 的流式片段同样不带 + # ``type``(由 merge_tool_call_fragment 补成 + # ``function``),若不在这里校验,半截 JSON 会被 + # 当作完整工具调用交给调用方。 + validate_complete_tool_call(tool_call, "Anthropic Messages") completed_tool_calls.add(key) yield StreamEvent( type="tool_call_completed", @@ -1392,10 +1796,7 @@ def count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) if request.timeout is not None: params["timeout"] = request.timeout response = self._get_client().messages.count_tokens(**params) - value = field(response, "input_tokens") - if value is None: - raise RuntimeError("Anthropic token count 响应缺少 input_tokens。") - return int(value) + return require_token_count(field(response, "input_tokens"), "Anthropic", "input_tokens") def beta_create(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: """调用 Anthropic Beta Messages,保留 Beta 专属参数转发能力。""" @@ -1458,7 +1859,12 @@ def beta_count_tokens(self, request: CompletionRequest | None = None, **kwargs: params["speed"] = request.speed if request.user is not None: params["user_profile_id"] = request.user - _, profile_betas = self._user_profile_fields( + # 取**校验后**的 ``profile_headers``,不能用 ``request.extra_headers``—— + # 后者仍带着 ``_user_profile_fields`` 已经剔除掉的非字符串/``None`` 值, + # 原样透传会让 httpx 抛 ``TypeError: Header value must be str or bytes``。 + # 同文件的 ``beta=False`` 分支(``messages.count_tokens``)用的就是校验后的 + # 返回值,这里原先漏了。 + profile_headers, profile_betas = self._user_profile_fields( request.user, request.extra_headers, request.betas, beta=True ) if profile_betas is not None: @@ -1469,17 +1875,16 @@ def beta_count_tokens(self, request: CompletionRequest | None = None, **kwargs: "Anthropic Beta Messages token count", "extra_body", ) - if request.extra_headers is not None: - params["extra_headers"] = request.extra_headers + if profile_headers is not None: + params["extra_headers"] = profile_headers if request.extra_query is not None: params["extra_query"] = request.extra_query if request.timeout is not None: params["timeout"] = request.timeout response = self._get_client().beta.messages.count_tokens(**params) - value = field(response, "input_tokens") - if value is None: - raise RuntimeError("Anthropic Beta token count 响应缺少 input_tokens。") - return int(value) + return require_token_count( + field(response, "input_tokens"), "Anthropic Beta", "input_tokens" + ) def beta_stream(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: """返回 Anthropic Beta Messages 流式上下文管理器。""" @@ -1607,10 +2012,7 @@ async def async_count_tokens( response = await self._resolve_async_result( self._get_aclient().messages.count_tokens(**params) ) - value = field(response, "input_tokens") - if value is None: - raise RuntimeError("Anthropic token count 响应缺少 input_tokens。") - return int(value) + return require_token_count(field(response, "input_tokens"), "Anthropic", "input_tokens") async def async_beta_create( self, request: CompletionRequest | None = None, **kwargs: Any @@ -1677,7 +2079,12 @@ async def async_beta_count_tokens( params["speed"] = request.speed if request.user is not None: params["user_profile_id"] = request.user - _, profile_betas = self._user_profile_fields( + # 取**校验后**的 ``profile_headers``,不能用 ``request.extra_headers``—— + # 后者仍带着 ``_user_profile_fields`` 已经剔除掉的非字符串/``None`` 值, + # 原样透传会让 httpx 抛 ``TypeError: Header value must be str or bytes``。 + # 同文件的 ``beta=False`` 分支(``messages.count_tokens``)用的就是校验后的 + # 返回值,这里原先漏了。 + profile_headers, profile_betas = self._user_profile_fields( request.user, request.extra_headers, request.betas, beta=True ) if profile_betas is not None: @@ -1688,8 +2095,8 @@ async def async_beta_count_tokens( "Anthropic Beta Messages token count", "extra_body", ) - if request.extra_headers is not None: - params["extra_headers"] = request.extra_headers + if profile_headers is not None: + params["extra_headers"] = profile_headers if request.extra_query is not None: params["extra_query"] = request.extra_query if request.timeout is not None: @@ -1697,10 +2104,9 @@ async def async_beta_count_tokens( response = await self._resolve_async_result( self._get_aclient().beta.messages.count_tokens(**params) ) - value = field(response, "input_tokens") - if value is None: - raise RuntimeError("Anthropic Beta token count 响应缺少 input_tokens。") - return int(value) + return require_token_count( + field(response, "input_tokens"), "Anthropic Beta", "input_tokens" + ) def async_beta_stream(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: """返回 AsyncAnthropic Beta 流式上下文管理器。""" @@ -1935,7 +2341,7 @@ def invoke( duration = time.perf_counter() - start_time logger.info(f"Anthropic LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s") except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Anthropic LLM (%s) 出错: %s", self._model_name, @@ -2010,7 +2416,7 @@ async def ainvoke( duration = time.perf_counter() - start_time logger.info(f"Anthropic LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s") except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Anthropic LLM (%s) 异步出错: %s", self._model_name, diff --git a/src/providers/factory.py b/src/providers/factory.py index e66f03a..954b26e 100644 --- a/src/providers/factory.py +++ b/src/providers/factory.py @@ -15,13 +15,50 @@ ModelDetail, ModelProtocol, get_settings, + require_detail_attributes, + require_model_name, + require_provider_name, + safe_repr, ) from src.utils.log_manager import get_module_logger # 导入日志管理器 -from src.utils.security import redact_sensitive_text, validate_secret_free_options +from src.utils.security import safe_exception_text, validate_secret_free_options logger = get_module_logger(__name__) # 获取当前模块的日志器 +def _normalize_protocol_text(value: Any, *, label: str) -> str: + """把协议名收敛成小写、连字符转下划线的形态。 + + ``protocol_status`` 与 ``capability_report`` 是公开的诊断入口(配置界面与 + 排障工具会调),参数来自调用方而非 ``Settings``——``ModelDetail.protocol`` + 有 validator,但这两个入口收的是**裸取值**,没有任何校验。此前直接写 + ``str(value).strip().lower()``,于是 ``10**4301`` 撞上 CPython 的整数位数 + 上限,抛的是 ``ValueError: Exceeds the limit (4300 digits) for integer + string conversion``——既没有「哪个参数错了」,也没有「可选值是什么」, + 用户拿到的是与协议诊断毫无关系的整数转换错误。 + + 非字符串一律显式拒绝:协议名只能是字符串,``None``/列表/整数都说明调用方 + 传错了参数,而不是「某个协议没被支持」。 + """ + if not isinstance(value, str): + raise ValueError(f"{label} 必须是字符串,但得到 {safe_repr(value)}。") + return value.strip().lower().replace("-", "_") + + +def _normalize_role_text(value: Any) -> str: + """把角色名收敛成小写、去空白的形态。 + + 与 ``_normalize_protocol_text`` 同一判据、同一理由:``str(10**4301)`` 自己就抛 + ``ValueError: Exceeds the limit (4300 digits)``,于是「角色名写错了」变成整数 + 转换错误。三个公开诊断入口(``capabilities``/``capability_report``/ + ``protocol_status``)此前各自写 ``str(role).strip().lower()``,**早于** + ``_provider_info_for_role`` 内部的守卫,因此那里单独加的判据覆盖不到它们。 + """ + if not isinstance(value, str): + raise ValueError(f"模型角色必须是字符串,但得到 {safe_repr(value)}。") + return value.strip().lower() + + class ModelProviderFactory: """模型提供商工厂""" @@ -194,7 +231,7 @@ def _get_provider_class(provider_name: str) -> type: logger.error( "导入提供商模块失败: %s - %s", provider_info["module"], - redact_sensitive_text(str(e)), + safe_exception_text(e), ) raise ImportError(f"无法加载提供商模块: {provider_info['module']}") from e except AttributeError as e: @@ -202,7 +239,7 @@ def _get_provider_class(provider_name: str) -> type: "在模块中找不到提供商类: %s in %s - %s", provider_info["class"], provider_info["module"], - redact_sensitive_text(str(e)), + safe_exception_text(e), ) raise AttributeError(f"无法找到提供商类: {provider_info['class']}") from e @@ -212,7 +249,25 @@ def _resolve_provider_name(provider_name: str, role: str) -> str: @classmethod def _provider_info_for_role(cls, provider_name: str, role: str) -> tuple[str, dict[str, Any]]: - normalized_role = str(role).strip().lower() + # 键形态必须在任何成员判断**之前**收敛,判据与同文件的 + # ``_get_model_detail`` 逐字一致。这里此前完全漏了,而本函数是三个**公开** + # 诊断入口(``capabilities``/``capability_report``/``protocol_status``, + # 源码注释写明「配置界面和诊断使用」)的共同路径。实测修复前: + # * ``["a"]``/``{"k": 1}`` —— 下面 ``_role_aliases.get(role, {}).get(...)`` + # 与 ``_provider_map.get(...)`` 的字典查找抛裸 ``TypeError: + # unhashable type: 'list'``; + # * ``10**4301`` —— ``f"不支持的模型提供商: {provider_name}"`` 抛 + # ``ValueError: Exceeds the limit (4300 digits)``。 + # 两条都不是「渠道名写错了」,而同一形态走 ``get_llm_provider`` 得到的是 + # ``_get_model_detail`` 给出的带指引 ``ValueError``——同一个错误的收场 + # 不该取决于调用的是哪条入口。 + if not isinstance(provider_name, str): + raise ValueError( + f"provider 名称必须是字符串,但得到 {safe_repr(provider_name)}。" + " 请检查配置里的 *provider 字段。" + ) + # ``role`` 同样要收敛(详见 ``_normalize_role_text``)。 + normalized_role = _normalize_role_text(role) if normalized_role not in cls._roles: allowed = ", ".join(sorted(cls._roles)) raise ValueError(f"不支持的模型角色: {role}。可选值: {allowed}") @@ -234,6 +289,21 @@ def _create_provider( protocol: ModelProtocol | str | None = None, options: Mapping[str, Any] | None = None, ) -> Any: + # 形态先收敛:本方法把 ``provider_name`` 拿去 ``_role_aliases.get(...)``/ + # ``_provider_map.get(...)`` 做字典查找、``_get_provider_class`` 做成员判定, + # 并在「不支持的模型提供商」消息里 f-string 插值——不可哈希取值抛裸 + # ``TypeError: unhashable type: 'list'``、位数超限的整数抛 + # ``ValueError: Exceeds the limit``,都不是本模块其余拒绝路径承诺的带指引 + # ``ValueError``。``model_name`` 原样透传给 provider 构造器(``local-hash`` + # 直接 ``.rsplit()``,非字符串抛裸 ``AttributeError``)。 + # + # 判据只做了一半:诊断入口 ``_provider_info_for_role``(``832abf0``)与 + # 展示层/``RetrievalService`` 都已用 ``require_provider_name`` 收敛, + # 唯独**真正建 provider** 的这里漏了。三个 getter 走 ``_get_model_detail`` + # 也会到达本方法,因此这一处同时覆盖「getter 路径」与「直接调 + # ``_create_provider`` 的嵌入方/测试」。 + provider_name = require_provider_name(provider_name, label="provider 名称") + model_name = require_model_name(model_name) # ``ModelDetail`` normally normalizes protocol values before they reach # the factory, but this internal helper is also used directly by # integrations and tests. Normalize again at the boundary so a plain @@ -312,6 +382,16 @@ def _runtime_verified_protocols( """读取诊断调用使用的实例级服务端协议登记。""" if options is None: return frozenset() + # 容器形态必须先判,且要用与 ``validate_secret_free_options`` 相同的 + # ``ValueError``:``"http_options" in options`` 对**列表**是元素成员判断 + # (``["http_options"]`` 会命中 google 分支),对整数抛裸 + # ``TypeError: argument of type 'int' is not iterable``,命中后 + # ``options.items()`` 又抛裸 ``AttributeError``。``capability_report`` 与 + # ``protocol_status`` 是公开诊断入口,调用方按 ``ValueError`` 分类; + # 非 google 渠道走 else 分支时本来就得到 ``ValueError``,两个分支的 + # 异常分类必须一致。 + if not isinstance(options, Mapping): + raise ValueError("Provider options 必须是对象。") if str(provider_name or "").strip().lower() == "google" and "http_options" in options: # ``http_options`` is a supported google-genai SDK container. Keep # the general options boundary strict while allowing only this @@ -342,7 +422,7 @@ def _runtime_verified_protocols( } normalized: set[str] = set() for value in configured: - protocol = str(value).strip().lower().replace("-", "_") + protocol = _normalize_protocol_text(value, label="server_verified_protocols") protocol = aliases.get(protocol, protocol) try: normalized_protocol = ModelProtocol(protocol).value @@ -363,7 +443,7 @@ def _runtime_verified_protocols( @classmethod def capabilities(cls, provider_name: str, role: str = "llm") -> frozenset[str]: """返回指定角色的渠道能力集合,供 UI/配置校验使用。""" - normalized_role = str(role).strip().lower() + normalized_role = _normalize_role_text(role) resolved, _info = cls._provider_info_for_role(provider_name, normalized_role) provider_class = cls._get_provider_class(resolved) if normalized_role in {"embedding", "rerank"}: @@ -387,7 +467,7 @@ def capability_report( ``server_verified`` 仍只表示目标服务端有明确验证证据,不会由 SDK 的本地资源树自动推导。 """ - normalized_role = str(role).strip().lower() + normalized_role = _normalize_role_text(role) resolved, info = cls._provider_info_for_role(provider_name, normalized_role) provider_class = cls._get_provider_class(resolved) sdk_package = info.get("sdk_package") @@ -446,6 +526,17 @@ def protocol_status( LLM 是默认角色;未显式指定时,对 embedding/rerank-only provider 自动选择其唯一角色,以便诊断工具可以统一查询所有渠道。 """ + # 形态判据必须在**最前**:下面 ``role is None`` 的推断分支会先对 + # ``provider_name`` 做两次字典查找(``_resolve_provider_name`` 与 + # ``_provider_map.get``),不可哈希的取值在那里就抛裸 + # ``TypeError: unhashable type``——早于 ``_provider_info_for_role`` 内部的 + # 守卫,因此那里单独加的判据覆盖不到本入口。实测修复前 + # ``protocol_status(["a"], "chat_completions")`` 复现。 + if not isinstance(provider_name, str): + raise ValueError( + f"provider 名称必须是字符串,但得到 {safe_repr(provider_name)}。" + " 请检查配置里的 *provider 字段。" + ) if role is None: candidates = ("llm", "embedding", "rerank") normalized_role = next( @@ -462,9 +553,9 @@ def protocol_status( if normalized_role is None: raise ValueError(f"无法为提供商 {provider_name} 推断模型角色,请显式传入 role。") else: - normalized_role = str(role).strip().lower() + normalized_role = _normalize_role_text(role) resolved, info = cls._provider_info_for_role(provider_name, normalized_role) - normalized = str(protocol).strip().lower().replace("-", "_") + normalized = _normalize_protocol_text(protocol, label="protocol") normalized = { "chat": ModelProtocol.CHAT_COMPLETIONS.value, "completion": ModelProtocol.CHAT_COMPLETIONS.value, @@ -499,10 +590,72 @@ def _get_model_detail( ) -> ModelDetail: if configurations is None: configurations = getattr(get_settings(), settings_field) + # 容器形态要**先**判:``provider_key not in configurations`` 对不可迭代的 + # 取值抛裸 ``TypeError: argument of type 'int' is not iterable``,而字符串 + # 容器会走**子串**判定(``"k" in "k"`` 为真),随后 + # ``"k"["k"]`` 抛 ``TypeError: string indices must be integers``。两条都 + # 不是 ``ValueError``。取值来自可直写的 ``SessionConfig.llm_configurations``, + # 经 ``Chatbot.reload_llm`` 原样传到这里。 + if not isinstance(configurations, Mapping): + raise ValueError( + f"{settings_field} 必须是配置映射,但得到 {type(configurations).__name__}。" + f" 请检查 {role_label} 的当前配置项。" + ) + # 键形态同样要在成员判断**之前**收敛:``key not in dict`` 对不可哈希的取值抛 + # 裸 ``TypeError: unhashable type: 'list'``(不是 ``ValueError``,穿透 + # ``Chatbot.reload_llm`` 的 ``except Exception`` 分类与上游的 + # ``except ValueError``),而消息里的 f-string 对位数超限的整数抛 + # ``ValueError: Exceeds the limit``——类型对,内容与字段无关。 + # ``key`` 来源是 ``SessionConfig.active_llm_configuration`` 等可直写属性。 + # 同一判据在 ``src/runtime/contracts.py`` 的 ``_require_known_field`` 里 + # 已认领过(同样的 ``key not in ...`` + f-string 组合),这里对齐。 + if not isinstance(provider_key, str): + raise ValueError( + f"{settings_field} 的 key 必须是字符串,但得到 {safe_repr(provider_key)}。" + f" 请检查 {role_label} 的当前配置项。" + ) if provider_key not in configurations: - logger.error(f"在{role_label}配置中未找到key: {provider_key}") - raise ValueError(f"在{role_label}配置中未找到key: {provider_key}") - return configurations[provider_key] + # 消息与 ``display_utils._require_model_detail`` 的同名守卫对齐: + # 带可选值与指引——同一判据两套消息会让用户经 ``reload_llm`` 看到 + # 残缺版(只有「未找到 key」),无从知道有哪些可填的键。键名必须经 + # ``safe_repr``:configurations 是可直写映射,pickle 快照可携带 + # 位数超限的整数键,裸 ``str(k)`` 会让**守卫自己**崩在拼消息时 + # (实测消息语义从「未找到key」错位成 ``Exceeds the limit``), + # 与 ``display_utils._require_model_detail`` 的既有处理对齐。 + available = "、".join(sorted(safe_repr(k) for k in configurations)) or "无" + logger.error(f"在{role_label}配置中未找到key: {provider_key}。可选值: {available}。") + raise ValueError( + f"在{role_label}配置中未找到key: {safe_repr(provider_key)}。" + f"可选值: {available}。" + " 请检查 config.toml 里的 default_*_provider 与 *_configurations 是否对应。" + ) + # 取值的形态也要在这里收敛:``key in mapping`` 只证明**键**在,不证明值 + # 可用。``{"k": None}`` 的键确实存在,于是不会走上面「未找到key」的分支, + # 而是把 ``None`` 原样返回给调用方——三个入口紧接着都读 ``config.provider`` + # 等属性,在那里抛裸 ``AttributeError``(不是 ``ValueError``,穿透 + # ``Chatbot.reload_llm`` 的 ``except ValueError`` 分类)。 + # 值来源是 ``SessionConfig.llm_configurations`` 等可直写映射。 + detail = configurations[provider_key] + require_detail_attributes( + detail, + ("provider", "model_name", "protocol", "options"), + field=settings_field, + key=provider_key, + role_label=role_label, + ) + # 属性**存在**不代表取值可用:``ModelDetail`` 的构造器会拒绝非字符串 + # ``provider``/``model_name``,但属性赋值没有 ``__setattr__`` 守卫(实测 + # ``detail.provider = ["a"]`` 直接成功),``SimpleNamespace`` 替身同样能提供 + # 这些属性。三个 getter 紧接着读 ``config.provider``/``config.model_name`` + # 并写进 ``logger.info`` 的 f-string,随后交给 ``_create_provider``——前者对 + # 位数超限的整数抛 ``ValueError: Exceeds the limit``,后者对不可哈希取值抛 + # 裸 ``TypeError``。诊断入口 ``_provider_info_for_role`` 早已按同一判据收敛, + # 这里对齐(``_create_provider`` 也各自判一次,覆盖直接调用它的嵌入方)。 + require_provider_name(detail.provider, label=f"{role_label}配置 {provider_key} 的 provider") + require_model_name( + detail.model_name, label=f"{role_label}配置 {provider_key} 的 model_name" + ) + return detail @staticmethod def get_llm_provider( @@ -533,7 +686,7 @@ def get_llm_provider( "获取LLM提供商实例失败: %s (%s) - %s", provider_name, model_name, - redact_sensitive_text(str(e)), + safe_exception_text(e), ) raise @@ -565,7 +718,7 @@ def get_embedding_provider( "获取Embedding提供商实例失败: %s (%s) - %s", provider_name, model_name, - redact_sensitive_text(str(e)), + safe_exception_text(e), ) raise @@ -597,6 +750,6 @@ def get_rerank_provider( "获取Rerank提供商实例失败: %s (%s) - %s", provider_name, model_name, - redact_sensitive_text(str(e)), + safe_exception_text(e), ) raise diff --git a/src/providers/google.py b/src/providers/google.py index a626dfa..9482ba5 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -2,7 +2,6 @@ import binascii import inspect import json -import math import os import re import time @@ -17,6 +16,7 @@ from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential from src.providers.__base__.model_provider import ( + _MAX_CLIENT_TIMEOUT_SECONDS, _UNSET_TEMPERATURE, CompletionRequest, CompletionResult, @@ -25,15 +25,19 @@ TextEmbeddingModel, close_resource_sync, coerce_completion_request, + enum_field, extract_system_prompt, field, is_retryable_error, iterate_async, + normalize_client_timeout, normalize_embedding_vector, normalize_messages, normalize_usage, raise_for_stream_error_event, reject_unsupported_kwargs, + require_text_list, + require_token_count, retry_async_call, retry_async_stream, retry_sync_call, @@ -41,11 +45,19 @@ validate_complete_tool_call, ) from src.providers.resources import AsyncGoogleResources, GoogleResources -from src.utils.config import get_settings +from src.utils.config import ( + _MAX_EMBEDDING_DIMENSION, + get_settings, + is_double_convertible, + parse_decimal_int, + require_model_name, + safe_repr, +) from src.utils.log_manager import get_module_logger from src.utils.security import ( _model_dump_mapping, redact_sensitive_text, + safe_exception_text, validate_secret_free_payload, validate_secret_free_request_overrides, ) @@ -53,6 +65,24 @@ logger = get_module_logger(__name__) +# Google 整数型生成参数的上界。与项目其它上界常量同一用途:TOML 整数没有 +# 上界,不设界时 ``options.max_output_tokens = 10**400`` 会被原样编进请求体 +# 发给服务端。取值本身也不需要超过这个量——主流模型的最大输出是数万 token。 +_MAX_GENERATION_TOKENS = 1_000_000 + +# Vertex ``PartialArg.json_path`` 里数组下标的**上界**。 +# +# 下标由模型输出决定,而 ``_partial_args_to_mapping`` 用 +# ``while len(current) <= token: current.append(None)`` 把列表**填充**到该下标, +# 因此这个值直接决定一次分片要分配多少个元素。实测 ``$.items[20000000]`` +# 分配 2000 万个 ``None``(RSS 峰值 +76MB、耗时 6.5 秒),而它只是一个 +# 字符串字段的下标——模型(或中转网关)用一个数字就能让进程卡住。 +# +# 上界取 10 万:真实函数参数里的数组不会有比这更长的,而 10 万个元素是 +# 毫秒级、几 MB 的量。与 ``_MAX_GENERATION_TOKENS`` 同一用途。 +_MAX_PARTIAL_ARG_INDEX = 100_000 + + class GoogleProvider(LargeLanguageModel, TextEmbeddingModel): """ Google模型提供商,统一处理Gemini LLM和Embedding。 @@ -200,7 +230,11 @@ class GoogleProvider(LargeLanguageModel, TextEmbeddingModel): ) def __init__(self, model_name: str, options: dict[str, Any] | None = None): - self._model_name = model_name + # ``GoogleProvider`` 是公开类,嵌入方可以绕过工厂直接构造。工厂路径已用 + # ``require_model_name`` 收敛(``cc10585``),这里同样要判:非字符串取值 + # 会在下面的 ``logger.info`` 插值处抛裸 ``ValueError: Exceeds the limit`` + # (位数超限的整数),或静默原样发给 SDK(列表/``None``)。 + self._model_name = require_model_name(model_name) if options is not None and not isinstance(options, Mapping): raise ValueError("Google options 必须是对象。") self._options = dict(options or {}) @@ -355,6 +389,19 @@ def _as_bool(value: Any) -> bool: def _validate_options(cls, options: Mapping[str, Any]) -> None: if not isinstance(options, Mapping): raise ValueError("Google options 必须是对象。") + # 键形态必须先收敛:``sorted(set(options).difference(_OPTION_KEYS))`` 对 + # 非字符串键抛裸 ``TypeError: sequence item 0: expected str instance, + # int found``(混合类型键则是 ``'<' not supported between instances of + # ...``)。本类是公开类,嵌入方可以直接 ``GoogleProvider("m", {1: "x"})`` + # ——本仓测试就这么构造——而本方法的其余判据都按 ``ValueError`` 收敛。 + # 与 ``utils.security.validate_secret_free_options`` 的同名判据一致; + # 本类不走那个共享入口,故在此单列。 + for key in options: + if not isinstance(key, str): + raise ValueError( + f"Google options 的键必须是字符串,但得到 {safe_repr(key)}。" + " 请检查配置里的 options 表。" + ) unknown = sorted(set(options).difference(cls._OPTION_KEYS)) if unknown: raise ValueError("Google options 包含不支持的字段: " + ", ".join(unknown)) @@ -368,6 +415,12 @@ def _validate_options(cls, options: Mapping[str, Any]) -> None: cls._validate_http_options(options["http_options"]) if "timeout" in options: cls._validate_timeout(options["timeout"], "Google options.timeout") + # 整数型生成参数在构造期一并校验,与 ``timeout`` 同一时机。若留到 + # ``_generation_options()``(请求期)才判,非法取值要等首次提问才暴露, + # 而配置错误应当在配置加载时就报出来。 + for key in ("max_output_tokens", "candidate_count"): + if key in options and options[key] is not None: + cls._validate_generation_count(options[key], key) configured_body = options.get("extra_body") model_conflicts: list[str] = [] if options.get("http_options") is not None: @@ -470,19 +523,35 @@ def _validate_http_options(cls, value: Any) -> None: if "timeout" in payload and payload["timeout"] is not None: timeout = payload["timeout"] + # 先判可转换性:``math.isfinite(float(x))`` 对超出双精度范围的整数 + # 自己就抛 ``OverflowError``(**不是** ``ValueError``)。 + # 判据与 ``options.timeout`` 同源:先判可转换性 + # (``math.isfinite(float(x))`` 对超出双精度范围的整数自己就抛 + # ``OverflowError``),再判正数与**上界**。上界此前缺失,实测 + # ``http_options.timeout = 1e308`` 能通过这里,到真实 + # ``genai.Client(...)`` 构造时才抛 pydantic 的 + # ``Unable to parse input string as an integer``——错误消息与 + # Google 配置毫无关联。毫秒上界即秒上界换算而来。 if ( isinstance(timeout, bool) or not isinstance(timeout, (int, float)) - or not math.isfinite(float(timeout)) + or not is_double_convertible(timeout) or float(timeout) <= 0 or int(float(timeout)) != float(timeout) + or float(timeout) > _MAX_CLIENT_TIMEOUT_SECONDS * 1000 ): raise ValueError("Google options.http_options.timeout 必须是正整数毫秒。") found: list[str] = [] def normalized_key(key: Any) -> str: - return "".join(char for char in str(key).lower() if char.isalnum()) + # 字符串直接规范化:``safe_repr`` 对字符串加引号、并在 80 字符处 + # 截断成 ``'xxx...'``,超长键名的后缀(``...apikey``/``...secret``) + # 会被截掉,``is_sensitive_key`` 的 ``endswith`` 判据随即失配—— + # 一个长键名的凭证键就会被放行(实测复现)。非字符串才走 + # ``safe_repr``(不崩)。 + text = key if isinstance(key, str) else safe_repr(key) + return "".join(char for char in text.lower() if char.isalnum()) def is_sensitive_key(key: Any) -> bool: normalized = normalized_key(key) @@ -518,7 +587,12 @@ def check_url_query(value: Any, path: str) -> None: def visit(item: Any, path: str = "") -> None: if isinstance(item, Mapping): for key, nested in item.items(): - current = f"{path}.{key}" if path else str(key) + if not isinstance(key, str): + raise ValueError( + "Google options.http_options 的键必须是字符串," + f"但得到 {safe_repr(key)}。" + ) + current = f"{path}.{key}" if path else key if nested is None: continue if is_sensitive_key(key): @@ -550,15 +624,16 @@ def visit(item: Any, path: str = "") -> None: @staticmethod def _validate_timeout(value: Any, field_name: str) -> float: - """将请求 timeout 规范为有限正数,避免底层比较抛出 TypeError。""" - if ( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or not math.isfinite(float(value)) - or float(value) <= 0 - ): - raise ValueError(f"{field_name} 必须是正数。") - return float(value) + """将请求 timeout 规范为有限正数,避免底层比较抛出 TypeError。 + + 走与其它渠道同一个 helper,因此带**上界**:Google 是唯一把秒换算成 + 毫秒再交给 SDK 的渠道(``_build_http_options`` 的 + ``int(timeout * 1000)``),无上界时该派生值对超过 ``DBL_MAX/1000`` + 的秒数抛 ``OverflowError``——实测 ``options.timeout = 1e306`` 时配置 + 加载与 provider 构造都成功,首次提问才炸,且 ``OverflowError`` 不是 + ``ValueError``,穿透全部错误边界。 + """ + return normalize_client_timeout(value, "Google", field_name) @classmethod def _validate_resource_config(cls, config: Any) -> None: @@ -575,7 +650,10 @@ def _validate_resource_config(cls, config: Any) -> None: def visit(value: Any) -> None: if isinstance(value, Mapping): for key, nested in value.items(): - normalized = "".join(char for char in str(key).lower() if char.isalnum()) + # ``safe_repr`` 而非裸 ``str``:这个递归是**发现** ``http_options`` + # 用的,非字符串键只需不崩即可(它也不会等于 ``httpoptions``)。 + # ``str(10**4301)`` 会抛 ``ValueError: Exceeds the limit``。 + normalized = "".join(char for char in safe_repr(key).lower() if char.isalnum()) if normalized == "httpoptions": cls._validate_http_options(nested) continue @@ -595,6 +673,14 @@ def _coerce_debug_config(value: Any) -> Any: """将配置文件中的 DebugConfig 表转换为 SDK 对象。""" if value is None or not isinstance(value, Mapping): return value + # 键形态先收敛:``sorted(set(value).difference(allowed))`` 对非字符串键抛 + # 裸 ``TypeError: sequence item 0: expected str instance, int found`` + # (混合类型键则是 ``'<' not supported between instances of ...``)。 + # 与 ``_validate_options``/``utils.security.validate_secret_free_options`` + # 同一判据、同一理由。 + for key in value: + if not isinstance(key, str): + raise ValueError(f"Google debug_config 的键必须是字符串,但得到 {safe_repr(key)}。") allowed = {"client_mode", "replays_directory", "replay_id"} unknown = sorted(set(value).difference(allowed)) if unknown: @@ -824,7 +910,33 @@ def _embedding_options(self) -> dict[str, Any]: "document_ocr", "audio_track_extraction", } - return {key: value for key, value in self._options.items() if key in supported} + values = {key: value for key, value in self._options.items() if key in supported} + # ``output_dimensionality`` 此前只过键名白名单,取值原样透传给 + # ``EmbedContentConfig``。实测 ``-1``/``0``/``"abc"``/``1.5``/``True`` + # 全部构造成功并进入 ``embedContent`` 请求体,由服务端返回语义不明的 400; + # ``10**4301`` 更糟——它在 ``json.dumps`` 编码请求体时才抛「整数转字符串 + # 超限」。项目里 ``_MAX_EMBEDDING_DIMENSION`` 早就是这个量的上界, + # ``openai_compatible._validate_embedding_dimensions`` 已在同一语义上 + # 校验,这里是 google 渠道的对应实现。 + if "output_dimensionality" in values: + values["output_dimensionality"] = self._validate_embedding_dimensions( + values["output_dimensionality"] + ) + return values + + @staticmethod + def _validate_embedding_dimensions(value: Any) -> int: + """校验 ``options.output_dimensionality``,拒绝越界、非整数与超大字面量。""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"Google options.output_dimensionality 必须是整数,但得到 {safe_repr(value)}。" + ) + if not 1 <= value <= _MAX_EMBEDDING_DIMENSION: + raise ValueError( + f"Google options.output_dimensionality 必须在 1 到 " + f"{_MAX_EMBEDDING_DIMENSION} 之间,但得到 {safe_repr(value)}。" + ) + return value @staticmethod def _resource_config(config_type: Any, config: Any, values: dict[str, Any]) -> Any: @@ -983,7 +1095,36 @@ def _generation_options(self) -> dict[str, Any]: "model_armor_config", "should_return_http_response", } - return {key: value for key, value in self._options.items() if key in supported} + values = {key: value for key, value in self._options.items() if key in supported} + # 生成参数此前只做键名白名单,取值原样透传给 + # ``types.GenerateContentConfig``。实测 ``options.max_output_tokens`` + # 写成 ``10**400`` 时构造与调用都成功,请求体里就带着那个 401 位整数 + # 发给服务端(MockTransport 抓包确认);``candidate_count = -1``/``0`` + # 同样静默通过。项目里 ``chat_top_k``/``kb_chunk_size``/ + # ``_MAX_EMBEDDING_DIMENSION`` 都已有同类上界,这里是遗漏。 + for key in ("max_output_tokens", "candidate_count"): + if key in values: + values[key] = self._validate_generation_count(values[key], key) + return values + + @staticmethod + def _validate_generation_count(value: Any, field_name: str) -> int: + """校验 Google 的整数型生成参数(``max_output_tokens``/``candidate_count``)。 + + 只接受正整数并给上界:TOML 整数无上界,不设界时超大值会被原样编进 + 请求体,非法取值则原样发给服务端,由远端返回语义不明的 400。 + 上界取 ``_MAX_GENERATION_TOKENS``,见该常量的说明。 + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"Google options.{field_name} 必须是正整数,但得到 {safe_repr(value)}。" + ) + if not 1 <= value <= _MAX_GENERATION_TOKENS: + raise ValueError( + f"Google options.{field_name} 必须在 1 到 {_MAX_GENERATION_TOKENS} 之间," + f"但得到 {safe_repr(value)}。" + ) + return value @staticmethod def _response_format_values(response_format: Any) -> dict[str, Any]: @@ -1100,13 +1241,26 @@ def _http_options_for_request( def _convert_tools(tools: list[dict[str, Any]] | None) -> list[Any] | None: if not tools: return None + # 容器形态先判:``for ... in enumerate(tools)`` 对不可迭代的取值抛裸 + # ``TypeError: 'int' object is not iterable``——而同函数对**可迭代但 + # 元素非法**的形态(``"s"``/``{"a": 1}``/``b"x"``)给的都是带序号的 + # ``ValueError``。同一判据的两半必须都在,否则「工具定义写错」的收场 + # 取决于它是标量还是序列,且前者不落调用方按 ``ValueError`` 建立的 + # 分类。``tools=5`` 走 ``invoke`` 实测即抛该 ``TypeError``。 + if not isinstance(tools, (list, tuple)): + raise ValueError(f"Google 工具定义必须是列表,但得到 {safe_repr(tools)}。") converted_tools: list[Any] = [] declarations = [] - for tool in tools: - if not isinstance(tool, dict): - converted_tools.append(tool) - continue + for index, tool in enumerate(tools): + # 非 Mapping 的工具定义此前被原样放进 ``converted_tools``,直到 + # ``types.Tool(**tool)`` 或 ``function.get("name")`` 才以裸 + # ``AttributeError`` / ``TypeError`` 暴露——两者都不在调用方按 + # ``ValueError`` 建立的错误分类内。Anthropic 的同一入口( + # ``anthropic.py`` 的 ``_convert_tools``)早已转成带序号的 + # ``ValueError``,这里补齐同一判据。 + if not isinstance(tool, Mapping): + raise ValueError(f"Google 工具定义[{index}] 必须是对象,但得到 {safe_repr(tool)}。") native_keys = { "retrieval", "computer_use", @@ -1122,6 +1276,12 @@ def _convert_tools(tools: list[dict[str, Any]] | None) -> list[Any] | None: "exa_ai_search", } type_name = tool.get("type") + # ``type_name in type_aliases`` 对不可哈希取值抛 + # ``TypeError: unhashable type``;下面 ``native_keys.intersection(tool)`` + # 同理。类型标记本来就该是字符串,非字符串一律按「不是原生工具」处理, + # 落到下面的 function schema 分支(与未知类型标记同一条路径)。 + if not isinstance(type_name, str): + type_name = None type_aliases = { "google_search": "google_search", "code_execution": "code_execution", @@ -1138,9 +1298,13 @@ def _convert_tools(tools: list[dict[str, Any]] | None) -> list[Any] | None: converted_tools.append(types.Tool(**tool)) continue function = tool.get("function", tool) + if not isinstance(function, Mapping): + raise ValueError( + f"Google 工具定义[{index}].function 必须是对象,但得到 {safe_repr(function)}。" + ) name = function.get("name") - if not name: - raise ValueError("Google 工具定义缺少 function.name。") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"Google 工具定义[{index}] 缺少 function.name。") declarations.append( types.FunctionDeclaration( name=name, @@ -1167,9 +1331,7 @@ def _convert_tool_choice(tool_choice: Any) -> Any: # pydantic 会把违规取值原文写进 ``input_value=...``,调用方若直接 # ``str(exc)`` 就可能把凭证形态的值带出去。两个消毒器虽然都能 # 拦住(见 test_security_boundaries),但在源头收口才是纵深防御。 - raise ValueError( - f"Google tool_choice 无效: {redact_sensitive_text(str(exc))}" - ) from exc + raise ValueError(f"Google tool_choice 无效: {safe_exception_text(exc)}") from exc if isinstance(tool_choice, dict): if tool_choice.get("type") == "function": function = tool_choice.get("function", {}) @@ -1234,7 +1396,16 @@ def _build_generation_config( if configured_max_tokens is not None and configured_max_output_tokens is not None: raise ValueError("Google options.max_tokens 与 options.max_output_tokens 重复。") if configured_max_tokens is not None: - values["max_output_tokens"] = configured_max_tokens + # 与 ``_generation_options`` 里的 ``max_output_tokens`` 同一判据: + # 此前这条赋值绕过了 ``_validate_generation_count``,于是 + # ``options.max_tokens = -1``/``0``/``True``/``10**400`` 全部 + # 原样进入 ``GenerateContentConfig``(实测 pydantic 对 + # ``-1``/``True``/超大整数都不报错),非法取值由服务端返回语义 + # 不明的 400。同一个量在 ``max_output_tokens`` 名下会被拒绝, + # 换个别名就放行,判据必须一致。 + values["max_output_tokens"] = self._validate_generation_count( + configured_max_tokens, "max_tokens" + ) configured_thinking = self._options.get("thinking") configured_thinking_config = values.get("thinking_config") if configured_thinking is not None and configured_thinking_config is not None: @@ -1255,13 +1426,15 @@ def _build_generation_config( if temperature is not None or "temperature" not in values: values["temperature"] = 0.7 if temperature is None else temperature if max_tokens is not None: - values["max_output_tokens"] = max_tokens + values["max_output_tokens"] = self._validate_generation_count(max_tokens, "max_tokens") if top_p is not None: values["top_p"] = top_p if top_k is not None: values["top_k"] = top_k if candidate_count is not None: - values["candidate_count"] = candidate_count + values["candidate_count"] = self._validate_generation_count( + candidate_count, "candidate_count" + ) if frequency_penalty is not None: values["frequency_penalty"] = frequency_penalty if presence_penalty is not None: @@ -1476,7 +1649,7 @@ def function_response_value(value: Any) -> Any: response_parts = raw_content if isinstance(raw_content, list) else [raw_content] parts = [] for response_part in response_parts: - if isinstance(response_part, dict) and response_part.get("type") in { + if isinstance(response_part, dict) and enum_field(response_part, "type") in { "tool_result", "function_result", "function_response", @@ -1534,6 +1707,8 @@ def function_response_value(value: Any) -> Any: parts.append(part) continue part_type = part.get("type") + if not isinstance(part_type, str): + part_type = None if part_type in {"text", "input_text"}: parts.append(types.Part(text=part.get("text", ""))) elif part_type in {"image_url", "input_image"}: @@ -1641,6 +1816,16 @@ def function_response_value(value: Any) -> Any: if not isinstance(function_name, str) or not function_name.strip(): raise ValueError("Google function_call 缺少 name。") if call_id and function_name: + # 同一 id 重复登记说明上游给了自相矛盾的调用序列 + # (两条 function_call 共用一个 id),静默覆盖会让 + # 后续 ``function_response`` 关联到后写的那条名字、 + # 与它实际回应的调用不符。宁可显式报错。 + existing = tool_call_names.get(call_id) + if existing is not None and existing != function_name: + raise ValueError( + f"Google 工具调用 id {call_id!r} 重复且名字冲突: " + f"{existing!r} vs {function_name!r}。" + ) tool_call_names[call_id] = function_name arguments = part.get("arguments", part.get("args", {})) if isinstance(arguments, str): @@ -1680,7 +1865,21 @@ def function_response_value(value: Any) -> Any: else: raise ValueError(f"Google 不支持的消息内容块类型: {part_type!r}。") else: - parts = [types.Part(text=str(raw_content))] + # 字符串 content 原样透传;其余标量 content 的渲染走 ``safe_repr`` + # 而不是裸 ``str()``:``normalize_messages`` 只校验 ``messages`` + # 是「对象序列」,不校验 content 的形态(实测 + # ``{"role": "user", "content": 10**4301}`` 原样通过),因此这里 + # 是标量 content 的唯一收口。``str(10**4301)`` 抛 + # ``ValueError: Exceeds the limit``——类型对、内容却与「消息 + # 内容写错了」无关。字符串**必须**排除在 ``safe_repr`` 之外: + # 后者会加引号并在 80 字符处截断,把普通长文本消息改写成 + # ``'前 77 字...'`` 发给模型。非字符串的兜底判据与同文件的 + # ``_normalize_finish_reason`` 一致。 + parts = [ + types.Part( + text=raw_content if isinstance(raw_content, str) else safe_repr(raw_content) + ) + ] for index, call in enumerate(message.get("tool_calls", []) or []): if not isinstance(call, Mapping): raise ValueError(f"Google assistant tool_calls[{index}] 必须是对象。") @@ -1692,6 +1891,12 @@ def function_response_value(value: Any) -> Any: if not isinstance(function_name, str) or not function_name.strip(): raise ValueError(f"Google assistant tool_calls[{index}] 缺少 function.name。") if call_id and function_name: + existing = tool_call_names.get(call_id) + if existing is not None and existing != function_name: + raise ValueError( + f"Google 工具调用 id {call_id!r} 重复且名字冲突: " + f"{existing!r} vs {function_name!r}。" + ) tool_call_names[call_id] = function_name arguments = function.get("arguments", function.get("args", {})) if isinstance(arguments, str): @@ -1718,13 +1923,13 @@ def function_response_value(value: Any) -> Any: @staticmethod def _response_text(response: Any) -> str: """读取文本而不触发 Google SDK 纯工具响应的 ValueError。""" - candidates = field(response, "candidates", []) or [] + candidates = GoogleProvider._require_candidates(response, source="响应") if candidates: chunks: list[str] = [] saw_parts = False for candidate in candidates: content = field(candidate, "content") - for part in field(content, "parts", []) or []: + for part in GoogleProvider._require_parts(content, source="响应"): saw_parts = True if field(part, "thought", False): continue @@ -1742,7 +1947,7 @@ def _response_text(response: Any) -> str: chunks = [] for candidate in candidates: content = field(candidate, "content") - for part in field(content, "parts", []) or []: + for part in GoogleProvider._require_parts(content, source="响应"): if field(part, "thought", False): continue text = field(part, "text", "") @@ -1762,11 +1967,75 @@ def _response_text(response: Any) -> str: } ) + @staticmethod + def _require_parts(owner: Any, *, source: str) -> list[Any]: + """把内容块里的 ``parts`` 收敛成列表,非法形态给出可读的 ``RuntimeError``。 + + ``parts`` 是 ``candidates[].content`` 下的**嵌套**容器,本文件有 **5 处** + 直接迭代它。此前每一处都只写 ``field(content, "parts", []) or []``——与 + 顶层 ``candidates`` 修复前的写法完全相同,同样只挡住 ``None`` 与空值。 + 实测 ``content.parts = 5`` 抛裸 ``TypeError: 'int' object is not iterable``, + 而本 provider 的错误分类按 ``RuntimeError`` 写。 + + 与 ``_require_candidates`` 的**可达性说明相同**:google-genai 的响应经 + ``GenerateContentResponse._from_response`` → ``cls.model_validate(response)`` + 构造,**会**执行 pydantic 校验,畸形容器在 SDK 层就被拒。因此本条守的是 + 替身场景与「将来 SDK 改成不校验」的契约一致性,不是线上流量。 + """ + parts = field(owner, "parts", []) or [] + if not isinstance(parts, (list, tuple)): + raise RuntimeError(f"Google {source}的 parts 必须是列表,但得到 {safe_repr(parts)}。") + return list(parts) + + @staticmethod + def _require_embeddings(owner: Any, *, source: str) -> list[Any]: + """把 Embedding 响应的 ``embeddings`` 收敛成列表,非法形态抛 ``RuntimeError``。 + + 本文件有 **4 处**读它(同步/异步 × Vertex 单内容/批量),此前都写 + ``list(field(response, "embeddings", []) or [])``——``or []`` 只挡 ``None`` + 与空值,``list(5)`` 抛裸 ``TypeError: 'int' object is not iterable``,而 + 调用方的错误分类按 ``RuntimeError`` 写(紧邻的「返回数量与输入文本数量 + 不一致」就是 ``RuntimeError``)。可达性同 ``_require_parts``。 + """ + embeddings = field(owner, "embeddings", []) or [] + if not isinstance(embeddings, (list, tuple)): + raise RuntimeError( + f"Google {source}的 embeddings 必须是列表,但得到 {safe_repr(embeddings)}。" + ) + return list(embeddings) + + @staticmethod + def _require_candidates(owner: Any, *, source: str) -> list[Any]: + """把响应/流块里的 ``candidates`` 收敛成列表,非法形态给出可读的 ``RuntimeError``。 + + ``candidates`` 是 Gemini 响应的顶层容器,本文件有 **6 处**直接迭代它 + (``_response_text``、``_extract_refusal``、``_extract_result`` 两处、 + ``_stream_events_from_chunk``、``_stream_chunk_has_terminal_event``)。 + 此前每一处都只写 ``field(x, "candidates", []) or []``——那只挡住 ``None`` + 与空值。实测走**真实入口**(``GoogleProvider.complete``)时: + + * ``candidates`` 是整数 -> 裸 ``TypeError: 'int' object is not iterable``; + * 是 JSON 对象 -> 更晚一步,在 ``candidates[0]`` 处抛裸 ``KeyError: 0``。 + + 两者都不是 ``RuntimeError``——而本 provider 的错误分类(以及调用方的 + ``except``)按 ``RuntimeError`` 写。同一个形态在 + ``openai_compatible._raise_for_chat_response_error`` 早已有显式守卫 + (「响应的 choices 必须是列表」),两个 provider 的收场必须一致。 + + 返回列表而不是原值:调用方全都只做迭代,收敛后不必再各自 ``or []``。 + """ + candidates = field(owner, "candidates", []) or [] + if not isinstance(candidates, (list, tuple)): + raise RuntimeError( + f"Google {source}的 candidates 必须是列表,但得到 {safe_repr(candidates)}。" + ) + return list(candidates) + @classmethod def _candidate_refusal(cls, candidate: Any) -> str | None: """读取 Gemini 内容块或安全拦截元数据中的拒答原因。""" content = field(candidate, "content") - for part in field(content, "parts", []) or []: + for part in GoogleProvider._require_parts(content, source="响应"): value = field(part, "refusal") if not value and field(part, "type") == "refusal": value = field(part, "text") or field(part, "reason") @@ -1793,17 +2062,31 @@ def _normalize_finish_reason(value: Any) -> str | None: if isinstance(name, str) and name.strip(): return name.strip().upper() raw = getattr(value, "value", value) - text = str(raw).strip() - if not text: + if raw is None: return None - if "." in text: - text = text.rsplit(".", 1)[-1] + # 非字符串走 ``safe_repr`` 而不是裸 ``str()``:``str(10**4301)`` 抛 + # ``ValueError: Exceeds the limit``,而 ``finish_reason`` 来自服务端响应、 + # 形态不受本地控制,这个位置又在**响应解析**里——用户已经拿到部分输出后 + # 才看到一条与「停止原因」毫无关系的整数转换错误。 + # + # 去点号前缀只对**字符串**做:那条逻辑是给 ``"FinishReason.MAX_TOKENS"`` + # 这类带模块前缀的枚举名准备的,而 ``safe_repr`` 的截断标记恰好也是 + # ``...``——对它套 ``rsplit(".")`` 会把整段数字切光,只剩空串。这类取值 + # 本就该原样呈现(下面的 ``upper()`` 即可),不该被当成带前缀的名字。 + if isinstance(raw, str): + text = raw.strip() + if not text: + return None + if "." in text: + text = text.rsplit(".", 1)[-1] + else: + text = safe_repr(raw).strip() return text.upper() @classmethod def _extract_refusal(cls, response: Any) -> str | None: """提取 Gemini 响应中的显式拒答或安全拦截信息。""" - for candidate in field(response, "candidates", []) or []: + for candidate in cls._require_candidates(response, source="响应"): refusal = cls._candidate_refusal(candidate) if refusal: return refusal @@ -1819,7 +2102,19 @@ def _extract_refusal(cls, response: Any) -> str | None: message = field(feedback, "block_reason_message") if not isinstance(message, str) or not message.strip(): message = field(feedback, "message") - return str(message or f"Google 提示被安全策略拦截: {block_reason_text}") + # 非字符串的 ``message`` 也要能渲染:``field`` 明确支持**字典形态** + # 的响应(见其 docstring「兼容 Pydantic SDK 对象和字典响应」),而 + # 字典里的 ``message`` 不受 SDK 的 ``Optional[str]`` 约束。位数超限的 + # 整数让裸 ``str()`` 抛 ``ValueError: Exceeds the limit``——本方法的 + # 职责是**报告拒答**,在正要报出拦截原因时自己崩掉,用户拿到的是与 + # 「提示被安全策略拦截」毫无关联的整数转换错误,而本 provider 的错误 + # 分类按 ``RuntimeError`` 写。 + # + # 字符串走原样(``safe_repr`` 会加引号,改掉正常取值的显示形态), + # 非字符串才降级——与 ``_render_value``/``_path_text`` 同一取舍。 + if not isinstance(message, str): + message = safe_repr(message) if message else "" + return message or f"Google 提示被安全策略拦截: {block_reason_text}" return None @staticmethod @@ -1836,8 +2131,15 @@ def _partial_arg_value(partial: Any) -> Any: @staticmethod def _partial_arg_tokens(json_path: str) -> list[str | int]: """解析 Vertex PartialArg 使用的常见 JSONPath 子集。""" + # ``json_path`` 来自模型的流式 function call 分片,形态不受控。两条拒绝 + # 消息都用 ``safe_repr`` 而不是 ``!r``:``repr(10**4301)`` 自己抛 + # ``ValueError: Exceeds the limit``——正要报出「json_path 无效」的那一步 + # 变成整数转换错误。对字符串取值两者等价(``safe_repr`` 也加引号), + # 因此正常路径的显示形态不变。 if not isinstance(json_path, str) or not json_path.startswith("$"): - raise ValueError(f"Google FunctionCall.partial_args 的 json_path 无效: {json_path!r}") + raise ValueError( + f"Google FunctionCall.partial_args 的 json_path 无效: {safe_repr(json_path)}" + ) if json_path in {"", "$"}: return [] rest = json_path[1:] @@ -1850,7 +2152,26 @@ def _partial_arg_tokens(json_path: str) -> list[str | int]: continue indexed = re.match(r"^\[(\d+)\]", rest) if indexed: - tokens.append(int(indexed.group(1))) + # ``\d+`` 对任意长度的数字串都匹配,而 ``int("9" * 4301)`` 撞上 + # CPython 的 ``sys.get_int_max_str_digits()``(默认 4300)抛 + # ``ValueError: Exceeds the limit``——类型是本函数的契约,内容却 + # 是整数转换问题,与「json_path 不合法」无关,同函数另外两条拒绝 + # 分支都带着原始 ``json_path`` 与指引。走 ``parse_decimal_int`` + # (解析不了返回 ``None``,绝不抛)与全项目同一判据。 + index_value = parse_decimal_int(indexed.group(1)) + if index_value is None: + raise ValueError( + f"Google FunctionCall.partial_args 的 json_path 索引无效: {json_path!r}" + ) + # 上界与 ``_MAX_PARTIAL_ARG_INDEX`` 同源:下界由下面写入循环里的 + # ``token < 0`` 判据覆盖,这里补上界。缺失时 ``_partial_args_to_mapping`` + # 会按该下标把列表填充到那么大。 + if index_value > _MAX_PARTIAL_ARG_INDEX: + raise ValueError( + f"Google FunctionCall.partial_args 的 json_path 索引过大: " + f"{json_path!r}(下标不得超过 {_MAX_PARTIAL_ARG_INDEX})。" + ) + tokens.append(index_value) rest = rest[indexed.end() :] continue quoted = re.match(r"^\[['\"]([^'\"]+)['\"]\]", rest) @@ -1858,7 +2179,9 @@ def _partial_arg_tokens(json_path: str) -> list[str | int]: tokens.append(quoted.group(1)) rest = rest[quoted.end() :] continue - raise ValueError(f"Google FunctionCall.partial_args 不支持的 json_path: {json_path!r}") + raise ValueError( + f"Google FunctionCall.partial_args 不支持的 json_path: {safe_repr(json_path)}" + ) return tokens @classmethod @@ -2160,9 +2483,9 @@ def _extract_result(response: Any) -> CompletionResult: usage_dict = normalize_usage(usage) calls = [] reasoning: list[str] = [] - for candidate in field(response, "candidates", []) or []: + for candidate in GoogleProvider._require_candidates(response, source="响应"): content = field(candidate, "content") - for part in field(content, "parts", []) or []: + for part in GoogleProvider._require_parts(content, source="响应"): function_call = field(part, "function_call") if function_call: calls.append( @@ -2180,7 +2503,10 @@ def _extract_result(response: Any) -> CompletionResult: tool_calls=calls, usage=usage_dict, finish_reason=GoogleProvider._normalize_finish_reason( - field((field(response, "candidates", []) or [None])[0], "finish_reason") + field( + next(iter(GoogleProvider._require_candidates(response, source="响应")), None), + "finish_reason", + ) ), response_id=field(response, "response_id"), refusal=GoogleProvider._extract_refusal(response), @@ -2192,9 +2518,9 @@ def _extract_result(response: Any) -> CompletionResult: def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: events: list[StreamEvent] = [] response_id = field(chunk, "response_id") - for candidate in field(chunk, "candidates", []) or []: + for candidate in cls._require_candidates(chunk, source="流块"): content = field(candidate, "content") - parts = field(content, "parts", []) or [] + parts = cls._require_parts(content, source="流块") refusal_emitted = False for part in parts: function_call = field(part, "function_call") @@ -2205,6 +2531,19 @@ def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: "type": "function", "name": field(function_call, "name"), "arguments": cls._function_call_arguments(function_call), + # Vertex 开启 ``stream_function_call_arguments`` 时会把一次 + # 调用的参数拆成多个 chunk(SDK 自带 fixture + # ``test_function_call_streaming.py`` 记录的正是这个形状: + # 分片1 带 ``will_continue=True``,分片2 带 ``partial_args`` + # 与 ``will_continue=False``,两者都**没有** id/index)。 + # 读侧 ``_merge_tool_call`` 专门为它写了判据( + # ``tool_call.get("will_continue") is True``), + # ``_merge_tool_call`` 的字段循环也早已把 + # ``will_continue`` 列入复制名单,唯独这里构造 delta 字典时 + # 漏掉了它——于是第二个分片三项判据全假,抛 + # ``RuntimeError: 缺少稳定 id/index``,该 SDK 模式下**每一个** + # 多分片调用都失败。 + "will_continue": field(function_call, "will_continue"), } events.append( StreamEvent( @@ -2232,7 +2571,7 @@ def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: ) ) - part_type = field(part, "type") + part_type = enum_field(part, "type") part_refusal = field(part, "refusal") if not part_refusal and part_type == "refusal": part_refusal = field(part, "text") or field(part, "reason") @@ -2297,7 +2636,26 @@ def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: block_reason = field(feedback, "block_reason") if block_reason: message = field(feedback, "block_reason_message") or field(feedback, "message") - refusal = str(message or f"Google 提示被安全策略拦截: {block_reason}") + # 与 ``_extract_refusal`` 同判据、同理由:``field`` 支持字典形态的 + # 响应,那里的 ``message`` 不受 SDK 的 ``Optional[str]`` 约束,位数 + # 超限的整数让裸 ``str()`` 抛 ``ValueError: Exceeds the limit``——而 + # 这段代码正在**构造拒答事件**,崩掉会让一次真实的安全拦截表现为 + # 整数转换错误。字符串原样,非字符串降级成量级描述。 + if not isinstance(message, str): + message = safe_repr(message) if message else "" + # 兜底文案里插值的 ``block_reason`` 同样要收敛,判据与非流式的 + # ``_extract_refusal`` 逐字一致(枚举取 name、字符串取 strip、其余 + # 空串):它同样来自字典形态的响应、不受 SDK 枚举约束,位数超限的 + # 整数让裸插值抛 ``ValueError: Exceeds the limit``。``message`` 非空 + # 时兜底文案不会被用到,但 f-string 在**求值期**就拼好——不收敛的话 + # 这条路径仍会崩。 + if isinstance(block_reason, Enum): + block_reason_text = block_reason.name or str(block_reason) + elif isinstance(block_reason, str): + block_reason_text = block_reason.strip() + else: + block_reason_text = "" + refusal = message or f"Google 提示被安全策略拦截: {block_reason_text}" if not any(event.type == "refusal_delta" for event in events): events.append( StreamEvent( @@ -2345,7 +2703,7 @@ def _stream_chunk_has_terminal_event(chunk: Any) -> bool: return True return any( field(candidate, "finish_reason", None) is not None - for candidate in field(chunk, "candidates", []) or [] + for candidate in GoogleProvider._require_candidates(chunk, source="流块") ) def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> CompletionResult: @@ -2482,10 +2840,7 @@ def count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) config=types.CountTokensConfig(**config_values) if config_values else None, ) ) - value = field(response, "total_tokens") - if value is None: - raise RuntimeError("Google token count 响应缺少 total_tokens。") - return int(value) + return require_token_count(field(response, "total_tokens"), "Google", "total_tokens") def compute_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: request = coerce_completion_request(request, kwargs, "Google compute_tokens") @@ -2720,10 +3075,7 @@ async def async_count_tokens( config=types.CountTokensConfig(**config_values) if config_values else None, ) ) - value = field(response, "total_tokens") - if value is None: - raise RuntimeError("Google token count 响应缺少 total_tokens。") - return int(value) + return require_token_count(field(response, "total_tokens"), "Google", "total_tokens") async def async_compute_tokens( self, request: CompletionRequest | None = None, **kwargs: Any @@ -4073,7 +4425,7 @@ def invoke( duration = time.perf_counter() - start_time logger.info(f"Google LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s") except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Google LLM (%s) 出错: %s", self._model_name, @@ -4179,7 +4531,7 @@ async def ainvoke( duration = time.perf_counter() - start_time logger.info(f"Google LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s") except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Google LLM (%s) 异步出错: %s", self._model_name, @@ -4195,6 +4547,8 @@ async def ainvoke( ) def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """同步向量化文档。""" + # 形态校验必须先于日志里的 ``len(texts)``(同 openai_compatible)。 + texts = require_text_list(texts, "Google") logger.info(f"调用 Google Embedding ({self._model_name}),数量: {len(texts)}") start_time = time.perf_counter() @@ -4224,7 +4578,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: response = client.models.embed_content( model=self._model_name, contents=[text], config=config ) - values = list(field(response, "embeddings", []) or []) + values = GoogleProvider._require_embeddings(response, source="Embedding 响应") if len(values) != 1: raise RuntimeError( "Google Vertex Embedding 单内容请求未返回唯一 embedding。" @@ -4234,7 +4588,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: response = client.models.embed_content( model=self._model_name, contents=texts, config=config ) - values = list(field(response, "embeddings", []) or []) + values = GoogleProvider._require_embeddings(response, source="Embedding 响应") if len(values) != len(texts): raise RuntimeError("Google Embedding 返回数量与输入文本数量不一致。") embeddings = [normalize_embedding_vector(field(item, "values")) for item in values] @@ -4242,7 +4596,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: logger.info(f"Google Embedding ({self._model_name}) 完成,耗时: {duration:.2f}s") return embeddings except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Google Embedding (%s) 出错: %s", self._model_name, @@ -4258,6 +4612,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: ) async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """异步向量化文档。""" + texts = require_text_list(texts, "Google") logger.info(f"异步调用 Google Embedding ({self._model_name}),数量: {len(texts)}") start_time = time.perf_counter() @@ -4289,7 +4644,7 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f model=self._model_name, contents=[text], config=config ) ) - values = list(field(response, "embeddings", []) or []) + values = GoogleProvider._require_embeddings(response, source="Embedding 响应") if len(values) != 1: raise RuntimeError( "Google Vertex Embedding 单内容请求未返回唯一 embedding。" @@ -4301,7 +4656,7 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f model=self._model_name, contents=texts, config=config ) ) - values = list(field(response, "embeddings", []) or []) + values = GoogleProvider._require_embeddings(response, source="Embedding 响应") if len(values) != len(texts): raise RuntimeError("Google Embedding 返回数量与输入文本数量不一致。") embeddings = [normalize_embedding_vector(field(item, "values")) for item in values] @@ -4309,7 +4664,7 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f logger.info(f"Google Embedding ({self._model_name}) 异步完成,耗时: {duration:.2f}s") return embeddings except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Google Embedding (%s) 异步出错: %s", self._model_name, @@ -4318,12 +4673,28 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f raise def embed_query(self, text: str, **kwargs: Any) -> list[float]: + # 覆写只为设 ``task_type``,但基类 ``TextEmbeddingModel.embed_query`` + # 还有一层**空结果兜底**(``if not vectors: raise ValueError(...)``), + # 覆写时被一并丢掉了,这里补回来——判据与消息与基类逐字对齐。 + # + # 空返回不是假想输入:``embed_documents`` 的条数校验是按 ``len(texts)`` + # 做的,传空 ``texts`` 时 ``len(values) == len(texts) == 0`` 成立,方法 + # 正常返回 ``[]``;网关截断响应或返回空 ``embeddings`` 字段也走到同一 + # 形态。此时原先抛裸 ``IndexError: list index out of range``——它不是 + # ``ValueError``/``RuntimeError``,会穿透调用方按这两类做的错误分类与 + # 日志脱敏,而消息与「provider 返回了什么」毫无关系。 kwargs.setdefault("task_type", "RETRIEVAL_QUERY") - return self.embed_documents([text], **kwargs)[0] + vectors = self.embed_documents([text], **kwargs) + if not vectors: + raise ValueError("Embedding provider 返回空结果。") + return vectors[0] async def aembed_query(self, text: str, **kwargs: Any) -> list[float]: kwargs.setdefault("task_type", "RETRIEVAL_QUERY") - return (await self.aembed_documents([text], **kwargs))[0] + vectors = await self.aembed_documents([text], **kwargs) + if not vectors: + raise ValueError("Embedding provider 返回空结果。") + return vectors[0] def close(self) -> None: """释放同步 Client 及其关联的异步资源。 diff --git a/src/providers/jina.py b/src/providers/jina.py index 07247a9..abe11be 100644 --- a/src/providers/jina.py +++ b/src/providers/jina.py @@ -8,15 +8,22 @@ from src.providers.__base__.model_provider import ( RerankModel, is_retryable_error, + normalize_client_timeout, validate_secret_free_options, ) -from src.utils.config import get_settings +from src.utils.config import get_settings, is_double_convertible, require_model_name, safe_repr from src.utils.log_manager import get_module_logger -from src.utils.security import redact_sensitive_text, validate_secret_free_payload +from src.utils.security import safe_exception_text, validate_secret_free_payload logger = get_module_logger(__name__) +# rerank 请求 ``top_n`` 的上界。上游 API 会自行截断,因此它不需要与文档数 +# 挂钩;这个界的作用是挡住 TOML/调用方传进来的天文数字——它们会被静默接受, +# 直到 ``json.dumps`` 编码请求体时才抛与 rerank 无关的位数错误。 +_MAX_RERANK_TOP_N = 10_000 + + class JinaProvider(RerankModel): """Jina Rerank Provider(HTTP JSON)。 @@ -43,7 +50,11 @@ class JinaProvider(RerankModel): ) def __init__(self, model_name: str, options: dict[str, Any] | None = None): - self._model_name = model_name + # ``JinaProvider`` 是公开类,嵌入方可以绕过工厂直接构造。工厂路径已用 + # ``require_model_name`` 收敛,这里同样要判:非字符串取值会在下面的 + # ``logger.info`` 插值处抛裸 ``ValueError: Exceeds the limit``(位数超限 + # 的整数),或静默原样发给 SDK(列表/``None``)。 + self._model_name = require_model_name(model_name) self._options = validate_secret_free_options(options, "Jina Rerank") unknown = sorted(set(self._options).difference(self._OPTION_KEYS)) if unknown: @@ -95,15 +106,11 @@ def _prepare_payload(self, query: str, documents: list[str], top_n: int) -> dict return payload def _request_timeout(self) -> float: - timeout = self._options.get("timeout", 30.0) - if ( - isinstance(timeout, bool) - or not isinstance(timeout, (int, float)) - or not math.isfinite(float(timeout)) - or timeout <= 0 - ): - raise ValueError("Jina Rerank options.timeout 必须是正数。") - return float(timeout) + # 走共享 helper:它带**上界**。此前只有下界,实测 ``options.timeout`` + # 写成 ``10**400`` 时构造成功,到 httpx 比较超时才以形态各异的错误暴露。 + return normalize_client_timeout( + self._options.get("timeout", 30.0), "Jina", "options.timeout" + ) @staticmethod def _validate_inputs(query: str, documents: list[str], top_n: int) -> None: @@ -113,8 +120,23 @@ def _validate_inputs(query: str, documents: list[str], top_n: int) -> None: raise ValueError("Jina Rerank documents 必须是非空列表。") if any(not isinstance(document, str) for document in documents): raise ValueError("Jina Rerank documents 必须全部是字符串。") - if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n < 1: - raise ValueError("Jina Rerank top_n 必须是大于等于 1 的整数。") + # 上界此前缺失:``10**400`` 与 ``10**4301`` 都被静默接受,后者在 + # ``json.dumps`` 编码请求体时才抛「整数转字符串超限」——错误消息与 + # rerank 参数毫无关联,而同一份文件里 ``options.timeout`` 与响应 + # ``relevance_score`` 都已判过双精度范围,只有这里漏了。 + # + # 上界取 ``_MAX_RERANK_TOP_N`` 而不是 ``len(documents)``:上游 API 自己 + # 会截断,``top_n`` 超过文档数是合法输入(由 + # ``test_rerank_accepts_top_n_larger_than_document_count`` 固定)。 + if ( + not isinstance(top_n, int) + or isinstance(top_n, bool) + or not 1 <= top_n <= _MAX_RERANK_TOP_N + ): + raise ValueError( + f"Jina Rerank top_n 必须是 1 到 {_MAX_RERANK_TOP_N} 之间的整数," + f"但得到 {safe_repr(top_n)}。" + ) def _parse_response( self, results: list[dict], documents: list[str] @@ -152,6 +174,23 @@ def _parse_response( if not isinstance(doc_content, str) or not positions.get(doc_content): raise RuntimeError("Jina Rerank 响应无法映射到输入文档。") index = positions[doc_content].pop(0) + # 取分数用 ``.get`` 而不是 ``[]``:``res["relevance_score"]`` 在字段 + # 缺失时抛 ``KeyError``,而它**不在**下面那个 ``try`` 的覆盖范围内 + # (那一段只包 ``float(...)``),于是本方法承诺的 ``RuntimeError`` + # 变成裸 ``KeyError: 'relevance_score'``。响应来自网络 JSON,一个 + # 不含该字段的结果项就能触发;调用方的错误分类与日志脱敏都按 + # ``RuntimeError`` 写,接不住 ``KeyError``。 + # + # ``float(10**400)`` 抛 ``OverflowError``——它同样不是 ``ValueError``, + # 因此下面这个 ``except`` 也接不住。响应来自网络 JSON,一个超大整数 + # 字面量就能触发。这里先判可转换性再交给 ``float``。 + raw_score = res.get("relevance_score") if isinstance(res, Mapping) else None + if ( + isinstance(raw_score, int) + and not isinstance(raw_score, bool) + and not is_double_convertible(raw_score) + ): + raise RuntimeError("Jina Rerank 响应 relevance_score 超出双精度范围。") try: score = float(res["relevance_score"]) except (KeyError, TypeError, ValueError) as exc: @@ -170,6 +209,13 @@ def _parse_response( ) def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: """同步 Rerank (CSE Sensor)。""" + # ``len()`` 必须放在校验**之后**:``documents`` 是公开入口的参数,非序列 + # 取值在这里抛裸 ``TypeError: object of type 'int' has no len()``——而 + # 紧随其后的 ``_prepare_payload`` 对同一取值给的是带指引的 + # ``ValueError: documents 必须是非空列表``。同一判据的两半顺序反了, + # 「参数写错」的收场就取决于日志级别是否够低(DEBUG 关闭时 f-string + # 仍会求值,所以它与日志级别无关,只是位置错了)。 + self._validate_inputs(query, documents, top_n) logger.info(f"调用 Jina Rerank ({self._model_name}),文档数: {len(documents)}") import httpx @@ -195,7 +241,7 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int logger.info(f"Jina Rerank ({self._model_name}) 完成,耗时: {duration:.2f}s") return indices, scores except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Jina Rerank (%s) 出错: %s", self._model_name, @@ -213,6 +259,8 @@ async def arerank( self, query: str, documents: list[str], top_n: int ) -> tuple[list[int], list[float]]: """异步 Rerank (CSE Sensor)。""" + # 与同步入口同判据:``len()`` 必须在 ``_validate_inputs`` 之后。 + self._validate_inputs(query, documents, top_n) logger.info(f"异步调用 Jina Rerank ({self._model_name}),文档数: {len(documents)}") import httpx @@ -238,7 +286,7 @@ async def arerank( logger.info(f"Jina Rerank ({self._model_name}) 异步完成,耗时: {duration:.2f}s") return indices, scores except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "Jina Rerank (%s) 异步出错: %s", self._model_name, diff --git a/src/providers/local_hash.py b/src/providers/local_hash.py index 3721bbb..a8290ce 100644 --- a/src/providers/local_hash.py +++ b/src/providers/local_hash.py @@ -5,7 +5,12 @@ import jieba # type: ignore[import-untyped] -from src.providers.__base__.model_provider import TextEmbeddingModel +from src.providers.__base__.model_provider import TextEmbeddingModel, require_text_list +from src.utils.config import ( + _MAX_EMBEDDING_DIMENSION, + parse_decimal_int, + require_model_name, +) DEFAULT_DIMENSION = 256 TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]") @@ -14,8 +19,34 @@ def _parse_dimension(model_name: str) -> int: + """从 ``model_name`` 的数字后缀取维度,超过上界时直接拒绝。 + + 校验必须在这里而不是调用方:``_embed_text`` 会**先按该维度分配出完整向量** + 再返回,等调用方拿到结果再判已经晚了——实测 ``local-hash-100000000`` 单次 + 调用耗时 136 秒、峰值内存 3.9 GB。维度是用户可写的 ``model_name`` 决定的, + 与 ``chat_top_k`` 那类规模参数同理,都需要上界。 + """ + # 形态先收敛:下一行对 ``model_name`` 调 ``.rsplit()``,非字符串在那里抛裸 + # ``AttributeError: 'int' object has no attribute 'rsplit'``——不是本模块其余 + # 拒绝路径承诺的 ``ValueError``(维度超上界给的是带指引的 ``ValueError``)。 + # ``LocalHashEmbeddingProvider`` 是公开类,嵌入方可以绕过工厂直接构造;工厂 + # 路径已用 ``require_model_name`` 收敛,这条路径同样要有判据,且与其它六个 + # provider 构造器共用同一 helper。 + model_name = require_model_name(model_name, label="local-hash 的 model_name") suffix = model_name.rsplit("-", maxsplit=1)[-1] - return int(suffix) if suffix.isdigit() and int(suffix) > 0 else DEFAULT_DIMENSION + # 解析走 ``parse_decimal_int``:它同时挡住「``isdigit()`` 为真但 ``int()`` + # 抛错」(``²`` ``①`` ``፩`` …)与「位数超过 ``sys.get_int_max_str_digits()``」 + # (``"9" * 4301`` 是合法整数却照样抛错)两类。任何一种漏掉都会变成一条 + # 不带 ``model_name`` 的裸解析错误(实测消息只有 "invalid literal for int() + # with base 10: '²'"),用户无从知道是配置里的哪个字段写坏了。 + parsed = parse_decimal_int(suffix) + dimension = parsed if parsed is not None and parsed > 0 else DEFAULT_DIMENSION + if dimension > _MAX_EMBEDDING_DIMENSION: + raise ValueError( + f"local-hash 的维度超过上界:model_name={model_name!r} 解析出 {dimension}," + f"上界 {_MAX_EMBEDDING_DIMENSION}。请检查配置里的 model_name。" + ) + return dimension def _tokenize(text: str) -> list[str]: @@ -57,6 +88,7 @@ def _validate_kwargs(kwargs: dict) -> None: def embed_documents(self, texts: list[str], **kwargs) -> list[list[float]]: self._validate_kwargs(kwargs) + texts = require_text_list(texts, "LocalHash") return [self._embed_text(text) for text in texts] async def aembed_documents(self, texts: list[str], **kwargs) -> list[list[float]]: diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index 20cc48f..6f6d819 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -1,6 +1,5 @@ import asyncio import inspect -import math import time from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator, Mapping from typing import Any, ClassVar, cast @@ -10,6 +9,7 @@ from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential from src.providers.__base__.model_provider import ( + _MAX_CLIENT_TIMEOUT_SECONDS, _UNSET_TEMPERATURE, CompletionRequest, CompletionResult, @@ -19,9 +19,13 @@ close_resource_sync, coerce_completion_request, content_to_text, + enum_field, field, + hashable_key_part, is_retryable_error, merge_tool_call_fragment, + normalize_client_retries, + normalize_client_timeout, normalize_embedding_vector, normalize_messages, normalize_responses_input, @@ -29,6 +33,8 @@ normalize_usage, raise_for_stream_error_event, reject_unsupported_kwargs, + require_text_list, + require_token_count, retry_async_call, retry_async_stream, retry_sync_call, @@ -41,10 +47,19 @@ AsyncOpenAICompatibleResources, OpenAICompatibleResources, ) -from src.utils.config import get_settings +from src.utils.config import ( + _MAX_EMBEDDING_DIMENSION, + get_settings, + is_double_convertible, + parse_decimal_int, + require_model_name, + require_provider_name, + safe_repr, +) from src.utils.log_manager import get_module_logger from src.utils.security import ( redact_sensitive_text, + safe_exception_text, validate_secret_free_payload, validate_secret_free_resource_args, validate_secret_free_resource_kwargs, @@ -230,10 +245,23 @@ def __init__( protocol: str = "chat_completions", options: dict[str, Any] | None = None, ): - self._model_name = model_name - self._provider = provider + # ``OpenAICompatibleProvider`` 是公开类,嵌入方可以绕过工厂直接构造。 + # 工厂路径已用 ``require_model_name`` 收敛(``cc10585``),这里同样要判: + # 非字符串取值会在下面的 ``logger.info`` 插值处抛裸 + # ``ValueError: Exceeds the limit``(位数超限的整数),或静默原样发给 + # SDK(列表/``None``)。 + self._model_name = require_model_name(model_name) + # ``provider`` 同处、同类:下面 ``provider.lower()`` 直接调用,非字符串抛裸 + # ``AttributeError: 'list' object has no attribute 'lower'``,而它随后要拼出 + # ``{provider}_api_key`` 字段名——本类其余拒绝路径都给带指引的 + # ``ValueError``。工厂路径已用 ``require_provider_name`` 收敛,直接构造这条 + # 路径漏了。 + self._provider = require_provider_name(provider) self._protocol = self._normalize_protocol(protocol) self._options = validate_secret_free_options(options, provider) + # 客户端构造参数在配置边界就校验:非法取值不能拖到首次发请求时才暴露, + # 那时错误形态与「配置写错了」无关(见 ``_normalize_client_options``)。 + self._normalized_client_options = self._normalize_client_options(self._options) self._server_verified_protocols = self._normalize_verified_protocols( self._options.get("server_verified_protocols", ()) ) @@ -245,9 +273,14 @@ def __init__( self._api_key = getattr(settings, api_key_name, None) self._base_url = getattr(settings, base_url_name, None) + # 记下取值**实际来自哪个字段**,错误消息据此命名:openai 渠道的现行 + # 字段是旧名 ``openai_api_base``(``openai_base_url`` 在 ``Settings`` 里 + # 并不存在),只报 ``base_url_name`` 会把用户指向一个不存在的配置项。 + base_url_source = base_url_name if self._base_url is None: # OpenAI 旧配置字段使用 `openai_api_base`,继续兼容现有配置文件。 - self._base_url = getattr(settings, f"{settings_prefix}_api_base", None) + base_url_source = f"{settings_prefix}_api_base" + self._base_url = getattr(settings, base_url_source, None) if provider in ["ollama", "lm-studio"] and not self._api_key: self._api_key = "no-key-required" @@ -255,6 +288,27 @@ def __init__( if not self._api_key: logger.error(f"{provider} API Key 未设置。") raise ValueError(f"{api_key_name} is required for {provider}") + + # 空串/纯空白必须与「未配置」区分开,两者此前被混为一谈: + # + # ``getattr`` 只在**键缺失**时返回默认值,而这两个字段的默认值非空、 + # ``.env`` 里又带着 ``OPENAI_API_BASE``,因此「显式置空」会得到空串而 + # 不是 ``None``——下面那个 ``is None`` 的回落分支不触发。随后 + # ``openai.OpenAI(base_url="")`` 得到 ``URL('')``,请求 URL 变成相对 + # 路径 ``chat/completions``(没有主机名),失败信息与「地址配错」毫无 + # 关系;``" "`` 更会被 SDK 编码成 ``%20%20%20/``。 + # + # ``None`` 是**未配置**,必须继续放行——openai 渠道依赖 SDK 自带的默认 + # 地址(实测默认客户端为 ``https://api.openai.com/v1/``),所以这里只拒绝 + # 字符串形态的空值,不把 falsy 一律拒掉。 + if isinstance(self._base_url, str) and not self._base_url.strip(): + logger.error(f"{provider} Base URL 配置为空。") + raise ValueError( + f"{base_url_source} 不能为空字符串(得到 {self._base_url!r})。" + " 空值会让请求落到没有主机名的相对路径上;" + "如需使用默认地址,请删除该配置项而不是置空。" + ) + if provider != "openai" and not self._base_url: logger.error(f"{provider} Base URL 未设置。") raise ValueError(f"{base_url_name} is required for {provider}") @@ -273,7 +327,16 @@ def async_resources(self) -> AsyncOpenAICompatibleResources: @staticmethod def _normalize_protocol(protocol: str) -> str: - normalized = str(protocol).strip().lower().replace("-", "_") + # 非字符串显式拒绝:``str(10**4301)`` 撞上 CPython 的整数位数上限,抛的是 + # ``ValueError: Exceeds the limit``——既没有「哪个参数错了」,也没有「可选 + # 值是什么」。协议名只能是字符串,其余形态都说明调用方传错了参数。用 + # ``safe_repr`` 而不是裸取值插值:下面那条消息里也要插值同一个取值。 + if not isinstance(protocol, str): + raise ValueError( + f"OpenAI 兼容渠道的协议必须是字符串,但得到 {safe_repr(protocol)}。" + " 可选值: chat_completions, responses" + ) + normalized = protocol.strip().lower().replace("-", "_") if normalized in {"chat", "completion", "chat_completion", "chat_completions"}: return "chat_completions" if normalized in {"response", "responses"}: @@ -298,27 +361,81 @@ def _normalize_verified_protocols(cls, configured: Any) -> frozenset[str]: normalized.add(cls._normalize_protocol(value)) return frozenset(normalized) + @classmethod + def _normalize_request_timeout(cls, timeout: Any) -> Any: + """校验请求级 ``timeout``,与构造期 ``options.timeout`` 用同一判据。 + + 为什么不能直接 ``request["timeout"] = timeout``:SDK 对请求级超时不做 + 任何检查,非法取值要到 httpx 计算超时时才以形态各异的异常暴露,而 + ``openai`` SDK 会把它们统一包成 ``APIConnectionError('Connection error.')`` + ——``is_retryable_error`` 按类名匹配到 ``connection`` 就判定可重试,于是 + **同一个注定失败的请求被重试 3 次**,原始异常只留在 ``__cause__`` 里。 + 实测 ``timeout=1e306`` → ``OverflowError: timestamp out of range for + platform time_t``;``timeout="abc"`` → ``TypeError``;``timeout=-1`` → + ``ValueError: Timeout value out of range``。用户看到的是「连接错误」, + 与「超时配错了」毫无关联。 + + 构造期的 ``options.timeout`` 早已走 ``normalize_client_timeout``(见 + ``_normalize_client_options``),同一个量在两个入口的判据必须一致。 + ``httpx.Timeout`` 对象原样保留:SDK 接受,且无法用 TOML 表达。 + """ + if timeout is None or hasattr(timeout, "connect"): + return timeout + return normalize_client_timeout(timeout, "OpenAI 兼容渠道", "请求级 timeout") + + @classmethod + def _normalize_client_options(cls, options: Mapping[str, Any]) -> dict[str, Any]: + """校验并规范化客户端构造参数(``timeout``/``max_retries``)。 + + 这两个键在 ``_get_client`` 里被原样透传给 ``openai.OpenAI(...)``,而 SDK + 对它们不做任何校验——非法取值要到实际发请求时才以形态各异的错误暴露。 + ``max_retries=-1`` 尤其隐蔽:SDK 的重试循环是 ``range(max_retries + 1)``, + 负数让请求**一次都不发出**,最终抛 SDK 内部的 ``AssertionError``。 + + 这里与 Ark 渠道的 ``_normalize_client_options`` 保持同一套判据,差别只在 + ``timeout``:Ark SDK 接受 ``httpx.Timeout`` 对象,openai SDK 同样接受, + 因此两边都保留该形态。 + """ + normalized: dict[str, Any] = {} + if "timeout" in options: + timeout = options["timeout"] + if hasattr(timeout, "connect"): + # httpx.Timeout:SDK 接受,且无法用 TOML 表达,原样保留。 + normalized["timeout"] = timeout + else: + normalized["timeout"] = normalize_client_timeout( + timeout, "OpenAI 兼容渠道", "options.timeout" + ) + if "max_retries" in options: + normalized["max_retries"] = normalize_client_retries( + options["max_retries"], "OpenAI 兼容渠道" + ) + return normalized + + def _client_options(self) -> dict[str, Any]: + """返回已校验的客户端构造参数,结果缓存在实例上。 + + 校验放在 ``__init__`` 期执行(见那里的 ``_normalize_client_options``), + 这里只做缓存读取;懒构造客户端的路径因此不会重复校验,也不会在校验失败 + 时留下「部分构造」的客户端。 + """ + return dict(getattr(self, "_normalized_client_options", {}) or {}) + def _get_client(self) -> openai.OpenAI: if self._client is None: - client_options = { - key: value - for key, value in self._options.items() - if key in {"timeout", "max_retries"} - } self._client = openai.OpenAI( - api_key=self._api_key, base_url=self._base_url, **client_options + api_key=self._api_key, + base_url=self._base_url, + **self._client_options(), ) return self._client def _get_aclient(self) -> openai.AsyncOpenAI: if self._aclient is None: - client_options = { - key: value - for key, value in self._options.items() - if key in {"timeout", "max_retries"} - } self._aclient = openai.AsyncOpenAI( - api_key=self._api_key, base_url=self._base_url, **client_options + api_key=self._api_key, + base_url=self._base_url, + **self._client_options(), ) return self._aclient @@ -431,22 +548,38 @@ def _background_poll_config(self) -> tuple[float, float]: ) timeout = options.get( "background_timeout", - options.get("responses_poll_timeout", 300.0), + options.get("responses_timeout", options.get("responses_poll_timeout", 300.0)), ) + # ``math.isfinite(float(x))`` 对超出双精度范围的整数自己就抛 + # ``OverflowError``(**不是** ``ValueError``,会穿透调用方的 + # ``except ValueError``),因此先判可转换性再比较。 if ( isinstance(interval, bool) or not isinstance(interval, (int, float)) - or not math.isfinite(float(interval)) + or not is_double_convertible(interval) or interval <= 0 + or interval > _MAX_CLIENT_TIMEOUT_SECONDS ): - raise ValueError("Responses background_poll_interval 必须是正数。") + raise ValueError( + f"Responses background_poll_interval 必须是大于 0 且不超过 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 的正数。" + ) if ( isinstance(timeout, bool) or not isinstance(timeout, (int, float)) - or not math.isfinite(float(timeout)) + or not is_double_convertible(timeout) or timeout <= 0 + or timeout > _MAX_CLIENT_TIMEOUT_SECONDS ): - raise ValueError("Responses background_timeout 必须是正数。") + raise ValueError( + f"Responses background_timeout 必须是大于 0 且不超过 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 的正数。" + ) + # 上界与客户端 timeout 同一个常量(``_MAX_CLIENT_TIMEOUT_SECONDS``): + # 该常量存在的理由就是「凡在该值上再做算术的下游都会炸」——这里 + # ``deadline = time.monotonic() + timeout`` 与 ``time.sleep(min(interval, + # remaining))`` 正是两处算术,``1e19`` 会让 ``time.sleep`` 抛 + # ``OverflowError: timestamp out of range for platform time_t``(实测), + # 而它不是 ``TimeoutError``、也不在调用方的 ``except ValueError`` 分类里。 + # 超大值从发请求开始就是不可达状态,根本没有「等得久」这个合法语义。 return float(interval), float(timeout) @classmethod @@ -458,12 +591,18 @@ def _response_retrieve_kwargs(cls, params: Mapping[str, Any]) -> dict[str, Any]: } def _poll_background_response(self, response: Any, params: Mapping[str, Any]) -> Any: - status = field(response, "status") + status = enum_field(response, "status") if status not in {"queued", "in_progress", "pending"}: return response response_id = field(response, "id") if not response_id: raise RuntimeError("Responses background 响应缺少 id,无法轮询。") + # 收敛成字符串供消息插值:``response_id`` 来自网关 JSON 的 ``id`` 字段, + # 形态不受控——位数超限的整数让下面 f-string 抛 ``ValueError: Exceeds the + # limit``,于是**正要报告超时**的那一步变成整数转换错误,而调用方按 + # ``TimeoutError`` 分类。判据与同文件 ``_error_message`` 一致(字符串原样, + # 其余走 ``safe_repr``)。``retrieve`` 调用本身仍用原始取值。 + response_id_text = response_id if isinstance(response_id, str) else safe_repr(response_id) interval, timeout = self._background_poll_config() deadline = time.monotonic() + timeout retrieve_kwargs = self._response_retrieve_kwargs(params) @@ -471,25 +610,27 @@ def _poll_background_response(self, response: Any, params: Mapping[str, Any]) -> remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError( - f"Responses background 响应 {response_id} 在 {timeout:g}s 内未完成。" + f"Responses background 响应 {response_id_text} 在 {timeout:g}s 内未完成。" ) if interval: time.sleep(min(interval, remaining)) response = retry_sync_call( lambda: self._get_client().responses.retrieve(response_id, **retrieve_kwargs) ) - status = field(response, "status") + status = enum_field(response, "status") return response async def _poll_background_response_async( self, response: Any, params: Mapping[str, Any] ) -> Any: - status = field(response, "status") + status = enum_field(response, "status") if status not in {"queued", "in_progress", "pending"}: return response response_id = field(response, "id") if not response_id: raise RuntimeError("Responses background 响应缺少 id,无法轮询。") + # 与同步分支同判据(见 ``_poll_background_response`` 的说明)。 + response_id_text = response_id if isinstance(response_id, str) else safe_repr(response_id) interval, timeout = self._background_poll_config() deadline = time.monotonic() + timeout retrieve_kwargs = self._response_retrieve_kwargs(params) @@ -497,14 +638,14 @@ async def _poll_background_response_async( remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError( - f"Responses background 响应 {response_id} 在 {timeout:g}s 内未完成。" + f"Responses background 响应 {response_id_text} 在 {timeout:g}s 内未完成。" ) if interval: await asyncio.sleep(min(interval, remaining)) response = await retry_async_call( lambda: self._get_aclient().responses.retrieve(response_id, **retrieve_kwargs) ) - status = field(response, "status") + status = enum_field(response, "status") return response def _compat_extra_options(self) -> dict[str, Any]: @@ -702,7 +843,32 @@ def _embedding_options(self) -> dict[str, Any]: f"{getattr(self, '_provider', 'OpenAI-compatible')} Embedding 模型 options", {key: value for key, value in options.items() if key not in allowed}, ) - return {key: value for key, value in options.items() if key in supported} + normalized = {key: value for key, value in options.items() if key in supported} + if "dimensions" in normalized: + normalized["dimensions"] = self._validate_embedding_dimensions(normalized["dimensions"]) + return normalized + + @staticmethod + def _validate_embedding_dimensions(value: Any) -> int: + """校验 ``options.dimensions``,拒绝越界、非整数与超大字面量。 + + 这个值从 ``config.toml`` 直达请求体,此前**完全没有校验**:实测 + ``-1``、``0``、``"abc"``、``1.5``、``True`` 全部原样发给远端,由服务端 + 返回语义不明的 400;``10**4301`` 更糟——它在 ``json.dumps`` 编码请求体 + 时才抛「整数转字符串超限」,错误消息与 embedding 参数毫无关联。 + 项目里 ``_MAX_EMBEDDING_DIMENSION`` 早就是这个量的上界(``local_hash`` + 与 ``embedding_service`` 都在用),provider 边界漏了同一判据。 + """ + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"Embedding options.dimensions 必须是整数,但得到 {safe_repr(value)}。" + ) + if not 1 <= value <= _MAX_EMBEDDING_DIMENSION: + raise ValueError( + f"Embedding options.dimensions 必须在 1 到 {_MAX_EMBEDDING_DIMENSION} 之间," + f"但得到 {safe_repr(value)}。" + ) + return value @staticmethod def _validate_embedding_kwargs(kwargs: Mapping[str, Any]) -> None: @@ -1003,7 +1169,7 @@ def _build_chat_request( if user is not None: request["user"] = user if timeout is not None: - request["timeout"] = timeout + request["timeout"] = self._normalize_request_timeout(timeout) if extra_headers is not None: request["extra_headers"] = extra_headers if extra_query is not None: @@ -1048,6 +1214,12 @@ def _convert_responses_tools(tools: list[dict[str, Any]] | None) -> list[dict[st if not tools: return None + # 容器形态先判:``enumerate(tools)`` 对不可迭代的取值抛裸 + # ``TypeError``,而同函数对可迭代但元素非法的形态给的是带序号的 + # ``ValueError``。同一判据的两半必须都在——实测 ``complete(tools=5)`` + # 在 responses 协议下抛该 ``TypeError``。 + if not isinstance(tools, (list, tuple)): + raise ValueError(f"Responses 工具定义必须是列表,但得到 {safe_repr(tools)}。") converted: list[dict[str, Any]] = [] for index, tool in enumerate(tools): if not isinstance(tool, Mapping): @@ -1222,8 +1394,7 @@ def _build_responses_request( raise ValueError( "Responses options 不能同时设置多个输出长度字段: " + ", ".join(configured_limits) ) - if configured_max_output_tokens is not None: - configured_options.pop("max_output_tokens", None) + configured_options.pop("max_output_tokens", None) configured_frequency_penalty = configured_options.pop("frequency_penalty", None) configured_presence_penalty = configured_options.pop("presence_penalty", None) configured_top_logprobs = configured_options.pop("top_logprobs", None) @@ -1377,7 +1548,7 @@ def _build_responses_request( if user is not None: request["user"] = user if timeout is not None: - request["timeout"] = timeout + request["timeout"] = self._normalize_request_timeout(timeout) if extra_headers is not None: request["extra_headers"] = extra_headers if extra_query is not None: @@ -1497,7 +1668,13 @@ def _extract_response_text(cls, response: Any) -> str: return output_text chunks: list[str] = [] - choices = field(response, "choices", []) or [] + # ``choices`` 同样要收敛。**Responses 路径没有别处替它把关**: + # ``_raise_for_chat_response_error``(含 choices 形态守卫)只被 Chat 路径 + # 调用,而 ``_invoke_responses`` 走的是 ``_raise_for_response_error``—— + # 后者只查 ``error`` 与 ``status``,不碰 ``choices``。实测 Responses 响应 + # 带 ``choices=5`` 时本行抛裸 ``TypeError: 'int' object is not iterable``, + # 而调用方的错误分类按 ``RuntimeError`` 写。 + choices = cls._require_response_list(field(response, "choices", []) or [], label="choices") for choice in choices: message = field(choice, "message") or field(choice, "delta") text = field(message, "content") @@ -1505,20 +1682,59 @@ def _extract_response_text(cls, response: Any) -> str: chunks.append(content_to_text(text)) if chunks: return "".join(chunks) - for item in field(response, "output", []) or []: - for content in field(item, "content", []) or []: + for item in cls._require_response_list(field(response, "output", []) or [], label="output"): + for content in cls._require_response_list( + field(item, "content", []) or [], label="output[].content" + ): text = field(content, "text") if isinstance(text, str): chunks.append(text) return "".join(chunks) + @staticmethod + def _require_response_list(value: Any, *, label: str) -> list[Any]: + """把响应里的容器字段收敛成列表,非法形态给出可读的 ``RuntimeError``。 + + 与 ``_raise_for_chat_response_error`` 对 ``choices`` 的守卫同一判据 + (「响应的 choices 必须是列表」),只是这里覆盖 ``output`` 与 ``tool_calls``: + 本文件有 8 处直接迭代它们,此前每一处都只写 ``field(x, ..., []) or []`` + ——那只挡住 ``None`` 与空值。 + + **可达性取决于 SDK 的响应构造方式**,这一点必须写清楚,否则很容易把 + 守卫加在不可达的位置上:``openai`` 与 ``volcenginesdkarkruntime`` 的 + ``_base_client._process_response_data`` 在 ``_strict_response_validation`` + 为假(默认)时走 ``construct_type``——它**不执行 pydantic 校验**,畸形 + 容器会原样进入响应对象。实测走真实入口: + + * Chat 路径 ``message.tool_calls=5`` -> 裸 + ``TypeError: 'int' object is not iterable``; + * Responses 路径 ``output=5`` -> 同一个裸 ``TypeError``。 + + 两者都不是 ``RuntimeError``,而调用方的错误分类按它写。对照:``google-genai`` + 的 ``_from_response`` 走 ``cls.model_validate(response)``——**会**校验, + 因此那里的同类守卫只在测试替身下可达(google.py 的 ``_require_candidates`` + 即属此列,它的价值是契约一致性与不真实夹具的暴露,不是拦截线上流量)。 + """ + if not isinstance(value, (list, tuple)): + raise RuntimeError(f"响应的 {label} 必须是列表,但得到 {safe_repr(value)}。") + return list(value) + @classmethod def _extract_tool_calls(cls, response: Any) -> list[dict[str, Any]]: calls: list[dict[str, Any]] = [] - choices = field(response, "choices", []) or [] + # ``choices`` 的形态守卫必须与 ``_extract_response_text`` 同判据:三者在 + # 同一次 ``complete()``/``acomplete()`` 里被依次调用,而 + # ``_extract_response_text`` 在 Responses 路径下会**提前返回**(``output_text`` + # 是字符串时根本不碰 ``choices``),畸形取值于是留给本函数与 + # ``_extract_reasoning``,在裸 ``for`` 上抛 ``TypeError: 'int' object is + # not iterable``——与同一次调用里另一个提取器给出的 ``RuntimeError`` 收场 + # 不同,而调用方的错误分类按 ``RuntimeError`` 写。 + choices = cls._require_response_list(field(response, "choices", []) or [], label="choices") for choice in choices: message = field(choice, "message") or field(choice, "delta") - for call in field(message, "tool_calls", []) or []: + for call in cls._require_response_list( + field(message, "tool_calls", []) or [], label="tool_calls" + ): function = field(call, "function") calls.append( { @@ -1528,8 +1744,8 @@ def _extract_tool_calls(cls, response: Any) -> list[dict[str, Any]]: "arguments": normalize_tool_arguments(field(function, "arguments", "")), } ) - for item in field(response, "output", []) or []: - if field(item, "type") in {"function_call", "custom_tool_call"}: + for item in cls._require_response_list(field(response, "output", []) or [], label="output"): + if enum_field(item, "type") in {"function_call", "custom_tool_call"}: calls.append( { "id": field(item, "call_id") or field(item, "id"), @@ -1549,15 +1765,18 @@ def _extract_usage(cls, response: Any) -> dict[str, Any]: @classmethod def _extract_reasoning(cls, response: Any) -> str: values: list[str] = [] - choices = field(response, "choices", []) or [] + # 与 ``_extract_tool_calls`` 同一处、同一判据:本函数在同一次 + # ``complete()`` 里紧随 ``_extract_response_text`` 之后运行,而后者在 + # Responses 路径下提前返回,``choices`` 的形态守卫覆盖不到这里。 + choices = cls._require_response_list(field(response, "choices", []) or [], label="choices") for choice in choices: message = field(choice, "message") or field(choice, "delta") for name in ("reasoning_content", "reasoning"): value = field(message, name) if isinstance(value, str): values.append(value) - for item in field(response, "output", []) or []: - if field(item, "type") in {"reasoning", "reasoning_item"}: + for item in cls._require_response_list(field(response, "output", []) or [], label="output"): + if enum_field(item, "type") in {"reasoning", "reasoning_item"}: summary = field(item, "summary") if isinstance(summary, str): values.append(summary) @@ -1566,7 +1785,9 @@ def _extract_reasoning(cls, response: Any) -> str: text = field(entry, "text") if isinstance(text, str): values.append(text) - for content in field(item, "content", []) or []: + for content in cls._require_response_list( + field(item, "content", []) or [], label="output[].content" + ): text = field(content, "text") if isinstance(text, str): values.append(text) @@ -1578,17 +1799,23 @@ def _extract_reasoning(cls, response: Any) -> str: @classmethod def _extract_refusal(cls, response: Any) -> str | None: """提取 Chat Completions 与 Responses 的统一拒答文本。""" - choices = field(response, "choices", []) or [] + # 与 ``_extract_tool_calls``/``_extract_reasoning`` 同一处、同一判据: + # 三者在同一次 ``complete()`` 里被依次调用,而 Responses 路径下 + # ``_extract_response_text`` 提前返回、``_raise_for_response_error`` 也不碰 + # ``choices``,畸形取值只能在这里收敛。 + choices = cls._require_response_list(field(response, "choices", []) or [], label="choices") for choice in choices: message = field(choice, "message") or field(choice, "delta") refusal = field(message, "refusal") if isinstance(refusal, str) and refusal: return refusal - for item in field(response, "output", []) or []: + for item in cls._require_response_list(field(response, "output", []) or [], label="output"): refusal = field(item, "refusal") if isinstance(refusal, str) and refusal: return refusal - for content in field(item, "content", []) or []: + for content in cls._require_response_list( + field(item, "content", []) or [], label="output[].content" + ): if field(content, "type") != "refusal": continue refusal = field(content, "refusal") or field(content, "text") @@ -1603,11 +1830,24 @@ def _chat_status_is_error(cls, status: Any) -> bool: return False if isinstance(status, int): return status >= 400 - normalized = str(status).strip().lower() + # 只有**字符串**才谈得上「是不是某个错误状态名」。此前对非 ``int`` 的 + # 取值一律 ``str(status).strip().lower()``,而 ``str([10**4301])`` 自己 + # 抛 ``ValueError: Exceeds the limit``——本方法 docstring 承诺的 + # 「形态奇怪的 status 不能打断错误分类」只做了一半:位数超限的**数字串** + # 挡住了,装在容器里的位数超限整数没有。``status`` 来自网关 JSON,形态 + # 不受控;非字符串一律判为「不是错误状态」,与它落在集合外的原语义一致。 + if not isinstance(status, str): + return False + normalized = status.strip().lower() if not normalized: return False - if normalized.isdigit(): - return int(normalized) >= 400 + # 解析走 ``parse_decimal_int``:这个方法在**解析网关响应**的路径上, + # 一个形态奇怪的 status 值不能让错误分类本身崩掉——那会把「网关返回了 + # 业务错误」变成一条解析异常。``int()`` 抛错的情形有两类:非十进制数字 + # 字符,以及位数超过 ``sys.get_int_max_str_digits()``。 + parsed_status = parse_decimal_int(normalized) + if parsed_status is not None: + return parsed_status >= 400 return normalized in { "error", "failed", @@ -1628,7 +1868,10 @@ def _chat_response_error_detail(cls, response: Any) -> str | None: continue if name == "error": return redact_sensitive_text(cls._error_message(value, "未知错误。")) - return redact_sensitive_text(str(value)) + # ``str(value)`` 对 4301 位整数抛 ``ValueError``——这些字段 + # (``code``/``error_code``/``status``…)正是网关最容易回显 + # 大整数的地方。 + return redact_sensitive_text(safe_repr(value)) return None @classmethod @@ -1647,19 +1890,35 @@ def _raise_for_chat_response_error( 字段仍必须显式失败。 """ detail = cls._chat_response_error_detail(response) + # 这里**不能**用 ``enum_field``:``status`` 随后要进 + # ``_chat_status_is_error`` 做 ``>= 400`` 的数值比较,收敛成空串会让 + # 数字型业务错误(``{"status": 503}``)漏判。形态防护放在插值处。 status = field(response, "status") if detail is not None and field(response, "error") is not None: raise RuntimeError(f"{provider} 返回错误: {detail}") if cls._chat_status_is_error(status): suffix = f": {detail}" if detail else "。" - raise RuntimeError(f"{provider} 返回业务错误 (status={status}){suffix}") + # ``status`` 来自网关 JSON,可以是任意大整数:``f"{10**4300}"`` 抛 + # ``ValueError: Exceeds the limit (4300 digits)``,于是「网关返回了 + # 业务错误」变成「整数转字符串超限」。 + raise RuntimeError(f"{provider} 返回业务错误 (status={safe_repr(status)}){suffix}") choices = field(response, "choices") + # ``choices`` 必须先是序列,才谈得上「空」。此前只判了 None 与「序列为空」, + # 网关返回 ``{"choices": {"0": {...}}}``(对象)或整数时两条判据都不命中, + # 调用方的 ``choices[0]`` 于是抛裸 ``KeyError: 0`` / ``TypeError``——按 + # ValueError/RuntimeError 分类的错误处理与日志脱敏全部接不住。流式路径的 + # usage chunk 不带 choices(None),仍按原语义放行。 + if choices is not None and not isinstance(choices, (list, tuple)): + suffix = f": {detail}" if detail else "。" + raise RuntimeError( + f"{provider} 响应的 choices 必须是列表,但得到 {safe_repr(choices)}{suffix}" + ) if allow_empty_choices: if choices is None and detail is not None: raise RuntimeError(f"{provider} 返回错误: {detail}") return - if choices is None or (isinstance(choices, (list, tuple)) and not choices): + if choices is None or not choices: suffix = f": {detail}" if detail else "。" raise RuntimeError(f"{provider} 响应缺少有效 choices{suffix}") @@ -1755,7 +2014,9 @@ def _chat_stream_events(cls, chunk: Any) -> list[StreamEvent]: text = content_to_text(field(delta, "content", "")) reasoning = field(delta, "reasoning_content") or field(delta, "reasoning") or "" refusal = field(delta, "refusal") - tool_calls = field(delta, "tool_calls", []) or [] + tool_calls = cls._require_response_list( + field(delta, "tool_calls", []) or [], label="tool_calls" + ) finish_reason = field(choice, "finish_reason") events: list[StreamEvent] = [] @@ -1792,7 +2053,14 @@ def _chat_stream_events(cls, chunk: Any) -> list[StreamEvent]: "index": field(call, "index", position), "type": field(call, "type", "function"), "name": field(function, "name"), - "arguments": field(function, "arguments", ""), + # 与 ``_extract_tool_calls``(非流式)同一判据:网关可以 + # 把 ``arguments`` 写成对象/数组/整数(``construct_type`` + # 不做校验),非流式路径经 ``normalize_tool_arguments`` + # 统一成 JSON 字符串,流式路径此前原样交付,于是**同一个 + # 输入在两条路径上给出不同类型**——调用方按字符串 + # ``json.loads`` 时会拿到 dict 并抛 ``TypeError``。 + # 字符串片段原样透传,拼接语义不变。 + "arguments": normalize_tool_arguments(field(function, "arguments", "")), }, response_id=response_id, raw=chunk, @@ -1816,12 +2084,6 @@ def _chat_stream_events(cls, chunk: Any) -> list[StreamEvent]: events.append(StreamEvent(type="usage", usage=usage, raw=chunk)) return events - @classmethod - def _chat_stream_event(cls, chunk: Any) -> StreamEvent | None: - """兼容旧调用方,返回当前 chunk 的第一个统一事件。""" - events = cls._chat_stream_events(chunk) - return events[0] if events else None - @classmethod def _response_tool_keys(cls, event: Any, item: Any = None) -> list[Any]: """返回 Responses 工具事件可用的稳定关联键。""" @@ -1858,7 +2120,7 @@ def _remember_response_tool_metadata( ) -> None: """记录 output_item.added 中后续 delta/done 事件需要的工具元数据。""" item = field(event, "item") or event - item_type = field(item, "type") + item_type = enum_field(item, "type") if item_type not in {"function_call", "custom_tool_call"}: return item_id = field(item, "id") or field(event, "item_id") @@ -1912,7 +2174,7 @@ def _responses_stream_event( error_provider: str = "Responses API", response_error_handler: Any | None = None, ) -> StreamEvent | None: - event_type = field(event, "type", "") + event_type = enum_field(event, "type") if event_type == "response.output_item.added": if tool_metadata is not None: cls._remember_response_tool_metadata(event, tool_metadata) @@ -1968,7 +2230,7 @@ def _responses_stream_event( }: item = field(event, "item") metadata = cls._response_tool_metadata(event, tool_metadata, item) - item_type = field(item, "type") if item is not None else None + item_type = enum_field(item, "type") if item is not None else None item_type = item_type or metadata.get("type") if ( item_type in {"function_call", "custom_tool_call"} @@ -2027,7 +2289,7 @@ def _responses_stream_event( }: response = field(event, "response") or event usage = cls._extract_usage(response) - status = field(response, "status") + status = enum_field(response, "status") if status in {"failed", "incomplete", "cancelled"}: handler = response_error_handler or cls._raise_for_response_error handler(response) @@ -2053,8 +2315,14 @@ def _merge_response_tool_call( ``function_call_arguments.done`` 使用 item_id,而 ``output_item.done`` 通常带 call_id;两者必须按 output_index 归并。 """ - index = tool_call.get("index") - call_id = tool_call.get("call_id") or tool_call.get("id") + # ``index``/``call_id`` 来自 Responses 事件 JSON。``construct_type`` 与 + # ``extra="allow"`` 都会让数组/对象形态原样到达这里,而 + # ``accumulator.setdefault(("index", [1, 2]), ...)`` 抛裸 + # ``TypeError: unhashable type``——调用方按 ValueError/RuntimeError 分类, + # 接不住。同文件 ``_response_tool_keys`` 对同一批取值已有 ``hash()`` 守卫, + # 这里补齐同一判据(不可哈希等价于「没有稳定标识」,退回位置键)。 + index = hashable_key_part(tool_call.get("index")) + call_id = hashable_key_part(tool_call.get("call_id") or tool_call.get("id")) key: Any = ("index", index) if index is not None else ("id", call_id) if key == ("id", None): key = ("position", len(accumulator)) @@ -2148,7 +2416,7 @@ def stream_events( ): if terminal_seen: continue - event_type = field(event, "type", "") + event_type = enum_field(event, "type") converted = self._responses_stream_event(event, response_tool_metadata) if converted: if converted.type == "finish": @@ -2239,7 +2507,7 @@ async def astream_events( ): if terminal_seen: continue - event_type = field(event, "type", "") + event_type = enum_field(event, "type") converted = self._responses_stream_event(event, response_tool_metadata) if converted: if converted.type == "finish": @@ -2389,10 +2657,13 @@ def _error_message(cls, error: Any, default: str) -> str: return error message = field(error, "message") if message: - return str(message) + # ``message`` 来自网关 JSON,可能是任意大整数或容器: + # ``str(10**4301)`` 自己抛 ``ValueError: Exceeds the limit``, + # 于是「报告网关错误」这一步改抛与错误内容无关的异常。 + return safe_repr(message) if not isinstance(message, str) else message code = field(error, "code") if code: - return f"{code}" + return safe_repr(code) if not isinstance(code, str) else code return default @classmethod @@ -2404,21 +2675,21 @@ def _raise_for_response_error(cls, response: Any) -> None: message = redact_sensitive_text(cls._error_message(error, "未知错误。")) raise RuntimeError(f"Responses API 返回错误: {message}") - status = field(response, "status") + status = enum_field(response, "status") if status not in {"failed", "incomplete", "cancelled"}: return if status == "incomplete": details = field(response, "incomplete_details") reason = field(details, "reason") - detail = f"原因: {reason}" if reason else "未提供原因。" + detail = f"原因: {safe_repr(reason)}" if reason else "未提供原因。" else: detail = "未提供原因。" raise RuntimeError(f"Responses API 响应状态为 {status}: {redact_sensitive_text(detail)}") @classmethod def _stream_delta(cls, event: Any) -> str: - event_type = field(event, "type", "") + event_type = enum_field(event, "type") if event_type in {"response.output_text.delta", "response.refusal.delta"}: delta = field(event, "delta") return delta if isinstance(delta, str) else "" @@ -2443,9 +2714,18 @@ def _invoke_responses(self, request: dict[str, Any]) -> Iterator[str]: lambda: self._get_client().responses.create(**request), lambda event: raise_for_stream_error_event(event, self._provider), ): - event_type = field(event, "type", "") + event_type = enum_field(event, "type") if event_type == "response.refusal.delta": refusal = field(event, "delta") + # ``delta`` 来自网关 JSON,形态不受控。同一次循环里正文分支 + # (``_stream_delta``)与非流式的 ``_extract_refusal`` 都做了 + # 形态收敛,唯独这条拒答分支直接插值:位数超限的整数让 + # f-string 在**求值期**抛裸 ``ValueError: Exceeds the limit`` + # ——正要报出拒答的那一步变成整数转换错误,而本 provider 的 + # 错误分类按 ``RuntimeError`` 写。收敛口径与 google 的拒答 + # 消息一致:字符串原样,其余降级成 ``safe_repr`` 的量级描述。 + if not isinstance(refusal, str): + refusal = safe_repr(refusal) if refusal else "" if refusal: raise RuntimeError(f"{self._provider} Responses 返回拒答: {refusal}") delta = self._stream_delta(event) @@ -2474,9 +2754,18 @@ async def _ainvoke_responses(self, request: dict[str, Any]) -> AsyncIterator[str lambda: self._get_aclient().responses.create(**request), lambda event: raise_for_stream_error_event(event, self._provider), ): - event_type = field(event, "type", "") + event_type = enum_field(event, "type") if event_type == "response.refusal.delta": refusal = field(event, "delta") + # ``delta`` 来自网关 JSON,形态不受控。同一次循环里正文分支 + # (``_stream_delta``)与非流式的 ``_extract_refusal`` 都做了 + # 形态收敛,唯独这条拒答分支直接插值:位数超限的整数让 + # f-string 在**求值期**抛裸 ``ValueError: Exceeds the limit`` + # ——正要报出拒答的那一步变成整数转换错误,而本 provider 的 + # 错误分类按 ``RuntimeError`` 写。收敛口径与 google 的拒答 + # 消息一致:字符串原样,其余降级成 ``safe_repr`` 的量级描述。 + if not isinstance(refusal, str): + refusal = safe_repr(refusal) if refusal else "" if refusal: raise RuntimeError(f"{self._provider} Responses 返回拒答: {refusal}") delta = self._stream_delta(event) @@ -2617,7 +2906,7 @@ def invoke( f"{self._provider} LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s" ) except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "%s LLM (%s) 出错: %s", self._provider, @@ -2748,7 +3037,7 @@ async def ainvoke( f"{self._provider} LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s" ) except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "%s LLM (%s) 异步出错: %s", self._provider, @@ -2765,6 +3054,10 @@ async def ainvoke( ) def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """同步向量化文档。""" + # 形态校验必须先于日志里的 ``len(texts)``:非序列取值在那里抛裸 + # ``TypeError: object of type 'int' has no len()``,而 ``texts="abc"`` + # 会被静默当成三条单字符文本。 + texts = require_text_list(texts, self._provider) logger.info(f"调用 {self._provider} 嵌入 ({self._model_name}),数量: {len(texts)}") start_time = time.perf_counter() overlap = sorted({"input", "model"}.intersection(kwargs)) @@ -2806,7 +3099,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: logger.info(f"{self._provider} 嵌入 ({self._model_name}) 完成,耗时: {duration:.2f}s") return embeddings except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "%s 嵌入 (%s) 出错: %s", self._provider, @@ -2823,6 +3116,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: ) async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """异步向量化文档。""" + texts = require_text_list(texts, self._provider) logger.info(f"异步调用 {self._provider} 嵌入 ({self._model_name}),数量: {len(texts)}") start_time = time.perf_counter() overlap = sorted({"input", "model"}.intersection(kwargs)) @@ -2866,7 +3160,7 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f ) return embeddings except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "%s 嵌入 (%s) 异步出错: %s", self._provider, @@ -3167,10 +3461,7 @@ def count_input_tokens(self, request: CompletionRequest | None = None, **kwargs: kwargs=params, operation="OpenAI Responses 输入 token 统计", ) - value = field(response, "input_tokens") - if value is None: - raise RuntimeError("OpenAI token count 响应缺少 input_tokens。") - return int(value) + return require_token_count(field(response, "input_tokens"), "OpenAI", "input_tokens") def create_vector_store(self, **kwargs: Any) -> Any: return self._call_sdk_resource( @@ -4353,10 +4644,7 @@ async def async_count_input_tokens( kwargs=params, operation="OpenAI Responses 输入 token 统计", ) - value = field(response, "input_tokens") - if value is None: - raise RuntimeError("OpenAI token count 响应缺少 input_tokens。") - return int(value) + return require_token_count(field(response, "input_tokens"), "OpenAI", "input_tokens") async def async_create_vector_store(self, **kwargs: Any) -> Any: return await self._call_async_sdk_resource( diff --git a/src/providers/resources.py b/src/providers/resources.py index 51a31e3..19115df 100644 --- a/src/providers/resources.py +++ b/src/providers/resources.py @@ -295,13 +295,6 @@ class AsyncOpenAICompatibleResources(_NativeFacade): """Async native-client access for OpenAI-compatible channels.""" -class _AsyncOnlyResourceFacade(_NativeFacade): - """Expose async-only SDK resources while retaining native delegation.""" - - _delegate_native = True - _delegate_client_name = "async_sdk_client" - - class OpenAIResources(OpenAICompatibleResources): """OpenAI 官方资源 Facade;兼容渠道不会继承此类能力声明。""" diff --git a/src/providers/siliconflow_rerank.py b/src/providers/siliconflow_rerank.py index 533b672..b25da93 100644 --- a/src/providers/siliconflow_rerank.py +++ b/src/providers/siliconflow_rerank.py @@ -8,15 +8,22 @@ from src.providers.__base__.model_provider import ( RerankModel, is_retryable_error, + normalize_client_timeout, validate_secret_free_options, ) -from src.utils.config import get_settings +from src.utils.config import get_settings, is_double_convertible, require_model_name, safe_repr from src.utils.log_manager import get_module_logger -from src.utils.security import redact_sensitive_text, validate_secret_free_payload +from src.utils.security import safe_exception_text, validate_secret_free_payload logger = get_module_logger(__name__) +# rerank 请求 ``top_n`` 的上界。上游 API 会自行截断,因此它不需要与文档数 +# 挂钩;这个界的作用是挡住 TOML/调用方传进来的天文数字——它们会被静默接受, +# 直到 ``json.dumps`` 编码请求体时才抛与 rerank 无关的位数错误。 +_MAX_RERANK_TOP_N = 10_000 + + class SiliconflowRerankProvider(RerankModel): """SiliconFlow Rerank Provider(HTTP JSON)。 @@ -33,7 +40,11 @@ class SiliconflowRerankProvider(RerankModel): _OPTION_KEYS = frozenset({"return_documents", "truncate", "extra_body", "timeout"}) def __init__(self, model_name: str, options: dict[str, Any] | None = None): - self._model_name = model_name + # ``SiliconflowRerankProvider`` 是公开类,嵌入方可以绕过工厂直接构造。 + # 工厂路径已用 ``require_model_name`` 收敛,这里同样要判:非字符串取值 + # 会在下面的 ``logger.info`` 插值处抛裸 ``ValueError: Exceeds the limit`` + # (位数超限的整数),或静默原样发给 SDK(列表/``None``)。 + self._model_name = require_model_name(model_name) self._options = validate_secret_free_options(options, "SiliconFlow Rerank") unknown = sorted(set(self._options).difference(self._OPTION_KEYS)) if unknown: @@ -91,15 +102,11 @@ def _prepare_payload(self, query: str, documents: list[str], top_n: int) -> dict return payload def _request_timeout(self) -> float: - timeout = self._options.get("timeout", 30.0) - if ( - isinstance(timeout, bool) - or not isinstance(timeout, (int, float)) - or not math.isfinite(float(timeout)) - or timeout <= 0 - ): - raise ValueError("SiliconFlow Rerank options.timeout 必须是正数。") - return float(timeout) + # 走共享 helper:它带**上界**。此前只有下界,实测 ``options.timeout`` + # 写成 ``10**400`` 时构造成功,到 httpx 比较超时才以形态各异的错误暴露。 + return normalize_client_timeout( + self._options.get("timeout", 30.0), "SiliconFlow", "options.timeout" + ) @staticmethod def _validate_inputs(query: str, documents: list[str], top_n: int) -> None: @@ -109,8 +116,23 @@ def _validate_inputs(query: str, documents: list[str], top_n: int) -> None: raise ValueError("SiliconFlow Rerank documents 必须是非空列表。") if any(not isinstance(document, str) for document in documents): raise ValueError("SiliconFlow Rerank documents 必须全部是字符串。") - if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n < 1: - raise ValueError("SiliconFlow Rerank top_n 必须是大于等于 1 的整数。") + # 上界此前缺失:``10**400`` 与 ``10**4301`` 都被静默接受,后者在 + # ``json.dumps`` 编码请求体时才抛「整数转字符串超限」——错误消息与 + # rerank 参数毫无关联,而同一份文件里 ``options.timeout`` 与响应 + # ``relevance_score`` 都已判过双精度范围,只有这里漏了。 + # + # 上界取 ``_MAX_RERANK_TOP_N`` 而不是 ``len(documents)``:上游 API 自己 + # 会截断,``top_n`` 超过文档数是合法输入(由 + # ``test_rerank_accepts_top_n_larger_than_document_count`` 固定)。 + if ( + not isinstance(top_n, int) + or isinstance(top_n, bool) + or not 1 <= top_n <= _MAX_RERANK_TOP_N + ): + raise ValueError( + f"SiliconFlow Rerank top_n 必须是 1 到 {_MAX_RERANK_TOP_N} 之间的整数," + f"但得到 {safe_repr(top_n)}。" + ) def _parse_response( self, results: list[dict], documents: list[str] @@ -147,6 +169,23 @@ def _parse_response( if not isinstance(doc_content, str) or not positions.get(doc_content): raise RuntimeError("SiliconFlow Rerank 响应无法映射到输入文档。") index = positions[doc_content].pop(0) + # 取分数用 ``.get`` 而不是 ``[]``:``res["relevance_score"]`` 在字段 + # 缺失时抛 ``KeyError``,而它**不在**下面那个 ``try`` 的覆盖范围内 + # (那一段只包 ``float(...)``),于是本方法承诺的 ``RuntimeError`` + # 变成裸 ``KeyError: 'relevance_score'``。响应来自网络 JSON,一个 + # 不含该字段的结果项就能触发;调用方的错误分类与日志脱敏都按 + # ``RuntimeError`` 写,接不住 ``KeyError``。 + # + # ``float(10**400)`` 抛 ``OverflowError``——它同样不是 ``ValueError``, + # 因此下面这个 ``except`` 也接不住。响应来自网络 JSON,一个超大整数 + # 字面量就能触发。这里先判可转换性再交给 ``float``。 + raw_score = res.get("relevance_score") if isinstance(res, Mapping) else None + if ( + isinstance(raw_score, int) + and not isinstance(raw_score, bool) + and not is_double_convertible(raw_score) + ): + raise RuntimeError("SiliconFlow Rerank 响应 relevance_score 超出双精度范围。") try: score = float(res["relevance_score"]) except (KeyError, TypeError, ValueError) as exc: @@ -165,6 +204,9 @@ def _parse_response( ) def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: """同步 Rerank (CSE Sensor)。""" + # ``len()`` 必须在校验**之后**:非序列取值在此抛裸 ``TypeError``, + # 而 ``_prepare_payload`` 对同一取值给的是带指引的 ``ValueError``。 + self._validate_inputs(query, documents, top_n) logger.info(f"调用 SiliconFlow Rerank ({self._model_name}),文档数: {len(documents)}") import httpx @@ -191,7 +233,7 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int logger.info(f"SiliconFlow Rerank ({self._model_name}) 完成,耗时: {duration:.2f}s") return indices, scores except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "SiliconFlow Rerank (%s) 出错: %s", self._model_name, @@ -209,6 +251,8 @@ async def arerank( self, query: str, documents: list[str], top_n: int ) -> tuple[list[int], list[float]]: """异步 Rerank (CSE Sensor)。""" + # 与同步入口同判据:``len()`` 必须在 ``_validate_inputs`` 之后。 + self._validate_inputs(query, documents, top_n) logger.info(f"异步调用 SiliconFlow Rerank ({self._model_name}),文档数: {len(documents)}") import httpx @@ -235,7 +279,7 @@ async def arerank( logger.info(f"SiliconFlow Rerank ({self._model_name}) 异步完成,耗时: {duration:.2f}s") return indices, scores except Exception as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "SiliconFlow Rerank (%s) 异步出错: %s", self._model_name, diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index e5828a1..1eff2ee 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -1,5 +1,5 @@ import inspect -import math +import time from collections.abc import AsyncGenerator, Collection, Generator, Mapping from typing import Any @@ -15,9 +15,12 @@ close_resource_sync, coerce_completion_request, content_to_text, + enum_field, field, is_retryable_error, merge_tool_call_fragment, + normalize_client_retries, + normalize_client_timeout, normalize_embedding_vector, normalize_messages, normalize_responses_input, @@ -25,6 +28,8 @@ normalize_usage, raise_for_stream_error_event, reject_unsupported_kwargs, + require_text_list, + require_token_count, retry_async_call, retry_async_stream, retry_sync_call, @@ -35,9 +40,9 @@ ) from src.providers.openai_compatible import OpenAICompatibleProvider from src.providers.resources import ArkResources, AsyncArkResources -from src.utils.config import get_settings +from src.utils.config import get_settings, is_double_convertible, require_model_name, safe_repr from src.utils.log_manager import get_module_logger -from src.utils.security import validate_secret_free_payload +from src.utils.security import safe_exception_text, validate_secret_free_payload logger = get_module_logger(__name__) @@ -82,10 +87,50 @@ def _load_ark_clients() -> tuple[type[Any], type[Any]]: ) +def _require_ark_list(value: Any, *, label: str) -> list[Any]: + """把 Ark 响应里的容器字段收敛成列表,非法形态给出可读的 ``RuntimeError``。 + + 两个容器此前只有 ``field(x, ..., []) or []``——那挡住 ``None`` 与空值,挡不住 + 其它形态。实测走**真实入口**(``VolcengineProvider.complete``): + + * Responses 路径 ``output=5`` -> 裸 ``TypeError: 'int' object is not iterable``; + * Chat 路径 ``message.tool_calls=5`` -> 同一个裸 ``TypeError``。 + + 两者都不是 ``RuntimeError``——而本 provider 的错误分类(以及调用方的 + ``except``)按 ``RuntimeError`` 写。同一个形态在 + ``openai_compatible._raise_for_chat_response_error`` 对 ``choices`` 早已有 + 显式守卫,两个 provider 的收场必须一致。 + + 返回列表而不是原值:调用方全都只做迭代,收敛后不必再各自 ``or []``。 + """ + if not isinstance(value, (list, tuple)): + raise RuntimeError(f"Ark {label} 必须是列表,但得到 {safe_repr(value)}。") + return list(value) + + +def _require_ark_content(item: Any, *, label: str) -> list[Any]: + """把 Responses 输出项里的嵌套 ``content`` 收敛成列表。 + + ``output[].content`` 是 ``output`` 之下的一层容器,本文件有 **3 处**直接迭代 + 它,此前都写 ``field(item, "content", []) or []``——与顶层 ``output`` 修复前的 + 写法完全相同。实测 ``item.content = 5`` 抛裸 + ``TypeError: 'int' object is not iterable``,而本 provider 的错误分类按 + ``RuntimeError`` 写(顶层 ``output``/``tool_calls``/``choices`` 都已按同一 + 判据修过)。 + + 与 google 不同,**本条线上可达**:Ark SDK 的 ``_base_client`` 走 + ``construct_type``,不执行 pydantic 校验(对照表见 ``_require_ark_list``)。 + """ + content = field(item, "content", []) or [] + if not isinstance(content, (list, tuple)): + raise RuntimeError(f"Ark {label} 必须是列表,但得到 {safe_repr(content)}。") + return list(content) + + def _ark_responses_has_builtin_tool_items(response: Any) -> bool: """判断响应里是否含 Ark 内置工具的调用项。""" - for item in field(response, "output", []) or []: - if field(item, "type") in _ARK_BUILTIN_TOOL_ITEM_TYPES: + for item in _require_ark_list(field(response, "output", []) or [], label="响应的 output"): + if enum_field(item, "type") in _ARK_BUILTIN_TOOL_ITEM_TYPES: return True return False @@ -99,9 +144,9 @@ def _ark_responses_output_text(response: Any) -> str: 混进正文。 """ chunks: list[str] = [] - for item in field(response, "output", []) or []: - for content in field(item, "content", []) or []: - if field(content, "type") not in {"output_text", "text"}: + for item in _require_ark_list(field(response, "output", []) or [], label="响应的 output"): + for content in _require_ark_content(item, label="响应的 output[].content"): + if enum_field(content, "type") not in {"output_text", "text"}: continue piece = field(content, "text") if isinstance(piece, str): @@ -463,6 +508,16 @@ class VolcengineProvider(LargeLanguageModel, TextEmbeddingModel): } ) + # Classification 资源支持的请求参数。业务参数(``query``/``model``/``labels``) + # 必须显式列进来:动态路径 ``resources.classification.create(...)`` 把它们放进 + # ``kwargs``,若不在白名单里会被「不支持请求参数」拒掉——而静态 ``classify()`` + # 是把这三个当**命名参数**传给 SDK、不经 kwargs 校验,于是两条路径对同一组 + # 输入给出相反结论。此前白名单只含扩展字段,恒拒唯一的合法动态用法。 + _ARK_CLASSIFICATION_KEYS = frozenset( + {"user", "extra_headers", "extra_query", "extra_body", "timeout"} + | {"query", "model", "labels"} + ) + capabilities = frozenset( { "chat", @@ -490,14 +545,26 @@ class VolcengineProvider(LargeLanguageModel, TextEmbeddingModel): def __init__( self, model_name: str, protocol: str = "ark", options: dict[str, Any] | None = None ): - normalized = str(protocol).strip().lower().replace("-", "_") + # 非字符串显式拒绝:``str(10**4301)`` 撞上 CPython 的整数位数上限,抛的是 + # ``ValueError: Exceeds the limit``,与「协议名不支持」完全无关。协议名只 + # 能是字符串。 + if not isinstance(protocol, str): + raise ValueError( + f"Volcengine 的协议必须是字符串,但得到 {safe_repr(protocol)}。" + " 可选值: ark/chat_completions、responses" + ) + normalized = protocol.strip().lower().replace("-", "_") if normalized in {"ark", "chat", "chat_completion", "chat_completions"}: self._protocol = "chat_completions" elif normalized in {"response", "responses"}: self._protocol = "responses" else: raise ValueError("Volcengine 仅支持 ark/chat_completions 或 responses 协议。") - self._model_name = model_name + # ``VolcengineProvider`` 是公开类,嵌入方可以绕过工厂直接构造。工厂路径 + # 已用 ``require_model_name`` 收敛,这里同样要判:非字符串取值此前**全程 + # 静默接受**(没有日志插值兜底),原样进 ``"model": self._model_name`` + # 发给服务端。 + self._model_name = require_model_name(model_name) self._options = validate_secret_free_options(options, "Volcengine") self._options = self._normalize_client_options(self._options) self._server_verified_protocols = self._normalize_verified_protocols( @@ -554,43 +621,30 @@ def _normalize_client_options(options: Mapping[str, Any]) -> dict[str, Any]: normalized = dict(options) if "timeout" in normalized: value = normalized["timeout"] - if isinstance(value, bool): - raise ValueError("Volcengine options.timeout 必须是正数。") - if isinstance(value, str): - try: - value = float(value.strip()) - except ValueError as exc: - raise ValueError("Volcengine options.timeout 必须是正数。") from exc - if not isinstance(value, (int, float)) or not math.isfinite(float(value)): - # httpx.Timeout is accepted by the Ark SDK but cannot be - # represented in TOML. Preserve such explicit SDK objects. - if not hasattr(value, "connect"): - raise ValueError("Volcengine options.timeout 必须是正数或 httpx.Timeout。") - elif float(value) <= 0: - raise ValueError("Volcengine options.timeout 必须是正数。") + # httpx.Timeout 被 Ark SDK 接受,但无法用 TOML 表达,原样保留。 + if hasattr(value, "connect"): + normalized["timeout"] = value else: - normalized["timeout"] = float(value) + # Ark 渠道一直接受数字字符串(``.env`` 来源本来就以字符串到达), + # 这里保留该行为,再交给共享 helper 做上界与有限性判断。其余三个 + # 渠道的既有契约是拒绝字符串,故不做统一。 + if isinstance(value, str): + try: + value = float(value.strip()) + except ValueError as exc: + raise ValueError("Volcengine options.timeout 必须是正数。") from exc + normalized["timeout"] = normalize_client_timeout( + value, "Volcengine", "options.timeout" + ) if "max_retries" in normalized: - value = normalized["max_retries"] - if isinstance(value, bool): - raise ValueError("Volcengine options.max_retries 必须是大于等于 0 的整数。") - if isinstance(value, str): - text = value.strip() - try: - parsed = int(text) - except ValueError as exc: - raise ValueError( - "Volcengine options.max_retries 必须是大于等于 0 的整数。" - ) from exc - if text not in {str(parsed), f"+{parsed}"}: - raise ValueError("Volcengine options.max_retries 必须是大于等于 0 的整数。") - value = parsed - elif isinstance(value, float) and math.isfinite(value) and value.is_integer(): - value = int(value) - if not isinstance(value, int) or value < 0: - raise ValueError("Volcengine options.max_retries 必须是大于等于 0 的整数。") - normalized["max_retries"] = value + # 走与 OpenAI 兼容渠道同一个 helper:两边都是 SDK 的客户端构造参数, + # 判据必须一致。此前的内联实现**没有上界**,实测 ``max_retries`` 写成 + # ``10**400`` 会被原样接受并传给 Ark SDK(``client.max_retries`` 就是 + # 那个 401 位整数),而 SDK 的重试循环按该值决定次数,等价于无限重试。 + normalized["max_retries"] = normalize_client_retries( + normalized["max_retries"], "Volcengine" + ) if "region" in normalized: value = normalized["region"] @@ -670,6 +724,23 @@ def _require_provider_resource( 带着 ``instructions`` 与 ``caching={"type": "enabled"}`` 直接发出。 """ segments = method_name.split(".") + if "classification" in segments: + # AGENTS.md 要求 Ark Classification 资源「需通过显式 Facade/入口 + # 调用,不能把缺少资源挂载静默当成成功」。Facade 侧已用 + # ``_get_classification_resource()`` 做到(SDK 未挂载时抛带指引的 + # ``NotImplementedError``),但动态路径此前既不过该 Facade、也不做 + # 参数白名单,实测 ``resources.classification.create(恶意字段=1)`` + # 一路放行,最终拿到的是 SDK 内部的 + # ``AttributeError: 'Ark' object has no attribute 'classification'``。 + # 这里让动态路径与 Facade 受同一约束。 + self._require_classification_resource(method_name) + if kwargs: + self._validate_resource_kwargs( + "Classification", + dict(kwargs), + self._ARK_CLASSIFICATION_KEYS, + ) + return if ( "responses" in segments or method_name.endswith("_response") @@ -681,8 +752,24 @@ def _require_provider_resource( # ``async_create`` 这类 SDK 演进后新增的写法)。凡是参数里出现 # 受校验字段就执行同一套校验——这正是「动态路径与 Facade 受同一 # 约束」的判据。 - if kwargs and self._ARK_RESPONSES_VALIDATED_KWARGS.intersection(kwargs): - self._validate_native_response_kwargs(kwargs) + # + # 但「一个触发字段都没出现」不等于「不是创建调用」:此前那种情况下 + # 整套校验被跳过,请求直达 SDK,撞上 ``_utils/_utils.py`` 的 + # ``assert "model" in kwargs``,抛出 **``AssertionError``**——它既不 + # 落 ``except ValueError`` 也不落 ``except RuntimeError``。实测 + # ``resources.responses.create()``/``create(store=True)``/ + # ``create(text={...})`` 三条都得到 ``AssertionError: you need to + # support model``,而同样参数走 Facade 得到的是带指引的 + # ``ValueError: Ark Responses 原生调用必须显式提供 model。``。 + # + # ``create`` 是 ``responses`` 段下的动态方法(路径形如 + # ``responses.create``/``responses.async_create``),其 ``model`` + # 与 ``input`` 都是必填——无参调用本就该被本地拒绝。因此对创建方法 + # 无条件执行校验,其余(retrieve/delete/list/子资源)仍按触发字段 + # 判断,以免 ``retrieve("r1")`` 被误要求提供 model。 + is_create = any(segment in {"create", "async_create"} for segment in segments) + if is_create or (kwargs and self._ARK_RESPONSES_VALIDATED_KWARGS.intersection(kwargs)): + self._validate_native_response_kwargs(kwargs or {}) def _require_responses_resource(self, operation: str) -> None: """阻止未验证的 Ark Responses 请求到达远端。""" @@ -701,6 +788,22 @@ def _require_responses_resource(self, operation: str) -> None: "server_verified_protocols=['responses']。" ) + def _require_classification_resource(self, operation: str) -> None: + """阻止未挂载的 Ark Classification 请求以 SDK 内部错误收场。 + + 与 ``_require_responses_resource`` 同一目的:动态路径 + (``resources.classification.create(...)``)不经过 Facade,若不在这里 + 显式检查,资源缺失会退化成 + ``AttributeError: 'Ark' object has no attribute 'classification'``—— + 用户拿不到「该资源未挂载」这个可定位的结论。这里主动构造一次资源, + 让 ``_get_classification_resource`` 的 ``NotImplementedError`` 成为 + 唯一出口。 + """ + try: + self._get_classification_resource() + except NotImplementedError as exc: + raise NotImplementedError(f"Ark {operation}: {exc}") from exc + def _get_client(self) -> Any: if self._client is None: ark_class, _ = _load_ark_clients() @@ -744,17 +847,40 @@ def _request_options(self) -> dict[str, Any]: _merge_extra_body = staticmethod(OpenAICompatibleProvider._merge_extra_body) @staticmethod - def _convert_responses_format(response_format: dict[str, Any]) -> dict[str, Any]: + def _convert_responses_format(response_format: Any) -> dict[str, Any]: + """把统一的 ``response_format`` 转成 Ark Responses 的 ``format``。 + + 容器形态判在这里:本函数的两个来源(``options.response_format`` 与请求参数) + 都会走到它,而非 Mapping 时 ``.get()`` 抛裸 ``AttributeError``——不是 + ``ValueError``,穿透调用方的错误分类。判据与 ``google.py`` 的 + ``_response_format_values`` 一致。 + """ + if response_format is None: + return {} + if not isinstance(response_format, Mapping): + raise ValueError( + f"Ark Responses 的 response_format 必须是对象,但得到 {safe_repr(response_format)}。" + ) if response_format.get("type") == "json_schema": schema = response_format.get("json_schema", response_format) - if not isinstance(schema, dict): + if not isinstance(schema, Mapping): raise ValueError("Ark Responses 的 json_schema 必须是对象。") + # 无 schema 时不能回退成 ``{}``:那会伪造出一个空 JSON Schema 发给 + # 服务端,客户端以为拿到了结构化输出约束,实际没有。与 + # ``openai_compatible._convert_responses_format`` 同一判据——那份 + # 副本已在 453d096 修过,本副本是同一转换逻辑的 Ark 分支,同批漏修。 + json_schema = schema.get("schema") + if not isinstance(json_schema, Mapping) or not json_schema: + raise ValueError( + "Ark Responses 的 json_schema 缺少 schema 字段;" + "请提供 {'type': 'json_schema', 'json_schema': {'name': ..., 'schema': {...}}}。" + ) return { "format": { "type": "json_schema", "json_schema": { "name": schema.get("name", "response"), - "schema": schema.get("schema", response_format.get("schema", {})), + "schema": json_schema, "strict": schema.get("strict", True), }, } @@ -762,7 +888,7 @@ def _convert_responses_format(response_format: dict[str, Any]) -> dict[str, Any] if response_format.get("type") == "json_object": return {"format": {"type": "json_object"}} if "format" in response_format: - return response_format + return dict(response_format) raise ValueError("Ark Responses 的 response_format 仅支持 json_schema 或 json_object。") @staticmethod @@ -770,6 +896,11 @@ def _convert_responses_tools(tools: list[dict[str, Any]] | None) -> list[dict[st """将统一的 Chat 工具 schema 转换为 Ark Responses 工具 schema。""" if not tools: return None + # 容器形态先判:``enumerate(tools)`` 对不可迭代的取值抛裸 + # ``TypeError``,而同函数对可迭代但元素非法的形态给的是带序号的 + # ``ValueError``。同一判据的两半必须都在。 + if not isinstance(tools, (list, tuple)): + raise ValueError(f"Ark Responses 工具定义必须是列表,但得到 {safe_repr(tools)}。") converted: list[dict[str, Any]] = [] for index, tool in enumerate(tools): if not isinstance(tool, Mapping): @@ -824,7 +955,12 @@ def _convert_responses_tool_choice(tool_choice: Any) -> Any: return aliases[normalized] raise ValueError(f"Ark Responses 不支持 tool_choice: {tool_choice}") if isinstance(tool_choice, dict): - if tool_choice.get("type") == "function": + choice_type = tool_choice.get("type") + # 来自调用方的 dict,形态不受控:``in {...}`` 对 list/dict 抛 + # ``TypeError``,而本函数的契约是 ``ValueError``。 + if not isinstance(choice_type, str): + choice_type = None + if choice_type == "function": function = tool_choice.get("function", tool_choice) if not isinstance(function, Mapping): raise ValueError("Ark Responses 的 function tool_choice 必须是对象。") @@ -832,7 +968,7 @@ def _convert_responses_tool_choice(tool_choice: Any) -> Any: if not name: raise ValueError("Ark Responses 的 function tool_choice 缺少 name。") return {"type": "function", "name": name} - if tool_choice.get("type") in {"mcp", "web_search", "knowledge_search"}: + if choice_type in {"mcp", "web_search", "knowledge_search"}: return dict(tool_choice) raise ValueError("Ark Responses 的 tool_choice 必须是 auto、none、required 或工具对象。") @@ -1344,10 +1480,12 @@ def _validate_file_wait_args( def positive_finite(value: float | None, name: str) -> float | None: if value is None: return None + # 先判可转换性:``math.isfinite(float(x))`` 对超出双精度范围的整数 + # 自己就抛 ``OverflowError``(**不是** ``ValueError``)。 if ( isinstance(value, bool) or not isinstance(value, (int, float)) - or not math.isfinite(float(value)) + or not is_double_convertible(value) or float(value) <= 0 ): raise ValueError(f"Ark 文件等待的 {name} 必须是有限正数。") @@ -1389,13 +1527,23 @@ def _prepare_batch_kwargs( @staticmethod def _extract_result(response: Any) -> CompletionResult: - choices = field(response, "choices", []) or [] + # ``choices`` 的形态守卫不能只靠 Chat 路径:``complete``/``acomplete`` 在 + # Responses 分支走的是 ``_raise_for_response_error``,它只查 ``error`` 与 + # ``status``,**不碰 choices**(对照 ``openai_compatible`` 的同名方法: + # 那边三个提取器也是这个形状)。此前这里直接 ``field(response, "choices", + # []) or []``,Responses 响应带 ``choices=5`` 时下面 ``choices[0]`` 抛裸 + # ``TypeError: 'int' object is not subscriptable``——本 provider 的错误分类 + # 按 ``RuntimeError`` 写。与 ``output``/``tool_calls`` 同判据走 + # ``_require_ark_list``。 + choices = _require_ark_list(field(response, "choices", []) or [], label="响应的 choices") text = "" calls: list[dict[str, Any]] = [] if choices: message = field(choices[0], "message") text = content_to_text(field(message, "content", "")) - for call in field(message, "tool_calls", []) or []: + for call in _require_ark_list( + field(message, "tool_calls", []) or [], label="响应的 tool_calls" + ): fn = field(call, "function") calls.append( { @@ -1419,8 +1567,8 @@ def _extract_result(response: Any) -> CompletionResult: # 入口都会返回空文本而不报错。仅在没有 choices 文本时启用,避免 # 覆盖 Chat Completions 分支已经取到的内容。 text = _ark_responses_output_text(response) - for item in field(response, "output", []) or []: - item_type = field(item, "type") + for item in _require_ark_list(field(response, "output", []) or [], label="响应的 output"): + item_type = enum_field(item, "type") if item_type in {"function_call", "custom_tool_call"}: calls.append( { @@ -1444,8 +1592,8 @@ def _extract_result(response: Any) -> CompletionResult: candidate_refusal = field(message, "refusal") if isinstance(candidate_refusal, str) and candidate_refusal: refusal = candidate_refusal - for item in field(response, "output", []) or []: - if field(item, "type") in {"reasoning", "reasoning_item"}: + for item in _require_ark_list(field(response, "output", []) or [], label="响应的 output"): + if enum_field(item, "type") in {"reasoning", "reasoning_item"}: summary = field(item, "summary") if isinstance(summary, str): reasoning += summary @@ -1454,7 +1602,7 @@ def _extract_result(response: Any) -> CompletionResult: reasoning_text = field(entry, "text") if isinstance(reasoning_text, str): reasoning += reasoning_text - for content in field(item, "content", []) or []: + for content in _require_ark_content(item, label="响应的 output[].content"): reasoning_text = field(content, "text") if isinstance(reasoning_text, str): reasoning += reasoning_text @@ -1465,7 +1613,7 @@ def _extract_result(response: Any) -> CompletionResult: candidate_refusal = field(item, "refusal") or field(item, "text") if isinstance(candidate_refusal, str) and candidate_refusal: refusal = candidate_refusal - for content in field(item, "content", []) or []: + for content in _require_ark_content(item, label="响应的 output[].content"): if field(content, "type") != "refusal": continue candidate_refusal = field(content, "refusal") or field(content, "text") @@ -1516,11 +1664,17 @@ def _raise_for_response_error(response: Any) -> None: if isinstance(error, str) else field(error, "message", "Ark Responses 请求失败") ) - raise RuntimeError(str(message)) + # ``message`` 来自网关 JSON,形态不受控:位数超限的整数让裸 ``str()`` + # 抛 ``ValueError: Exceeds the limit``,正要报告网关错误的那一步变成 + # 整数转换错误,而调用方按 ``RuntimeError`` 分类。 + raise RuntimeError(message if isinstance(message, str) else safe_repr(message)) if status == "incomplete": details = field(response, "incomplete_details") reason = field(details, "reason", "unknown") - raise RuntimeError(f"Ark Responses 响应不完整: {reason}") + # ``reason`` 来自网关 JSON,形态不受控:``f"{10**4300}"`` 抛 + # ``ValueError: Exceeds the limit``,于是「响应不完整」这条错误 + # 被替换成与原因无关的位数错误。 + raise RuntimeError(f"Ark Responses 响应不完整: {safe_repr(reason)}") if status == "cancelled": raise RuntimeError("Ark Responses 请求已取消") @@ -1541,7 +1695,7 @@ def _chat_stream_events(chunk: Any) -> list[StreamEvent]: text = content_to_text(field(delta, "content", "")) reasoning = field(delta, "reasoning_content") or field(delta, "reasoning") or "" refusal = field(delta, "refusal") - calls = field(delta, "tool_calls", []) or [] + calls = _require_ark_list(field(delta, "tool_calls", []) or [], label="流块的 tool_calls") finish_reason = field(choice, "finish_reason") events: list[StreamEvent] = [] if isinstance(refusal, str) and refusal: @@ -1568,7 +1722,16 @@ def _chat_stream_events(chunk: Any) -> list[StreamEvent]: "index": field(call, "index", position), "type": field(call, "type", "function"), "name": field(function, "name"), - "arguments": field(function, "arguments", ""), + # 与另两条路径同判据(非流式 ``_extract_result`` 与 + # OpenAI 兼容的 ``_chat_stream_events`` 都走 + # ``normalize_tool_arguments``)。网关可以把 + # ``arguments`` 写成对象/数组/整数(``construct_type`` + # 不做校验),原样透传会让**同一个输入在 Ark 的两条 + # 路径上给出不同类型**——调用方按字符串 ``json.loads`` + # 时拿到 dict 并抛裸 ``TypeError``;位数超限的整数则让 + # 拼接/``json.dumps`` 抛 ``ValueError: Exceeds the + # limit``。字符串片段原样透传,拼接语义不变。 + "arguments": normalize_tool_arguments(field(function, "arguments", "")), }, response_id=response_id, raw=chunk, @@ -1669,7 +1832,7 @@ def stream_events( ): if terminal_seen: continue - event_type = field(event, "type", "") + event_type = enum_field(event, "type") converted = self._responses_stream_event(event, response_tool_metadata) if converted: if converted.type == "finish": @@ -1769,7 +1932,7 @@ async def astream_events( ): if terminal_seen: continue - event_type = field(event, "type", "") + event_type = enum_field(event, "type") converted = self._responses_stream_event(event, response_tool_metadata) if converted: if converted.type == "finish": @@ -1912,92 +2075,110 @@ def invoke( extra_body: dict[str, Any] | None = None, **kwargs: Any, ) -> Generator[str, None, None]: - request = CompletionRequest( - prompt=prompt, - system_prompt=system_prompt, - messages=messages, - tools=tools, - stream=stream, - temperature=temperature, - max_tokens=max_tokens, - top_p=top_p, - stop=stop, - response_format=response_format, - tool_choice=tool_choice, - extra_body=extra_body, - **kwargs, + logger.info( + f"调用 {getattr(self, '_provider', 'volcengine')} LLM ({self._model_name}),流式: {stream}" ) - if self._protocol == "responses": - self._require_responses_resource("invoke") - if tools: - raise ValueError( - "Ark Responses invoke 仅返回文本,不支持 tools;" - "请使用 complete 或 stream_events 获取工具调用。" - ) + start_time = time.perf_counter() + try: + request = CompletionRequest( + prompt=prompt, + system_prompt=system_prompt, + messages=messages, + tools=tools, + stream=stream, + temperature=temperature, + max_tokens=max_tokens, + top_p=top_p, + stop=stop, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + **kwargs, + ) + if self._protocol == "responses": + self._require_responses_resource("invoke") + if tools: + raise ValueError( + "Ark Responses invoke 仅返回文本,不支持 tools;" + "请使用 complete 或 stream_events 获取工具调用。" + ) + if stream: + terminal_seen = False + for event in retry_sync_stream( + lambda: self._get_client().responses.create( + **self._build_responses_request(request) + ), + lambda event: raise_for_stream_error_event(event, "Ark Responses"), + ): + raise_for_stream_error_event(event, "Ark Responses") + converted = self._responses_stream_event(event) + if field(event, "type") == "response.completed": + terminal_seen = True + if converted and converted.type == "refusal_delta" and converted.refusal: + raise RuntimeError(f"Ark Responses 返回拒答: {converted.refusal}") + if converted and converted.text: + yield converted.text + if not terminal_seen: + raise RuntimeError("Ark Responses 流在 response.completed 之前结束。") + else: + response = retry_sync_call( + lambda: self._get_client().responses.create( + **self._build_responses_request(request) + ) + ) + self._raise_for_response_error(response) + result = self._extract_result(response) + if result.refusal: + raise RuntimeError(f"Ark Responses 返回拒答: {result.refusal}") + if result.text: + yield result.text + return if stream: terminal_seen = False - for event in retry_sync_stream( - lambda: self._get_client().responses.create( - **self._build_responses_request(request) + for chunk in retry_sync_stream( + lambda: self._get_client().chat.completions.create( + **self._build_chat_request(**request.to_invoke_kwargs()) ), - lambda event: raise_for_stream_error_event(event, "Ark Responses"), + lambda chunk: raise_for_stream_error_event(chunk, "Ark Chat Completions"), ): - raise_for_stream_error_event(event, "Ark Responses") - converted = self._responses_stream_event(event) - if field(event, "type") == "response.completed": - terminal_seen = True - if converted and converted.type == "refusal_delta" and converted.refusal: - raise RuntimeError(f"Ark Responses 返回拒答: {converted.refusal}") - if converted and converted.text: - yield converted.text + raise_for_stream_error_event(chunk, "Ark Chat Completions") + for event in self._chat_stream_events(chunk): + if event.type == "finish": + terminal_seen = True + if event.type == "refusal_delta" and event.refusal: + raise RuntimeError(f"Ark Chat Completions 返回拒答: {event.refusal}") + if event.type == "text_delta" and event.text: + yield event.text if not terminal_seen: - raise RuntimeError("Ark Responses 流在 response.completed 之前结束。") + raise RuntimeError("Ark Chat Completions 流在 finish 事件之前结束。") else: response = retry_sync_call( - lambda: self._get_client().responses.create( - **self._build_responses_request(request) + lambda: self._get_client().chat.completions.create( + **self._build_chat_request(**request.to_invoke_kwargs()) ) ) - self._raise_for_response_error(response) + OpenAICompatibleProvider._raise_for_chat_response_error( + response, + provider="Ark Chat Completions", + ) result = self._extract_result(response) if result.refusal: - raise RuntimeError(f"Ark Responses 返回拒答: {result.refusal}") + raise RuntimeError(f"Ark Chat Completions 返回拒答: {result.refusal}") if result.text: yield result.text - return - if stream: - terminal_seen = False - for chunk in retry_sync_stream( - lambda: self._get_client().chat.completions.create( - **self._build_chat_request(**request.to_invoke_kwargs()) - ), - lambda chunk: raise_for_stream_error_event(chunk, "Ark Chat Completions"), - ): - raise_for_stream_error_event(chunk, "Ark Chat Completions") - for event in self._chat_stream_events(chunk): - if event.type == "finish": - terminal_seen = True - if event.type == "refusal_delta" and event.refusal: - raise RuntimeError(f"Ark Chat Completions 返回拒答: {event.refusal}") - if event.type == "text_delta" and event.text: - yield event.text - if not terminal_seen: - raise RuntimeError("Ark Chat Completions 流在 finish 事件之前结束。") - else: - response = retry_sync_call( - lambda: self._get_client().chat.completions.create( - **self._build_chat_request(**request.to_invoke_kwargs()) - ) + duration = time.perf_counter() - start_time + logger.info( + f"{getattr(self, '_provider', 'volcengine')} LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s" ) - OpenAICompatibleProvider._raise_for_chat_response_error( - response, - provider="Ark Chat Completions", + except Exception as e: + error_text = safe_exception_text(e) + logger.exception( + "%s LLM (%s) 出错: %s", + getattr(self, "_provider", "volcengine"), + self._model_name, + error_text, ) - result = self._extract_result(response) - if result.refusal: - raise RuntimeError(f"Ark Chat Completions 返回拒答: {result.refusal}") - if result.text: - yield result.text + raise async def ainvoke( self, @@ -2008,85 +2189,103 @@ async def ainvoke( temperature: float | None = _UNSET_TEMPERATURE, # type: ignore[assignment] **kwargs: Any, ) -> AsyncGenerator[str, None]: - if prompt is not None: - kwargs["prompt"] = prompt - kwargs["system_prompt"] = system_prompt - if tools is not None: - kwargs["tools"] = tools - kwargs["stream"] = stream - kwargs["temperature"] = temperature - request = CompletionRequest(**kwargs) - if self._protocol == "responses": - self._require_responses_resource("ainvoke") - if request.tools: - raise ValueError( - "Ark Responses ainvoke 仅返回文本,不支持 tools;" - "请使用 acomplete 或 astream_events 获取工具调用。" - ) + logger.info( + f"调用 {getattr(self, '_provider', 'volcengine')} LLM ({self._model_name}),流式: {stream}" + ) + start_time = time.perf_counter() + try: + if prompt is not None: + kwargs["prompt"] = prompt + kwargs["system_prompt"] = system_prompt + if tools is not None: + kwargs["tools"] = tools + kwargs["stream"] = stream + kwargs["temperature"] = temperature + request = CompletionRequest(**kwargs) + if self._protocol == "responses": + self._require_responses_resource("ainvoke") + if request.tools: + raise ValueError( + "Ark Responses ainvoke 仅返回文本,不支持 tools;" + "请使用 acomplete 或 astream_events 获取工具调用。" + ) + if request.stream: + terminal_seen = False + async for event in retry_async_stream( + lambda: self._get_aclient().responses.create( + **self._build_responses_request(request) + ), + lambda event: raise_for_stream_error_event(event, "Ark Responses"), + ): + raise_for_stream_error_event(event, "Ark Responses") + converted = self._responses_stream_event(event) + if field(event, "type") == "response.completed": + terminal_seen = True + if converted and converted.type == "refusal_delta" and converted.refusal: + raise RuntimeError(f"Ark Responses 返回拒答: {converted.refusal}") + if converted and converted.text: + yield converted.text + if not terminal_seen: + raise RuntimeError("Ark Responses 异步流在 response.completed 之前结束。") + else: + response = await retry_async_call( + lambda: self._get_aclient().responses.create( + **self._build_responses_request(request) + ) + ) + self._raise_for_response_error(response) + result = self._extract_result(response) + if result.refusal: + raise RuntimeError(f"Ark Responses 返回拒答: {result.refusal}") + if result.text: + yield result.text + return if request.stream: terminal_seen = False - async for event in retry_async_stream( - lambda: self._get_aclient().responses.create( - **self._build_responses_request(request) + async for chunk in retry_async_stream( + lambda: self._get_aclient().chat.completions.create( + **self._build_chat_request(**request.to_invoke_kwargs()) ), - lambda event: raise_for_stream_error_event(event, "Ark Responses"), + lambda chunk: raise_for_stream_error_event(chunk, "Ark Chat Completions"), ): - raise_for_stream_error_event(event, "Ark Responses") - converted = self._responses_stream_event(event) - if field(event, "type") == "response.completed": - terminal_seen = True - if converted and converted.type == "refusal_delta" and converted.refusal: - raise RuntimeError(f"Ark Responses 返回拒答: {converted.refusal}") - if converted and converted.text: - yield converted.text + raise_for_stream_error_event(chunk, "Ark Chat Completions") + for event in self._chat_stream_events(chunk): + if event.type == "finish": + terminal_seen = True + if event.type == "refusal_delta" and event.refusal: + raise RuntimeError(f"Ark Chat Completions 返回拒答: {event.refusal}") + if event.type == "text_delta" and event.text: + yield event.text if not terminal_seen: - raise RuntimeError("Ark Responses 异步流在 response.completed 之前结束。") + raise RuntimeError("Ark Chat Completions 异步流在 finish 事件之前结束。") else: response = await retry_async_call( - lambda: self._get_aclient().responses.create( - **self._build_responses_request(request) + lambda: self._get_aclient().chat.completions.create( + **self._build_chat_request(**request.to_invoke_kwargs()) ) ) - self._raise_for_response_error(response) + OpenAICompatibleProvider._raise_for_chat_response_error( + response, + provider="Ark Chat Completions", + ) result = self._extract_result(response) if result.refusal: - raise RuntimeError(f"Ark Responses 返回拒答: {result.refusal}") + raise RuntimeError(f"Ark Chat Completions 返回拒答: {result.refusal}") if result.text: yield result.text - return - if request.stream: - terminal_seen = False - async for chunk in retry_async_stream( - lambda: self._get_aclient().chat.completions.create( - **self._build_chat_request(**request.to_invoke_kwargs()) - ), - lambda chunk: raise_for_stream_error_event(chunk, "Ark Chat Completions"), - ): - raise_for_stream_error_event(chunk, "Ark Chat Completions") - for event in self._chat_stream_events(chunk): - if event.type == "finish": - terminal_seen = True - if event.type == "refusal_delta" and event.refusal: - raise RuntimeError(f"Ark Chat Completions 返回拒答: {event.refusal}") - if event.type == "text_delta" and event.text: - yield event.text - if not terminal_seen: - raise RuntimeError("Ark Chat Completions 异步流在 finish 事件之前结束。") - else: - response = await retry_async_call( - lambda: self._get_aclient().chat.completions.create( - **self._build_chat_request(**request.to_invoke_kwargs()) - ) + duration = time.perf_counter() - start_time + logger.info( + f"{getattr(self, '_provider', 'volcengine')} LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s" ) - OpenAICompatibleProvider._raise_for_chat_response_error( - response, - provider="Ark Chat Completions", + except Exception as e: + error_text = safe_exception_text(e) + logger.exception( + "%s LLM (%s) 异步出错: %s", + getattr(self, "_provider", "volcengine"), + self._model_name, + error_text, ) - result = self._extract_result(response) - if result.refusal: - raise RuntimeError(f"Ark Chat Completions 返回拒答: {result.refusal}") - if result.text: - yield result.text + raise @retry( stop=stop_after_attempt(3), @@ -2098,41 +2297,61 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: overlap = sorted({"input", "model"}.intersection(kwargs)) if overlap: raise ValueError(f"Ark Embedding 不允许覆盖请求字段: {', '.join(overlap)}") - request: dict[str, Any] = {"input": texts, "model": self._model_name} - configured_options = self._embedding_options() - configured_extra_body = self._validated_extra_body( - configured_options.pop("extra_body", None), - "Ark Embedding", - set(request).union(configured_options), - ) - request_options = dict(kwargs) - self._validate_resource_kwargs( - "Embedding", - request_options, - { - "encoding_format", - "dimensions", - "user", - "extra_body", - "extra_headers", - "extra_query", - "timeout", - }, - ) - request_extra_body = self._validated_extra_body( - request_options.pop("extra_body", None), - "Ark Embedding", - set(request).union(configured_options).union(request_options), - ) - request.update(configured_options) - request.update(request_options) - merged_extra_body = self._merge_extra_body( - configured_extra_body, request_extra_body, "Ark Embedding" + texts = require_text_list(texts, "Ark") + logger.info( + f"调用 {getattr(self, '_provider', 'volcengine')} 嵌入 ({self._model_name}),数量: {len(texts)}" ) - if merged_extra_body: - request["extra_body"] = merged_extra_body - response = self._get_client().embeddings.create(**request) - return [normalize_embedding_vector(item.embedding) for item in response.data] + start_time = time.perf_counter() + try: + request: dict[str, Any] = {"input": texts, "model": self._model_name} + configured_options = self._embedding_options() + configured_extra_body = self._validated_extra_body( + configured_options.pop("extra_body", None), + "Ark Embedding", + set(request).union(configured_options), + ) + request_options = dict(kwargs) + self._validate_resource_kwargs( + "Embedding", + request_options, + { + "encoding_format", + "dimensions", + "user", + "extra_body", + "extra_headers", + "extra_query", + "timeout", + }, + ) + request_extra_body = self._validated_extra_body( + request_options.pop("extra_body", None), + "Ark Embedding", + set(request).union(configured_options).union(request_options), + ) + request.update(configured_options) + request.update(request_options) + merged_extra_body = self._merge_extra_body( + configured_extra_body, request_extra_body, "Ark Embedding" + ) + if merged_extra_body: + request["extra_body"] = merged_extra_body + response = self._get_client().embeddings.create(**request) + embeddings = [normalize_embedding_vector(item.embedding) for item in response.data] + duration = time.perf_counter() - start_time + logger.info( + f"{getattr(self, '_provider', 'volcengine')} 嵌入 ({self._model_name}) 完成,耗时: {duration:.2f}s" + ) + return embeddings + except Exception as e: + error_text = safe_exception_text(e) + logger.exception( + "%s 嵌入 (%s) 出错: %s", + getattr(self, "_provider", "volcengine"), + self._model_name, + error_text, + ) + raise @retry( stop=stop_after_attempt(3), @@ -2144,43 +2363,63 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f overlap = sorted({"input", "model"}.intersection(kwargs)) if overlap: raise ValueError(f"Ark Embedding 不允许覆盖请求字段: {', '.join(overlap)}") - request: dict[str, Any] = {"input": texts, "model": self._model_name} - configured_options = self._embedding_options() - configured_extra_body = self._validated_extra_body( - configured_options.pop("extra_body", None), - "Ark Embedding", - set(request).union(configured_options), - ) - request_options = dict(kwargs) - self._validate_resource_kwargs( - "Embedding", - request_options, - { - "encoding_format", - "dimensions", - "user", - "extra_body", - "extra_headers", - "extra_query", - "timeout", - }, - ) - request_extra_body = self._validated_extra_body( - request_options.pop("extra_body", None), - "Ark Embedding", - set(request).union(configured_options).union(request_options), + texts = require_text_list(texts, "Ark") + logger.info( + f"调用 {getattr(self, '_provider', 'volcengine')} 嵌入 ({self._model_name}),数量: {len(texts)}" ) - request.update(configured_options) - request.update(request_options) - merged_extra_body = self._merge_extra_body( - configured_extra_body, request_extra_body, "Ark Embedding" - ) - if merged_extra_body: - request["extra_body"] = merged_extra_body - response = await self._resolve_async_result( - self._get_aclient().embeddings.create(**request) - ) - return [normalize_embedding_vector(item.embedding) for item in response.data] + start_time = time.perf_counter() + try: + request: dict[str, Any] = {"input": texts, "model": self._model_name} + configured_options = self._embedding_options() + configured_extra_body = self._validated_extra_body( + configured_options.pop("extra_body", None), + "Ark Embedding", + set(request).union(configured_options), + ) + request_options = dict(kwargs) + self._validate_resource_kwargs( + "Embedding", + request_options, + { + "encoding_format", + "dimensions", + "user", + "extra_body", + "extra_headers", + "extra_query", + "timeout", + }, + ) + request_extra_body = self._validated_extra_body( + request_options.pop("extra_body", None), + "Ark Embedding", + set(request).union(configured_options).union(request_options), + ) + request.update(configured_options) + request.update(request_options) + merged_extra_body = self._merge_extra_body( + configured_extra_body, request_extra_body, "Ark Embedding" + ) + if merged_extra_body: + request["extra_body"] = merged_extra_body + response = await self._resolve_async_result( + self._get_aclient().embeddings.create(**request) + ) + embeddings = [normalize_embedding_vector(item.embedding) for item in response.data] + duration = time.perf_counter() - start_time + logger.info( + f"{getattr(self, '_provider', 'volcengine')} 嵌入 ({self._model_name}) 异步完成,耗时: {duration:.2f}s" + ) + return embeddings + except Exception as e: + error_text = safe_exception_text(e) + logger.exception( + "%s 嵌入 (%s) 异步出错: %s", + getattr(self, "_provider", "volcengine"), + self._model_name, + error_text, + ) + raise def upload_file(self, file: Any, purpose: str, **kwargs: Any) -> Any: purpose = self._require_non_empty_string(purpose, "Ark 文件 purpose") @@ -2363,10 +2602,24 @@ def count_tokens(self, text: str | list[str], **kwargs: Any) -> int: response = self._get_client().tokenization.create( text=text, model=self._model_name, **kwargs ) - values = field(response, "data", []) or [] + # ``data`` 的形态同样要收敛:``or []`` 只挡 ``None`` 与空值,实测 + # ``data=5`` 时下面的生成器迭代抛裸 + # ``TypeError: 'int' object is not iterable``——而紧邻的「缺少 data」 + # 是 ``RuntimeError``,同一份响应的两种形态不该得到两种异常分类。 + values = _require_ark_list( + field(response, "data", []) or [], label="tokenization 响应的 data" + ) if not values: raise RuntimeError("Ark tokenization 响应缺少 data。") - return sum(int(field(item, "total_tokens", 0)) for item in values) + # 与其余七个 token 计数入口(``require_token_count``)保持同一契约: + # 此前写成 ``int(field(item, "total_tokens", 0))``,于是 ``"abc"`` 抛裸 + # ``ValueError``、``None`` 抛裸 ``TypeError``、位数超限的数字串抛与 token + # 计数无关的位数错误,且消息里连字段名都没有。``0`` 默认值也不合适—— + # 缺字段时静默算成 0 个 token,把「响应不完整」报成「没有 token」。 + return sum( + require_token_count(field(item, "total_tokens"), "Ark", "total_tokens") + for item in values + ) def multimodal_embed(self, inputs: Any, **kwargs: Any) -> Any: self._validate_resource_kwargs( @@ -2705,10 +2958,24 @@ async def async_count_tokens(self, text: str | list[str], **kwargs: Any) -> int: response = await self._resolve_async_result( self._get_aclient().tokenization.create(text=text, model=self._model_name, **kwargs) ) - values = field(response, "data", []) or [] + # ``data`` 的形态同样要收敛:``or []`` 只挡 ``None`` 与空值,实测 + # ``data=5`` 时下面的生成器迭代抛裸 + # ``TypeError: 'int' object is not iterable``——而紧邻的「缺少 data」 + # 是 ``RuntimeError``,同一份响应的两种形态不该得到两种异常分类。 + values = _require_ark_list( + field(response, "data", []) or [], label="tokenization 响应的 data" + ) if not values: raise RuntimeError("Ark tokenization 响应缺少 data。") - return sum(int(field(item, "total_tokens", 0)) for item in values) + # 与其余七个 token 计数入口(``require_token_count``)保持同一契约: + # 此前写成 ``int(field(item, "total_tokens", 0))``,于是 ``"abc"`` 抛裸 + # ``ValueError``、``None`` 抛裸 ``TypeError``、位数超限的数字串抛与 token + # 计数无关的位数错误,且消息里连字段名都没有。``0`` 默认值也不合适—— + # 缺字段时静默算成 0 个 token,把「响应不完整」报成「没有 token」。 + return sum( + require_token_count(field(item, "total_tokens"), "Ark", "total_tokens") + for item in values + ) async def async_multimodal_embed(self, inputs: Any, **kwargs: Any) -> Any: self._validate_resource_kwargs( diff --git a/src/retrieval/retriever.py b/src/retrieval/retriever.py index a1cb9e7..c189627 100644 --- a/src/retrieval/retriever.py +++ b/src/retrieval/retriever.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import inspect from copy import deepcopy from rich.console import Console @@ -19,6 +20,10 @@ _merge_hybrid_results, ) from ..utils.config import RetrievalMethod, get_settings +from ..utils.log_manager import get_module_logger +from ..utils.security import safe_exception_text + +logger = get_module_logger(__name__) __all__ = [ "HybridReranker", @@ -126,4 +131,21 @@ async def aretrieve_documents( query=query, session_config=session_config, console=console ) finally: - await retrieval_service.aclose() + # 释放失败**不能**顶替主流程的结果或异常。``finally`` 里裸 ``await`` + # 的收场是二选一,两种都丢信息: + # * 检索成功、关闭失败 —— 那批命中结果被丢弃,调用方只看到与检索 + # 无关的关闭异常(实测:返回 1 条命中的检索整体抛 + # ``RuntimeError: 关闭失败``); + # * 检索已抛异常、关闭也失败 —— 关闭异常**替换**掉原始异常,真正的 + # 失败原因从栈上消失。 + # 与仓库内既有的两处退出清理同一契约(``retrieval_test.core`` 的 + # ``_aclose_quietly`` 与 ``Chatbot.close``):失败只记警告,不打断 + # 主流程。 + close = getattr(retrieval_service, "aclose", None) + if callable(close): + try: + result = close() + if inspect.isawaitable(result): + await result + except Exception as exc: # noqa: BLE001 - teardown must not mask the main flow + logger.warning("释放检索服务失败: %s", safe_exception_text(exc)) diff --git a/src/retrieval/snapshot_repository.py b/src/retrieval/snapshot_repository.py index 847efb5..8f08ec9 100644 --- a/src/retrieval/snapshot_repository.py +++ b/src/retrieval/snapshot_repository.py @@ -8,30 +8,173 @@ from pathlib import Path from src.runtime.contracts import SCHEMA_VERSION, KnowledgeSnapshotManifest, RunConfig +from src.utils.config import safe_repr from src.utils.security import resolve_within +# 快照 id 的长度上界。本项目自己生成的 id 形如 ``-<12 hex>``(几十个 +# 字符),legacy 前缀也不过十几个字符;这个界留了极大余量,只可能被畸形数据 +# 触及。它的作用是把「名字太长导致 stat 失败」这类**文件系统层**错误收敛成 +# 带指引的 ``ValueError``——大多数文件系统的单个路径分量上限是 255 字节,而 +# 中文字符在 UTF-8 下占 3 字节,因此这里按字符数取一个保守值。 +_MAX_SNAPSHOT_ID_LENGTH = 64 + class SnapshotRepository: def __init__(self, run_config: RunConfig): self.run_config = run_config - self.root = run_config.snapshot_root + root = run_config.snapshot_root + # 形态先收敛:下一行直接对 ``root`` 调 ``.mkdir()``,非 ``Path`` 取值在那里 + # 抛裸 ``AttributeError: 'int' object has no attribute 'mkdir'``——不是本类 + # 其余拒绝路径承诺的带指引 ``ValueError``。``RunConfig`` 是 frozen + # dataclass、字段没有校验器,嵌入方可以自拼 ``snapshot_root=5`` 的配置。 + # 只接受 ``Path``(``RunConfig`` 的标注就是它),不做静默强转。 + if not isinstance(root, Path): + raise ValueError( + f"snapshot_root 必须是 Path,但得到 {type(root).__name__}。" + " 请检查 config.toml 里的 snapshot_root 配置项。" + ) + self.root = root self.root.mkdir(parents=True, exist_ok=True) def get_active_snapshot_id(self) -> str | None: marker = self.run_config.active_snapshot_marker if not marker.exists(): return None - snapshot_id = marker.read_text(encoding="utf-8").strip() - return snapshot_id or None + # 读取本身要收敛:``exists()`` 对**目录**也为真,``read_text`` 于是抛 + # ``IsADirectoryError``;标记文件不是 UTF-8 时抛 ``UnicodeDecodeError``。 + # 两者都不是本类其余拒绝路径承诺的带指引 ``ValueError``,而 + # ``VectorStoreFactory._load_existing_state`` 的第一步就是本方法—— + # 启动会以裸 ``OSError``/``UnicodeDecodeError`` 收场。标记文件是磁盘上的 + # 任意字节,外部工具写坏即触发。 + try: + snapshot_id = marker.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError) as exc: + raise ValueError( + f"知识快照的 ACTIVE_SNAPSHOT 标记无法读取: {marker}" + f"({type(exc).__name__}: {exc})。" + " 该路径可能是一个目录,或文件内容不是 UTF-8;" + "请检查该文件,或重建知识快照。" + ) from exc + if not snapshot_id: + return None + self._require_snapshot_id_within_root(snapshot_id, source=str(marker)) + return snapshot_id + + def _require_snapshot_id_within_root(self, snapshot_id: str, *, source: str) -> None: + """确认 ``snapshot_id`` 拼进路径后仍直接位于快照根目录之下。 + + 快照 id 有两个来源:标记文件(磁盘上的任意文本)与调用方传参。两者都 + 会被拼成 ``self.root / snapshot_id``,而该表达式对绝对路径会**顶掉** + root(``Path("/a") / "/b"`` 得 ``/b``),对 ``..`` 与 ``a/../..`` 则会 + 向上逃逸。本类的 ``create_temp_snapshot_dir`` / ``cleanup_temp_snapshot_dir`` + / ``finalize_snapshot`` 都对这个路径做 ``rmtree``,所以逃逸出去就是 + 「删掉快照根目录之外的东西」——实测 ``finalize_snapshot(root/".tmp-x", + "../../")`` 会把 root 外的目录整个删掉。 + + 判定用「解析后父目录必须等于 root」,而不是检查字符串里有没有分隔符: + 后者漏掉 ``..``(不含分隔符,却能跳出 root)。 + """ + # 判定用「必须是一个单一路径分量」,而不是「解析后的父目录等于 root」。 + # 后者拦不住 ``"x/../victim"``:它解析成 ``root/victim``,父目录**正是** + # root,于是放行。而三个方法实际操作的路径是 + # ``self.root / f".tmp-{snapshot_id}"``——前缀 ``.tmp-`` 会**改变** ``..`` + # 的消解结果(``.tmp-x`` 是一个真实路径分量),``".tmp-x/../victim"`` + # 最终落在 ``root/victim``,即一个**正式快照**。实测 + # ``cleanup_temp_snapshot_dir("x/../victim")`` 对 ``root/victim`` 执行了 + # rmtree——「只清理任务临时目录」的意图被绕过,删的是正式快照。 + # + # 快照 id 由本项目的 ``generate_snapshot_id()`` 产出(``-<12 hex>``), + # 或来自标记文件,两者都不含分隔符,因此「单分量」不会误伤合法值。 + # 判定放在 ``root`` 解析之前,对 ``Path`` 的规范化行为不敏感。 + # 形态先收敛:下面第一行就对 ``snapshot_id`` 构造 ``Path``,而 + # ``Path(5)``/``Path(["a"])``/``Path(b"abc")`` 抛裸 ``TypeError: + # expected str, bytes or os.PathLike object, not ...``——不是本类承诺的 + # 带指引 ``ValueError``。位数超限的整数更隐蔽:``Path(10**4301)`` 抛 + # ``ValueError: Exceeds the limit``,类型对、内容却与快照 id 无关。 + # 快照 id 的来源之一是磁盘上的标记文件(``get_active_snapshot_id``), + # 而三个 ``*_temp_snapshot_dir``/``finalize_snapshot`` 都对拼出的路径做 + # ``rmtree``,形态判据必须与 ``resolve_within``/``ensure_trusted_source`` + # 一致:构造 ``Path`` 之前先判形态。``str`` 之外的 ``bytes`` 也不接受 + # ——本项目所有快照路径都是文本路径,``os.fspath`` 对 ``bytes`` 会给出 + # 另一种报错,与其下游行为不一致。 + if not isinstance(snapshot_id, str): + raise ValueError( + f"快照 id 不合法: {safe_repr(snapshot_id)}(来源: {source})。" + " 它必须是字符串形式的单一目录名。请检查该值,或重建知识快照。" + ) + if ( + not snapshot_id + or Path(snapshot_id).is_absolute() + or Path(snapshot_id).name != snapshot_id + or snapshot_id in {".", ".."} + ): + raise ValueError( + f"快照 id 不合法: {snapshot_id!r}(来源: {source})。" + " 它必须是单一目录名(不含路径分隔符,也不是 ``.``/``..``)。" + " 请检查该值,或重建知识快照。" + ) + # 长度也要判:上面的判据对超长名字**全部为真**(单个分量、相对、非 + # ``.``),但拼成路径后 ``.exists()`` 抛 + # ``OSError: [Errno 63] File name too long``——而 + # ``VectorStoreFactory._load_existing_state`` 的第一步就是 + # ``get_active_snapshot_dir()``,因此一个被外部工具写坏的标记文件会让 + # **启动**以 ``OSError`` 失败,而本类的契约是带指引的 ``ValueError``。 + # 上界取 ``_MAX_SNAPSHOT_ID_LENGTH``,见该常量说明。 + if len(snapshot_id) > _MAX_SNAPSHOT_ID_LENGTH: + raise ValueError( + f"快照 id 过长: {len(snapshot_id)} 个字符(来源: {source})," + f"上界 {_MAX_SNAPSHOT_ID_LENGTH}。" + " 请检查该值,或重建知识快照。" + ) + # ``resolve()`` 对符号链接环抛 ``RuntimeError: Symlink loop from ...``, + # 它**不是** ``ValueError``,会穿透本类的契约(本类的其余判据一律抛带 + # 指引的 ``ValueError``)。标记文件是磁盘上的任意文本,用户手工摆一个 + # 环即触发;而 ``VectorStoreFactory._load_existing_state`` 的第一步就是 + # ``get_active_snapshot_dir()``,于是**启动**以 pathlib 的内部错误收场。 + # 判据与 ``utils.security.resolve_within``/``utils.cleanup + # ._reject_unsafe_cache_dir`` 一致:把 ``(OSError, RuntimeError)`` 统一 + # 收敛成 ``ValueError``。``strict=False`` 不覆盖这种情况——环是**解析期** + # 的失败,与目标是否存在无关。 + try: + root = Path(self.root).resolve() + candidate = (self.root / snapshot_id).resolve() + except (OSError, RuntimeError) as exc: + raise ValueError( + f"快照 id 无法解析(来源: {source},id={snapshot_id!r}):" + f"{type(exc).__name__}: {exc}。" + " 快照根目录或其下的路径可能包含符号链接环或过长的间接层," + "请检查该值,或重建知识快照。" + ) from exc + if candidate.parent != root: + raise ValueError( + f"快照 id 不合法: {snapshot_id!r}(来源: {source})。" + f" 它解析到 {candidate},不在快照根目录 {root} 之下。" + " 请检查该值,或重建知识快照。" + ) def get_active_snapshot_dir(self) -> Path | None: snapshot_id = self.get_active_snapshot_id() if not snapshot_id: return None snapshot_dir = self.root / snapshot_id - return snapshot_dir if snapshot_dir.exists() else None + if not snapshot_dir.exists(): + # 标记存在、id 合法、目录消失——激活快照被外部删除。返回 ``None`` + # 会让 ``_load_existing_state`` 落入 legacy/空 store 分支:legacy + # 文件恰好存在时旧数据被导入并**改写激活标记**(用户误以为重建了 + # 快照),否则静默以空 store 启动、检索永远空结果——两者都把 + # 「激活标记已损坏」这一真实状态掩盖成「没有快照」(实测)。 + # 显式报错并给出重建指引。 + raise FileNotFoundError( + f"ACTIVE_SNAPSHOT 标记指向的快照目录不存在: {snapshot_dir}" + f"(标记文件: {self.run_config.active_snapshot_marker}," + f"内容: {snapshot_id})。快照目录可能被移动或删除;" + "请执行主菜单「向量化处理」重新构建知识快照," + "或删除该标记后走 legacy 导入。" + ) + return snapshot_dir def create_temp_snapshot_dir(self, snapshot_id: str) -> Path: + self._require_snapshot_id_within_root(snapshot_id, source="create_temp_snapshot_dir") temp_dir = self.root / f".tmp-{snapshot_id}" if temp_dir.exists(): shutil.rmtree(temp_dir) @@ -40,11 +183,32 @@ def create_temp_snapshot_dir(self, snapshot_id: str) -> Path: def cleanup_temp_snapshot_dir(self, snapshot_id: str) -> None: """删除指定构建任务的临时目录,不触碰正式快照。""" + self._require_snapshot_id_within_root(snapshot_id, source="cleanup_temp_snapshot_dir") temp_dir = self.root / f".tmp-{snapshot_id}" if temp_dir.exists(): shutil.rmtree(temp_dir) def finalize_snapshot(self, temp_dir: Path, snapshot_id: str) -> Path: + self._require_snapshot_id_within_root(snapshot_id, source="finalize_snapshot") + # 兄弟入参 ``temp_dir`` 同样要判形态:本方法的两个参数,此前只有 + # ``snapshot_id`` 有判据。``temp_dir.replace(...)`` 是 ``Path`` 的方法, + # 非 ``Path`` 取值在这里抛的是与「快照目录写错了」无关的裸异常——实测 + # ``5``/``None``/``["a"]`` 抛 ``AttributeError: ... object has no + # attribute 'replace'``,而 ``str`` 更隐蔽:字符串**有** ``replace``, + # 只是语义完全不同,抛的是 ``TypeError: replace expected at least 2 + # arguments, got 1``。本类其余拒绝路径一律给带指引的 ``ValueError``。 + # + # 字符串形态尤其现实:同项目里目录参数普遍标注 ``str | os.PathLike`` + # (``FaissStore.save_snapshot(snapshot_dir: str)``、本类的 + # ``save_snapshot`` 等),调用方按那个惯例传字符串就会命中这里。 + # 判据与 ``__init__`` 对 ``snapshot_root`` 的一致:只接受 ``Path`` + # (本方法的标注就是它),不做静默强转。 + if not isinstance(temp_dir, Path): + raise ValueError( + "finalize_snapshot 的 temp_dir 必须是 Path," + f"但得到 {type(temp_dir).__name__}。" + " 请传入 create_temp_snapshot_dir() 的返回值。" + ) final_dir = self.root / snapshot_id if final_dir.exists(): shutil.rmtree(final_dir) @@ -53,6 +217,20 @@ def finalize_snapshot(self, temp_dir: Path, snapshot_id: str) -> Path: return final_dir def activate_snapshot(self, snapshot_id: str) -> None: + # 与另外三个接收 ``snapshot_id`` 的方法(``create_temp_snapshot_dir``/ + # ``cleanup_temp_snapshot_dir``/``finalize_snapshot``)共用同一判据。 + # 本方法此前是**唯一**漏掉它的:它把 ``snapshot_id`` 直接写进标记文件, + # 而标记文件正是 ``get_active_snapshot_id``/``get_active_snapshot_dir`` + # 的输入——两个读取侧都会用 ``_require_snapshot_id_within_root`` 校验。 + # 于是同一个值在**写**侧被接受、在**读**侧被拒绝,收场是「激活成功」之后 + # 每一次启动都在 ``VectorStoreFactory._load_existing_state`` 的第一步失败。 + # 实测 ``activate_snapshot("../evil")`` 正常返回并把 ``../evil`` 写进 + # ``ACTIVE_SNAPSHOT``,随后 ``get_active_snapshot_id()`` 抛 + # 「快照 id 不合法: '../evil'」;非字符串(``5``/``None``)与位数超限的 + # 整数则在这一行抛裸 ``TypeError: data must be str``/``ValueError: + # Exceeds the limit``——而本类的契约是带指引的 ``ValueError``。 + # 判据在写入之前,异常时标记文件保持不变。 + self._require_snapshot_id_within_root(snapshot_id, source="activate_snapshot") marker = self.run_config.active_snapshot_marker marker.parent.mkdir(parents=True, exist_ok=True) temp_marker = marker.with_suffix(".tmp") @@ -63,6 +241,24 @@ def generate_snapshot_id(self, prefix: str = "kb") -> str: return f"{prefix}-{uuid.uuid4().hex[:12]}" def write_manifest(self, snapshot_dir: Path, manifest: KnowledgeSnapshotManifest) -> None: + # 形态判据与同类的 ``__init__``(``snapshot_root``)、``finalize_snapshot`` + # (``temp_dir``)一致:只接受 ``Path``,不静默强转。此前这一处漏了,非 + # ``Path`` 取值在 ``snapshot_dir / "manifest.toml"`` 处抛裸 + # ``TypeError: unsupported operand type(s) for /: 'str' and 'str'``——没有 + # 字段名、也没有「该传什么」的指引,而本类的其余拒绝路径一律给带指引的 + # ``ValueError``。 + # + # 字符串形态尤其现实:同一个 ``temp_dir`` 在 + # ``KnowledgeBuildService.build`` 里紧邻的两行分别以 ``str``( + # ``vector_store.save_snapshot(str(temp_dir))``)与 ``Path`` + # (``write_manifest(temp_dir, ...)``)传给两个 API,调用方按前者的惯例 + # 传字符串就会命中这里。 + if not isinstance(snapshot_dir, Path): + raise ValueError( + "write_manifest 的 snapshot_dir 必须是 Path," + f"但得到 {type(snapshot_dir).__name__}。" + " 请传入 create_temp_snapshot_dir() 的返回值。" + ) (snapshot_dir / "manifest.toml").write_text(manifest.to_toml(), encoding="utf-8") def load_manifest(self, snapshot_dir: Path) -> KnowledgeSnapshotManifest: @@ -74,9 +270,64 @@ def load_manifest(self, snapshot_dir: Path) -> KnowledgeSnapshotManifest: 分块与 embedding 的落盘结构),照着当前代码去解释未知版本会得到错误结果 而不是报错。 """ - with (snapshot_dir / "manifest.toml").open("rb") as file: - data = tomllib.load(file) - manifest = KnowledgeSnapshotManifest.from_mapping(data) + # 入参形态与 ``write_manifest`` 同判据:两者是同一份 manifest 的写入/读取 + # 兄弟,写入侧已按 ``__init__``(``snapshot_root``)、``finalize_snapshot`` + # (``temp_dir``)的同一判据收紧,读取侧这一半此前漏了。非 ``Path`` 取值 + # 在下面的 ``snapshot_dir / "manifest.toml"`` 处抛 + # ``TypeError: unsupported operand type(s) for /: 'str' and 'str'``,被紧接 + # 着的 ``except Exception`` 接住后**误报成「知识快照不自洽,请重建」**—— + # 一个完好的快照会被要求重建,真正的错因(路径传成了字符串)在消息里完全 + # 不可见。判据必须在 ``try`` **之外**:形态问题不是「快照坏了」。 + # + # 字符串形态尤其现实:``VectorStoreFactory._load_existing_state`` 里相邻 + # 两行分别以 ``Path`` 与同一变量传给 ``validate_snapshot_dir`` 与 + # ``load_manifest``,而 ``save_snapshot`` 的惯例又是 ``str``。 + if not isinstance(snapshot_dir, Path): + raise ValueError( + "load_manifest 的 snapshot_dir 必须是 Path," + f"但得到 {type(snapshot_dir).__name__}。" + " 请传入 create_temp_snapshot_dir() 的返回值。" + ) + # 解析失败与其它快照文件同样转成「快照不自洽」的 ValueError:本方法在 + # ``VectorStoreFactory`` 里紧跟在 ``validate_snapshot_dir`` 之后,是启动 + # 路径上的第二步。抛裸的 ``TOMLDecodeError``(它继承 ``ValueError``,但 + # 消息是 ``Unterminated string`` 这类纯语法描述)既没有「快照坏了」的 + # 语义,也没有重建指引,与 chunks.pkl/parents.pkl/stats.json 的收场 + # 方式不一致。 + try: + with (snapshot_dir / "manifest.toml").open("rb") as file: + data = tomllib.load(file) + except FileNotFoundError as exc: + # 与「文件存在但内容坏」分开归因:rmtree/replace 竞态(另一进程 + # 正在删除)实测会把 FileNotFoundError 归成「写入或复制截断」, + # 用户会去重建一个好快照——文件**不存在**和文件**坏了**是两种故障。 + raise ValueError( + "知识快照不完整:manifest.toml 不存在" + f"(目录: {snapshot_dir})。目录可能正被并发构建或删除," + "请稍后重试或重新构建知识快照。" + ) from exc + except Exception as exc: + raise ValueError( + f"知识快照不自洽:manifest.toml 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + # ``from_mapping`` 是纯解析,字段缺失抛 ``KeyError``、计数字段不是整数 + # 时抛 ``int()`` 的裸 ``ValueError``(``"abc"`` -> invalid literal; + # 位数超限 -> Exceeds the limit)。这些同样属于「快照写坏了」,却都没有 + # 「快照不自洽」的语义与重建指引——而同一份文件语法错时给的正是那种消息。 + # 校验放在这里而不是 ``from_mapping`` 里:那里保持纯解析,不塞策略。 + # + # ``OverflowError`` 也接住:``int(inf)`` 抛它,而 TOML 有原生的 ``inf`` + # 浮点字面量——``document_count = inf`` 是合法的 manifest.toml, + # ``tomllib`` 直接解析成 ``float('inf')``(实测)。它继承 + # ``ArithmeticError`` 而非 ``ValueError``,三个既有分支都接不住。 + try: + manifest = KnowledgeSnapshotManifest.from_mapping(data) + except (KeyError, TypeError, ValueError, OverflowError) as exc: + raise ValueError( + f"知识快照不自洽:manifest.toml 的字段无法解析({type(exc).__name__}: {exc})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc if manifest.schema_version != SCHEMA_VERSION: raise ValueError( "知识快照的 schema 版本与当前程序不兼容。" @@ -86,21 +337,30 @@ def load_manifest(self, snapshot_dir: Path) -> KnowledgeSnapshotManifest: return manifest def validate_snapshot_dir(self, snapshot_dir: Path) -> None: - """校验快照文件齐全**且三个数据源的行数自洽**。 + """校验快照文件齐全**且各数据源的行数自洽**。 只查文件存在性是不够的:实测手工构造 ``documents=3`` 而 - ``embeddings=(2,4)``(``faiss_index.ntotal=2``)的 store, - ``save_snapshot`` 正常落盘、本方法通过、加载后也不报错——不一致被 - 完整持久化。之后 ``semantic_search`` 拿 ``indices`` 去索引 - ``self.documents`` 会越界,而报错现场离真正的原因(写入时就不一致) - 很远。这里在加载/切换快照的入口就把三者对齐。 + ``embeddings=(2,4)`` 的 store,``save_snapshot`` 正常落盘、本方法通过、 + 加载后也不报错——不一致被完整持久化。之后 ``semantic_search`` 拿 + ``indices`` 去索引 ``self.documents`` 会越界,而报错现场离真正的原因 + (写入时就不一致)很远。这里在加载/切换快照的入口就把它们对齐。 """ # 先确认目录归属,再谈内容:快照目录里的 chunks.pkl/parents.pkl/ # lexical.index 都是 pickle,反序列化即执行代码,而「路径长在 root 附近」 # 不构成来源证明。resolve_within 会把符号链接逃逸(root/link -> /etc) # 一并拦下。 resolved = resolve_within(snapshot_dir, self.root, label="知识快照目录") - if snapshot_dir.is_symlink(): + # 符号链接判据必须看**原始**(未解析)取值:``resolve_within`` 已经把 + # 符号链接解析掉了,对 ``resolved`` 调 ``is_symlink()`` 恒为 False,等于 + # 没有这条判据。但原始取值的形态不受本方法的标注约束——``resolve_within`` + # 显式接受 ``str | os.PathLike``(见它的形态判据),因此调用方传字符串是 + # 合法输入,而 ``str`` 没有 ``is_symlink``:实测传字符串时本方法在 + # ``resolve_within`` 通过之后抛裸 ``AttributeError: 'str' object has no + # attribute 'is_symlink'``——本类其余拒绝路径一律给带指引的 ``ValueError``。 + # 判据落在 ``resolve_within`` 之后,因此这里只需包一层 ``Path``: + # ``int``/``None``/``list`` 已被它拒掉,剩下的必然是 ``str`` 或 + # ``os.PathLike``。 + if Path(snapshot_dir).is_symlink(): raise ValueError(f"知识快照目录不能是符号链接: {snapshot_dir}") if resolved.parent != Path(self.root).resolve(): raise ValueError( @@ -120,11 +380,23 @@ def validate_snapshot_dir(self, snapshot_dir: Path) -> None: if missing_files: raise FileNotFoundError(f"知识快照不完整,缺少文件: {', '.join(missing_files)}") + # manifest 也在 required_files 里,但此前只查存在性、不查能否解析。 + # 结果是「先 validate 再 load_manifest」的调用方(``VectorStoreFactory``) + # 在校验这一步拿到通过,到下一步才炸,且炸出的是裸 TOMLDecodeError。 + try: + with (snapshot_dir / "manifest.toml").open("rb") as file: + tomllib.load(file) + except Exception as exc: + raise ValueError( + f"知识快照不自洽:manifest.toml 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + self._validate_snapshot_row_counts(snapshot_dir) @staticmethod def _validate_snapshot_row_counts(snapshot_dir: Path) -> None: - """确认 chunks / embeddings / 向量索引三者的行数一致。 + """确认 chunks / embeddings / 两个索引文件的行数一致。 读取全部走本地受信快照目录(与 ``FaissStore.load_snapshot`` 同一 前提),且只取形状不取内容语义。 @@ -151,7 +423,20 @@ def _validate_snapshot_row_counts(snapshot_dir: Path) -> None: " 快照可能在写入或复制过程中被截断,请重建。" ) from exc - chunk_count = len(chunks) if chunks is not None else 0 + # 顶层必须是序列。``None`` 会被下面的 ``len()`` 当成 0 个分块而放行, + # 但 ``FaissStore._normalize_loaded_documents`` 随后对 ``None`` 迭代, + # 抛裸的 ``TypeError: 'NoneType' object is not iterable``——与「快照 + # 不自洽」无关的类型,也没有重建指引。 + if chunks is None: + raise ValueError( + "知识快照不自洽:chunks.pkl 的顶层是 None,无法作为分块列表使用。请重建快照。" + ) + if isinstance(chunks, (str, bytes, dict)) or not hasattr(chunks, "__len__"): + raise ValueError( + "知识快照不自洽:chunks.pkl 的顶层不是列表" + f"({type(chunks).__name__}),无法作为分块列表使用。请重建快照。" + ) + chunk_count = len(chunks) if embeddings.ndim != 2: raise ValueError(f"知识快照的 embeddings.npy 不是二维数组: ndim={embeddings.ndim}。") embedding_rows = int(embeddings.shape[0]) @@ -163,6 +448,43 @@ def _validate_snapshot_row_counts(snapshot_dir: Path) -> None: " 快照可能在写入或复制过程中被截断,请重建。" ) + # ``parents.pkl`` 是三个 ``pickle.load`` 目标之一,同为 required file,但 + # 此前只有它没被检查:写坏时本方法照常通过,直到 + # ``FaissStore.load_snapshot`` 抛裸的 ``UnpicklingError: invalid load key``; + # 顶层写成 ``None``/``int`` 则在 ``dict(self.parent_documents)`` 处抛 + # ``TypeError: 'NoneType' object is not iterable``。两者都没有「快照坏了」 + # 的语义,也没有重建指引——而同一份快照里 ``chunks.pkl`` 坏掉时给出的正是 + # 有指引的 ValueError,同类失败不该有两种收场方式。 + try: + with (snapshot_dir / "parents.pkl").open("rb") as file: + parents = pickle.load(file) # nosec B301 - 应用自管的快照目录 + except Exception as exc: + raise ValueError( + f"知识快照不自洽:parents.pkl 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + if not isinstance(parents, dict): + raise ValueError( + "知识快照不自洽:parents.pkl 的顶层不是映射" + f"({type(parents).__name__}),无法作为父块索引使用。请重建快照。" + ) + # 父条目要么是 dict(取 ``content``),要么是 str(整体即内容)—— + # ``FaissStore._normalize_parent_document`` 的取值契约。其他类型会让 + # 加载期以 ``'list' object has no attribute 'get'`` 收场。 + # ``safe_repr`` 而不是 ``str``:这段代码的唯一用途是**拼错误消息**, + # 而键来自磁盘上的 parents.pkl(pickle 是二进制格式,不受十进制位数 + # 限制)。用 ``str`` 时,正要报「父条目形态不对」的那一步自己抛 + # ``ValueError: Exceeds the limit``,用户拿到与快照无关的整数转换错误。 + invalid_parents = [ + safe_repr(key) for key, value in parents.items() if not isinstance(value, (dict, str)) + ] + if invalid_parents: + preview = "、".join(invalid_parents[:3]) + raise ValueError( + "知识快照不自洽:parents.pkl 中的父条目既不是映射也不是字符串" + f"(例如 {preview}),无法取出父块内容。请重建快照。" + ) + stats_path = snapshot_dir / "stats.json" try: with stats_path.open("rb") as file: @@ -174,9 +496,117 @@ def _validate_snapshot_row_counts(snapshot_dir: Path) -> None: ) from exc if not isinstance(stats, dict): raise ValueError("知识快照不自洽:stats.json 的顶层不是对象。") + # ``int(declared)`` 同样不能直接调:``"abc"`` 抛裸 ValueError、列表抛 + # TypeError、超长数字串抛位数超限的 ValueError,三者都没有「快照不自洽」 + # 的语义与重建指引。与上面 ``from_mapping`` 的处理同理。 + # + # ``OverflowError`` 必须一并接住:``int(inf)`` 抛的是它,而它继承 + # ``ArithmeticError``、**不是** ``ValueError``。这不是假想输入—— + # ``json.load`` 把 JSON 的 ``Infinity``/``1e999`` 都解析成 ``inf`` + # (前者是 Python 扩展字面量,后者是**合法 JSON**),于是启动路径上 + # 用户看到的是 ``OverflowError: cannot convert float infinity to + # integer``,既无「快照不自洽」语义也无重建指引。对照:``NaN`` 走 + # ``ValueError`` 分支,原本就能得到正确消息——只有 ``inf`` 这一支漏了。 declared = stats.get("chunk_count") - if declared is not None and int(declared) != chunk_count: + if declared is not None: + try: + declared_count = int(declared) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError( + "知识快照不自洽:stats.json 的 chunk_count 不是整数" + f"({type(declared).__name__}: {declared!r},{type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + if declared_count != chunk_count: + raise ValueError( + "知识快照不自洽:stats.json 的 chunk_count 与实际分块数不一致。" + f" stats.json={declared},chunks.pkl={chunk_count}。" + ) + + # 向量索引的条数也要对齐。只查 chunks 与 embeddings 是不够的: + # ``FaissStore.load_snapshot`` 在 ``lexical.index`` 存在时**不会**重建 + # FAISS 索引,直接使用盘上的 ``semantic.index``。实测 ntotal 比 + # chunks 多(3 vs 5)时本方法放行,加载后 ``semantic_search`` 拿 + # ``indices`` 去索引 ``self.documents`` 抛 + # ``IndexError: list index out of range``——正是本方法要防的那类失败。 + import faiss + + try: + index = faiss.read_index(str(snapshot_dir / "semantic.index")) + except Exception as exc: raise ValueError( - "知识快照不自洽:stats.json 的 chunk_count 与实际分块数不一致。" - f" stats.json={declared},chunks.pkl={chunk_count}。" + f"知识快照不自洽:semantic.index 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + index_total = int(index.ntotal) + if index_total != chunk_count: + raise ValueError( + "知识快照不自洽:向量索引条数与分块数不一致。" + f" semantic.index={index_total},chunks.pkl={chunk_count}。" + " 快照可能在写入或复制过程中被截断,请重建。" ) + # 维度也要对齐。只比行数是不够的:实测分块数/embedding 行数/ntotal + # 都是 1,但 ``embeddings.npy`` 是 4 维而 ``semantic.index.d`` 是 3 时 + # 校验放行,加载后 ``semantic_search`` 把 4 维查询向量送进 3 维索引, + # FAISS 抛的是**空消息的 AssertionError**——没有「快照坏了」的语义, + # 也没有重建指引。这类不一致同样只可能来自写入或复制损坏。 + embedding_dimension = int(embeddings.shape[1]) + index_dimension = int(index.d) + if index_dimension != embedding_dimension: + raise ValueError( + "知识快照不自洽:向量索引维度与 embedding 维度不一致。" + f" semantic.index.d={index_dimension},embeddings.npy={embedding_dimension}。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + + # 关键词索引的语料条数同理。``FaissStore.load_snapshot`` 直接用盘上的 + # ``lexical.index`` 建 BM25,而 ``keyword_search`` 拿 + # ``np.argsort(doc_scores)`` 的下标去索引 ``self.documents``——语料比 + # 分块多时,命中越界位置的文档会让下标超出 ``documents`` 而抛 + # ``IndexError``(实测语料 5 条、分块 3 条时复现)。 + lexical_path = snapshot_dir / "lexical.index" + if lexical_path.exists(): + try: + with lexical_path.open("rb") as file: + tokenized = pickle.load(file) # nosec B301 - 应用自管的快照目录 + except Exception as exc: + raise ValueError( + f"知识快照不自洽:lexical.index 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + # 形态守卫与上面 ``chunks.pkl`` 的一致:``is not None`` 只挡 + # ``None``,而 ``lexical.index`` 顶层是 ``int`` 时 ``len()`` 抛裸 + # ``TypeError: object of type 'int' has no len()``——无「快照不自洽」 + # 语义、无重建指引,与该文件其它三个 pickle 的收场方式不一致。 + # 字符串形态更隐蔽:``len("ab") == 2`` 会被当成 2 条语料静默通过。 + if isinstance(tokenized, (str, bytes, dict)) or ( + tokenized is not None and not hasattr(tokenized, "__len__") + ): + raise ValueError( + "知识快照不自洽:lexical.index 的顶层不是列表" + f"({type(tokenized).__name__}),无法作为关键词索引语料使用。请重建快照。" + ) + lexical_rows = len(tokenized) if tokenized is not None else 0 + if lexical_rows != chunk_count: + raise ValueError( + "知识快照不自洽:关键词索引语料条数与分块数不一致。" + f" lexical.index={lexical_rows},chunks.pkl={chunk_count}。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + # **条目**形态:上面只覆盖顶层与条数,而 ``FaissStore.load_snapshot`` + # 拿到盘上的语料后直接交给 ``BM25Okapi``,它在 ``_initialize`` 里对 + # 每条语料调 ``len(document)``。实测 ``[7]``/``[1.5]``/``[None]`` + # 三种条目都能穿过本方法,随后在载入期抛裸 ``TypeError: object of + # type 'int' has no len()``——而本方法的职责正是在**激活之前**拦住 + # 坏快照:放行的后果是坏快照被 finalize 成活动快照,此后每次启动都 + # 以与「快照坏了」无关的异常类型失败,且用户拿不到重建指引。 + # + # 只判「是不是序列」而不要求 ``list[str]``:语料由本应用写出,形态 + # 确定;这里挡的是「根本不是序列」这一类。 + for position, entry in enumerate(tokenized if tokenized is not None else []): + if isinstance(entry, (str, bytes)) or not hasattr(entry, "__len__"): + raise ValueError( + "知识快照不自洽:lexical.index 的第 " + f"{position} 条语料不是序列({type(entry).__name__})," + "无法作为关键词索引语料使用。请重建快照。" + ) diff --git a/src/retrieval/vdb/factory.py b/src/retrieval/vdb/factory.py index c0824d3..aa25eaf 100644 --- a/src/retrieval/vdb/factory.py +++ b/src/retrieval/vdb/factory.py @@ -2,11 +2,14 @@ # 原始来源: https://github.com/langgenius/dify # 遵循修改后的 Apache License 2.0 许可证。详情请参阅项目根目录下的 DIFY_LICENSE 文件。 -from collections.abc import Iterable +import os +from collections.abc import Iterable, Mapping from pathlib import Path +from typing import Any from ...runtime.contracts import KnowledgeSnapshotManifest, build_run_config -from ...utils.config import get_settings +from ...ui.display_utils import document_source +from ...utils.config import get_settings, require_detail_attributes, safe_repr from ...utils.log_manager import get_module_logger from ..snapshot_repository import SnapshotRepository from .base import VectorStoreBase @@ -23,6 +26,13 @@ def get_vector_store( *, trusted_paths: Iterable[str | Path] | None = None, ) -> VectorStoreBase: + # 先判类型再调 ``.lower()``:非字符串取值抛 ``AttributeError``,而本方法 + # 的契约是 ``ValueError``。``Settings.default_vector_store`` 已能拦住 + # 配置来源的非字符串,但本方法是公开入口(测试与库调用方直接调用), + # 与 ``RecursiveTextSplitter.structure_mode``、``SessionConfig`` 的融合 + # 策略同一判据。 + if not isinstance(store_type, str): + raise ValueError(f"不支持的向量存储类型: {safe_repr(store_type)}") if store_type.lower() == "faiss": return FaissStore(file_path=file_path, trusted_paths=trusted_paths) raise ValueError(f"不支持的向量存储类型: {store_type}") @@ -36,6 +46,59 @@ def get_default_vector_store(load_existing: bool = True) -> VectorStoreBase: VectorStoreFactory._load_existing_state(store, run_config) return store + @staticmethod + def _require_embedding_detail(run_config) -> Any: + """按 ``default_embedding_provider`` 取活动 embedding 配置详情。 + + ``_load_existing_state`` 的两条分支(活动快照与 legacy 导入)都要用同一 + 份配置去构造 manifest。此前两条分支的判据不一致:活动快照分支显式判过 + 「键不在映射里」,给出带字段名与指引的 ``ValueError``;legacy 分支直接 + 下标取值,配置里把 provider 名打错(或漏写整段 + ``embedding_configurations``)时抛的是裸 ``KeyError: 'local-hash-typo'`` + ——没有字段名、没有可选值,也不是调用方按 ``ValueError`` 建立的错误 + 分类。同一个「embedding 配置缺失」的错误,收场不该取决于磁盘上恰好有 + 没有活动快照。 + + 容器形态也要判:``RunConfig`` 是 frozen dataclass、字段无校验器,嵌入方 + 可以自拼一个 ``embedding_configurations`` 非映射的配置送进来,那时 + ``.get`` 抛裸 ``AttributeError``。判据与 + ``EmbeddingService.embedding_model_detail`` 一致(同一字段、同一来源)。 + + 键在**不**代表值可用(同 ``EmbeddingService.embedding_model_detail``): + ``{"local-hash": None}`` 的键确实存在,于是不会走「缺少活动提供商」的 + 分支,而调用方紧接着读 ``.model_name``——抛的是裸 ``AttributeError``, + 穿透调用方按 ``ValueError`` 建立的错误分类。这里一并收敛。 + """ + configurations = run_config.embedding_configurations + key = run_config.default_embedding_provider + # 键形态必须在成员判断**之前**收敛,判据与措辞都与 + # ``EmbeddingService.embedding_model_detail`` 的同名守卫一致(两处是同一段 + # 逻辑的副本,同一字段、同一来源):``key not in configurations`` 对不可 + # 哈希的取值抛裸 ``TypeError: unhashable type: 'list'``,而本函数的契约是 + # 带指引的 ``ValueError``。 + if not isinstance(key, str): + raise ValueError( + "default_embedding_provider 必须是字符串," + f"但得到 {safe_repr(key)}。" + " 请检查 config.toml 里的 default_embedding_provider 与" + " embedding_configurations 是否对应。" + ) + if not isinstance(configurations, Mapping) or key not in configurations: + raise ValueError( + f"当前 embedding 配置缺少活动提供商: {safe_repr(key)}。" + " 请检查 config.toml 里的 default_embedding_provider 与" + " embedding_configurations 是否对应。" + ) + detail = configurations[key] + require_detail_attributes( + detail, + ("provider", "model_name"), + field="embedding_configurations", + key=key, + role_label="Embedding", + ) + return detail + @staticmethod def _load_existing_state(store: VectorStoreBase, run_config) -> None: snapshot_repository = SnapshotRepository(run_config) @@ -43,13 +106,7 @@ def _load_existing_state(store: VectorStoreBase, run_config) -> None: if active_snapshot_dir is not None: snapshot_repository.validate_snapshot_dir(active_snapshot_dir) manifest = snapshot_repository.load_manifest(active_snapshot_dir) - embedding_detail = run_config.embedding_configurations.get( - run_config.default_embedding_provider - ) - if embedding_detail is None: - raise ValueError( - f"当前 embedding 配置缺少活动提供商: {run_config.default_embedding_provider}" - ) + embedding_detail = VectorStoreFactory._require_embedding_detail(run_config) if ( manifest.embedding_provider != run_config.default_embedding_provider or manifest.embedding_model != embedding_detail.model_name @@ -63,32 +120,54 @@ def _load_existing_state(store: VectorStoreBase, run_config) -> None: store.load_snapshot(str(active_snapshot_dir), snapshot_root=run_config.snapshot_root) return - legacy_path = Path(run_config.legacy_pkl_path) + # 形态先收敛:``Path(5)``/``Path(["a"])`` 抛裸 ``TypeError: expected str, + # bytes or os.PathLike object, not ...``,位数超限的整数抛 ``ValueError: + # Exceeds the limit``——两条都不是本模块其余拒绝路径承诺的带指引 + # ``ValueError``。``RunConfig`` 是 frozen dataclass、字段没有校验器(同文件 + # ``_require_embedding_detail`` 已认领这条可达性),而本方法由 + # ``get_default_vector_store`` 在 ``Chatbot`` 初始化时调用。判据与 + # ``FaissStore.load``/``resolve_within`` 的同类守卫一致。 + legacy_value = run_config.legacy_pkl_path + if not isinstance(legacy_value, (str, os.PathLike)): + raise ValueError( + "legacy_pkl_path 必须是字符串或路径," + f"但得到 {type(legacy_value).__name__}。" + " 请检查 config.toml 里的 pkl_path 配置项。" + ) + legacy_path = Path(legacy_value) if not legacy_path.exists(): return # 只信任配置里声明的 legacy 文件所在目录:这条路径要 pickle.load, # 而路径本身不能证明来源。 store.load(str(legacy_path), trusted_paths=[legacy_path.parent]) + # 配置校验放在 ``save_snapshot`` **之前**:空 legacy 文件(合法 pickle、 + # 无内容)会让 ``save_snapshot`` 抛「当前语义索引为空」的裸 + # ``RuntimeError``,用户无从知道真实原因是 legacy 文件是空的——若 + # 配置也缺,先撞哪个取决于磁盘状态而非原因主次。先验配置,空库场景 + # 给出可读的原因。 + embedding_detail = VectorStoreFactory._require_embedding_detail(run_config) snapshot_id = snapshot_repository.generate_snapshot_id(prefix="legacy") temp_dir = snapshot_repository.create_temp_snapshot_dir(snapshot_id) finalized = False try: store.save_snapshot(str(temp_dir)) + # 与活动快照分支同一处取值、同一判据(见 ``_require_embedding_detail``): + # 此前这里直接下标,缺配置时抛裸 ``KeyError``。 manifest = KnowledgeSnapshotManifest.create( snapshot_id=snapshot_id, store_type=run_config.default_vector_store, embedding_provider=run_config.default_embedding_provider, - embedding_model=run_config.embedding_configurations[ - run_config.default_embedding_provider - ].model_name, + embedding_model=embedding_detail.model_name, chunk_mode="legacy-import", source_digest="legacy-import", + # ``metadata`` 的形态由助手收敛:``FaissStore`` 会在载入期归一化, + # 但本分支正是为**第三方 store** 准备的(``get_default_vector_store`` + # 可以返回任何 ``VectorStoreBase`` 实现),它的 ``documents`` 里 + # ``metadata=None``/列表、甚至条目本身不是映射都会让这里的 + # ``.get(...).get(...)`` 抛 ``AttributeError``。 document_count=len( - { - doc.get("metadata", {}).get("source") - for doc in getattr(store, "documents", []) - } + {document_source(doc, "") for doc in getattr(store, "documents", [])} ), chunk_count=len(getattr(store, "documents", [])), ) diff --git a/src/retrieval/vdb/faiss_store.py b/src/retrieval/vdb/faiss_store.py index 44ed7e0..fc88180 100644 --- a/src/retrieval/vdb/faiss_store.py +++ b/src/retrieval/vdb/faiss_store.py @@ -8,7 +8,8 @@ # Pickle is retained only for trusted local legacy snapshots. import pickle # nosec B403 import time -from collections.abc import Iterable +import warnings +from collections.abc import Iterable, Mapping from copy import deepcopy from pathlib import Path from typing import Any @@ -24,14 +25,120 @@ "FAISS is not installed. Please install it with `pip install faiss-cpu`." ) from exc +from ...ui.display_utils import document_source +from ...utils.config import _MAX_RETRIEVAL_TOP_K, safe_repr from ...utils.log_manager import get_module_logger -from ...utils.security import ensure_trusted_source, resolve_within +from ...utils.security import ensure_trusted_source, resolve_within, safe_exception_text from .base import VectorStoreBase logger = get_module_logger(__name__) jieba.setLogLevel(jieba.logging.ERROR) +def _parent_key(parent_id: Any) -> str: + """把 ``parent_id`` 收敛成父文档字典的键。 + + 四处都用裸 ``str()``,而 ``parent_id`` 来自知识快照的元数据——本项目加载 + pickle 格式的快照,pickle 是二进制格式、不受 CPython 十进制位数限制 + (实测 ``pickle.loads(pickle.dumps(10**4301))`` 正常往返)。位数超限时 + ``str()`` 抛 ``ValueError: Exceeds the limit``:在 ``load_snapshot`` 里它让 + **加载宣布成功之后**的每一次检索都崩,在 ``resolve_parent_content`` 里它让 + 父块召回整体不可用。 + + 不能用 ``safe_repr``:它对位数超限的值只给量级描述(``<整数,约 4302 位 + 十进制>``),对 81~4300 位的整数给 ``text[:77] + "..."`` 截断——两者都让 + **不同**取值塌成**同一**键(``10**4301`` 与 ``10**4301+1`` 得到同一键), + 于是 ``_normalize_documents`` 的父块字典推导让后一条静默覆盖前一条, + ``resolve_parent_content`` 对两个不同 id 返回同一父块正文——父块内容张冠 + 李戴。按二进制补码取 sha256 摘要才能既稳定又唯一——**摘要算法**(signed + big-endian ``to_bytes`` + sha256)与 ``retrieval_service._key_text`` 一致, + 但**触发阈值有意不同**:这里只对 ``str()`` 无法表达的超限整数(>4300 位) + 启用摘要,``_key_text`` 对 repr 超过 80 字符的整数就启用。键是写盘后长期 + 存在的格式,main 时代构建的快照里整数 id 的键是 ``str(parent_id)``(``1`` → + ``"1"``),无差别换键会让旧快照在载入后召回不到任何整数 id 的父块(实测 + 复现)。``str`` 对可表达的整数是**精确**转换,本身不塌键;塌键风险只 + 来自 ``safe_repr`` 的截断。因此先试 ``str()``,抛 ``ValueError`` 才落 + 摘要——旧快照零破坏,超限整数仍唯一稳定。 + """ + if isinstance(parent_id, str): + return parent_id + if isinstance(parent_id, int): + try: + return str(parent_id) + except ValueError: # 十进制位数超 CPython str() 上限(4300 位) + import hashlib + + raw = parent_id.to_bytes((parent_id.bit_length() + 8) // 8, "big", signed=True) + digest = hashlib.sha256(raw, usedforsecurity=False).hexdigest() + return f"<整数,{digest}>" + # numpy 标量(``np.int64``/``np.float64``/``np.bool_``)在 numpy 2.x 下 + # **不是** ``int``/``float`` 的子类,落不进上面的分支。main 用 ``str()`` + # 得到 ``"3"``,``safe_repr`` 得到 ``"np.int64(3)"``——同一份旧快照在两版 + # 代码间键形态不同,载入后召回静默 miss(实测复现)。numpy 标量提供 + # ``.item()``,归一成原生 Python 值后与 main 的 ``str()`` 逐字一致。 + item_method = getattr(parent_id, "item", None) + if callable(item_method): + try: + return _parent_key(item_method()) + except Exception as item_exc: # noqa: BLE001 - item() 不可用即退回通用兜底 + logger.debug("parent_id 的 item() 归一失败: %s", safe_exception_text(item_exc)) + # 其余非 str/int 取值不能退回 ``safe_repr``:repr 超 80 字符的取值被截成 + # ``text[:77] + "..."``,两个不同的长 bytes/tuple 塌成同一个键(实测复现), + # 字典推导静默并条。repr 未超限的原样保留,超限的对 repr 字节取摘要。 + # 判据同样落在真实 repr 上:``safe_repr`` 截断输出恰好 80 字符,拿 + # ``len(rendered) > 80`` 判断永远不触发(实测复现)。repr 不可用时退回 + # ``safe_repr``。 + rendered = safe_repr(parent_id) + try: + raw_repr = repr(parent_id) + except Exception: # noqa: BLE001 - repr 不可用时只能量级描述 + return rendered + if len(raw_repr) > 80: + import hashlib + + raw = raw_repr.encode("utf-8", errors="replace") + digest = hashlib.sha256(raw, usedforsecurity=False).hexdigest() + return f"<{type(parent_id).__name__},{digest}>" + return rendered + + +def _require_top_k(top_k: Any) -> int: + """校验检索方法的 ``top_k``,返回原值。 + + ``semantic_search`` 与 ``keyword_search`` 同名参数、同一份上界来源,判据必须 + 一致——此前两处完全不同,同一取值给出三种收场: + + * ``0``/负数:``semantic_search`` 抛 FAISS 内建 ``assert k > 0`` 产生的 + **无消息** ``AssertionError``(``str(e)`` 是空串,栈里只有 C 层); + ``keyword_search`` 则因为循环体先 ``append`` 再判 ``len(results) >= top_k``, + **返回 1 条**——调用方要求「不要结果」却拿到一条,且无任何提示。 + * 非整数:FAISS 报 ``TypeError: an integer is required``, + ``keyword_search`` 在 ``int >= str`` 处报 ``TypeError: '>=' not supported`` + ——两者都是裸 ``TypeError``,不是本项目的 ``ValueError``,调用方按 + ``except ValueError`` 写的处理接不住。 + * 过大:FAISS 不报错也不截断,直接按 k 分配结果数组——实测 ``top_k=10**9`` + 挂起(>30s),``10**8`` 耗时 15.3s。配置层的 ``_MAX_RETRIEVAL_TOP_K`` 拦不住 + 这里:它只作用在 ``Settings.chat_top_k`` 与 ``SessionConfig.top_k`` 上,而 + 这两个方法是 ``VectorStoreBase`` 的公开方法,参数一路透传到 FAISS。 + + ``bool`` 显式排除:它是 ``int`` 子类,``True`` 会被当成 1 放行,而那几乎必然 + 是上游把标志位写进了规模字段。 + """ + if isinstance(top_k, bool) or not isinstance(top_k, int): + raise ValueError(f"top_k 必须是整数,但得到 {safe_repr(top_k)}。") + if not 1 <= top_k <= _MAX_RETRIEVAL_TOP_K: + # 这条消息里也必须用 ``safe_repr``,与上面那条一致。裸 ``{top_k}`` 对位数 + # 超过 ``sys.get_int_max_str_digits()``(默认 4300)的整数会抛 + # ``ValueError: Exceeds the limit``——正要报「top_k 越界」的那一步自己 + # 抛错,用户拿到的是整数转换错误,与 top_k 毫无关系。实测 + # ``top_k=10**4301`` 时两个方法都复现;``10**4000`` 则正常(位数在上限 + # 内,只是消息被 ``safe_repr`` 的 80 字符截断规则处理得更可读)。 + raise ValueError( + f"top_k 必须是 1 到 {_MAX_RETRIEVAL_TOP_K} 之间的整数,但得到 {safe_repr(top_k)}。" + ) + return top_k + + class FaissStore(VectorStoreBase): def __init__( self, @@ -56,6 +163,29 @@ def __init__( if not file_path: return + # 形态判据与 ``save``/``load``/``save_snapshot``/``load_snapshot`` 一致: + # 构造器是同一个 ``file_path`` 的另一条公开入口,而它第一件事就是 + # ``os.path.exists``——那里早于 ``load`` 里的形态守卫,非路径取值的收场 + # 不在本类的 ``ValueError`` 契约内。实测: + # * ``["a"]``/``{"a": 1}``/``object()`` -> 裸 + # ``TypeError: stat: path should be string, bytes, os.PathLike or + # integer, not ...``; + # * 位数超限的整数(``10**4301``)-> 裸 ``OverflowError: fd is greater + # than maximum``——``ArithmeticError`` 子类,**不是** ``ValueError``, + # 穿透调用方全部 ``except ValueError`` 分类; + # * 普通整数(``5``)更隐蔽:``os.path.exists`` 把它当成**文件描述符** + # 探测,fd 未打开时静默返回 ``False``,构造"成功"并把整数留在 + # ``self.file_path`` 上——与 ``save(5)`` 把字节写进 fd 5 是同一类 + # 「整数被当成 fd」的问题,只是后果从写错文件变成静默空 store。 + # 可达路径:``FaissStore`` 是公开类,且 + # ``VectorStoreFactory.get_vector_store(store_type, file_path)`` 把 + # ``file_path`` 原样透传到这里。 + if not isinstance(file_path, (str, os.PathLike)): + raise ValueError( + "FaissStore 的 file_path 必须是字符串或路径," + f"但得到 {type(file_path).__name__}。" + " 请检查该路径来源(通常来自 RunConfig 的 legacy_pkl_path)。" + ) if not os.path.exists(file_path): return self.load(file_path, trusted_paths=trusted_paths) @@ -71,6 +201,33 @@ def _normalize_parent_document(parent_document: Any) -> dict[str, Any]: return {"content": parent_document, "metadata": {}} return {"content": "", "metadata": {}} + @staticmethod + def _index_text_of(document: Any) -> str: + """从任意形态的条目里取出建索引用文本,非映射条目返回空串。 + + ``_build_index_text`` 的契约是「拿到一个映射」,而 ``self.documents`` + 的**历史**内容不受写入侧校验的约束:``load_snapshot`` 会从 pickle 读回 + 任意形态(虽有 ``_validate_document_shape``,但那是后来才加的边界),而 + 重建索引是**由 documents 派生的缓存**,不该在派生期抛异常——一抛就卡死 + 整个 store(见 ``_rebuild_indices`` 的说明)。这里把非法形态降级成空串: + 该条目缺席检索,但其余条目照常可用,store 也不再卡死。 + """ + if not isinstance(document, Mapping): + return "" + # ``_build_index_text`` 的两处取值同样要求形态正确:``metadata`` 非映射时 + # ``.get`` 抛 ``AttributeError``,``page_content`` 非字符串时要么在 + # ``in`` 上抛 ``TypeError``,要么把列表交给 ``jieba.cut`` 抛 + # ``AttributeError: 'list' object has no attribute 'decode'``。这里先归一化 + # 成契约形态再交给它——非字符串内容没有可检索的文本表示,降级为空串。 + metadata = document.get("metadata") + content = document.get("page_content", "") + return FaissStore._build_index_text( + { + "page_content": content if isinstance(content, str) else "", + "metadata": metadata if isinstance(metadata, Mapping) else {}, + } + ) + @staticmethod def _build_index_text(document: dict[str, Any]) -> str: metadata = document.get("metadata") or {} @@ -93,16 +250,36 @@ def _build_bm25_index(tokenized_docs: list[list[str]]) -> BM25Okapi | None: ``lexical.index`` 读回 ``[]``(空快照或手工构造的目录),旧实现 在此崩溃且不报「快照为空」这个真实原因。 """ - if not tokenized_docs: + # 两个条件缺一不可:``not tokenized_docs`` 只挡住**外层为空**,而 + # ``rank_bm25`` 的 ``_calc_idf`` 在**所有 token 都为空**时也会除零 + # (``idf_sum / len(self.idf)``,分母为 0)。实测 ``[[]]`` 与 ``[[], []]`` + # 都抛 ``ZeroDivisionError: division by zero``——``jieba.cut("")`` 返回 + # ``[]``,因此一条 ``page_content=""`` 的文档就足以构造出这个形态,而空 + # ``page_content`` 在快照里是合法的(``_validate_document_shape`` 只要求 + # 它是 ``str``)。两种形态的语义相同:没有可检索的语料,返回 ``None``。 + if not tokenized_docs or not any(tokenized_docs): return None return BM25Okapi(tokenized_docs) def _rebuild_indices(self) -> None: if self.documents: - self._tokenized_docs_cache = [ - list(jieba.cut(self._build_index_text(doc))) for doc in self.documents - ] - self.bm25_index = self._build_bm25_index(self._tokenized_docs_cache) + # 先整体算完再赋值。原先直接给 ``self._tokenized_docs_cache`` 赋一个 + # 列表推导式的结果——推导式在赋值**前**整体求值,中途抛异常时该属性 + # 保持旧值,而 ``self.documents`` 已经多出条目,于是 + # ``_tokenized_docs_cache[i]`` 与 ``self.documents[i]`` 不再一一对应。 + # 两个消费点都按下标取值:``keyword_search`` 拿 + # ``self._tokenized_docs_cache[idx]`` 打分却取 ``self.documents[idx]`` + # 作结果,``_promote_parent_context`` 同样按下标取文档。错配即张冠李戴。 + # + # 更要紧的是它让 store **卡死**:``_build_index_text`` 对非映射条目抛 + # ``AttributeError``,因此只要 documents 里混进一条孤儿,此后**每一次** + # ``upsert_embeddings`` 都会在重建索引时重复抛出——连喂完全合法的文档 + # 也失败。实测连续 3 次合法 upsert 全部报同一个错,documents 停在 5 条 + # 不再增长,向量库事实上报废。索引重建是「由 documents 派生的缓存」, + # 必须容忍 documents 里的任何形态,把校验交给写入侧的入口。 + tokenized_docs = [list(jieba.cut(self._index_text_of(doc))) for doc in self.documents] + self._tokenized_docs_cache = tokenized_docs + self.bm25_index = self._build_bm25_index(tokenized_docs) else: self._tokenized_docs_cache = [] self.bm25_index = None @@ -114,14 +291,221 @@ def _rebuild_indices(self) -> None: else: self.faiss_index = None - def _normalize_loaded_documents(self) -> None: - normalized_parent_documents = dict(self.parent_documents) - for document in self.documents: - metadata = document.get("metadata") or {} + @staticmethod + def _validate_embeddings_shape(embeddings: Any, *, source: str) -> None: + """确认向量矩阵是二维的,非法形态给出可读的 ``ValueError``。 + + 两个载入入口(``load``/``load_snapshot``)原先把取值直接赋给 + ``self.embeddings``,由 ``_rebuild_indices`` 事后读 ``len()`` 与 + ``shape[1]``——于是形状错误抛的是 ``TypeError: len() of unsized object`` + (0 维)、``IndexError: tuple index out of range``(1 维)或 + ``AttributeError: 'list' object has no attribute 'shape'``(legacy pkl 里 + 是普通列表)。三者都不是 ``ValueError``,穿透调用方的错误分类;更要紧的 + 是校验发生在赋值**之后**,因此失败会留下半更新的 store——而两个入口的 + 契约都是「失败必须让 store 保持原状」。 + + 写入侧 ``upsert_embeddings`` 早已有同一断言(``ndim != 2``),这里补齐 + 读取侧,判据与措辞对齐。 + + ``None`` 是合法取值:表示「这份 legacy/快照没有向量」,后续 + ``_rebuild_indices`` 会把 ``faiss_index`` 置为 ``None``。 + """ + if embeddings is None: + return + ndim = getattr(embeddings, "ndim", None) + if ndim != 2: + raise ValueError( + f"{source}的 embeddings 必须是二维数组" + f"(得到 {type(embeddings).__name__},ndim={ndim!r})。" + " 请重建知识快照或修正数据来源。" + ) + # 有限性同样要判,且必须判在 ``astype(np.float32)`` **之后**:写入侧 + # ``EmbeddingService._as_float32_matrix`` 早就拦了非有限向量,理由写在 + # 那里——FAISS 把 NaN 向量照单收下(``ntotal`` 正常增加),既不报错也不 + # 跳过,直到查询时那个下标干脆不出现在结果里,用户看到的是「搜到了」而 + # 不是「这份快照坏了」。载入侧此前只判 ``ndim``,于是同一份内容写入侧 + # 拒绝、载入侧放行。 + # + # 判 cast 之后的矩阵而不是原数组:``1e300`` 在 float64 里是**有限**的, + # cast 成 float32 才溢出成 ``inf``(写入侧正是靠这个顺序把这一类也拦住)。 + # 只对原数组调 ``isfinite`` 会让载入侧在这一点上再次与写入侧分叉。 + # ``astype`` 只是校验用的副本,不改写 ``embeddings`` 本身,因此失败时 + # store 仍保持原状。 + # + # 溢出警告在这里是预期行为(正是它说明 cast 确实产生了 inf),不能让它 + # 冒充成新的警告泄漏出去。 + if isinstance(embeddings, np.ndarray): + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + cast = embeddings.astype(np.float32) + except (TypeError, ValueError, OverflowError) as exc: + # ``dtype=object`` 的 ndarray 在 ``astype`` 时抛裸 + # ``OverflowError: int too large to convert to float``——它不是 + # ``ValueError``,而载入侧对「来源不自洽」的承诺是带指引的 + # ``ValueError``(与写入侧 ``_as_float32_matrix`` 一致)。pickle 是 + # 二进制格式、不受十进制位数限制,``10**400`` 这种元素能正常往返。 + raise ValueError( + f"{source}的 embeddings 无法转为 float32({type(exc).__name__}: {exc})。" + " 请重建知识快照或修正数据来源。" + ) from exc + if not np.isfinite(cast).all(): + raise ValueError( + f"{source}的 embeddings 包含非有限值(NaN 或无穷大)。" + " 请重建知识快照或修正数据来源。" + ) + + @staticmethod + def _validate_row_alignment( + documents: Any, + embeddings: Any, + faiss_index: Any, + *, + source: str, + ) -> None: + """确认三条行数必须对齐:文档数、embeddings 行数、语义索引条数。 + + 三者由 ``save_snapshot``/``upsert_embeddings`` 从同一份内存状态写出, + 行数一致才保证 ``semantic_search`` 的 ``indices`` 下标对应 + ``documents``。实测 embeddings 两行、文档 2 条但 ``semantic.index`` 只有 + 1 个向量时:载入成功、无任何提示,``semantic_search`` 拿 ``indices`` + 索引 ``documents``——第 2 条文档永远不出现在结果里,用户看到的是 + 「搜不到」而不是「这份快照坏了」。反向(索引或矩阵比文档多)同样危险: + ``self.documents[doc_index]`` 抛 ``IndexError``,与「快照不自洽」无关的 + 类型。``embeddings=None`` 表示这份快照没有向量,不参与对齐。 + """ + doc_rows = len(documents) + if embeddings is not None: + matrix_rows = int(embeddings.shape[0]) + if matrix_rows != doc_rows: + raise ValueError( + f"{source}不自洽:向量矩阵行数与分块数不一致。" + f" embeddings={matrix_rows},chunks.pkl={doc_rows}。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + if faiss_index is not None: + index_rows = int(faiss_index.ntotal) + if index_rows != doc_rows: + raise ValueError( + f"{source}不自洽:语义索引条数与分块数不一致。" + f" semantic.index={index_rows},chunks.pkl={doc_rows}。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + + @staticmethod + def _validate_document_shape(document: Any, position: int, *, source: str) -> None: + """确认条目符合 ``Document`` 的契约,``source`` 用于说明它来自哪里。 + + ``Document`` 的字段是 ``content: str`` + ``metadata: dict`` + (见 ``src/models/document.py``),而载入路径原先把条目直接当成 ``Document`` + 用,只做 ``metadata or {}`` 的兜底。实测 ``page_content=None`` 的快照能通过 + ``validate_snapshot_dir``、加载成功,直到 ``display_results`` 里 + ``.replace()`` 才抛 ``AttributeError: 'NoneType' object has no attribute + 'replace'``——报错现场离真正的原因(快照里这一条不是字符串)很远。 + ``metadata`` 非 dict 与条目本身不是 dict 同理。 + + 两个调用点的契约相同、处置不同,因此消息里的处置指引必须分开: + ``load_snapshot`` 遇到的是**盘上已有的**坏数据,用户能做的是重建快照; + ``upsert_embeddings`` 遇到的是**正在写入**的坏数据,用户能做的是修正来源 + 并重试,让他「重建快照」会指向一个还没写坏的旧快照。原先写死「请重建 + 快照」的措辞只在载入侧成立。 + """ + if not isinstance(document, dict): + raise ValueError( + f"{source}:第 {position} 条文档不是映射" + f"({type(document).__name__}),无法作为文档使用。" + ) + # ``page_content`` 在 ``Document`` 里是必填的 ``str``(没有默认值), + # 因此 ``None`` 与其它非字符串同样是违规——消费者里 + # ``display_results`` 调 ``.replace()``、``_display_retrieved_docs`` 切片, + # 都会在 None 上崩。键**缺失**则不同:各消费者都写了默认值 + # (``.get("page_content", "")``),因此缺失照旧放行。 + content = document.get("page_content", "") + if not isinstance(content, str): + raise ValueError( + f"{source}:第 {position} 条文档的 page_content 不是字符串" + f"({type(content).__name__})。" + ) + metadata = document.get("metadata") + if metadata is not None and not isinstance(metadata, dict): + raise ValueError( + f"{source}:第 {position} 条文档的 metadata 不是映射({type(metadata).__name__})。" + ) + + @staticmethod + def _normalize_documents( + documents: Any, parent_documents: Any + ) -> tuple[list[Any], dict[str, dict[str, Any]]]: + """校验并归一化载入的条目,返回新值,**不改动 ``self``**。 + + 盘上的父条目也要过一遍 ``_normalize_parent_document``,与 + ``register_parent_documents`` 同一条契约。 + + 快照校验器**显式放行** ``str`` 形态的父条目(它的注释写着「要么是 dict + (取 ``content``),要么是 str(整体即内容)——``FaissStore._normalize_parent_document`` + 的取值契约」),但载入路径原样赋值、只补不归一化,于是那个契约在载入 + 侧从未被履行:``resolve_parent_content`` 拿 str 去 ``.get("content")``。 + + 实测后果:``parents.pkl`` 为 ``{"p1": "父块正文"}`` 时, + ``validate_snapshot_dir``、``load_manifest``、``load_snapshot`` **全部** + 通过(快照被激活并宣布加载成功),而聊天与召回测试的每一次检索都在 + ``_promote_parent_context`` 抛 + ``AttributeError: 'str' object has no attribute 'get'``。这条形态不是 + 假想输入:legacy 导入分支 ``store.load()`` → ``save_snapshot()`` 会把 + 第三方 pkl 里的 str 值原样写进新快照。 + + **不改动 ``self`` 是刻意的**:调用方在全部校验通过后才一次性赋值,失败 + 时 store 保持原状。原先本方法是就地改 ``self.documents``/ + ``self.parent_documents`` 的,而两个调用方又已经先把新值赋了进去——中途 + 失败就留下半更新的 store(见 ``load`` 的说明)。 + """ + # 顶层形态同样要有守卫。``SnapshotRepository.validate_snapshot_dir`` 已经 + # 覆盖这些形态,但 ``load``/``load_snapshot`` 是 ``VectorStoreBase`` 的 + # 契约方法,直接调它们的嵌入方不经过那道校验(与 + # ``_validate_document_shape`` 同一取舍)。实测后果都是与「快照不自洽」 + # 无关的裸异常: + # 分块列表是 int/None -> ``TypeError: 'int' object is not iterable`` + # 分块列表是 dict/str -> 逐字符/逐键当成文档,报「第 0 条文档不是映射」 + # (误导:真正的问题是顶层形态) + # 父块索引是 int/None/list -> ``AttributeError: 'int' object has no + # attribute 'items'`` + # 判定与 ``validate_snapshot_dir`` 的同名守卫一致:拒绝 str/bytes/映射, + # 并要求有 ``__len__``。``len("ab") == 2`` 这类会静默通过的形态正是靠 + # 类型判断拦下的。 + if isinstance(documents, (str, bytes, Mapping)) or not hasattr(documents, "__len__"): + raise ValueError( + "知识快照不自洽:分块列表的顶层不是序列" + f"({type(documents).__name__}),无法作为文档列表使用。请重建快照。" + ) + if not isinstance(parent_documents, Mapping): + raise ValueError( + "知识快照不自洽:父块索引的顶层不是映射" + f"({type(parent_documents).__name__}),无法作为父块索引使用。请重建快照。" + ) + + normalized_parent_documents = { + _parent_key(parent_id): FaissStore._normalize_parent_document(parent_document) + for parent_id, parent_document in parent_documents.items() + } + normalized_documents: list[Any] = [] + for position, document in enumerate(documents): + FaissStore._validate_document_shape( + document, + position, + source="知识快照不自洽,请重建快照", + ) + # 浅拷贝而不是原地改:``metadata.pop("parent_content", ...)`` 会写进 + # 调用方持有的那个字典,而「任何一条校验失败都不留痕」要求连输入也 + # 不被改写。归一化后的内容完全一致,只是换了对象。 + metadata = dict(document.get("metadata") or {}) parent_id = metadata.get("parent_id") parent_content = metadata.pop("parent_content", None) - if parent_id and parent_content and str(parent_id) not in normalized_parent_documents: - normalized_parent_documents[str(parent_id)] = { + if ( + parent_id + and parent_content + and _parent_key(parent_id) not in normalized_parent_documents + ): + normalized_parent_documents[_parent_key(parent_id)] = { "content": parent_content, "metadata": { key: value @@ -129,40 +513,139 @@ def _normalize_loaded_documents(self) -> None: if key not in {"chunk_id", "doc_id", "parent_id", "parent_chunk_index"} }, } - document["metadata"] = metadata - self.parent_documents = normalized_parent_documents + normalized_documents.append({**document, "metadata": metadata}) + return normalized_documents, normalized_parent_documents def register_parent_documents(self, parent_documents: dict[str, dict[str, Any]]): + # 顶层形态的判据与载入侧 ``_normalize_documents`` 的同名守卫一致(同一条 + # 注释:「``load``/``load_snapshot`` 是 ``VectorStoreBase`` 的契约方法, + # 直接调它们的嵌入方不经过那道校验」——本方法同样是契约方法,取舍同样 + # 适用)。此前只有载入侧有这条守卫:非映射传进来时,这里在 ``.items()`` + # 上抛裸 ``AttributeError: 'int' object has no attribute 'items'``,而 + # 载入侧对同一形态给的是带指引的 ``ValueError``。同一件事两种收场。 + # + # 本仓自带流程到不了这里(``knowledge_build_service`` 传的是 + # ``dict``,且前面已用 ``isinstance(..., dict)`` 过滤),因此在生产上 + # 不可达——修的是契约一致性:嵌入方直接调这个公开方法时,拿到的错误 + # 应当和其它入口一样指向「配置项形态错了」。 + if not isinstance(parent_documents, Mapping): + raise ValueError( + "父块索引的顶层不是映射" + f"({type(parent_documents).__name__}),无法作为父块索引使用。" + ) for parent_id, parent_document in parent_documents.items(): normalized = self._normalize_parent_document(parent_document) if normalized["content"]: - self.parent_documents[str(parent_id)] = normalized + self.parent_documents[_parent_key(parent_id)] = normalized def resolve_parent_content(self, parent_id: str | None) -> str | None: if not parent_id: return None - parent_document = self.parent_documents.get(str(parent_id)) + parent_document = self.parent_documents.get(_parent_key(parent_id)) if not parent_document: return None content = parent_document.get("content") return content if isinstance(content, str) and content else None def upsert_embeddings(self, documents: list[dict[str, Any]], embeddings: Any): + # 顶层容器形态先判:下一行 ``if not documents`` 之后就是 ``len(documents)``。 + # 实测 ``documents=5`` 抛裸 ``TypeError: object of type 'int' has no len()`` + # ——不是本方法其余拒绝路径承诺的 ``ValueError``;``documents=None`` 更糟, + # 它被 ``if not documents`` 当成「无写入」**静默返回**,调用方以为写入成功 + # 而 store 一个字都没变。判据与载入侧 ``_normalize_documents`` 的同名守卫 + # 一致(拒绝 ``str``/``bytes``/映射,要求有 ``__len__``)。 + if isinstance(documents, (str, bytes, Mapping)) or not hasattr(documents, "__len__"): + raise ValueError( + "待写入的 documents 必须是文档列表,但得到 " + f"{type(documents).__name__},无法作为文档列表使用。" + ) if not documents: return - new_embeddings = np.array(embeddings, dtype=np.float32) + # cast 溢出(``1e300`` → ``inf``)在这里是**被校验的对象**,不是要给 + # 调用方的警告:下面的有限性判据正是靠它把这一类拦住。不抑制的话它会以 + # ``RuntimeWarning`` 的形式泄漏出去,且出现在报错之前。 + # + # 转换失败同样要收敛成 ``ValueError``,判据与同文件 ``semantic_search`` + # 的查询向量转换逐字相同——那里早已按同一分类修过,这一半此前漏了。 + # ``np.array`` 的失败分三类,只有一类是本项目契约里的类型: + # ``OverflowError``——``np.array(10**4301, dtype=np.float32)``,超出 + # 双精度范围的整数。它是 ``ArithmeticError`` 子类而**不是** + # ``ValueError``,穿透调用方全部 ``except ValueError`` 分类。 + # ``TypeError``——``object()``/``{"a": 1}``,消息是 ``float() + # argument must be a string or a real number``。 + # ``ValueError``——``"abc"``,消息是 numpy 的 ``could not convert + # string to float``,与本项目的措辞不同。 + # 三者的消息都不指向「这批向量形态不对」,而 ``documents`` 那一半已经 + # 给出了带指引的 ``ValueError``(见上)——同一方法的两个入参,收场不该 + # 一个可读一个裸抛。 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + new_embeddings = np.array(embeddings, dtype=np.float32) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError( + "待写入的 embeddings 必须是二维数值数组,但得到 " + f"{type(embeddings).__name__}({type(exc).__name__}: {exc})。" + " 请检查向量来源(通常来自 EmbeddingService)。" + ) from exc if new_embeddings.ndim != 2: raise ValueError("embeddings 必须是二维数组。") if len(documents) != len(new_embeddings): raise ValueError("documents 与 embeddings 的数量不一致。") + # 维度检查必须在改动 ``self.documents`` **之前**:原先先 extend 再判维度, + # 维度不一致时抛异常但 documents 已经多出一批没有对应向量的条目。实测 + # 拒绝之后 documents=2 而 embeddings=(1,4),store 已不自洽,而 + # ``save_snapshot`` 会照常把这份不自洽落盘(之后的语义检索会拿 + # ``indices`` 去索引 ``documents``,越界或错配)。失败必须不留痕。 + if self.embeddings is not None and self.embeddings.shape[1] != new_embeddings.shape[1]: + raise ValueError("新增向量维度与现有索引不一致。") + + # 有限性:与载入侧 ``_validate_embeddings_shape`` 同一判据。此前只有载入 + # 侧校验,写入侧放行——同一份内容「写得进、读不出」:FAISS 把 NaN/inf + # 向量照单收下(``ntotal`` 正常增加),既不报错也不跳过,直到查询时那个 + # 下标干脆不出现在结果里,用户看到的是「搜到了」而不是「这份数据坏了」。 + # 更糟的是 ``save_snapshot`` 会把它落盘,此后**载入侧**才拒绝——快照 + # 已经写坏,而报错现场离写入处很远。 + # + # 判 ``new_embeddings``(已经 cast 成 float32)而不是原数组:``1e300`` + # 在 float64 里有限、cast 成 float32 才溢出成 ``inf``,载入侧正是靠这个 + # 顺序把这一类也拦住。``np.array(..., dtype=np.float32)`` 在溢出时会发 + # ``RuntimeWarning``,那是预期行为,在下面统一抑制,避免冒充成新的警告 + # 泄漏给调用方。 + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + cast = np.asarray(embeddings, dtype=np.float32) + if not np.isfinite(cast).all(): + raise ValueError( + "待写入的 embeddings 包含非有限值(NaN 或无穷大),已拒绝写入。" + " 请修正向量来源后重试。" + ) + + # 条目形态同样必须在改动 ``self.documents`` **之前**确认,理由与上面那条 + # 维度检查一致:写入侧原先只校验 embeddings,文档形态则放任 ``extend`` + # 之后由 ``_rebuild_indices`` 在派生索引时撞出来。实测一条非映射条目 + # (第三方 store 的返回值、手工构造的 pkl)会让 + # ``_build_index_text`` 抛 ``AttributeError: 'str' object has no attribute + # 'get'``——异常看似「拒绝写入」,实际上 documents 已经多出这条孤儿, + # ``save_snapshot`` 会把它写进快照,而 store 在此之后**再也无法接受任何 + # 写入**。错误类型也从「快照不自洽」退化成裸 ``AttributeError``。 + # + # 判据与载入边界 ``_validate_document_shape`` 完全一致(同一份 + # ``Document`` 契约),因此复用同一份实现;只是这里的下标从 0 起算, + # 而载入路径的 position 用于描述「第几条」,两者语义相同。 + for position, document in enumerate(documents): + self._validate_document_shape( + document, + position, + source="待写入的文档不符合 Document 契约,已拒绝写入", + ) + self.documents.extend(documents) if self.embeddings is None: self.embeddings = new_embeddings else: - if self.embeddings.shape[1] != new_embeddings.shape[1]: - raise ValueError("新增向量维度与现有索引不一致。") self.embeddings = np.vstack((self.embeddings, new_embeddings)) self._rebuild_indices() @@ -178,7 +661,35 @@ def semantic_search(self, query_embedding: Any, top_k: int = 5) -> list[dict[str if self.faiss_index is None or self.embeddings is None: return [] - query_vector = np.asarray(query_embedding, dtype=np.float32) + # 校验放在调 ``faiss_index.search()`` **之前**:FAISS 对非法 k 的收场 + # 不可用(详见 ``_require_top_k``)。 + _require_top_k(top_k) + + # 转换失败要收敛成 ``ValueError``。``np.asarray`` 的失败分三类,只有一类 + # 是本项目契约里的类型: + # ``OverflowError``——``np.asarray(10**4301, dtype=np.float32)``,超双精度 + # 范围的整数(容器里混入这类元素同样)。它是 ``ArithmeticError`` 子类 + # 而**不是** ``ValueError``,穿透调用方全部 ``except ValueError`` 分类。 + # ``TypeError``——``object()``/集合/生成器,消息是 ``float() argument + # must be a string or a real number``。 + # ``ValueError``——``"abc"`` 这类字符串,消息是 numpy 的 + # ``could not convert string to float``,与本项目的措辞不同。 + # 本函数已按同一判据修过 ``_require_top_k``(那里拦住的是 FAISS 的裸 + # ``TypeError``/``AssertionError``),查询向量这条漏了:同一个「参数形态 + # 非法」的处置,两处收场不同。 + # + # 不逐元素预检:``np.zeros(4, dtype=np.float32)`` 是 ``embed_query`` 的 + # 真实输出,逐元素判有限性会把合法的 numpy 标量一并拒掉(实测 + # ``to_finite_float(np.float32(0.1))`` 返回 ``None``——它只认 Python + # 的 int/float)。非有限值不在这里拦:``ndim`` 守卫之后 FAISS 自己处理。 + try: + query_vector = np.asarray(query_embedding, dtype=np.float32) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError( + "查询向量必须是一维数值序列,但得到 " + f"{type(query_embedding).__name__}({type(exc).__name__}: {exc})。" + " 请检查嵌入模型的输出。" + ) from exc if query_vector.ndim != 1 or query_vector.size == 0: raise ValueError("查询向量必须是一维且非空。") expected_dimension = int(self.embeddings.shape[1]) @@ -204,6 +715,16 @@ def keyword_search(self, query_text: str, top_k: int = 5) -> list[dict[str, Any] if self.bm25_index is None: return [] + # 与 ``semantic_search`` 同一判据(详见 ``_require_top_k``)。这里尤其 + # 重要:下面的收集循环是「先 append 再判长度」,``top_k=0`` 会返回 1 条。 + _require_top_k(top_k) + + # ``jieba.cut`` 对非字符串抛 ``AttributeError: 'int' object has no + # attribute 'decode'``(它按字节处理输入)——不是本项目的 ``ValueError``, + # 消息也指向 jieba 内部。查询文本只能来自用户输入或调用方,形态必须显式 + # 校验,与 ``_require_top_k``/查询向量同一判据。 + if not isinstance(query_text, str): + raise ValueError(f"关键词检索的查询文本必须是字符串,但得到 {safe_repr(query_text)}。") tokenized_query = list(jieba.cut(query_text)) doc_scores = self.bm25_index.get_scores(tokenized_query) top_indices = np.argsort(doc_scores)[::-1] @@ -255,6 +776,17 @@ async def asearch( ) def save(self, path: str): + # 形态守卫与读取侧 ``load``/``load_snapshot`` 同判据:``open()`` 对**整数** + # 入参不报错,而是把它当成**文件描述符**——``save(5)`` 会把 pickle 字节写进 + # fd 5(实测复现),既没落到预期路径,也可能覆盖进程里恰好占着该 fd 的 + # 文件;对 ``list`` 抛裸 ``TypeError: expected str, bytes or os.PathLike + # object, not ...``,位数超限的整数抛 ``ValueError: Exceeds the limit``。 + # 三条都不是本类其余拒绝路径承诺的带指引 ``ValueError``。 + if not isinstance(path, (str, os.PathLike)): + raise ValueError( + f"向量存储文件路径必须是字符串或路径,但得到 {type(path).__name__}。" + " 请检查该路径来源(通常来自 RunConfig 或 SnapshotRepository)。" + ) with open(path, "wb") as file: pickle.dump( { @@ -277,20 +809,82 @@ def load( ``RunConfig.legacy_pkl_path`` 的所在目录)。为 ``None`` 或空时拒绝加载: pickle 会执行任意代码,而「路径看起来像本项目的文件」不构成来源证明。 """ + # ``os.path.exists`` 对非路径取值抛裸 ``TypeError``(``expected str, + # bytes or os.PathLike object, not ...``),早于下面的来源校验——而本 + # 方法(及其调用方 ``VectorStoreFactory``)按 ``ValueError`` 分类。判据与 + # ``resolve_within`` 的同类守卫一致,形态判在第一次使用之前。 + if not isinstance(path, (str, os.PathLike)): + raise ValueError( + f"legacy 向量存储文件路径必须是字符串或路径,但得到 {type(path).__name__}。" + " 请检查该路径来源(通常来自 RunConfig 的 legacy_pkl_path)。" + ) if not os.path.exists(path): raise FileNotFoundError(f"向量存储文件未找到: {path}") ensure_trusted_source(path, trusted_paths, label="legacy 向量存储文件") - with open(path, "rb") as file: - data = pickle.load(file) # nosec B301 - 上方已校验来源 - self.documents = data.get("documents", []) - self.embeddings = data.get("embeddings") - self.parent_documents = data.get("parent_documents", {}) - self._normalize_loaded_documents() + # 空/截断文件让 ``pickle.load`` 抛 ``EOFError``/``UnpicklingError``, + # 目录/无读权限抛 ``IsADirectoryError``/``PermissionError``——裸异常 + # 穿透 ``Chatbot.__init__`` 后被 ``main.py`` 的 + # ``except (KeyboardInterrupt, EOFError)`` **误判为用户中断**,程序以 + # 「检测到中断信号」正常退出(实测复现),真因(文件损坏)无任何提示。 + # 本方法其余拒绝路径承诺带指引的 ``ValueError``,这里一并收敛。 + try: + with open(path, "rb") as file: + data = pickle.load(file) # nosec B301 - 上方已校验来源 + except (OSError, EOFError, pickle.UnpicklingError) as exc: + raise ValueError( + f"legacy 向量存储文件无法读取({type(exc).__name__}: {exc}):" + f"{path}。文件可能为空、被截断或是目录;请检查该文件," + "或重新构建知识快照。" + ) from exc + # 全部解析、校验到**局部变量**,最后一次性赋给 ``self``。原先先把三个 + # 字段赋进 ``self`` 再调 ``_normalize_loaded_documents``,中途失败就留下 + # 半更新的 store——实测一次被拒的载入之后:``documents`` 换成了新值、 + # ``embeddings`` 是新值,而 ``faiss_index`` 与 ``_tokenized_docs_cache`` + # 还是**旧**的(索引条数 2 对应 1 条文档),随后 ``semantic_search`` 抛 + # ``TypeError: 'str' object does not support item assignment``——报错现场 + # 与真正的原因(载入被拒)毫无关联。失败必须让 store 保持原状。 + if not isinstance(data, Mapping): + raise ValueError( + "legacy 向量存储文件不自洽:顶层不是映射" + f"({type(data).__name__}),无法取出 documents/embeddings。" + ) + documents, parent_documents = self._normalize_documents( + data.get("documents", []), data.get("parent_documents", {}) + ) + embeddings = data.get("embeddings") + # 形状必须在赋值**之前**判:下面的 ``self.documents = documents`` 之后 + # ``_rebuild_indices`` 才读形状,那时失败已经留下半更新的 store。 + self._validate_embeddings_shape(embeddings, source="legacy 向量存储文件") + # legacy pkl 没有独立的 semantic.index(``_rebuild_indices`` 会重建), + # 但 **embeddings 行数**仍必须与 documents 对齐:``_rebuild_indices`` 按 + # ``len(embeddings)`` 建索引,行数错配的 legacy 文件载入后文档与向量 + # 一一错位,``semantic_search`` 返回的张冠李戴结果。``load_snapshot`` 的 + # 同一守卫还包含 ``semantic.index`` 条数分支,这里索引恒为 ``None``。 + self._validate_row_alignment(documents, embeddings, None, source="legacy 向量存储文件") + + self.documents = documents + self.parent_documents = parent_documents + self.embeddings = embeddings self._rebuild_indices() def save_snapshot(self, snapshot_dir: str): if self.faiss_index is None or self.embeddings is None: - raise RuntimeError("当前语义索引为空,无法保存快照。") + # 消息带上「来源是空数据」的可能:legacy 导入分支传入空 legacy + # 文件(合法 pickle、``documents: []``)时也会走到这里,裸 + # 「语义索引为空」让用户无从知道真实原因是**来源文件没有内容**。 + raise RuntimeError( + "当前语义索引为空,无法保存快照。" + "若来源是 legacy 向量存储文件,请确认其包含 documents 与" + " embeddings 数据(空文件无法构建快照)。" + ) + # 与 ``save`` 同一处、同一判据(详见那里的说明):``Path(5)`` 抛裸 + # ``TypeError``、位数超限的整数抛 ``ValueError: Exceeds the limit``,都绕开 + # 本类的 ``ValueError`` 契约。写入侧入口的形态判据与读取侧对齐。 + if not isinstance(snapshot_dir, (str, os.PathLike)): + raise ValueError( + f"快照目录必须是字符串或路径,但得到 {type(snapshot_dir).__name__}。" + " 请检查该路径来源(通常来自 RunConfig 或 SnapshotRepository)。" + ) snapshot_path = Path(snapshot_dir) snapshot_path.mkdir(parents=True, exist_ok=True) with (snapshot_path / "chunks.pkl").open("wb") as file: @@ -301,10 +895,21 @@ def save_snapshot(self, snapshot_dir: str): pickle.dump(self._tokenized_docs_cache, file) np.save(snapshot_path / "embeddings.npy", self.embeddings) faiss.write_index(self.faiss_index, str(snapshot_path / "semantic.index")) + # ``.get("metadata", {})`` 的默认值只在**键缺失**时生效;键存在而值为 + # ``None`` 时它返回 ``None``,随后的 ``.get`` 抛 + # ``AttributeError: 'NoneType' object has no attribute 'get'``。同文件另外 + # 三处(``_build_index_text``、``_normalize_loaded_documents``、 + # ``_normalize_parent_document``)用的都是 ``or {}``,这里与它们对齐。 + # 实测 ``upsert_embeddings`` 接受 ``metadata=None`` 的条目并正常建好索引 + # (``_rebuild_indices`` 里走的是 ``or {}``),到 ``save_snapshot`` 才崩—— + # 同一份数据两条路径的判据不一致。 stats = { - "document_count": len( - {doc.get("metadata", {}).get("source") for doc in self.documents} - ), + # 集合推导会对取值取哈希,而 ``source`` 来自磁盘上的快照/legacy + # pkl,形态不受控:``source=["a"]`` 或 ``{"a": 1}`` 抛 + # ``TypeError: unhashable type``,而这条路径正是把文档写进快照的那 + # 一步——异常类型与「快照写入失败」毫无关联。``document_source`` + # 对非字符串安全返回 ``"N/A"``,与 ``factory.py`` 用的是同一个。 + "document_count": len({document_source(doc) for doc in self.documents}), "chunk_count": len(self.documents), "parent_count": len(self.parent_documents), "embedding_dimension": int(self.embeddings.shape[1]) @@ -340,39 +945,207 @@ def load_snapshot( " 快照目录内的文件是 pickle,必须由调用方声明信任根(" "RunConfig.snapshot_root 或 SnapshotRepository.root)。" ) - snapshot_path = resolve_within( - Path(snapshot_dir), Path(snapshot_root), label="知识快照目录" - ) + # 直接把**原始取值**交给 ``resolve_within``,不要先包一层 ``Path``: + # ``Path(5)``/``Path(["a"])`` 抛裸 ``TypeError: expected str, bytes or + # os.PathLike object, not ...``,位数超限的整数抛 ``ValueError: Exceeds + # the limit``——两条都发生在 ``resolve_within`` 的形态判据**之前**,于是 + # 本方法其余拒绝路径承诺的带指引 ``ValueError`` 收场被绕过。``load`` + # (legacy 入口)早已按同一判据修过,这一半此前漏了。 + snapshot_path = resolve_within(snapshot_dir, snapshot_root, label="知识快照目录") if not snapshot_path.is_dir(): raise FileNotFoundError(f"知识快照目录不存在: {snapshot_path}") marker = snapshot_path.parent / "ACTIVE_SNAPSHOT" if not marker.exists(): raise ValueError(f"知识快照目录未被激活,缺少标记文件: {marker}") - active_id = marker.read_text(encoding="utf-8").strip() - active_dir = (marker.parent / active_id).resolve() + # 读取本身要收敛:``exists()`` 对**目录**也为真,``read_text`` 于是抛 + # ``IsADirectoryError``;标记文件不是 UTF-8 时抛 ``UnicodeDecodeError``。 + # 两者都不是本方法的 ``ValueError`` 契约,而调用方 + # (``VectorStoreFactory._load_existing_state``)按它分类——启动会以裸 + # ``OSError`` 收场。判据与下面 ``resolve()`` 的 ``(OSError, RuntimeError)`` + # 收敛一致。 + try: + active_id = marker.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError) as exc: + raise ValueError( + f"知识快照的 ACTIVE_SNAPSHOT 标记无法读取: {marker}" + f"({type(exc).__name__}: {exc})。" + " 该路径可能是一个目录,或文件内容不是 UTF-8;" + "请检查该文件,或重建知识快照。" + ) from exc + # ``resolve()`` 对符号链接环抛 ``RuntimeError: Symlink loop from ...``, + # 不是 ``ValueError``——本方法的其余拒绝路径一律抛 ``ValueError``,且 + # 调用方(``VectorStoreFactory._load_existing_state``)按 ``ValueError`` + # 分类。标记文件是磁盘上的任意文本,用户手工摆一个环即触发,于是「标记 + # 坏了,请重建快照」变成 pathlib 的内部错误。判据与 + # ``SnapshotRepository._require_snapshot_id_within_root``、 + # ``utils.security.resolve_within`` 一致:``(OSError, RuntimeError)`` + # 统一收敛成 ``ValueError``。 + try: + active_dir = (marker.parent / active_id).resolve() + except (OSError, RuntimeError) as exc: + raise ValueError( + f"知识快照的 ACTIVE_SNAPSHOT 标记无法解析: {marker}" + f"({type(exc).__name__}: {exc})。" + " 标记内容或其路径可能包含符号链接环,请检查该文件,或重建知识快照。" + ) from exc if active_dir != snapshot_path: raise ValueError( "知识快照目录不是当前活动快照。" f" 传入={snapshot_path},ACTIVE_SNAPSHOT 指向={active_dir}。" ) + # 与 ``load()`` 同一取舍:全部解析、校验到**局部变量**,最后一次性赋给 + # ``self``。原先每读一个文件就直接写 ``self``,中途任何一步失败都留下 + # 半更新的 store——实测一次被拒的 ``load_snapshot`` 之后 ``documents`` + # 是新值而 ``faiss_index`` 还是旧的,后续检索在错配的下标上崩出与 + # 「快照坏了」无关的异常。失败必须让 store 保持原状。 with (snapshot_path / "chunks.pkl").open("rb") as file: - self.documents = pickle.load(file) # nosec B301 + raw_documents = pickle.load(file) # nosec B301 with (snapshot_path / "parents.pkl").open("rb") as file: - self.parent_documents = pickle.load(file) # nosec B301 + raw_parent_documents = pickle.load(file) # nosec B301 embeddings_path = snapshot_path / "embeddings.npy" - self.embeddings = np.load(embeddings_path) if embeddings_path.exists() else None + embeddings = np.load(embeddings_path) if embeddings_path.exists() else None + self._validate_embeddings_shape(embeddings, source="知识快照") faiss_index_path = snapshot_path / "semantic.index" - self.faiss_index = ( - faiss.read_index(str(faiss_index_path)) if faiss_index_path.exists() else None - ) + faiss_index = faiss.read_index(str(faiss_index_path)) if faiss_index_path.exists() else None + # 顺序与 ``load()`` 一致:先归一化文档,再建索引。此前这里是反的, + # 于是「``lexical.index`` 是否存在」决定了用户看到哪条错误——实测同一 + # 份畸形 chunks(``page_content`` 是 ``int``): + # 有 lexical.index → ``ValueError: 知识快照不自洽:第 0 条文档的 + # page_content 不是字符串(int)。请重建快照。`` + # 缺 lexical.index → ``AttributeError: 'int' object has no attribute + # 'decode'``(``_rebuild_indices`` 先跑,在 ``_build_index_text`` 里崩) + # 而 ``validate_snapshot_dir`` 的必需文件清单里没有 ``lexical.index``, + # 缺它是合法状态(下面的 ``else`` 分支就是为它准备的)。``load()`` 早已 + # 是正确的顺序,两处对齐。 + documents, parent_documents = self._normalize_documents(raw_documents, raw_parent_documents) + # 维度不变量:矩阵与索引都在时维度必须一致。行数全对齐但维度错配的 + # 快照能载入成功,查询时 FAISS 抛**空消息** ``AssertionError``(实测) + # ——用户看到的不是「这份快照坏了」。生产路径由 + # ``validate_snapshot_dir`` 挡住,这里是库级契约方法的同类守卫。 + if embeddings is not None and faiss_index is not None: + index_dim = getattr(faiss_index, "d", None) + if index_dim is not None and int(index_dim) != int(embeddings.shape[1]): + raise ValueError( + "知识快照不自洽:语义索引维度与向量矩阵不一致。" + f" semantic.index.d={int(index_dim)}," + f"embeddings.shape[1]={int(embeddings.shape[1])}。" + " 请重建知识快照。" + ) + # ``embeddings`` 缺失而索引存在是自相矛盾形态(重建也无从着手), + # 同样拦下——但**索引缺失而矩阵存在**是合法形态:下方无 + # ``lexical.index`` 的分支会从矩阵重建索引。它的缺陷形态(有 + # lexical 就不重建 → 静默空语义检索)在该分支内拦,见下。 + if embeddings is None and faiss_index is not None: + raise ValueError( + "知识快照不自洽:向量矩阵缺失而语义索引存在。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + # 三条行数必须对齐:embeddings 行数、``semantic.index`` 条数与 + # ``documents`` 数。此前只校验了索引这一条,embeddings 行数错配仍能 + # 载入成功——``semantic_search`` 的分数来自矩阵行、文档来自下标,错配 + # 即张冠李戴或 ``IndexError``。``load()`` 的 legacy 路径调用同一守卫。 + self._validate_row_alignment(documents, embeddings, faiss_index, source="知识快照") + # ``semantic.index`` 的行数是**第三条**必须与 ``documents`` 对齐的量, + # 而此前只校验了 ``lexical.index`` 的条数。实测 documents=2、embeddings + # 两行、但索引里只有 1 个向量时:加载成功、无任何提示,而 + # ``semantic_search`` 拿 ``indices`` 去索引 ``documents``——第 2 条文档 + # 永远不出现在结果里,用户看到的是「搜不到」而不是「这份快照坏了」。 + # 反向(索引比文档多)同样危险:``self.documents[doc_index]`` 抛 + # ``IndexError``,与「快照不自洽」无关的类型。 + # + # 只断言这一条最小不变量,不比对向量内容:``embeddings`` 与索引由 + # ``save_snapshot`` 从同一份内存状态写出,条数一致即可保证下标对应关系。 + # (断言本体在上方 ``_validate_row_alignment``——索引条数分支已由它覆盖, + # 此处不再重复。) lexical_path = snapshot_path / "lexical.index" if lexical_path.exists(): with lexical_path.open("rb") as file: - self._tokenized_docs_cache = pickle.load(file) # nosec B301 - self.bm25_index = self._build_bm25_index(self._tokenized_docs_cache) + tokenized_docs = pickle.load(file) # nosec B301 + # 盘上的 ``lexical.index`` 与 ``self.documents`` 必须等长,否则 + # ``keyword_search`` 拿 ``argsort`` 的下标去索引 ``self.documents`` + # 会越界或错配(实测语料 3 条、文档 2 条 -> ``IndexError: list index + # out of range``;语料 1 条、文档 2 条 -> 静默丢结果)。 + # + # 生产路径**已经**由 ``SnapshotRepository.validate_snapshot_dir`` 挡住 + # 这些形态,本方法是库级公开 API(``VectorStoreBase`` 的契约方法), + # 直接调它的嵌入方不会经过那道校验——``_validate_document_shape`` 的 + # 注释已说明同一取舍。这里只断言「顶层是序列且长度对齐」这条最小 + # 不变量,不逐条校验语料内容:条目本身是什么形态不影响下标对应关系。 + # + # 判定必须在 ``_build_bm25_index`` **之前**,否则非序列形态会先在 + # ``any()`` 上抛裸 ``TypeError: 'int' object is not iterable``——与 + # 「快照不自洽」无关的类型,也没有重建指引。``None`` 同样要拦: + # ``_build_bm25_index(None)`` 返回 ``None``,``keyword_search`` 于是 + # 对一份有 2 条文档的快照静默返回空结果,用户看到的是「搜不到」而不是 + # 「快照坏了」。 + if isinstance(tokenized_docs, (str, bytes, dict)) or ( + tokenized_docs is not None and not hasattr(tokenized_docs, "__len__") + ): + raise ValueError( + "知识快照不自洽:lexical.index 的顶层不是列表" + f"({type(tokenized_docs).__name__}),无法作为关键词索引语料使用。请重建快照。" + ) + # ``len()`` 必须排在上面的形态守卫**之后**:``int`` 这类没有 + # ``__len__`` 的形态会先在这里抛裸 ``TypeError: object of type 'int' + # has no len()``,用户拿到的又是与快照无关的错误类型。 + tokenized_rows = 0 if tokenized_docs is None else len(tokenized_docs) + if tokenized_rows != len(documents): + raise ValueError( + "知识快照不自洽:关键词索引语料条数与分块数不一致。" + f" lexical.index={tokenized_rows}," + f" chunks.pkl={len(documents)}。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + # **条目**形态也要拦:上面两道守卫只覆盖顶层(是不是列表)与条数, + # 而 ``BM25Okapi._initialize`` 会对每条语料调 ``len(document)``—— + # 实测语料 ``[7]``、``[10**4301]``、``[1.5]``、``[None]`` 全部穿过 + # 顶层守卫与条数校验,在 ``_build_bm25_index`` 里抛裸 + # ``TypeError: object of type 'int' has no len()``。这条路径在生产上 + # 可达:``SnapshotRepository.validate_snapshot_dir`` 同样只看顶层与 + # 条数,于是坏快照能被 finalize 成**活动快照**,此后每次启动都在 + # 载入期以与「快照坏了」无关的异常类型失败。 + # + # 只判「有没有 ``__len__``」而不判「是不是 ``list[str]``」:语料是 + # 本应用写出的分词结果,形态确定;这里要挡的是「根本不是序列」, + # 过严会把将来可能的迭代器形态一并拒掉。 + for position, entry in enumerate(tokenized_docs if tokenized_docs is not None else []): + if isinstance(entry, (str, bytes)) or not hasattr(entry, "__len__"): + raise ValueError( + "知识快照不自洽:lexical.index 的第 " + f"{position} 条语料不是序列({type(entry).__name__})," + "无法作为关键词索引语料使用。请重建快照。" + ) + # ``_build_bm25_index`` 的入参已经过上面三道守卫(顶层形态、条数、 + # 条目形态),是「最后一个可以失败的步骤」——失败也只发生在局部 + # 变量上,``self`` 仍是旧快照的完整状态(「拒绝的载入不改 store」 + # 契约覆盖到最后一步)。缓存赋值整体推迟到本块末尾、与新快照字段 + # 同批进行,避免「缓存已是新值、documents 还是旧值」的中间态。 + lexical_cache = tokenized_docs if tokenized_docs is not None else [] + lexical_bm25 = self._build_bm25_index(lexical_cache) + # 有 lexical 就不重建索引:矩阵存在而索引缺失的快照会让 + # ``semantic_search`` 对有文档有向量的数据静默返回 ``[]``(实测)。 + # 合法快照(本项目写出)二者同在,此形态即「快照残缺」。判定必须在 + # **批量赋值之前**:守卫只读局部变量(``embeddings``/``faiss_index``/ + # ``lexical_path``),放赋值之后会在抛 ``ValueError`` 前把 store 五个 + # 字段全部改写——「拒绝的载入不改 store」契约被破坏(实测探针: + # before/after 五字段全变)。 + if lexical_path.exists() and embeddings is not None and faiss_index is None: + raise ValueError( + "知识快照不自洽:向量矩阵存在而语义索引缺失" + "(且存在 lexical.index,不会触发索引重建)。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + # 全部校验通过,到这里才改动 store。``_rebuild_indices`` 走 ``else`` 分支 + # 时会读 ``self.documents``,因此必须排在这些赋值之后。 + self.documents = documents + self.parent_documents = parent_documents + self.embeddings = embeddings + self.faiss_index = faiss_index + if lexical_path.exists(): + self._tokenized_docs_cache = lexical_cache + self.bm25_index = lexical_bm25 else: self._rebuild_indices() - self._normalize_loaded_documents() def import_legacy_snapshot( self, diff --git a/src/retrieval_test/core.py b/src/retrieval_test/core.py index 74e0d5c..11a2852 100644 --- a/src/retrieval_test/core.py +++ b/src/retrieval_test/core.py @@ -8,6 +8,7 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table +from rich.text import Text os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1") @@ -15,7 +16,13 @@ from ..runtime.contracts import build_run_config, build_session_config from ..services.embedding_service import EmbeddingService from ..services.retrieval_service import RetrievalService -from ..ui.display_utils import get_relative_path +from ..ui.display_utils import ( + document_content, + document_score, + document_source, + get_relative_path, + markup_text, +) from ..utils.config import get_settings from ..utils.log_manager import get_module_logger from ..utils.security import safe_exception_text @@ -26,23 +33,39 @@ def display_results(query: str, documents: list[dict]): + # 形态先收敛,与同一主循环里的 ``ExcelLogger.log_results`` 同判据(两者拿的是 + # 同一个 ``documents``)。实测非列表取值两种收场都不在契约内: + # ``"abc"`` 是合法可迭代对象,逐**字符**迭代后每个字符经 ``document_*`` 助手 + # 收敛成占位符,渲染出 3 行内容与来源都是 ``N/A`` 的伪结果表格(用户看到 + # 「召回了 3 条」,而实际返回的是字符串);``5`` 抛裸 + # ``TypeError: 'int' object is not iterable``。按「非列表视为无结果」收敛。 + if not isinstance(documents, list): + documents = [] if not documents: console.print( - Panel(f"对查询 “[bold yellow]{query}[/bold yellow]” 无结果。", border_style="red") + Panel( + markup_text("对查询 “[bold yellow]{}[/bold yellow]” 无结果。", query), + border_style="red", + ) ) return - table = Table(title=f"“{query}” 召回测试", show_header=True) + # 查询、来源与内容都来自用户输入或知识库文档,可能含字面量 ``[/]`` 这类 + # 标记形态;直接交给 rich 会被当作标记解析(轻则改写内容,重则抛 + # MarkupError,而该异常在异常处理通道里会二次抛出)。 + table = Table(title=markup_text("“{}” 召回测试", query), show_header=True) table.add_column("排名", width=4) table.add_column("内容") table.add_column("来源", width=30) table.add_column("分数", width=10) for index, doc in enumerate(documents): - content = doc.get("page_content", "N/A").replace("\n", " ")[:150] + "..." - source = get_relative_path(doc.get("metadata", {}).get("source", "N/A")) - score = doc.get("score", 0.0) - table.add_row(str(index + 1), content, source, f"{score:.4f}") + # 字段形态由 document_* 助手收敛:检索结果可以来自任何 VectorStoreBase + # 实现,第三方 store 不会像 FaissStore 那样在载入期归一化。展示是错误 + # 处理之后的最后一环,不该成为新的失败点。 + content = document_content(doc).replace("\n", " ")[:150] + "..." + source = get_relative_path(document_source(doc)) + table.add_row(str(index + 1), Text(content), Text(source), f"{document_score(doc):.4f}") console.print(table) @@ -67,7 +90,19 @@ async def run_retrieval_test_async(): try: excel_logger = ExcelLogger() except Exception as exc: # noqa: BLE001 - interactive setup reports all failures + # 必须同时告知用户:召回测试的**唯一产出**就是 Excel 日志,而这里失败 + # 之后每轮查询都走 ``if excel_logger:`` 静默跳过——用户会照常看到 + # 召回结果,以为测试记录下来了,实际一个文件都没有。本文件其它失败 + # 路径(数据库加载、服务初始化)都用 ``console.print`` 显式提示,这里 + # 此前只写日志,而日志级别由 ``log_level`` 决定,配成 ``ERROR`` 以上 + # 时连那行都看不到。 logger.error("ExcelLogger 初始化失败: %s", safe_exception_text(exc)) + console.print( + markup_text( + "[yellow]Excel 日志初始化失败,本次召回测试不会记录结果: {}[/yellow]", + safe_exception_text(exc), + ) + ) try: run_config = build_run_config(get_settings()) @@ -75,7 +110,7 @@ async def run_retrieval_test_async(): vector_store = VectorStoreFactory.get_default_vector_store() retrieval_service = RetrievalService(vector_store, EmbeddingService(run_config)) except Exception as exc: # noqa: BLE001 - interactive setup reports all failures - console.print(f"[red]数据库加载失败: {safe_exception_text(exc)}[/red]") + console.print(markup_text("[red]初始化失败: {}[/red]", safe_exception_text(exc))) return try: @@ -99,7 +134,7 @@ async def run_retrieval_test_async(): except (KeyboardInterrupt, EOFError): break except Exception as exc: # noqa: BLE001 - interactive loop remains usable - console.print(f"[red]召回测试出错: {safe_exception_text(exc)}[/red]") + console.print(markup_text("[red]召回测试出错: {}[/red]", safe_exception_text(exc))) logger.error("召回测试出错: %s", safe_exception_text(exc)) finally: # 与 chat 路径保持一致:Embedding provider 与向量存储持有 SDK 客户端和 diff --git a/src/retrieval_test/excel_logger.py b/src/retrieval_test/excel_logger.py index 076e807..ef9df4a 100644 --- a/src/retrieval_test/excel_logger.py +++ b/src/retrieval_test/excel_logger.py @@ -1,17 +1,58 @@ import json import os +from collections.abc import Mapping from datetime import datetime from typing import Any from openpyxl import Workbook # type: ignore[import-untyped] from openpyxl.worksheet.worksheet import Worksheet # type: ignore[import-untyped] -from ..utils.config import get_settings +from ..ui.display_utils import document_content, document_score, document_source +from ..utils.config import get_settings, is_double_convertible, safe_repr from ..utils.log_manager import get_module_logger logger = get_module_logger(__name__) +# xlsx 是 XML,**两处**各自拒绝不同的码位,只处理其中一处会让日志文件写出来 +# 却打不开: +# +# 1. ``openpyxl`` 的 ``check_string()`` 在 ``append()`` 期拒绝 C0 控制符 +# (``\x09``/``\x0a``/``\x0d`` 是合法空白,不在其中),抛 +# ``IllegalCharacterError``。 +# 2. XML 1.0 不允许的码位在 ``save()`` 期被 ``xml.etree`` 写成非法字符引用: +# **写入成功、读回失败**。``load_workbook`` 抛 ``ParseError``,而调用方 +# ``log_results`` 的 ``except Exception`` 把它吞成一行日志——用户看到「召回 +# 测试完成」,日志文件却打不开。 +# +# 第 2 类漏掉的正是孤立代理项(``U+D800``–``U+DFFF``)与 ``U+FFFE``/ +# ``U+FFFF``。前者经 legacy pickle 路径可达(pickle 是二进制格式,不受 UTF-8 +# 约束,实测 ``pickle.loads(pickle.dumps('caf\udce9'))`` 正常往返);后者是 +# **合法 UTF-8 码位**,``read_text(encoding="utf-8")`` 正常接受,``BasicCleaner`` +# 的 ``re.sub(r"\s+", " ")`` 也放行。 +# +# 替换而不是删除:日志的用途是记录「检索到了什么」,悄悄吃掉字符会让内容与 +# 知识库原文不一致而无人察觉。 +_ILLEGAL_XLSX_CHARS = str.maketrans( + { + code: "\ufffd" + for code in ( + *range(0x09), + *range(0x0B, 0x0D), + *range(0x0E, 0x20), + *range(0xD800, 0xE000), + 0xFFFE, + 0xFFFF, + ) + } +) + + +# 同一秒内的文件名后缀尝试次数。100 个同名日志已经远超真实使用(每次召回测试 +# 只构造一个 logger),到这一步说明目录里堆满了同一秒的产物,报错比继续试更合适。 +_MAX_LOG_NAME_ATTEMPTS = 100 + + class ExcelLogger: """ 一个用于将召回测试结果记录到 Excel 文件的日志记录器。 @@ -31,20 +72,62 @@ def __init__(self, log_dir: str | None = None): # 确保日志目录存在 os.makedirs(log_dir, exist_ok=True) - # 生成带时间戳的唯一文件名 timestamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S") - self.filepath = os.path.join(log_dir, f"recall_test_log_{timestamp}.xlsx") - - # 创建一个新的 Excel 工作簿和工作表 - self.workbook = Workbook() - self.worksheet: Worksheet = self.workbook.active # type: ignore - self.worksheet.title = "Recall Test Log" + self.filepath = self._reserve_log_path(log_dir, timestamp) + try: + # 创建一个新的 Excel 工作簿和工作表 + self.workbook = Workbook() + self.worksheet: Worksheet = self.workbook.active # type: ignore + self.worksheet.title = "Recall Test Log" - # 写入表头 - self._write_header() + # 写入表头 + self._write_header() + except BaseException: + # 占位文件已经建出来了,构造失败就必须收回去——否则日志目录里会 + # 留下一个 0 字节的 .xlsx,看起来像一次「没有结果的召回测试」。 + try: + os.unlink(self.filepath) + except OSError: + logger.debug("回收占位日志文件失败: %s", self.filepath) + raise logger.info(f"Excel 日志记录器初始化成功,日志将保存至: {self.filepath}") + @staticmethod + def _reserve_log_path(log_dir: str, timestamp: str) -> str: + """独占创建一个带时间戳的日志路径并返回它。 + + 文件名原先只有到**秒**的时间戳,于是同一秒内构造的第二个实例会拿到 + 同一个路径并覆盖前一个文件——实测两个实例的 ``filepath`` 完全相同。 + 召回测试的**唯一产出**就是这份日志,覆盖等于悄悄丢掉一次测试的结果。 + 可达路径:从召回测试菜单返回主菜单后再次进入(``main.py`` 的菜单循环), + 或嵌入方在同一秒内构造两个 logger。 + + 判据用 ``O_CREAT | O_EXCL`` 而不是 ``os.path.exists``:后者是「先查后写」, + 两个进程同时构造时会双双认为路径可用、然后互相覆盖。``O_EXCL`` 由内核 + 仲裁,冲突时抛 ``FileExistsError``,换下一个后缀重试即可。 + + 冲突时加 ``_1``、``_2``…… 后缀(第一个仍是原来的名字,保持既有文件名 + 形态不变)。 + """ + for suffix in range(_MAX_LOG_NAME_ATTEMPTS): + name = ( + f"recall_test_log_{timestamp}.xlsx" + if suffix == 0 + else f"recall_test_log_{timestamp}_{suffix}.xlsx" + ) + candidate = os.path.join(log_dir, name) + try: + handle = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + continue + os.close(handle) + return candidate + raise RuntimeError( + f"同一秒内已有 {_MAX_LOG_NAME_ATTEMPTS} 个召回测试日志,无法再创建新文件" + f"(目录: {log_dir})。请稍后重试。" + ) + def _write_header(self): """写入 Excel 文件的表头。""" headers = [ @@ -60,6 +143,134 @@ def _write_header(self): self.worksheet.append(headers) self.workbook.save(self.filepath) + @staticmethod + def _metadata_of(document: Any) -> dict[str, Any]: + """取出条目的 metadata,非映射时返回空 dict。""" + if not isinstance(document, Mapping): + return {} + metadata = document.get("metadata") + return dict(metadata) if isinstance(metadata, Mapping) else {} + + @staticmethod + def _raw_field(document: Any, key: str) -> Any: + """从条目里取一个顶层字段,条目不是映射时返回 ``None``。""" + if not isinstance(document, Mapping): + return None + return document.get(key) + + @staticmethod + def _cell_value(value: Any) -> Any: + """把单元格取值收敛成 ``openpyxl`` 能写入的形态。 + + ``openpyxl`` 只接受字符串与数字;映射、列表、集合等会抛 + ``ValueError: Cannot convert ... to Excel``。这类值没有可读的表格表示, + 降级成字符串(保留内容以便排查)比抛异常更合适。 + + ``int`` 同样不能无条件放行:``openpyxl`` 在写数值前会走 + ``safe_string()``,那里对值调 ``isnan(value)``——超出双精度范围的整数 + (``10**400`` 即 401 位)抛 ``OverflowError: int too large to convert + to float``。这条比 ``json.dumps`` 的位数上限**更早**触发(双精度上界约 + 1.8e308,即 309 位),因此判据用 ``is_double_convertible`` 而不是位数。 + + 后果比抛异常更隐蔽:``log_results`` 的 ``save`` 外面是 + ``except Exception: logger.exception(...)``,于是整个工作簿**一个字都 + 没落盘**,用户只看到日志里一行「保存 Excel 日志文件失败」,召回测试的 + 记录全部丢失。在取值处降级,保住其余字段。 + + ``str`` 也要过一遍:``openpyxl`` 有**两条独立的拒绝路径**,上面那条是 + ``save()`` 期的数值溢出,另一条是 ``append()`` 期的 + ``IllegalCharacterError``——``check_string()`` 拒绝 ``\x00-\x08``、 + ``\x0b-\x0c``、``\x0e-\x1f`` 这些控制字符。而 ``_append_text_row`` 的 + ``worksheet.append(row)`` 在 ``log_results`` 的 ``try`` **之外**,异常 + 直接冒到调用方:一次**成功**的召回被显示成「召回测试出错」,且该批 + **一条都不落盘**(实测 3 条结果落盘 0 条)。 + + 触发输入完全合法:``\x1b`` 是终端 ANSI 转义,用户把带颜色的 + ``systemctl status``/``git log --color`` 输出粘进 markdown 就是。 + 实测 23 个控制码位能穿过 ``BasicCleaner``(``re.sub(r"\\s+", " ")`` + 只吃空白类)并让 openpyxl 抛错。 + + 处理方式与 ``=`` 公式那支一致:**替换**而不是丢弃内容——控制字符没有 + 可读的表格表示,替换成可见的 ``\ufffd`` 既保住了「这里原本有东西」, + 也让日志能落盘。用 ``str.translate`` 一次扫完,不逐字符判断。 + """ + if value is None or isinstance(value, float): + return value + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.translate(_ILLEGAL_XLSX_CHARS) + if isinstance(value, int): + # 降级用 ``safe_repr`` 而不是 ``str``:位数超限的整数连 ``str()`` + # 都会抛(``str(10**4301)`` -> ``ValueError``),用它等于把 + # ``OverflowError`` 换成同一类错误的另一种形态。 + return value if is_double_convertible(value) else safe_repr(value) + return safe_repr(value).translate(_ILLEGAL_XLSX_CHARS) + + @staticmethod + def _json_safe(value: Any) -> Any: + """把要写进 ``json.dumps`` 的分数收敛成可序列化形态。 + + ``int`` 不能无条件放行:``json.dumps(10**4301)`` 抛 + ``ValueError: Exceeds the limit (4300 digits)``(CPython 的 + ``sys.get_int_max_str_digits()``,默认 4300),而本方法的职责正是「收敛成 + 可序列化形态」。这类值来自第三方 store 的 ``semantic_score``/ + ``keyword_score``——本函数就是为「第三方 store 不经 FaissStore 归一化」 + 而存在的,因此必须自己兜住,不能指望上游。 + + 位数在上限内的整数照常保留原类型(``json.dumps`` 能处理,且日志的 + 用途是记录真实取值);超出上限的降级成字符串,与其它无法序列化的形态 + 一致。判据用 ``is_double_convertible`` 不合适——它判的是双精度范围 + (约 1.8e308,即 309 位),比 ``json.dumps`` 的实际容忍度高得多,会把 + ``10**400`` 这种能正常序列化的值也降级掉。 + """ + if value is None or isinstance(value, (str, float, bool)): + return value + if isinstance(value, int): + # 同 ``_cell_value``:位数超限时连 ``str()`` 都会抛,判据走 + # ``safe_repr`` 的实现(超限时用 ``bit_length()`` 给量级)。 + try: + str(value) + except ValueError: + return safe_repr(value) + return value + return safe_repr(value) + + def _append_text_row(self, row: list[Any]) -> None: + """追加一行,并把其中的字符串单元格固定为文本类型。 + + ``openpyxl`` 对以 ``=`` 开头的字符串存成**公式**(``data_type='f'``), + Excel 打开日志时求值。文档内容、来源与查询都来自知识库与用户输入,实测 + ``=1+1`` 落盘后是公式、``=cmd|'/c calc'!A0`` 这类 DDE 载荷同样照单全收。 + 两条后果:日志不再忠实记录「检索到了什么」(内容列显示的是计算结果), + 以及在打开日志的机器上执行文档里的内容。 + + 这里写 ``data_type = "s"`` 而不是给值加前缀(``'=1+1``):前缀会改写内容, + 而日志的用途正是逐字记录。非字符串(分数、排名)保持原类型不动。 + + **本方法是整行的唯一收口点**,因此控制字符也在这里统一过滤,而不是指望 + 每个调用方记得处理:``openpyxl`` 的 ``check_string()`` 拒绝 + ``\x00-\x08``/``\x0b-\x0c``/``\x0e-\x1f``,命中即抛 + ``IllegalCharacterError``,而本方法的调用点位于 ``log_results`` 的 + ``try`` **之外**——异常直接冒到调用方,一次**成功**的召回被显示成 + 「召回测试出错」,且该批**一条都不落盘**(实测 3 条结果落盘 0 条)。 + + 触发输入完全合法:``\x1b`` 是终端 ANSI 转义,用户把带颜色的 + ``systemctl status``/``git log --color`` 输出粘进 markdown 就是。 + 实测 23 个控制码位能穿过 ``BasicCleaner``(``re.sub(r"\\s+", " ")`` + 只吃空白类)。逐列处理会漏掉 ``query`` 这类用户输入列,收口在这里则 + 连未来新增的列也一并覆盖。 + """ + sanitized = [ + value.translate(_ILLEGAL_XLSX_CHARS) if isinstance(value, str) else value + for value in row + ] + self.worksheet.append(sanitized) + last_row = self.worksheet.max_row + for column, value in enumerate(sanitized, start=1): + if isinstance(value, str): + self.worksheet.cell(row=last_row, column=column).data_type = "s" + def log_results(self, query: str, results: list[dict[str, Any]]): """ 将单次查询的结果记录到 Excel 文件中。 @@ -70,31 +281,72 @@ def log_results(self, query: str, results: list[dict[str, Any]]): """ timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") + # ``query`` 也要过 ``_cell_value``。它是本方法里唯一没走收敛的列: + # 其余列分别经 ``document_*``/``_cell_value``/``_json_safe``,而 + # ``query`` 直接进 ``row``。本方法签名声明 ``query: str``,但它是**公开 + # 方法**,且这里是「日志是旁路、失败不该丢记录」这条契约的入口。 + # + # 实测 ``query=10**4301``(位数超限的整数)时 ``openpyxl`` 在 + # ``save()`` 期对每个数值调 ``safe_string`` -> ``isnan(value)``,抛 + # ``OverflowError: int too large to convert to float``。后果正是 + # ``_cell_value`` 的 docstring 描述的那一种:``save`` 外的 + # ``except Exception`` 把它吞成一行日志,整个工作簿**一个字都没落盘** + # ——同一次查询里 3 条完全正常的结果一并丢失,用户看到「召回测试完成」 + # 而日志文件是坏的(``load_workbook`` 连 ``[Content_Types].xml`` 都 + # 找不到)。在取值处降级成 ``safe_repr`` 的量级描述,保住整批记录。 + query = self._cell_value(query) + + # ``results`` 的形态同样要判。本方法签名声明 ``list[dict]``,但它是**公开 + # 方法**,而两种非列表取值都不在契约内: + # * ``"abc"`` 是合法可迭代对象,下面的 ``enumerate`` 逐**字符**迭代, + # 每个字符经 ``document_*`` 助手收敛成占位符,于是写出 3 条内容与来源 + # 都是占位符的伪结果行——用户看到日志写着 3 条命中,而实际返回的是 + # 字符串; + # * ``5``/``True``/``1.5`` 抛裸 ``TypeError: 'int' object is not + # iterable``,而本方法的调用点位于 ``try`` **之外**(见 + # ``_append_text_row`` 的说明),异常直接冒到召回测试主循环的 + # ``except Exception``——一次**成功**的召回被显示成「召回测试出错」。 + # 按「非列表一律视为无结果」收敛:写一行「无结果」比写三条占位垃圾诚实, + # 与 ``if not results`` 的既有分支同一条出口。 + if not isinstance(results, list): + results = [] + row: list[Any] if not results: # 如果没有结果,也记录一条信息 row = [timestamp, query, "N/A", "N/A", "无结果", "N/A", "N/A", "N/A"] - self.worksheet.append(row) + self._append_text_row(row) else: for i, doc in enumerate(results): rank = i + 1 - score = doc.get("score", 0) - content = doc.get("page_content", "") - metadata = doc.get("metadata", {}) - source = metadata.get("source", "未知") - page = metadata.get("page", "N/A") + # 字段形态统一由 display_utils 的助手收敛,与展示层同一套判据: + # 检索结果可以来自任何 ``VectorStoreBase`` 实现,第三方 store 不 + # 经 ``FaissStore`` 的载入期归一化。日志是**旁路**——它失败不该让 + # 一次成功的召回测试以异常收场,也不该丢掉其余正常条目的记录。 + # + # 直接取值会抛异常的地方实测有六处:``metadata`` 为 None/列表 + # 与条目本身不是映射都会 ``AttributeError``;``page`` 是映射/ + # 列表时 ``openpyxl`` 抛 ``ValueError: Cannot convert ... to + # Excel``;``semantic_score`` 是 set 时 ``json.dumps`` 抛 + # ``TypeError``;``score`` 是映射/列表同样进不了 Excel。 + score = document_score(doc, 0.0) + content = document_content(doc, "") + source = document_source(doc, "未知") + page = self._cell_value(self._metadata_of(doc).get("page", "N/A")) - # 提取所有可用的分数 + # 提取所有可用的分数。``semantic_score``/``keyword_score`` 同样 + # 收敛——它们要经 ``json.dumps``,集合、映射这类形态无法序列化。 + # 条目本身可能不是映射(第三方 store),因此不直接 ``doc.get``。 all_scores = { "score": score, - "semantic_score": doc.get("semantic_score"), - "keyword_score": doc.get("keyword_score"), + "semantic_score": self._json_safe(self._raw_field(doc, "semantic_score")), + "keyword_score": self._json_safe(self._raw_field(doc, "keyword_score")), } # 过滤掉值为 None 的分数 all_scores_str = json.dumps({k: v for k, v in all_scores.items() if v is not None}) row = [timestamp, query, rank, score, source, page, content, all_scores_str] - self.worksheet.append(row) + self._append_text_row(row) try: self.workbook.save(self.filepath) diff --git a/src/runtime/contracts.py b/src/runtime/contracts.py index 422bb52..2359255 100644 --- a/src/runtime/contracts.py +++ b/src/runtime/contracts.py @@ -1,7 +1,7 @@ from __future__ import annotations import math -from collections.abc import Callable, Iterator, MutableMapping +from collections.abc import Callable, Iterator, Mapping, MutableMapping from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime @@ -9,11 +9,17 @@ from typing import Any, ClassVar from src.utils.config import ( + _MAX_RETRIEVAL_CANDIDATES, _MAX_RETRIEVAL_MULTIPLIER, _MAX_RETRIEVAL_TOP_K, ModelDetail, RetrievalMethod, Settings, + is_double_convertible, + require_chat_temperature, + require_retrieval_method, + safe_repr, + to_finite_float, ) SCHEMA_VERSION = "2" @@ -62,19 +68,43 @@ def __init__( rerank_configurations: dict[str, ModelDetail], chat_temperature: float, ): - self.retrieval_method = retrieval_method + # 构造器走与 ``__setitem__`` **同一组**校验器。 + # + # 类本身是公开的,库调用方可以直接 ``SessionConfig(...)`` 构造,只在 + # ``__setitem__`` 里守卫会留下一个完整的绕过口子——实测该构造器对 + # ``top_k=10**9``、``top_k=True``、``score_threshold=nan``、 + # ``chat_temperature=-1``、``vector_weight=nan`` 等**每一个**受校验 + # 字段都照单全收。其中 ``top_k`` 会一路走到 ``faiss_index.search()``, + # FAISS 既不报错也不截断,按 10 亿条分配内存。 + # + # 构造器自身是 ``build_session_config`` 的内部出口(值来自已校验的 + # ``Settings``),所以这条路径正常不会触发校验;这里校验的是「绕过 + # ``Settings`` 直接构造」的调用方。 + self.retrieval_method = self._validate_retrieval_method(retrieval_method) + vector_weight = self._validate_vector_weight(vector_weight) + keyword_weight = self._validate_keyword_weight(keyword_weight) self.vector_weight = vector_weight self.keyword_weight = keyword_weight - self.hybrid_fusion_strategy = hybrid_fusion_strategy - self.retrieval_candidate_multiplier = retrieval_candidate_multiplier - self.rerank_enabled = rerank_enabled - self.top_k = top_k - self.score_threshold = score_threshold + self.hybrid_fusion_strategy = self._validate_fusion_strategy(hybrid_fusion_strategy) + self.retrieval_candidate_multiplier = self._validate_candidate_multiplier( + retrieval_candidate_multiplier + ) + self.rerank_enabled = self._validate_rerank_enabled(rerank_enabled) + self.top_k = self._validate_top_k(top_k) + self.score_threshold = self._validate_score_threshold(score_threshold) self.active_llm_configuration = active_llm_configuration self.active_rerank_configuration = active_rerank_configuration self.llm_configurations = llm_configurations self.rerank_configurations = rerank_configurations - self.chat_temperature = chat_temperature + self.chat_temperature = self._validate_chat_temperature(chat_temperature) + + # 组合校验放最后:单字段都合法之后才谈字段之间的约束。 + if vector_weight == 0.0 and keyword_weight == 0.0: + raise ValueError( + "vector_weight 与 keyword_weight 不能同时为 0," + "否则混合检索的所有得分都是 0 并被分数阈值全部过滤,静默返回空结果。" + ) + self._reject_oversized_candidate_pool(self.top_k, self.retrieval_candidate_multiplier) # 可经 ``__setitem__`` 写入的字段及其校验器。``__init__`` 信任调用方 # (``build_session_config`` 从已校验的 ``Settings`` 取值,合法), @@ -87,21 +117,209 @@ def __init__( # 引用 `cls._validate_x` 会在定义期拿不到绑定方法)。 _VALIDATORS: ClassVar[dict[str, Callable[[Any], Any]]] = {} + # 已知字段名。``_require_known_field`` 用它做成员判断——不能用 + # ``key in self.to_dict()``,那会先把 key 拿去哈希、对不可哈希的取值抛 + # ``TypeError``。``to_dict`` 的键集就是字段集(下方 ``_VALIDATORS`` 的注释 + # 说明了二者的一致性),这里在类定义末尾由 ``_FIELD_NAMES`` 赋值填充。 + _FIELD_NAMES: ClassVar[frozenset[str]] = frozenset() + def __getitem__(self, key: str) -> Any: + # 读取侧此前直接写 ``getattr(self, key)``,与写入侧的判据只做了一半: + # ``__setitem__`` 会经 ``_require_known_field`` 拒绝未知字段(``KeyError``) + # 与非法形态(``ValueError``),而读取侧对同一 key 抛的是 ``AttributeError`` + # ——本类声明的是 ``MutableMapping[str, Any]``,而 ``Mapping`` 协议要求未知 + # 键抛 ``KeyError``。 + # + # 后果不是「消息不好看」:``in``/``.get(k, default)``/``.pop(k, default)`` + # /``.setdefault`` 这些 ``MutableMapping`` 的混入实现**只接 ``KeyError``**。 + # 实测 ``"nope" in cfg`` 抛 ``AttributeError``(本该是 ``False``)、 + # ``cfg.get("nope", "d")`` 抛 ``AttributeError``(本该返回 ``"d"``)—— + # 一个「探测字段是否存在」的调用方拿到的是异常而不是布尔值。 + # + # 另外 ``getattr`` 会把 ``__class__``/``__dict__``/``to_dict`` 这类非字段 + # 属性一并返回(实测 ``cfg["to_dict"]`` 拿到绑定方法),而写入侧对同名 key + # 抛 ``KeyError``——读写两侧对「这个 key 是不是字段」的答案必须一致。 + # + # 复用 ``_require_known_field`` 而不是就地判断:它与写入侧共用同一份 + # 判据与措辞,两侧不会各自演化。 + # + # 但读路径要把**非字符串**键从 ``ValueError`` 改判 ``KeyError``: + # ``Mapping.__contains__`` 只接 ``KeyError``(``_collections_abc`` 里 + # ``except KeyError: return False``),``ValueError`` 会直接冒出去—— + # 实测 ``5 in cfg`` 抛 ``ValueError: 字段名必须是字符串``(本该是 + # ``False``),而 ``10**4301 in cfg`` 更在 ``f"{key!r}"`` 拼 ``ValueError`` + # 消息时二次撞上 ``Exceeds the limit``,读一个键把整个调用炸死。写路径 + # 的 ``ValueError`` 语义不变:赋值给非法名字是参数错误。 + try: + self._require_known_field(key) + except ValueError: + raise KeyError(f"SessionConfig 没有字段 {safe_repr(key)}。") from None return getattr(self, key) def __setitem__(self, key: str, value: Any) -> None: - if key not in self.to_dict(): - raise KeyError(f"SessionConfig 没有字段 {key!r}。") + self._require_known_field(key) validator = self._VALIDATORS.get(key) - setattr(self, key, validator(value) if validator is not None else value) + validated = validator(value) if validator is not None else value + if key in ("vector_weight", "keyword_weight"): + # 兄弟字段可能绕过 validator 被属性直写(``SessionConfig`` 是公开类, + # 普通属性没有 ``__setattr__`` 守卫)。``set_many`` 对同一情形有 + # ``_require_finite_number`` 收敛,这里必须用同一判据——否则 + # ``cfg.keyword_weight = nan`` 之后 ``cfg["vector_weight"] = 0.0`` + # 会静默放行(``nan == 0.0`` 恒为 False),两个权重同时失效, + # 混合检索的所有得分都是 0 并被阈值全部过滤。 + other_key = "keyword_weight" if key == "vector_weight" else "vector_weight" + self._require_finite_number(getattr(self, other_key, None), other_key) + self._reject_both_weights_zero(key, validated) + if key in ("top_k", "retrieval_candidate_multiplier"): + # 改任一个都可能让乘积越界,因此按「改完之后」的组合判一次。 + # 兄弟字段同样可能未经 validator:``getattr`` 拿到 list/str 后 + # ``top_k * multiplier`` 与随后的 ``>`` 比较抛裸 ``TypeError``, + # 穿透调用方按 ``ValueError`` 建立的分类。用与 ``set_many`` 同一套 + # ``_require_int_value`` 收敛。 + other_key = "retrieval_candidate_multiplier" if key == "top_k" else "top_k" + self._reject_oversized_candidate_pool( + self._require_int_value( + validated if key == "top_k" else getattr(self, "top_k", validated), "top_k" + ), + self._require_int_value( + validated + if key == "retrieval_candidate_multiplier" + else getattr(self, other_key, validated), + other_key, + ), + ) + setattr(self, key, validated) + + def set_many(self, values: Mapping[str, Any]) -> None: + """批量写入,按**最终值**做组合校验,成功才整体生效。 + + 逐个调 ``__setitem__`` 会拿**副本当时的兄弟字段值**去校验——即尚未被 + 这批更新覆盖的旧值。于是同一个合法终态能否被接受取决于 dict 的遍历 + 顺序:初始 ``(top_k=5, mult=3)``、目标 ``(top_k=2000, mult=1)``(乘积 + 2000 完全合法),先写 ``top_k`` 会拿旧的 ``mult=3`` 算出 6000 而拒绝, + 先写 ``mult`` 则通过。 + + 这不是理论问题:配置编辑器的动态上界会**放行**这两个字段(先设 + ``mult=1`` 时上界是 100,再设 ``top_k=2000`` 时上界正好 2000), + 而 ``to_dict()`` 的键顺序恰好是能通过的那个;但嵌入方传入自己的 dict、 + 或将来调整字段顺序,就会变成「UI 放行、应用配置时报错并终止会话」。 + + 注意这与「两个权重同时为零」那条不同:那条是**等值**约束,最后一次写入 + 必然能看到另一个字段的新值,因此逐个写也不会漏。乘积是**上界**约束, + 中间态可能瞬时越界而终态合法,所以必须按终态判。 + + 组合校验在单字段校验全部通过之后、任何字段落盘之前执行;因此被拒时 + 不会留下改了一半的状态。 + """ + # 逐个过同一个 helper:``key not in self.to_dict()`` 对不可哈希的 key 抛 + # ``TypeError: unhashable type``,而消息里的 ``{key!r}`` 对位数超限的整数 + # 抛 ``ValueError: Exceeds the limit``。两条都与「字段名写错了」无关, + # 而同文件的 ``_validate_fusion_strategy`` 早已认领同一类线索。 + # 容器形态先判:``for key in values`` 对不可迭代的取值抛裸 + # ``TypeError: 'int' object is not iterable``——而紧随其后的 + # ``_require_known_field`` 对**可迭代但字段名非法**的形态给的是带字段名 + # 的 ``ValueError``。同一判据的两半必须都在,否则「参数写错」的收场 + # 取决于它是标量还是序列。``str``/``bytes`` 要显式排除:它们是合法的 + # ``Sequence``,迭代得到的是字符(``"ab"`` 会被拆成 ``a``/``b`` 两个 + # 字段名,报出与调用方写法无关的 ``KeyError``)。 + # 取值来源是公开的 ``Chatbot.chat_config`` setter(标注 + # ``SessionConfig | dict``,但运行时不受约束)。 + if isinstance(values, (str, bytes, bytearray)) or not isinstance(values, Mapping): + raise ValueError(f"批量写入必须是字段映射,但得到 {safe_repr(values)}。") + for key in values: + self._require_known_field(key) + + # 第一遍:逐字段校验,先在本地收敛成合法值(此时不改动 self)。 + validated: dict[str, Any] = {} + for key, value in values.items(): + validator = self._VALIDATORS.get(key) + validated[key] = validator(value) if validator is not None else value + + # 第二遍:组合校验按「这批更新全部生效之后」的取值判定。 + def final(field: str) -> Any: + if field in validated: + return validated[field] + return getattr(self, field, validated.get(field)) + + # 兄弟字段的取值可能**没经过 validator**:``SessionConfig`` 是公开类, + # 普通属性没有 ``__setattr__`` 守卫,嵌入方(或携带超大整数的 pickle + # 快照)可以直接 ``cfg.vector_weight = 10**400``。于是这里的 + # ``float(...)`` 抛 ``OverflowError``(``ArithmeticError`` 子类,**不是** + # ``ValueError``)、``int(inf)`` 抛 ``OverflowError``、``int(nan)`` 抛 + # ``ValueError``——实测三条都复现,而调用方只按 ``ValueError`` 分类。 + # ``_require_finite_number`` 把取值收敛成有限浮点或给出可读的 + # ``ValueError``,校验与运算用的是同一个值。 + vector_weight = self._require_finite_number(final("vector_weight"), "vector_weight") + keyword_weight = self._require_finite_number(final("keyword_weight"), "keyword_weight") + if vector_weight == 0.0 and keyword_weight == 0.0: + raise ValueError( + "vector_weight 与 keyword_weight 不能同时为 0," + "否则混合检索的所有得分都是 0 并被分数阈值全部过滤,静默返回空结果。" + ) + if "top_k" in validated or "retrieval_candidate_multiplier" in validated: + self._reject_oversized_candidate_pool( + self._require_int_value(final("top_k"), "top_k"), + self._require_int_value( + final("retrieval_candidate_multiplier"), "retrieval_candidate_multiplier" + ), + ) + + # 第三遍:全部通过,整体落盘。 + for key, value in validated.items(): + setattr(self, key, value) + + @staticmethod + def _require_known_field(key: Any) -> None: + """校验字段名已知;形态异常与未知字段都收敛成 ``KeyError``/``ValueError``。 + + ``key not in self.to_dict()`` 对不可哈希的 key 抛 ``TypeError: + unhashable type``,而 ``f"{key!r}"`` 对位数超限的整数抛 ``ValueError: + Exceeds the limit``——两条都与「字段名写错了」无关。同文件的 + ``_validate_fusion_strategy`` 早已认领同一类线索。 + """ + if not isinstance(key, str): + raise ValueError(f"SessionConfig 的字段名必须是字符串,但得到 {safe_repr(key)}。") + if key not in SessionConfig._FIELD_NAMES: + raise KeyError(f"SessionConfig 没有字段 {key!r}。") + + @staticmethod + def _require_finite_number(value: Any, label: str) -> float: + """把权重取值收敛成有限浮点,非法形态给出可读的 ``ValueError``。 + + 用于**兄弟字段**:它们可能绕过 validator 被属性直写。``float(10**400)`` + 抛 ``OverflowError``——``ArithmeticError`` 子类,**不是** ``ValueError``。 + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{label} 必须是数值,但得到 {safe_repr(value)}。") + number = to_finite_float(value) + if number is None: + raise ValueError(f"{label} 必须是有限数值,但得到 {safe_repr(value)}。") + return number + + @staticmethod + def _require_int_value(value: Any, label: str) -> int: + """把整数取值收敛成 ``int``,非法形态给出可读的 ``ValueError``。 + + ``int(inf)`` 抛 ``OverflowError``、``int(nan)`` 抛 ``ValueError``——前者 + 不是 ``ValueError``,会穿透调用方的错误分类。 + """ + if isinstance(value, bool) or not isinstance(value, int): + number = to_finite_float(value) + if number is None or not float(number).is_integer(): + raise ValueError(f"{label} 必须是整数,但得到 {safe_repr(value)}。") + return int(number) + return value @staticmethod def _require_bounded_int(value: Any, label: str, upper: int) -> int: if isinstance(value, bool) or not isinstance(value, int): - raise ValueError(f"{label} 必须是整数,但得到 {value!r}。") + raise ValueError(f"{label} 必须是整数,但得到 {safe_repr(value)}。") if not 1 <= value <= upper: - raise ValueError(f"{label} 必须是 1 到 {upper} 之间的整数,但得到 {value}。") + # 消息里必须用 ``safe_repr``:``repr(10**4301)`` 超过 + # ``sys.get_int_max_str_digits()``(默认 4300),自己抛 + # ``ValueError: Exceeds the limit``——错误消息反过来把异常内容 + # 换成了与字段无关的「整数转字符串超限」。 + raise ValueError(f"{label} 必须是 1 到 {upper} 之间的整数,但得到 {safe_repr(value)}。") return value @classmethod @@ -116,13 +334,20 @@ def _validate_candidate_multiplier(cls, value: Any) -> int: @staticmethod def _validate_unit_weight(value: Any, label: str) -> float: + # ``float(10**400)`` 抛 ``OverflowError``——它是 ``ArithmeticError`` 的 + # 子类,**不是** ``ValueError``,会穿透调用方的 ``except ValueError`` + # 链(UI 与库调用方都只接 ``ValueError``)。判据必须用 + # ``is_double_convertible``:``math.isfinite(float(x))`` 在同一个输入上 + # 自己就抛 ``OverflowError``,拿它当守卫等于没有守卫。 if isinstance(value, bool) or not isinstance(value, (int, float)): - raise ValueError(f"{label} 必须是数值,但得到 {value!r}。") + raise ValueError(f"{label} 必须是数值,但得到 {safe_repr(value)}。") + if not is_double_convertible(value): + raise ValueError(f"{label} 必须是有限数值,但得到 {safe_repr(value)}。") number = float(value) if not math.isfinite(number): - raise ValueError(f"{label} 必须是有限数值,但得到 {value!r}。") + raise ValueError(f"{label} 必须是有限数值,但得到 {safe_repr(value)}。") if not 0.0 <= number <= 1.0: - raise ValueError(f"{label} 必须在 0.0 到 1.0 之间,但得到 {value!r}。") + raise ValueError(f"{label} 必须在 0.0 到 1.0 之间,但得到 {safe_repr(value)}。") return number @classmethod @@ -133,42 +358,82 @@ def _validate_vector_weight(cls, value: Any) -> float: def _validate_keyword_weight(cls, value: Any) -> float: return cls._validate_unit_weight(value, "keyword_weight") + @staticmethod + def _reject_oversized_candidate_pool(top_k: int, multiplier: int) -> None: + """拒绝让候选规模超过上界的 ``top_k`` × ``retrieval_candidate_multiplier``。 + + 两个字段各自有上界,但**乘积**才是真正送进 ``faiss_index.search()`` 的 + k(见 ``RetrievalService.retrieve`` 的 ``effective_top_k``),而 FAISS + 对超过索引条数的 k 既不报错也不截断,按 k 全量分配。实测 dim=1536、 + k=10**6 时峰值 RSS 涨约 35 GB。``Settings`` 有同一条约束,这里是运行期 + 入口的对应实现。 + """ + candidates = top_k * multiplier + if candidates > _MAX_RETRIEVAL_CANDIDATES: + # ``safe_repr`` 而不是裸 f-string:``top_k``/``multiplier`` 可能被 + # 属性直写成位数超限的整数(见 ``_require_int_value``),插值 + # ``{candidates}`` 会在**正要报「候选规模过大」**的那一步抛 + # ``ValueError: Exceeds the limit``——异常类型仍是 ``ValueError``, + # 但内容与字段毫无关系。同文件 ``_require_bounded_int`` 早已用同一判据。 + raise ValueError( + f"top_k × retrieval_candidate_multiplier 不能超过 " + f"{_MAX_RETRIEVAL_CANDIDATES},但得到 {safe_repr(candidates)}。" + " 两者相乘才是实际送入向量检索的候选规模,过大会一次性申请巨量内存。" + ) + + def _reject_both_weights_zero(self, key: str, value: float) -> None: + """拒绝把混合检索的两个权重同时设为零。 + + ``Settings`` 在启动时校验过一次,但 ``SessionConfig`` 是运行期独立 + 入口(UI 与库调用方走 ``__setitem__``),单字段界拦不住这种组合。 + 两个权重同时为零时 ``weighted`` 融合的每个文档得分都是 0.0,随后被 + ``chat_score_threshold`` 全部过滤——检索静默返回空结果且无任何报错, + 正是「把配置错误伪装成检索质量差」的那类失败。 + """ + other = self.keyword_weight if key == "vector_weight" else self.vector_weight + if value == 0.0 and other == 0.0: + raise ValueError( + "vector_weight 与 keyword_weight 不能同时为 0," + "否则混合检索的所有得分都是 0 并被分数阈值全部过滤,静默返回空结果。" + ) + @classmethod def _validate_score_threshold(cls, value: Any) -> float: return cls._validate_unit_weight(value, "score_threshold") @classmethod def _validate_retrieval_method(cls, value: Any) -> RetrievalMethod: - if isinstance(value, RetrievalMethod): - return value - try: - return RetrievalMethod(value) - except ValueError as exc: - allowed = ", ".join(member.value for member in RetrievalMethod) - raise ValueError(f"retrieval_method 必须是 {allowed} 之一,但得到 {value!r}。") from exc + # 判据与措辞都交给 ``require_retrieval_method``:同一字段在配置边界 + # (这里)与运行期读取侧(``retrieval_service``/两个 UI 取用点)必须 + # 给出同一种收场,否则「写进去被拒」与「读出来报错」会分成两套消息。 + return require_retrieval_method(value) @staticmethod def _validate_fusion_strategy(value: Any) -> str: - if value not in {"rrf", "weighted"}: + # 集合成员判断对不可哈希的值抛 ``TypeError: unhashable type``,而本类的 + # 契约是 ``ValueError``(``__setitem__`` 与构造器都据此分类)。输入可达: + # 配置来自 TOML,``hybrid_fusion_strategy = ["rrf"]`` 是合法的 TOML 数组。 + # 实测修复前 ``cfg["hybrid_fusion_strategy"] = ["rrf"]`` 抛的是 + # ``TypeError``,绕过全部 ``except ValueError``。 + if not isinstance(value, str) or value not in {"rrf", "weighted"}: raise ValueError( - f"hybrid_fusion_strategy 必须是 'rrf' 或 'weighted',但得到 {value!r}。" + f"hybrid_fusion_strategy 必须是 'rrf' 或 'weighted',但得到 {safe_repr(value)}。" ) return str(value) @staticmethod def _validate_rerank_enabled(value: Any) -> bool: if not isinstance(value, bool): - raise ValueError(f"rerank_enabled 必须是布尔值,但得到 {value!r}。") + raise ValueError(f"rerank_enabled 必须是布尔值,但得到 {safe_repr(value)}。") return value @staticmethod def _validate_chat_temperature(value: Any) -> float: - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise ValueError(f"chat_temperature 必须是数值,但得到 {value!r}。") - number = float(value) - if not math.isfinite(number) or number < 0.0: - raise ValueError(f"chat_temperature 必须是非负有限数值,但得到 {value!r}。") - return number + # 判据与措辞都交给 ``require_chat_temperature``:同一字段在配置边界(这里) + # 与运行期读取侧(``ChatService.generate_reply``/``Chatbot.chat_async`` 的 + # 兜底分支)必须给出同一种收场,否则「写进去被拒」与「读出来报错」会分成 + # 两套消息。 + return require_chat_temperature(value) def __delitem__(self, key: str) -> None: raise TypeError("SessionConfig 不支持删除字段。") @@ -211,6 +476,62 @@ def to_dict(self) -> dict[str, Any]: "chat_temperature": SessionConfig._validate_chat_temperature, } +# ``to_dict()`` 的键集就是字段集——``__setitem__``/``set_many`` 的字段名校验 +# 直接以它为准,避免两处各维护一份列表而分叉。 +SessionConfig._FIELD_NAMES = frozenset( + SessionConfig( + retrieval_method=RetrievalMethod.HYBRID_SEARCH, + vector_weight=1.0, + keyword_weight=0.0, + hybrid_fusion_strategy="rrf", + retrieval_candidate_multiplier=1, + rerank_enabled=False, + top_k=1, + score_threshold=0.0, + active_llm_configuration="", + active_rerank_configuration="", + llm_configurations={}, + rerank_configurations={}, + chat_temperature=0.0, + ).to_dict() +) + + +def _manifest_count(value: Any, label: str) -> int: + """把清单的计数字段收敛成非负整数,非法取值给出可读的 ``ValueError``。 + + ``int(inf)`` 抛 ``OverflowError``——``ArithmeticError`` 子类而**不是** + ``ValueError``,会穿透 ``write_manifest`` 调用方的错误分类;``int("abc")`` + 抛 ``TypeError``。两者都收敛成带字段名的 ``ValueError``。 + """ + if isinstance(value, bool) or not isinstance(value, int): + number = to_finite_float(value) + if number is None or not float(number).is_integer(): + raise ValueError(f"清单字段 {label} 必须是非负整数,但得到 {safe_repr(value)}。") + value = int(number) + if value < 0: + raise ValueError(f"清单字段 {label} 必须是非负整数,但得到 {safe_repr(value)}。") + # 位数上界:``f"{value}"`` 在 TOML 拼接处撞上 CPython 的整数位数上限 + # (默认 4300),于是「写清单」抛的是 ``Exceeds the limit`` 而字段名与原因 + # 都不在消息里。TOML 本身能表示任意精度的整数,但这个量级的计数没有任何 + # 现实意义——真实快照是几十到几万。 + if value.bit_length() > 64: + raise ValueError( + f"清单字段 {label} 超出合理范围({safe_repr(value)}),快照计数不应超过 2**64。" + ) + return value + + +def _manifest_text(value: Any) -> str: + """把清单字段收敛成字符串,位数超限时降级为量级描述。 + + 与 ``from_mapping`` 的 docstring 同因:这些值来自磁盘上的清单文件, + ``str()`` 对超过 ``sys.get_int_max_str_digits()`` 的整数自己会抛。 + """ + if isinstance(value, str): + return value + return safe_repr(value) + @dataclass(frozen=True) class KnowledgeSnapshotManifest: @@ -250,36 +571,111 @@ def create( chunk_count=chunk_count, ) + @staticmethod + def _toml_string(value: Any) -> str: + r"""把一个值序列化成 TOML 基本字符串(含转义)。 + + 原实现用 ``f'key = "{value}"'`` 直接拼,没有做 TOML 字符串转义,而这些 + 值都来自 ``config.toml``/``.env``:``embedding_model`` 可以是自建网关的 + 模型路径(``models\qwen-7b``),``chunk_mode`` 与 ``snapshot_id`` 也由调用方 + 决定。实测模型名含反斜杠时 ``write_manifest`` 正常落盘、``load_manifest`` + 抛 ``TOMLDecodeError: Unescaped '\' in a string``——快照写得出、读不回, + 报错现场离真正的原因很远;含引号或换行的值则连落盘的 TOML 都不可解析。 + 同一份 manifest 在内存里是好的,落盘一圈就坏了。 + + 这里按 TOML 1.0 的规定转义 ``"``、``\`` 与不可打印字符:可打印字符原样 + 保留(含中文),其余走 ``\uXXXX``。``str`` 之外的值先转成字符串——写出来 + 的是文本,读回时由 ``from_mapping`` 的 ``str(...)`` 负责,与既有行为一致。 + """ + # 非字符串走 ``_manifest_text`` 而不是 ``str``:``str(10**4301)`` 撞上 + # CPython 的整数位数上限,在**正要写清单**时抛 ``ValueError: Exceeds the + # limit``——而读取侧的 ``from_mapping`` 早已按同一判据修过,只修一个方向 + # 会让「写得进、读不出」反过来变成「写得就崩」。写入侧的值同样来自 + # ``config.toml``/磁盘快照。 + text = value if isinstance(value, str) else _manifest_text(value) + escaped = [] + for char in text: + if char == "\\": + escaped.append("\\\\") + elif char == '"': + escaped.append('\\"') + elif char == "\b": + escaped.append("\\b") + elif char == "\t": + escaped.append("\\t") + elif char == "\n": + escaped.append("\\n") + elif char == "\f": + escaped.append("\\f") + elif char == "\r": + escaped.append("\\r") + elif char < " " or char == "\x7f": + escaped.append(f"\\u{ord(char):04X}") + else: + escaped.append(char) + return '"' + "".join(escaped) + '"' + def to_toml(self) -> str: + toml_string = self._toml_string return "\n".join( [ - f'schema_version = "{self.schema_version}"', - f'snapshot_id = "{self.snapshot_id}"', - f'created_at = "{self.created_at}"', - f'store_type = "{self.store_type}"', - f'embedding_provider = "{self.embedding_provider}"', - f'embedding_model = "{self.embedding_model}"', - f'chunk_mode = "{self.chunk_mode}"', - f'source_digest = "{self.source_digest}"', - f"document_count = {self.document_count}", - f"chunk_count = {self.chunk_count}", + f"schema_version = {toml_string(self.schema_version)}", + f"snapshot_id = {toml_string(self.snapshot_id)}", + f"created_at = {toml_string(self.created_at)}", + f"store_type = {toml_string(self.store_type)}", + f"embedding_provider = {toml_string(self.embedding_provider)}", + f"embedding_model = {toml_string(self.embedding_model)}", + f"chunk_mode = {toml_string(self.chunk_mode)}", + f"source_digest = {toml_string(self.source_digest)}", + # 计数同样可能来自磁盘(畸形快照)或调用方,``int()`` 对 + # ``inf`` 抛 ``OverflowError``(``ArithmeticError`` 子类,不是 + # ``ValueError``)、对位数超限的整数抛 ``ValueError``。这里经 + # ``_manifest_count`` 收敛,与 ``from_mapping`` 同源。 + f"document_count = {_manifest_count(self.document_count, 'document_count')!r}", + f"chunk_count = {_manifest_count(self.chunk_count, 'chunk_count')!r}", "", ] ) @classmethod def from_mapping(cls, data: dict[str, Any]) -> KnowledgeSnapshotManifest: + """从清单映射构造,非字符串字段一律经 ``safe_repr`` 收敛。 + + 不能直接写 ``str(data[key])``:清单文件来自磁盘,而**本项目加载 + pickle 格式的快照**,pickle 是二进制格式、不受 CPython 的十进制位数 + 限制(实测 ``pickle.loads(pickle.dumps(10**4301))`` 正常往返),因此 + 一个畸形快照可以让 ``str()`` 在**正要报告清单内容**时抛 + ``ValueError: Exceeds the limit (4300 digits)``——异常类型与内容都与 + 「清单字段不合法」无关,调用方的 ``except`` 分类随之失准。 + ``safe_repr`` 对位数超限的值给出量级描述,消息仍然可用。 + + **计数字段**同样要用写入侧的 ``_manifest_count``,不能写裸 ``int()``: + ``to_toml`` 早已按「拒 ``bool``、拒非整数浮点、拒负数、拒超 ``2**64``」 + 收敛,同一份清单的两个方向判据必须一致。裸 ``int()`` 的失败面有两类, + 且**静默的比抛异常的更危险**: + + * 抛异常:``"abc"`` 抛 ``ValueError: invalid literal for int()``(没有 + 字段名),``None``/``[1]`` 抛 ``TypeError``——不是本函数的 + ``ValueError`` 契约; + * 静默改写:``1.5`` 截断成 ``1``、``True`` 当成 ``1``、``-5`` 原样接受、 + ``10**300`` 原样接受——全部是写入侧明确拒绝的取值,读回来却成了一个 + 磁盘上并不存在的计数。而 manifest 是快照的准入凭据 + (``VectorStoreFactory`` 拿它比对 embedding 配置)。 + + 数字字符串仍照旧接受(``"3"`` -> ``3``):``.env``/TOML 来源本来就会 + 以字符串到达,``_manifest_count`` 经 ``to_finite_float`` 覆盖这条路径。 + """ return cls( - schema_version=str(data["schema_version"]), - snapshot_id=str(data["snapshot_id"]), - created_at=str(data["created_at"]), - store_type=str(data["store_type"]), - embedding_provider=str(data["embedding_provider"]), - embedding_model=str(data["embedding_model"]), - chunk_mode=str(data["chunk_mode"]), - source_digest=str(data["source_digest"]), - document_count=int(data["document_count"]), - chunk_count=int(data["chunk_count"]), + schema_version=_manifest_text(data["schema_version"]), + snapshot_id=_manifest_text(data["snapshot_id"]), + created_at=_manifest_text(data["created_at"]), + store_type=_manifest_text(data["store_type"]), + embedding_provider=_manifest_text(data["embedding_provider"]), + embedding_model=_manifest_text(data["embedding_model"]), + chunk_mode=_manifest_text(data["chunk_mode"]), + source_digest=_manifest_text(data["source_digest"]), + document_count=_manifest_count(data["document_count"], "document_count"), + chunk_count=_manifest_count(data["chunk_count"], "chunk_count"), ) diff --git a/src/services/chat_service.py b/src/services/chat_service.py index 8c79608..83e0b7c 100644 --- a/src/services/chat_service.py +++ b/src/services/chat_service.py @@ -6,8 +6,10 @@ from src.providers.__base__.model_provider import LargeLanguageModel from src.runtime.contracts import SessionConfig from src.services.retrieval_service import RetrievalService +from src.ui.display_utils import document_content, document_source +from src.utils.config import require_chat_temperature, safe_repr from src.utils.log_manager import get_chat_logger -from src.utils.security import redact_sensitive_text +from src.utils.security import safe_exception_text class ChatService: @@ -23,6 +25,13 @@ def _temperature_kwargs(self, temperature: float) -> dict[str, Any]: return {} async def identify_intent(self, user_query: str) -> str: + # 与 ``_build_prompt`` 同判据:非字符串取值不得以字面量进 prompt。 + # 这里的 f-string 在 ``try`` **之外**,因此位数超限的整数抛的是裸 + # ``ValueError: Exceeds the limit``(而不是本类承诺的 + # ``RuntimeError: 意图识别失败``),``None``/``5`` 则静默生成 + # ``用户问题: None`` 交给模型。 + if not isinstance(user_query, str): + raise ValueError(f"用户输入必须是字符串,但得到 {safe_repr(user_query)}。") prompt = f"你是一个意图识别助手。判断用户意图。\n用户问题: {user_query}\n直接输出意图简述。" response = "" try: @@ -31,20 +40,13 @@ async def identify_intent(self, user_query: str) -> str: async for chunk in self.llm_model.ainvoke(**invoke_kwargs): response += chunk except Exception as exc: - self.logger.error("异步意图识别失败: %s", redact_sensitive_text(str(exc))) + self.logger.error("异步意图识别失败: %s", safe_exception_text(exc)) raise RuntimeError("意图识别失败,无法继续检索。") from exc intent = response.strip() if not intent: raise RuntimeError("意图识别返回空结果,无法继续检索。") return intent - async def retrieve( - self, user_input: str, session_config: SessionConfig, console: Any - ) -> tuple[str, list[dict[str, Any]]]: - intent = await self.identify_intent(user_input) - documents = await self.retrieval_service.retrieve(intent, session_config, console=console) - return intent, documents - async def generate_reply( self, user_input: str, @@ -54,17 +56,60 @@ async def generate_reply( ) -> AsyncGenerator[str, None]: prompt = self._build_prompt(user_input, intent, documents) invoke_kwargs: dict[str, Any] = {"prompt": prompt, "stream": True} - invoke_kwargs.update(self._temperature_kwargs(session_config.chat_temperature)) + # 收敛后再用:``chat_temperature`` 的两个校验器都在**写入**侧,而 + # ``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫——直写 + # ``"abc"``/``None``/``[0.5]``/``True``/``-1`` 会原样进请求 kwargs + # (实测),而它们正是 ``session_config["chat_temperature"] = ...`` 明确 + # 拒绝的取值。非法值到服务端才暴露,用户看到的是接口参数错误。 + invoke_kwargs.update( + self._temperature_kwargs(require_chat_temperature(session_config.chat_temperature)) + ) async for chunk in self.llm_model.ainvoke(**invoke_kwargs): yield chunk @staticmethod def _build_prompt(user_input: str, intent: str, documents: list[dict[str, Any]]) -> str: - context = "\n".join( - f"来源: {(doc.get('metadata') or {}).get('source') or '未知来源'}\n" - f"内容: {doc.get('page_content', '')}" - for doc in documents - ) + # 标量入参的形态先收敛,判据与下面文档处理**同一条**:非字符串值不得以 + # 字面量进 prompt。这两个参数此前完全没有守卫,实测 + # ``user_input=None``/``5``/``["a"]`` 静默生成 + # ``用户问题: None``/``用户问题: 5``/``用户问题: ['a']``——模型会把 + # 字面量 ``None`` 当成一个真实的用户问题去回答(与 + # ``test_build_prompt_does_not_feed_malformed_content_to_the_model`` + # 钉住的 ``内容: None`` 是同一形态,只是那一条盯的是文档字段)。 + # 位数超限的整数则更干脆:``f"{10**4301}"`` 抛裸 + # ``ValueError: Exceeds the limit``,消息与「问题写错了」毫无关系。 + # + # 抛 ``ValueError`` 而不是静默替换成空串:本方法的两个调用点 + # (``ChatService.generate_reply`` 与 ``Chatbot.chat_async`` 的内联分支) + # 都在按 ``ValueError`` 分类的边界内,而「用户输入不是文本」是调用方的 + # 编码错误,不该被掩盖成一次正常问答。 + for label, value in (("用户输入", user_input), ("意图", intent)): + if not isinstance(value, str): + raise ValueError(f"{label}必须是字符串,但得到 {safe_repr(value)}。") + # ``documents`` 的容器形态同判据。上面两个标量参数有守卫而这一半此前 + # 漏了,实测两种收场都不在契约内:``5``/``None`` 抛裸 + # ``TypeError: 'int' object is not iterable``(不是 ``ValueError``,两个 + # 调用点的错误分类接不住);``"abc"`` 更隐蔽——字符串是合法可迭代对象, + # 逐**字符**迭代后每个字符都被下面的「内容为空则跳过」滤掉,于是**静默 + # 退化成「没有找到相关知识」**,一次带着知识调用的请求被无声降级,模型 + # 据此回答「我不知道」。 + if not isinstance(documents, list): + raise ValueError(f"documents 必须是文档列表,但得到 {safe_repr(documents)}。") + # 字段形态统一由 display_utils 的助手收敛。检索结果可以来自任何 + # ``VectorStoreBase`` 实现——``FaissStore`` 会在载入期校验并归一化, + # 第三方 store 不会。两处后果:``metadata`` 非映射时抛 + # ``AttributeError``;更隐蔽的是 ``page_content=None`` 不抛错,而是把 + # 字面量 ``内容: None`` 当作一条真实知识喂给模型。 + # + # 内容缺失时整条跳过而不是留一行空「内容:」:向模型提交一条没有正文的 + # 「知识」只会增加噪声,还可能让它顺着回答。 + entries = [] + for document in documents: + content = document_content(document, "") + if not content: + continue + entries.append(f"来源: {document_source(document, '未知来源')}\n内容: {content}") + context = "\n".join(entries) if context: return ( "你是一个智能客服。根据知识回答。\n" diff --git a/src/services/embedding_service.py b/src/services/embedding_service.py index 320f7ba..e64163d 100644 --- a/src/services/embedding_service.py +++ b/src/services/embedding_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +from collections.abc import Mapping from typing import Any import numpy as np @@ -8,6 +9,46 @@ from src.providers.__base__.model_provider import close_resource_sync from src.providers.factory import ModelProviderFactory from src.runtime.contracts import RunConfig +from src.utils.config import ( + _MAX_EMBEDDING_DIMENSION, + _MAX_KB_CHUNK_TOKENS, + is_double_convertible, + require_detail_attributes, + safe_repr, +) + + +def _reject_unconvertible_numbers(values: Any, label: str) -> None: + """拒绝会让 ``float()`` 抛 ``OverflowError`` 的整数。 + + 只递归检查 ``int``:``float`` 的 ``inf``/``nan`` 由调用方的 + ``np.isfinite`` 判据负责(消息更贴切),这里专管「形态是合法数字、值却 + 超出 C double」这一类。``bool`` 是 ``int`` 子类,一并跳过——它是合法取值。 + """ + if isinstance(values, bool): + return + if isinstance(values, int): + if not is_double_convertible(values): + raise ValueError( + f"{label}包含超出双精度范围的整数({safe_repr(values)});" + "请检查 embedding provider 的返回内容。" + ) + return + if isinstance(values, (list, tuple)): + for item in values: + _reject_unconvertible_numbers(item, label) + return + # ``ndarray`` 与「``ndarray`` 的 list」都是 ``_probe_dimension`` 显式支持的 + # 输入形态,因此它们会走到这里。不递归的话,``dtype=object`` 的数组带着 + # ``10**400`` 直接进 ``np.array(..., dtype=np.float32)``,由 numpy 抛裸 + # ``OverflowError``——同一批数据,``list`` 形态得到下面那条干净的 + # ``ValueError``,``ndarray`` 形态得到 ``ArithmeticError`` 子类,收场方式 + # 不一致,且穿透调用方的 ``except ValueError`` 分类与日志脱敏。 + # 只有 ``object`` dtype 能装下 Python 大整数;数值 dtype 的取值一定在 + # double 范围内,无需遍历(``tolist()`` 对高维数组代价不低)。 + if isinstance(values, np.ndarray) and values.dtype == object: + for item in values.ravel().tolist(): + _reject_unconvertible_numbers(item, label) class EmbeddingService: @@ -20,8 +61,72 @@ def embedding_provider_key(self) -> str: return self.run_config.default_embedding_provider @property - def embedding_model_detail(self): - return self.run_config.embedding_configurations[self.embedding_provider_key] + def embedding_model_detail(self) -> Any: + """按 key 取 embedding 模型详情,缺失时给出与工厂一致的 ``ValueError``。 + + 原实现是直接下标 ``configurations[key]``,而 ``default_embedding_provider`` + 没有任何 validator(``Settings`` 里只是普通 ``str``)。打错一个 provider + 名时这里抛裸 ``KeyError``,而同一份配置走 + ``ModelProviderFactory.get_embedding_provider`` 得到的是带可选值列表的 + ``ValueError``——两条路径的报错契约本该一致,否则调用方按 + ``ValueError`` 写的错误分类与脱敏接不住 ``KeyError``。 + + 调用点包括 ``embed_query``(Google 渠道分支)与 + ``knowledge_build_service`` 的清单写入,都在检索/建库的主路径上。 + """ + configurations = self.run_config.embedding_configurations + key = self.embedding_provider_key + # 键形态必须在成员判断**之前**收敛:``key not in configurations`` 对 + # 不可哈希的取值抛裸 ``TypeError: unhashable type: 'list'``——早于本方法 + # 任何拒绝路径,而本方法其余分支一律给带可选值列表的 ``ValueError``, + # 调用方(``embed_query`` 的 Google 分支与 ``knowledge_build_service`` + # 的清单写入)也按 ``ValueError`` 分类。可达性:``default_embedding_provider`` + # 没有 validator(``Settings`` 里只是普通 ``str``),而 ``RunConfig`` 是 + # frozen dataclass、字段无校验器。判据与 + # ``ModelProviderFactory._get_model_detail`` 的同名守卫一致(那里要求 + # 「必须是字符串」),两条路径必须给出同一种收场。 + if not isinstance(key, str): + raise ValueError( + "default_embedding_provider 必须是字符串," + f"但得到 {safe_repr(key)}。" + " 请检查 config.toml 里的 default_embedding_provider 与" + " embedding_configurations 是否对应。" + ) + if not isinstance(configurations, Mapping) or key not in configurations: + # 先渲染成字符串再排序,理由同 ``display_utils._require_model_detail`` + # 的同名位置:``sorted(configurations)`` 直接比较**原始键**,混合类型 + # 键抛裸 ``TypeError: '<' not supported between instances of 'str' and + # 'int'``,而这条消息的用途正是「告诉用户可选值是什么」。 + # ``embedding_configurations`` 由 ``RunConfig`` 携带(frozen + # dataclass、字段无校验器),嵌入方可自拼 ``RunConfig`` 送入任意键。 + available = ( + ", ".join(sorted(safe_repr(item) for item in configurations)) + if isinstance(configurations, Mapping) + else "无" + ) + raise ValueError( + f"在Embedding配置中未找到key: {safe_repr(key)}。可选值: {available}。" + " 请检查 config.toml 里的 default_embedding_provider 与" + " embedding_configurations 是否对应。" + ) + # 键在**不**代表值可用。``{"local-hash": None}`` 的键确实存在,于是不会 + # 走上面「未找到key」的分支,而是把 ``None`` 原样返回——两个调用点紧接着 + # 读 ``.provider``/``.model_name``,在那里抛裸 ``AttributeError``。 + # 与 ``ModelProviderFactory._get_model_detail`` 的同类情形对齐:同一个 + # ``{"k": None}`` 走工厂得到 ``ValueError``,走这里得到 ``AttributeError``, + # 而调用方按 ``ValueError`` 写的错误分类与日志脱敏接不住后者。 + # 判据用属性存在性而非 ``isinstance(..., ModelDetail)``,理由见 + # ``require_detail_attributes`` 的 docstring(测试用 ``SimpleNamespace`` + # 构造等价配置)。 + detail = configurations[key] + require_detail_attributes( + detail, + ("provider", "model_name"), + field="embedding_configurations", + key=key, + role_label="Embedding", + ) + return detail def _get_model(self): if self._embedding_model is None: @@ -46,7 +151,33 @@ async def embed_query(self, query: str) -> np.ndarray: return matrix[0] async def embed_in_batches(self, texts: list[str]) -> np.ndarray: - batch_size = max(1, self.run_config.kb_embedding_batch_size) + # 批次规模的可直写来源只有 ``RunConfig``——它是 ``frozen dataclass``, + # 字段没有校验器,而生产路径的唯一构造点 ``build_run_config`` 直接照搬 + # ``Settings.kb_embedding_batch_size``(那里有 1.._MAX_KB_CHUNK_TOKENS 的 + # 双向界)。因此嵌入方若自己拼一个 ``RunConfig``,这里的 ``max(1, ...)`` + # 只挡得住下界:实测 ``"32"``/``None`` 抛裸 + # ``TypeError: '>' not supported between instances of ...``,位数超限的 + # 整数抛 ``ValueError: Exceeds the limit``——两条都与「批次配错了」无关, + # 而本文件的其余判据都按 ``ValueError`` 收敛(见 ``_as_float32_matrix``)。 + # + # 上界必须用**本字段自己的**语义上界 ``_MAX_KB_CHUNK_TOKENS``。 + # 不能复用 ``require_scale_field``:它的上界是 + # ``_MAX_RETRIEVAL_CANDIDATES``(2000,检索候选数的上界),而 + # ``Settings.validate_positive_sizes`` 对这个字段批的界是 + # 1.._MAX_KB_CHUNK_TOKENS(1_000_000)。实测 ``kb_embedding_batch_size + # = 2001`` 被 ``Settings`` 正常接受,却会被那个 helper 判为「超出合理 + # 范围」——把一个合法配置在运行时拒掉,是比原缺陷更严重的过度收紧。 + batch_size_value = self.run_config.kb_embedding_batch_size + if isinstance(batch_size_value, bool) or not isinstance(batch_size_value, int): + raise ValueError( + f"kb_embedding_batch_size 必须是整数,但得到 {safe_repr(batch_size_value)}。" + ) + if not 1 <= batch_size_value <= _MAX_KB_CHUNK_TOKENS: + raise ValueError( + f"kb_embedding_batch_size 必须是 1 到 {_MAX_KB_CHUNK_TOKENS} 之间的整数," + f"但得到 {safe_repr(batch_size_value)}。" + ) + batch_size = batch_size_value batches: list[np.ndarray] = [] for start in range(0, len(texts), batch_size): batch = texts[start : start + batch_size] @@ -81,8 +212,50 @@ async def aclose(self) -> None: if inspect.isawaitable(result): await result + @staticmethod + def _probe_dimension(embeddings: Any) -> int | None: + """在不分配矩阵的前提下取出维度,取不到时返回 ``None``。 + + 上界校验必须在 ``np.array(...)`` **之前**:走到那一步时内存已经按该维度 + 分配过了。对 ``list`` 形态(本项目所有 embedding provider 的返回)看首行 + 长度即可,实测对 10⁷ 元素的列表耗时 9 微秒;``ndarray`` 直接读 shape; + 其余形态交给后面的正常路径处理。 + """ + if isinstance(embeddings, np.ndarray): + return int(embeddings.shape[1]) if embeddings.ndim == 2 else None + if isinstance(embeddings, (list, tuple)): + if not embeddings: + return None + first = embeddings[0] + if isinstance(first, np.ndarray): + return int(first.shape[0]) if first.ndim == 1 else None + if isinstance(first, (list, tuple)): + return len(first) + return None + @staticmethod def _as_float32_matrix(embeddings: Any, expected_rows: int | None = None) -> np.ndarray: + # 维度上界必须先于分配检查:``local-hash`` 的维度直接来自用户可写的 + # ``model_name`` 数字后缀,实测配成 ``local-hash-100000000`` 时一次 + # ``embed_texts(["测试"])`` 就分配 (1, 10⁸) 的 float32 矩阵——耗时 180 秒、 + # 峰值内存 3.9 GB;按真实知识库 115 个分块算,``np.vstack`` 需要约 4.3 GB, + # 进程会被 OOM 杀掉而不是得到一条可读的错误。与已经加过界的 + # ``chat_top_k × retrieval_candidate_multiplier`` 同一类问题。 + probed = EmbeddingService._probe_dimension(embeddings) + if probed is not None and probed > _MAX_EMBEDDING_DIMENSION: + raise ValueError( + "Embedding 维度超过上界:" + f"得到 {probed},上界 {_MAX_EMBEDDING_DIMENSION}。" + " 请检查 embedding_configurations 里的 model_name" + "(local-hash 的维度取自它的数字后缀)或 provider 的返回内容。" + ) + # 超大整数必须在 ``np.array`` **之前**拦下:numpy 走 ``float()`` 转换, + # ``10**400`` 抛裸 ``OverflowError``(它不是 ``ValueError``,穿透调用方 + # 全部 ``except ValueError`` 分类与日志脱敏),而同一批输入里的 ``inf`` + # 却能得到下面那条干净的 ``ValueError``——收场方式不一致。 + # 与 ``RetrievalService._validate_rerank_output`` 的守卫对称:那里同样 + # 收敛 provider 返回的超大整数。 + _reject_unconvertible_numbers(embeddings, "EmbeddingService 收到的向量") matrix = np.array(embeddings, dtype=np.float32) if matrix.ndim != 2: raise ValueError("EmbeddingService 期望二维向量矩阵。") @@ -91,7 +264,13 @@ def _as_float32_matrix(embeddings: Any, expected_rows: int | None = None) -> np. if expected_rows is not None and matrix.shape[0] != expected_rows: raise ValueError( "EmbeddingService 收到的向量数量与输入文本数量不一致。" - f" 输入 {expected_rows} 条,返回 {matrix.shape[0]} 条。" + # ``expected_rows`` 是调用方给的期望条数,形态不受控;``matrix`` + # 的 shape 来自刚建好的 ndarray,必是小的 Python int。前者要经 + # ``safe_repr``:裸插值在位数超限时抛 + # ``ValueError: Exceeds the limit``,把「条数不一致」这条诊断 + # 换成整数转换错误。实测 ``expected_rows=10**4301`` 时复现 + # (``10**4000`` 不会,位数在上限内)。 + f" 输入 {safe_repr(expected_rows)} 条,返回 {matrix.shape[0]} 条。" ) # 有限性是硬要求,不是洁癖:FAISS 会把 NaN 向量照单收下(ntotal 正常增加), # 直到查询时才以 3.4e38 的哨兵距离暴露出来,而那时「哪个文档坏了」已经无法 diff --git a/src/services/knowledge_build_service.py b/src/services/knowledge_build_service.py index c31e657..205c57d 100644 --- a/src/services/knowledge_build_service.py +++ b/src/services/knowledge_build_service.py @@ -32,11 +32,29 @@ def __init__( self.snapshot_repository = snapshot_repository async def build(self, splitter_structure_mode: str) -> dict[str, object]: - markdown_files = sorted(self.run_config.knowledge_base_path.glob("*.md")) - if not markdown_files: - raise FileNotFoundError( - f"知识库目录 '{self.run_config.knowledge_base_path}' 中未找到 Markdown 文件。" + # 形态先收敛:下一行直接对 ``knowledge_base_path`` 调 ``.glob()``,非 + # ``Path`` 取值在那里抛裸 ``AttributeError: 'int' object has no attribute + # 'glob'``——不是本服务其余拒绝路径承诺的带指引 ``ValueError``。 + # ``RunConfig`` 是 frozen dataclass、字段没有校验器,嵌入方可以自拼 + # ``knowledge_base_path=5`` 的配置。只接受 ``Path``(``RunConfig`` 的标注 + # 就是它),不做静默强转。 + knowledge_base_path = self.run_config.knowledge_base_path + if not isinstance(knowledge_base_path, Path): + raise ValueError( + "knowledge_base_path 必须是 Path," + f"但得到 {type(knowledge_base_path).__name__}。" + " 请检查 config.toml 里的 knowledge_base_path 配置项。" ) + # ``glob("*.md")`` 只按**名字**匹配,不看类型:一个名为 ``notes.md`` 的 + # 目录、FIFO 或符号链接都会被当成知识库文件。它们没有可读正文, + # ``_process_file`` 的 ``read_text`` 会抛 ``IsADirectoryError`` 等 + # ``OSError``——不是本服务其余拒绝路径承诺的带指引消息,而 ``build`` 由 + # CLI 与 ``main.py`` 的知识库向量化入口直接调用。这里只收**普通文件**: + # 跳过非文件条目,让其余正常文档照常构建(知识库目录里混入同名目录是 + # 导出工具与手工整理都会留下的常见形态)。 + markdown_files = sorted(path for path in knowledge_base_path.glob("*.md") if path.is_file()) + if not markdown_files: + raise FileNotFoundError(f"知识库目录 '{knowledge_base_path}' 中未找到 Markdown 文件。") snapshot_id = self.snapshot_repository.generate_snapshot_id() temp_dir = self.snapshot_repository.create_temp_snapshot_dir(snapshot_id) @@ -113,15 +131,60 @@ async def build(self, splitter_structure_mode: str) -> dict[str, object]: def _process_file(self, pipeline: Pipeline, file_path: Path) -> list[Document]: logger.info("正在处理文件: %s", file_path) - content = file_path.read_text(encoding="utf-8") + # 读取失败要把**文件名**带进错误里。知识库目录通常有几十个文件,而 + # ``UnicodeDecodeError`` 的消息只有字节偏移(``'utf-8' codec can't + # decode byte 0xff in position 0``),不说是哪个文件——用户拿到的是 + # 「程序运行期间发生错误」加一串偏移量,无从下手。实测 39 个文件的 + # 知识库里混入一个非 UTF-8 文件即是这种收场。 + # + # ``OSError`` 一并接住并带上文件名:权限不足(``PermissionError``)、 + # 读到一半被删除(``FileNotFoundError``)、条目其实是特殊文件 + # (``IsADirectoryError``)都会让 ``read_text`` 抛它,而裸 ``OSError`` + # 的消息虽有系统错误码,却没有「知识库目录里的哪个文件」这一层定位。 + try: + content = file_path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise ValueError( + f"知识库文件不是 UTF-8 编码,无法读取: {file_path}。" + f" 请把该文件转为 UTF-8 后重试({type(exc).__name__}: {exc})。" + ) from exc + except OSError as exc: + raise ValueError( + f"知识库文件无法读取: {file_path}。" + f" 请检查该文件的权限与类型({type(exc).__name__}: {exc})。" + ) from exc document = Document(content=content, metadata={"source": str(file_path)}) chunks = pipeline.process(document) return chunks @staticmethod def _compute_source_digest(markdown_files: Iterable[Path]) -> str: + """对知识库文件做内容摘要,读取失败给出与 ``_process_file`` 同一契约的指引。 + + 这是同一个 `*.md` 清单的**第二处**读取点,也是 ``06f6a67`` 漏掉的那半: + ``_process_file`` 逐个 ``read_text`` 已把 ``UnicodeDecodeError``/``OSError`` + 收敛成带文件名的 ``ValueError``;而本方法在**全部 embedding 完成之后** + (`build` 的 `source_digest=...` 那一步)再读一遍同一批文件,失败时抛的是 + 裸 ``PermissionError``/``FileNotFoundError``。 + + 两处差异在实测下都真实:`glob` 之后文件被删(TOCTOU)抛 + ``FileNotFoundError: No such file or directory: ...``;`chmod 000` 后抛 + ``PermissionError: [Errno 13] Permission denied``。此时 embedding 已经 + 全部跑完(最贵的一步),失败收场却是一条没有定位、没有「重试什么」指引的 + 裸 ``OSError``——不是 `build` 其余拒绝路径承诺的带指引 ``ValueError``。 + + 按文件收敛而不是整体 ``try``:一条坏文件要报出**是哪个文件**,与 + ``_process_file`` 的两条 `except` 逐字对齐。 + """ digest = hashlib.sha256() for file_path in markdown_files: + try: + content = file_path.read_bytes() + except OSError as exc: + raise ValueError( + f"知识库文件无法读取: {file_path}。" + f" 请检查该文件的权限与类型({type(exc).__name__}: {exc})。" + ) from exc digest.update(str(file_path).encode("utf-8")) - digest.update(file_path.read_bytes()) + digest.update(content) return digest.hexdigest() diff --git a/src/services/retrieval_service.py b/src/services/retrieval_service.py index c1575d1..759fcf1 100644 --- a/src/services/retrieval_service.py +++ b/src/services/retrieval_service.py @@ -2,6 +2,7 @@ import asyncio import copy +import hashlib import inspect import json import math @@ -15,12 +16,36 @@ from src.retrieval.vdb.base import VectorStoreBase from src.runtime.contracts import SessionConfig from src.services.embedding_service import EmbeddingService -from src.utils.config import RetrievalMethod +from src.ui.display_utils import coerce_score, document_content +from src.utils.config import ( + _MAX_RETRIEVAL_CANDIDATES, + RetrievalMethod, + is_double_convertible, + parse_decimal_int, + require_detail_attributes, + require_provider_name, + require_rerank_enabled, + require_retrieval_method, + require_scale_field, + safe_repr, + to_finite_float, +) from src.utils.log_manager import get_module_logger logger = get_module_logger(__name__) DEFAULT_RRF_K = 60.0 +# RRF 分母里 rank 的上界。``rrf_k + rank`` 是实际参与浮点运算的派生值,两者各自 +# 都能表示成 double 而和不能,因此 rank 需要一个能让和落在 double 范围内的界。 +# 检索结果数远小于它(``chat_top_k`` 上界 2000,候选倍数上界 100),因此这个界 +# 只可能被畸形数据触及,不会限制任何真实场景。 +_MAX_RRF_RANK = 10**9 + +# 与 ``safe_repr`` 的默认 ``limit`` 保持一致(见其定义)。``_key_text`` 用它判断 +# ``repr(int)`` 是否会被 ``safe_repr`` 截断——一旦截断就可能把不同整数塌成同一 +# 键,需改走摘要。 +_KEY_TEXT_TRUNCATE_LIMIT = 80 + class HybridReranker: def __init__( @@ -32,10 +57,36 @@ def __init__( ): self.vector_weight = vector_weight self.keyword_weight = keyword_weight + # 先判类型再调 ``.strip()``:非字符串取值抛 ``AttributeError``,而本类 + # 的契约是 ``ValueError``(``SessionConfig._validate_fusion_strategy`` + # 与 ``RecursiveTextSplitter.structure_mode`` 都已按同一判据修过)。 + # 本类是公开边界,``SessionConfig`` 之外还有库调用方。 + if not isinstance(fusion_strategy, str): + raise ValueError(f"不支持的混合检索融合策略: {safe_repr(fusion_strategy)}") self.fusion_strategy = fusion_strategy.strip().lower() - self.rrf_k = rrf_k + # ``rrf_k`` 必须有下界:它是 RRF 分母 ``rrf_k + rank`` 的一部分, + # ``rrf_k = -rank`` 时抛 ``ZeroDivisionError``(实测 ``rrf_k=-60`` 配 + # ``semantic_rank=60``)。非有限值与超大整数同样要拦——``rrf_k=nan`` + # 会让每个得分都是 ``nan``,而 ``nan`` 与分数阈值的比较恒为 False, + # 整批结果被静默丢弃;``rrf_k=10**400`` 抛裸 ``OverflowError``(它不是 + # ``ValueError``,穿透全部 ``except ValueError`` 分类)。 + # + # 上界取 ``is_double_convertible``:``rrf_k`` 只作为分母出现,任何能 + # 表示成 C double 的正数都是安全的。 + if ( + isinstance(rrf_k, bool) + or not isinstance(rrf_k, (int, float)) + or not is_double_convertible(rrf_k) + ): + raise ValueError(f"rrf_k 必须是有限数值,但得到 {safe_repr(rrf_k)}。") + if float(rrf_k) <= 0: + raise ValueError( + f"rrf_k 必须是正数,但得到 {safe_repr(rrf_k)}。" + " 它作为 RRF 的分母 ``rrf_k + rank``,非正数会导致除零或负权重。" + ) + self.rrf_k = float(rrf_k) if self.fusion_strategy not in {"rrf", "weighted"}: - raise ValueError(f"不支持的混合检索融合策略: {fusion_strategy}") + raise ValueError(f"不支持的混合检索融合策略: {safe_repr(fusion_strategy)}") @staticmethod def _normalize_scores(scores: list[float]) -> list[float]: @@ -49,21 +100,97 @@ def _normalize_scores(scores: list[float]) -> list[float]: return [max(0.0, min(1.0, (score - min_score) / score_range)) for score in scores] def rerank(self, documents: list[dict[str, Any]]) -> list[dict[str, Any]]: + # 入参形态与构造器/``_weight_tuple`` 同判据:本类的 docstring 明确 + # 「库调用方可能绕过 ``Settings`` 与 ``SessionConfig`` 直接构造」,那两个 + # 入口都为此收敛了,唯独入参这一半漏了。两个内部分支都直接 + # ``for doc in documents`` 再对 ``doc.get(...)`` 取值,非列表取值的收场 + # 不在契约内:``5``/``None``/``object()`` 抛裸 + # ``TypeError: ... is not iterable``;``"abc"``/``{"a": 1}`` 抛 + # ``AttributeError: 'str' object has no attribute 'get'``(逐字符/逐键 + # 迭代)。两者都不是 ``ValueError``,穿透调用方按 ``ValueError`` 建立的 + # 分类。判据与生产路径共用 ``_require_document_list``。 + documents = _require_document_list(documents, "混合检索融合") if self.fusion_strategy == "weighted": return self._weighted_rerank(documents) return self._rrf_rerank(documents) def _weight_tuple(self) -> tuple[float, float]: - total = self.vector_weight + self.keyword_weight - if total <= 0: - return 0.0, 0.0 + """返回归一化后的 (向量权重, 关键词权重)。 + + 两个权重同时为零时显式报错而不是返回 ``(0.0, 0.0)``:后者会让每个 + 文档得分都是 0.0,再被分数阈值全部过滤,表现为「检索无结果」而非 + 配置错误。这是所有构造路径的最后一道兜底(``Settings`` 与 + ``SessionConfig`` 各自也有校验,但库调用方可能绕过两者直接构造)。 + """ + # 判据用 ``is_double_convertible`` 而不是 ``math.isfinite``:后者对超出 + # C double 的整数**自己就抛** ``OverflowError``(实测 ``math.isfinite(10**400)`` + # 抛错),拿它当守卫等于没有守卫。这是本方法作为「所有构造路径的最后一道 + # 兜底」必须自己守住的——库调用方可以绕过 ``Settings`` 与 ``SessionConfig`` + # 直接构造 ``HybridReranker``。 + if not is_double_convertible(self.vector_weight) or not is_double_convertible( + self.keyword_weight + ): + raise ValueError( + "混合检索的 vector_weight 与 keyword_weight 必须是和为正的有限数值," + f"但得到 vector_weight={safe_repr(self.vector_weight)}、" + f"keyword_weight={safe_repr(self.keyword_weight)}。" + ) + # ``total`` 是**派生值**,必须自己判一次可转换性:两个权重各自都在 + # double 范围内(上面那两处 ``is_double_convertible`` 放行),但它们的 + # **和**可以超出——实测 ``HybridReranker(10**308, 10**308)`` 构造成功, + # 随后任意一次 ``rerank()`` 在 ``math.isfinite(total)`` 上抛 + # ``OverflowError``(``int`` 太大转不成 float)。该异常是 + # ``ArithmeticError`` 子类而**不是** ``ValueError``,穿透本类调用方的 + # 全部 ``except ValueError`` 分类,退化为裸 traceback。 + # 用 ``float()`` 相加而不是直接相加:两个 int 相加得到的是 int,其值 + # 可以远超 double 范围,下一行的 ``math.isfinite`` 随即抛 + # ``OverflowError``。先转 float 则溢出表现为 ``inf``,被 ``isfinite`` + # 正常判为非法,收场是带指引的 ``ValueError``。 + total = float(self.vector_weight) + float(self.keyword_weight) + if not math.isfinite(total) or total <= 0: + # ``nan`` 也走这一支:``nan <= 0`` 为 False,不显式判有限性就会 + # 漏过去,随后每个文档得分都是 ``nan``——排序结果不确定,且 + # ``nan`` 与分数阈值的比较恒为 False,整批结果被静默丢弃。 + raise ValueError( + "混合检索的 vector_weight 与 keyword_weight 必须是和为正的有限数值," + f"但得到 vector_weight={safe_repr(self.vector_weight)}、" + f"keyword_weight={safe_repr(self.keyword_weight)}。" + " 两个权重同时为 0(或含非有限值)会让所有文档得分都是 0 或 nan," + "再被分数阈值过滤,检索会静默返回空结果。" + ) + # **单个**权重还要落在 ``[0, 1]``:上面那条只判了「和为正」,于是 + # ``(-0.5, 1.5)`` 这种「一个为负、另一个补足和」的取值照常通过——而 + # ``total`` 恰为 1.0 时归一化返回的就是 ``(-0.5, 1.5)`` 本身(不是把它 + # 压回正值)。负权重让 weighted 融合按**反相关**打分:语义路得分高的文档 + # 得到更低的分数。实测走 ``retrieve``(``vector=-0.5``/``keyword=1.5``, + # 语义路 0.9 与 0.1 两条、关键词路 0.9 一条):结果从 3 条变成 2 条, + # 语义路的高分文档直接消失——「排序反了」被伪装成「结果少了几条」。 + # + # 值域判据与**写入侧**同源:``SessionConfig._validate_unit_weight`` 与 + # ``Settings.validate_hybrid_weights`` 都要求 0.0 到 1.0。本方法是这三条 + # 构造路径的共用兜底,这一半此前漏了。不担心过度收紧:两个权重都在 + # ``[0, 1]`` 且和为正时,归一化结果与任何一种合法加权等价(端点 + # ``(0, 1)``/``(1, 0)`` 也照常放行)。 + for label, weight in ( + ("vector_weight", self.vector_weight), + ("keyword_weight", self.keyword_weight), + ): + numeric = float(weight) + if not 0.0 <= numeric <= 1.0: + raise ValueError( + f"混合检索的 {label} 必须在 0.0 到 1.0 之间," + f"但得到 {safe_repr(weight)}(vector_weight=" + f"{safe_repr(self.vector_weight)}、keyword_weight=" + f"{safe_repr(self.keyword_weight)})。" + " 负权重会让加权融合按反相关的方向打分(高分文档被排到后面)。" + ) return self.vector_weight / total, self.keyword_weight / total def _weighted_rerank(self, documents: list[dict[str, Any]]) -> list[dict[str, Any]]: vector_weight, keyword_weight = self._weight_tuple() scored_documents = [copy.deepcopy(doc) for doc in documents] - keyword_scores = [float(doc.get("keyword_score", 0) or 0) for doc in scored_documents] - semantic_scores = [float(doc.get("semantic_score", 0) or 0) for doc in scored_documents] + keyword_scores = [coerce_score(doc.get("keyword_score")) for doc in scored_documents] + semantic_scores = [coerce_score(doc.get("semantic_score")) for doc in scored_documents] normalized_keyword = self._normalize_scores(keyword_scores) normalized_semantic = self._normalize_scores(semantic_scores) for index, doc in enumerate(scored_documents): @@ -77,45 +204,399 @@ def _rrf_rerank(self, documents: list[dict[str, Any]]) -> list[dict[str, Any]]: vector_weight, keyword_weight = self._weight_tuple() scored_documents = [copy.deepcopy(doc) for doc in documents] for doc in scored_documents: - semantic_rank = int(doc.get("semantic_rank") or 0) - keyword_rank = int(doc.get("keyword_rank") or 0) + # rank 来自 store 返回的文档(``semantic_rank`` 由本文件写入,但 + # ``keyword_rank`` 可以是第三方 store 原样带进来的值),因此形态 + # 不受控。``int()`` 会抛裸 ``ValueError``(非十进制字符或位数超限), + # 而 ``rrf_k + rank`` 是**派生值**:两个数各自都能表示成 double, + # 其和不能——实测 ``semantic_rank = 10**400`` 让该分母抛 + # ``OverflowError``,穿透调用方的全部 ``except ValueError`` 分类。 + # 非正或超范围的 rank 视为「未参与该路检索」,与 0 同一处理。 + semantic_rank = _coerce_rank(doc.get("semantic_rank")) + keyword_rank = _coerce_rank(doc.get("keyword_rank")) semantic_rrf = 1.0 / (self.rrf_k + semantic_rank) if semantic_rank > 0 else 0.0 keyword_rrf = 1.0 / (self.rrf_k + keyword_rank) if keyword_rank > 0 else 0.0 doc["score"] = vector_weight * semantic_rrf + keyword_weight * keyword_rrf return sorted(scored_documents, key=lambda item: item["score"], reverse=True) +def _is_hashable(value: Any) -> bool: + """判断取值能否作为 ``cache_key`` 元组的一项。 + + ``cache_key`` 会喂给 ``self._rerank_providers``(普通 ``dict``),元组里每 + 一项都要可哈希,否则在 ``dict.get`` 处抛裸 ``TypeError: unhashable type: + 'list'``——它不是 ``ValueError``,穿透调用方的错误分类。 + + 判据用**可哈希**而不是 ``isinstance(..., str)``:这些字段只是用来区分缓存 + 条目,非字符串但可哈希的取值(整数、元组)本来就能正常工作,按类型收窄会 + 误伤它们;位数超限的整数是可哈希的,不必在此拦截。 + """ + try: + hash(value) + except TypeError: + return False + return True + + +def _stable_fingerprint(value: Any) -> str: + """把任意值渲染成**能区分不同取值**的稳定字符串,且绝不抛异常。 + + ``repr()`` 对含超大整数的容器抛 ``ValueError``;``safe_repr`` 虽不抛,但对 + 容器只退回 ````/````,丢掉了区分度——用它做缓存键会让两份不同的 + ``options`` 撞到同一个 provider 实例。这里递归到叶子:整数走 ``safe_repr`` + 的量级描述,其余保持 ``repr`` 的形态。 + + dict/list/tuple/set/frozenset 都会**递归**(集合此前落到 ``repr()`` + 兜底,含超大整数时整个塌成 ````),并沿当前路径记录容器 ``id`` 以截断 + 自引用环——环上返回 ```` 而不是无限递归。 + + 非环的**深**嵌套仍受解释器递归深度限制(约 500 层),与修改前的行为一致; + ``tomllib`` 在约 900 层解析期就先抛 ``RecursionError``,因此配置来源到不了 + 那个深度。 + """ + return _fingerprint(value, set()) + + +def _fingerprint(value: Any, seen: set[int]) -> str: + """``_stable_fingerprint`` 的递归实现,``seen`` 记录当前路径上的容器身份。 + + 用「路径上的 id 集合」而不是全局去重:同一个对象在**兄弟**位置出现两次是 + 正常取值(``{"a": shared, "b": shared}``),只有回到**祖先**才是环。 + """ + if isinstance(value, dict): + if id(value) in seen: + return "" + seen.add(id(value)) + try: + return ( + "{" + + ", ".join( + f"{_fingerprint(k, seen)}: {_fingerprint(v, seen)}" for k, v in value.items() + ) + + "}" + ) + finally: + seen.discard(id(value)) + # 集合也要**递归**,不能落到 ``repr()`` 兜底:集合含位数超限的整数时 + # ``repr()`` 抛 ``ValueError``,整个集合会塌成 ````,区分度归零。 + # 实测 ``options={"x": {10**4301}}`` 与 ``{"x": {10**4302}}`` 都通过 + # ``validate_secret_free_options``,塌陷后两者得到同一个缓存键——两份不同的 + # rerank 配置静默复用同一个 provider,比崩溃更糟。 + # + # 排序取稳定顺序:集合本身无序,``repr`` 的顺序还受哈希随机化影响,不排序 + # 会让**同一个**取值在两次调用间产生不同的指纹(缓存永远命中不了)。 + # 元素用各自渲染结果的 ``repr`` 排序,避免直接比较不可比较的元素类型。 + if isinstance(value, (set, frozenset)): + if id(value) in seen: + return "" + seen.add(id(value)) + try: + items = sorted(_fingerprint(item, seen) for item in value) + prefix = "set" if isinstance(value, set) else "frozenset" + return f"{prefix}({{" + ", ".join(items) + "})" + finally: + seen.discard(id(value)) + if isinstance(value, (list, tuple)): + if id(value) in seen: + return "" + seen.add(id(value)) + try: + return "[" + ", ".join(_fingerprint(item, seen) for item in value) + "]" + finally: + seen.discard(id(value)) + try: + return repr(value) + except (ValueError, RuntimeError, RecursionError): + # 只有位数超限的整数会走到这里。``safe_repr`` 的量级描述(「约 N 位 + # 十进制」)对**同位长**的不同整数是同一个字符串,做缓存键会碰撞; + # 补上 ``bit_length`` 与 ``hash`` 使取值可区分。``hash`` 对 int 不经 + # 十进制转换,不会抛;缓存本身是进程内的,不要求跨进程稳定。 + if isinstance(value, int): + return f"" + return f"<{type(value).__name__}>" + + +def _coerce_threshold(value: Any) -> float: + """把分数阈值收敛成 ``[0, 1]`` 的有限浮点,非法取值给出可读 ``ValueError``。 + + ``retrieve`` 直接算 ``coerce_score(...) >= session_config.score_threshold``, + 而该字段可被直写(见 ``require_scale_field``)。非数值形态抛裸 + ``TypeError: '>=' not supported between instances of 'float' and 'NoneType'`` + ——落在 ``retrieve`` 既有的错误分类之外。``bool`` 显式拒绝:``True`` 会静默 + 变成 ``1.0``,看起来像一个真实阈值。 + + **值域同样要判**:本函数此前只收敛形态(是不是有限数值),而**写入侧**有完整 + 的值域判据(``SessionConfig._validate_unit_weight``/ + ``Settings.validate_chat_score_threshold``:必须在 0.0 到 1.0 之间)。同一个 + 字段的两个入口判据不一致,缺口还是**静默**的: + + * ``score_threshold = 2.0`` —— ``coerce_score(...) >= 2.0`` 对所有文档恒为假, + **每一次检索都返回空列表且无任何提示**,正是本项目反复认领的「静默返回空 + 结果」收场(``Settings`` 的 validator 与 ``SessionConfig.__setitem__`` 对 + 同一个取值都直接拒绝); + * ``score_threshold = -1.0`` —— 恒为真,阈值形同不存在,用户以为在过滤。 + + ``coerce_score`` 的输出已经被收敛到 ``[0, 1]``(非有限值退回 ``default``), + 因此用取值域 ``[0, 1]`` 的阈值总能表达出「全过」与「全滤」两个端点,收紧不会 + 误伤任何合法配置。 + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"score_threshold 必须是数值,但得到 {safe_repr(value)}。") + number = to_finite_float(value) + if number is None: + raise ValueError(f"score_threshold 必须是有限数值,但得到 {safe_repr(value)}。") + if not 0.0 <= number <= 1.0: + raise ValueError( + f"score_threshold 必须在 0.0 到 1.0 之间,但得到 {safe_repr(value)}。" + " 它会与 0.0 到 1.0 的归一化得分比较:大于 1 会让每一次检索都返回空结果," + "小于 0 则等于不过滤。请检查该配置项。" + ) + return number + + +def _coerce_rerank_enabled(value: Any) -> bool: + """确认 ``rerank_enabled`` 是真正的布尔值。 + + 原先走 ``if not session_config.rerank_enabled``,真值语义会把 ``None``/``0``/ + ``[]`` 静默当成「禁用」、把 ``"no"`` 当成「启用」——用户看到的是 rerank 行为 + 与配置不符,而不是一条配置错误。``SessionConfig`` 的校验器要求 ``bool``, + 这里对直写路径保持同一判据。 + + 判据与措辞都交给 ``require_rerank_enabled``:UI 的 5 个读取点此前用真值判断 + (直写 ``"false"`` 时配置表显示「启用」),现在与这里共用同一实现,读出来的 + 收场与写进去的拒绝一致。 + """ + return require_rerank_enabled(value) + + +def _coerce_fusion_strategy(value: Any) -> str: + """把融合策略收敛成规范形态,非法取值给出带字段名的 ``ValueError``。 + + 归一化(``.strip().lower()``)必须发生在这里,**且只发生在这里**: + ``HybridReranker`` 的构造器对同一字段做 ``fusion_strategy.strip().lower()``, + 而 ``_should_apply_score_threshold`` 此前用**原始**取值与字面量 ``"rrf"`` + 比较。于是 ``"RRF"``/``" rrf "`` 走的是 RRF 融合(分数是排名倒数之和), + 却**不**被识别为 RRF,阈值过滤照常套用——两种量纲混在一起,结果被静默 + 清空。同一个字段在两处的判据必须一致。 + + 与 ``SessionConfig._validate_fusion_strategy`` 的关系:那里只接受已规范化 + 的 ``"rrf"``/``"weighted"``,是配置边界的严格判据;这里是运行期把可直写 + 的字段收敛到同一形态,宽容但不静默——无法识别成任一策略时仍抛错,由 + ``HybridReranker`` 给出「不支持的融合策略」。 + """ + if not isinstance(value, str): + raise ValueError(f"hybrid_fusion_strategy 必须是字符串,但得到 {safe_repr(value)}。") + return value.strip().lower() + + +def _coerce_rank(value: Any) -> int: + """把文档里的 rank 收敛成非负整数,非法形态一律视为 0(未参与该路检索)。 + + rank 来自 store 返回的文档,形态不受控。``int(value)`` 有两类失败:非十进制 + 数字字符抛 ``ValueError``,位数超过 ``sys.get_int_max_str_digits()`` 同样抛 + ``ValueError``。而更大的问题在**下游的派生值**——``rrf_k + rank`` 对超过 + double 范围的 rank 抛 ``OverflowError``(``ArithmeticError`` 子类,不是 + ``ValueError``),穿透调用方的错误分类。这里把非法值收敛成 0:它的语义就是 + 「该文档没有在这条检索路径上出现」,与 rank 缺失时一致,不会改变排序结果。 + """ + if isinstance(value, bool) or not isinstance(value, (int, str)): + return 0 + if isinstance(value, str): + parsed = parse_decimal_int(value) + if parsed is None: + return 0 + value = parsed + if not 0 <= value <= _MAX_RRF_RANK: + return 0 + return value + + +def _document_metadata(document: Any) -> dict[str, Any]: + """取出文档元数据,非映射时退回空 dict。 + + ``document.get("metadata") or {}`` 只挡得住 ``None`` 与空值:**非空的非映射** + 会原样留下,随后 ``_deduplicate_parent_documents`` 里的 ``.get("parent_id")`` + 抛 ``AttributeError: 'list' object has no attribute 'get'``——而那是检索管线 + 内部,报错现场离「store 返回了什么」很远。 + + 这里退回空 dict 而不是报错:检索结果可以来自任何 ``VectorStoreBase`` 实现, + 元数据缺失/异常时仍能按内容检索出结果,比整批失败更有用;形态**类型**上的 + 契约(必须是文档列表)由 ``_require_document_list`` 把关。 + """ + if not isinstance(document, dict): + return {} + metadata = document.get("metadata") + return metadata if isinstance(metadata, dict) else {} + + +def _require_document_list(results: Any, stage: str) -> list[dict[str, Any]]: + """确认检索阶段返回的是「文档映射的列表」。 + + ``RetrievalService`` 是三个消费者(聊天 prompt、召回测试表格、聊天详情 + 表格)的共同上游,而检索结果可以来自任何 ``VectorStoreBase`` 实现——抽象 + 基类正是为替换实现而存在的。实测返回 ``None`` 或 ``list[str]`` 的 store 会 + 在这里抛 ``TypeError: 'NoneType' object is not iterable`` 或 + ``AttributeError: 'str' object has no attribute 'get'``,报错现场离 + 「你的 store 返回了什么」很远;而在下游(``_promote_parent_context`` 等) + 才炸的话,位置更偏。 + + 这里统一转成可读的 ``TypeError``:这是**接口契约**问题,不是数据内容问题, + 因此不静默丢弃不合规的条目——丢弃会让「store 实现错了」表现为「检索结果 + 变少了」。 + """ + if not isinstance(results, list): + raise TypeError( + f"{stage}返回的必须是文档列表,但得到 {type(results).__name__}。" + " 请检查 VectorStoreBase 实现是否遵守 semantic_search/keyword_search 的契约。" + ) + for position, document in enumerate(results): + if not isinstance(document, dict): + raise TypeError( + f"{stage}返回的第 {position} 条不是文档映射" + f"({type(document).__name__})。" + " 请检查 VectorStoreBase 实现是否遵守契约。" + ) + return results + + +def _unpack_rerank_result(result: Any) -> tuple[Any, Any]: + """把 rerank provider 的返回值解包成 ``(索引, 分数)`` 二元组。 + + 不能直接写 ``a, b = await provider.arerank(...)``:``None``/单值抛裸 + ``TypeError``、三元组抛裸 ``ValueError``,而这两条都在 + ``_validate_rerank_output``(正是「校验 provider 返回值」的边界)**之前**, + 用户拿到的是「无法解包」而不是「provider 返回值不合法」——错误消息指向 + 解包语法而非真正的契约违反。 + """ + if not isinstance(result, (tuple, list)) or len(result) != 2: + raise RuntimeError( + f"Rerank provider 必须返回 (索引列表, 分数列表) 二元组,但得到 {safe_repr(result)}。" + ) + return result[0], result[1] + + +def _key_text(value: Any) -> str: + """把元数据值收敛成去重键的一部分。 + + 不能直接写 ``str(value)``:元数据来自知识快照,而本项目加载 pickle 格式的 + 快照——pickle 是二进制格式、不受 CPython 十进制位数限制,一个 4301 位的 + 整数让 ``str()`` 抛 ``ValueError: Exceeds the limit``,于是「计算文档去重 + 键」这一步在正要合并检索结果时崩掉。 + + 但 ``safe_repr`` 的降级描述**只保留量级**(``<整数,约 4302 位十进制>``), + 用它当键会让不同的超限整数塌成同一个字符串:实测 ``10**4301`` 与 + ``10**4301 + 1`` 得到同一个键,于是 ``_merge_hybrid_results`` 把两条不同的 + 文档并成一条,其中一条静默消失。位数超限的整数**无法**转成十进制文本, + 但 ``int.to_bytes`` 不经过十进制转换,可以对它取摘要——键因此既稳定又唯一。 + + 非整数的失败形态(``repr`` 抛其它异常)退回 ``safe_repr``:那时没有更好的 + 取值,而量级描述至少保持稳定。 + """ + if isinstance(value, str): + return value + if isinstance(value, int): + try: + rendered = repr(value) + except (ValueError, RuntimeError, RecursionError): + # 位数超过 ``sys.get_int_max_str_digits()``。按二进制补码取摘要, + # 符号一并编进去,避免 ``n`` 与 ``-n`` 碰撞。 + raw = value.to_bytes((value.bit_length() + 8) // 8, "big", signed=True) + digest = hashlib.sha256(raw, usedforsecurity=False).hexdigest() + return f"<整数,{digest}>" + # 位数未超限、但 ``repr`` 已超过 ``safe_repr`` 的截断长度时不能再原样 + # 返回:``safe_repr`` 会截成 ``text[:77] + "..."``,``10**100`` 与 + # ``10**100+1`` 截断后得到**同一个键**,于是 ``_merge_hybrid_results``/ + # ``_deduplicate_parent_documents`` 把两条不同文档并成一条,其中一条 + # 静默消失。只有超过截断长度才走摘要——小整数(``chunk_index=3``)必须 + # 原样保留,否则 ``source:3`` 这类普通键会被改写成摘要。 + if len(rendered) > _KEY_TEXT_TRUNCATE_LIMIT: + raw = value.to_bytes((value.bit_length() + 8) // 8, "big", signed=True) + digest = hashlib.sha256(raw, usedforsecurity=False).hexdigest() + return f"<整数,{digest}>" + return rendered + # 非 str/int 的取值(bytes/tuple/frozenset/numpy 标量等)不能退回 + # ``safe_repr``:它对 repr 超 80 字符的值截断成 ``text[:77] + "..."``, + # ``b'A'*100`` 与 ``b'A'*100+b'B'`` 会塌成同一个键——与上面的超限整数 + # 是同一类失败,``_merge_hybrid_results``/``_deduplicate_parent_documents`` + # 同样静默并条(实测复现)。统一走 ``safe_repr`` 的截断阈值:repr 未超限 + # 的取值原样保留(``b'x'`` 仍是 ``b'x'``),超限的对 repr 字节取摘要—— + # 摘要输入是文本而非二进制值,但对键唯一性而言等价。 + # 判据必须落在**真实 repr** 上而不是 ``rendered``:``safe_repr`` 截断后 + # 的输出恰好是 80 字符(``text[:77] + "..."``),拿 ``len(rendered) > 80`` + # 判断永远不会触发(实测复现)。repr 本身抛异常的取值退回 ``safe_repr`` + # 的量级描述——那时没有更好的取值。 + rendered = safe_repr(value) + try: + raw_repr = repr(value) + except Exception: # noqa: BLE001 - repr 不可用时只能量级描述 + return rendered + if len(raw_repr) > _KEY_TEXT_TRUNCATE_LIMIT: + raw = raw_repr.encode("utf-8", errors="replace") + digest = hashlib.sha256(raw, usedforsecurity=False).hexdigest() + return f"<{type(value).__name__},{digest}>" + return rendered + + def _document_key(document: dict[str, Any]) -> str: - metadata = document.get("metadata") or {} + """返回用于混合合并/去重的文档键。 + + 优先用 ``chunk_id``/``doc_id``,其次 ``source`` + ``chunk_index``。两者都 + 缺失时**不能只截取内容前缀**:截断会让两份不同的长文档拿到同一个键—— + 实测同来源、前 64 字符相同的两篇文档在 ``_merge_hybrid_results`` 里被并成 + 一条,其中一篇的结果静默消失(``_deduplicate_parent_documents`` 同理,只是 + 那里丢的是得分较低的那篇)。因此这里改用整段内容的摘要:既保持定长,又 + 不因截断而碰撞。 + + ``chunk_id`` 在真实快照里恒存在(分片阶段必然写入),所以这一支是兜底 + 路径;但兜底路径同样不能产生错误结果。 + """ + metadata = _document_metadata(document) chunk_id = metadata.get("chunk_id") or metadata.get("doc_id") if chunk_id: - return str(chunk_id) - source = str(metadata.get("source", "")) + return _key_text(chunk_id) + source = _key_text(metadata.get("source", "")) chunk_index = metadata.get("chunk_index") if chunk_index is not None: - return f"{source}:{chunk_index}" - return f"{source}:{document.get('page_content', '')[:64]}" + return f"{source}:{_key_text(chunk_index)}" + content = _key_text(document.get("page_content", "")) + digest = hashlib.sha256(content.encode("utf-8"), usedforsecurity=False).hexdigest() + return f"{source}:{digest}" def _merge_hybrid_results( semantic_results: list[dict[str, Any]], keyword_results: list[dict[str, Any]] ) -> list[dict[str, Any]]: + # 两个入参的形态判据与生产路径共用 ``_require_document_list``:本函数经 + # ``src/retrieval/retriever.py`` 的 ``__all__`` 对外导出,是公开边界。 + # ``retrieve`` 内部调用它之前已校验过两路结果,但库调用方直接调用这条路径 + # 没有——函数体第一件事就是 ``for rank, document in enumerate(...)`` 再对 + # ``document.get(...)`` 取值,非列表取值的收场不在契约内:``5``/``None``/ + # ``object()`` 抛裸 ``TypeError: ... is not iterable``;``"abc"``/ + # ``{"a": 1}`` 抛 ``AttributeError: 'str' object has no attribute 'get'`` + # (逐字符/逐键迭代)。两者都不是 ``ValueError``。 + semantic_results = _require_document_list(semantic_results, "语义检索") + keyword_results = _require_document_list(keyword_results, "关键词检索") merged: dict[str, dict[str, Any]] = {} for rank, document in enumerate(semantic_results, start=1): merged_doc = copy.deepcopy(document) - merged_doc["semantic_score"] = float(merged_doc.get("score", 0) or 0) + merged_doc["semantic_score"] = coerce_score(merged_doc.get("score")) merged_doc["semantic_rank"] = rank - merged_doc["keyword_score"] = float(merged_doc.get("keyword_score", 0) or 0) + merged_doc["keyword_score"] = coerce_score(merged_doc.get("keyword_score")) + # ``score`` 自身也要收敛:融合路径随后会由 ``HybridReranker`` 重算它, + # 但把 ``nan``/``inf``/``dict`` 留在名为 ``score`` 的字段里,等于让 + # 一个中间产物带着不可用的值往下游走——任何读到它却没过 rerank 的调用方 + # (或将来调整了顺序的流水线)都会拿到坏分数。 + merged_doc["score"] = coerce_score(merged_doc.get("score")) merged[_document_key(merged_doc)] = merged_doc for rank, document in enumerate(keyword_results, start=1): key = _document_key(document) if key in merged: - merged[key]["keyword_score"] = float(document.get("score", 0) or 0) + merged[key]["keyword_score"] = coerce_score(document.get("score")) merged[key]["keyword_rank"] = rank else: merged_doc = copy.deepcopy(document) - merged_doc["semantic_score"] = float(merged_doc.get("semantic_score", 0) or 0) - merged_doc["keyword_score"] = float(document.get("score", 0) or 0) + merged_doc["semantic_score"] = coerce_score(merged_doc.get("semantic_score")) + merged_doc["keyword_score"] = coerce_score(document.get("score")) + merged_doc["score"] = coerce_score(merged_doc.get("score")) merged_doc["keyword_rank"] = rank merged[key] = merged_doc return list(merged.values()) @@ -127,11 +608,29 @@ def _promote_parent_context( promoted_documents: list[dict[str, Any]] = [] for document in documents: promoted_doc = copy.deepcopy(document) - metadata = promoted_doc.get("metadata") or {} + metadata = _document_metadata(promoted_doc) parent_content = resolver(metadata.get("parent_id")) if resolver else None if parent_content: - metadata.setdefault("matched_chunk_content", promoted_doc.get("page_content", "")) - promoted_doc["page_content"] = parent_content + # 收敛成字符串再写入:``page_content: str`` 是 ``Document`` 的契约 + # (``src/models/document.py``),``FaissStore._validate_document_shape`` + # 在快照载入期也按这条校验,而这里此前只判真值、不判类型。 + # + # ``parent_resolver`` 取自 ``getattr(self.vector_store, + # "resolve_parent_content", None)``,而 ``VectorStoreBase`` 正是为替换 + # 实现而存在的——签名声明 ``-> str | None``,第三方实现返回别的形态不会 + # 被任何地方拦下。实测返回列表让 ``page_content`` 变成 ``['a', 'b']``、 + # 返回整数变成 ``123``:展示层 ``document_content`` 把它收敛成 ``"N/A"`` + # (用户看到「无内容」而不是报错),而 prompt 构造对正文做 ``.lower()`` + # 时抛 ``AttributeError``,检索整体不可用。 + # + # 用 ``_key_text`` 而不是裸 ``str()``:位数超限的整数(pickle 是二进制 + # 格式、不受十进制位数限制,畸形快照能带进来)会让 ``str()`` 抛 + # ``ValueError: Exceeds the limit``,而本函数的职责是「把父块内容放进 + # 正文」,不该在正要成功时崩。``_key_text`` 对这类值给量级描述。 + parent_text = _key_text(parent_content) + if parent_text: + metadata.setdefault("matched_chunk_content", promoted_doc.get("page_content", "")) + promoted_doc["page_content"] = parent_text promoted_doc["metadata"] = metadata promoted_documents.append(promoted_doc) return promoted_documents @@ -140,15 +639,24 @@ def _promote_parent_context( def _deduplicate_parent_documents(documents: list[dict[str, Any]]) -> list[dict[str, Any]]: deduplicated: dict[str, dict[str, Any]] = {} for document in documents: - metadata = document.get("metadata") or {} + metadata = _document_metadata(document) parent_key = metadata.get("parent_id") - key = str(parent_key) if parent_key else _document_key(document) + key = _key_text(parent_key) if parent_key else _document_key(document) existing = deduplicated.get(key) - if existing is None or float(document.get("score", 0) or 0) > float( - existing.get("score", 0) or 0 + if existing is None or coerce_score(document.get("score")) > coerce_score( + existing.get("score") ): deduplicated[key] = copy.deepcopy(document) - return list(deduplicated.values()) + # 输出必须恢复分数降序:输入是有序的,但去重把代表文档留在**首次出现** + # 的位置——「先见低分、后见高分」的同组文档会让输出不再有序,而调用方 + # 直接 ``[:top_k]`` 截断,实测会丢弃全局最高分文档(探针:候选 + # s1(P1,0.40)→s2(P2,0.45)→s3(P3,0.50)、top_k=2 返回 s1+s2 而丢掉 s3)。 + # 排序在截断之前做,rerank 路径随后会按自己的分数重排,不受影响。 + return sorted( + deduplicated.values(), + key=lambda document: coerce_score(document.get("score")), + reverse=True, + ) class RetrievalService: @@ -160,20 +668,47 @@ def __init__(self, vector_store: VectorStoreBase, embedding_service: EmbeddingSe async def retrieve( self, query: str, session_config: SessionConfig, console: Console | None = None ) -> list[dict[str, Any]]: - retrieval_method = session_config.retrieval_method - effective_top_k = max(1, session_config.top_k) * max( - 1, session_config.retrieval_candidate_multiplier + # 检索模式同样要收敛:它是本函数里唯一没有对应助手的字段,而 + # ``==`` 判分支**不会**对未知取值报错——``"hybrid"``/``None``/``5`` + # 全部静默落进 else 分支,变成纯关键词检索(实测)。详见该助手的说明。 + retrieval_method = require_retrieval_method(session_config.retrieval_method) + # 形态必须在这里收敛:``SessionConfig`` 的属性可被直写,而 ``max()`` 对 + # 非数值抛裸 ``TypeError``(详见 ``require_scale_field``)。 + top_k = require_scale_field(session_config.top_k, "top_k") + multiplier = require_scale_field( + session_config.retrieval_candidate_multiplier, "retrieval_candidate_multiplier" ) + # 乘积同样要判:两个字段各自合法、乘积越界是既有缺陷类(``Settings`` 与 + # ``__setitem__`` 都按组合判),这里兜住直写路径。 + if top_k * multiplier > _MAX_RETRIEVAL_CANDIDATES: + raise ValueError( + "top_k 与 retrieval_candidate_multiplier 的乘积超出合理范围:" + f" {top_k} × {multiplier} > {_MAX_RETRIEVAL_CANDIDATES}。" + " 请检查这两项配置。" + ) + effective_top_k = top_k * multiplier + # 同一类前提的其余字段:都可被直写,读取时统一收敛(详见各 helper)。 + score_threshold = _coerce_threshold(session_config.score_threshold) + rerank_enabled = _coerce_rerank_enabled(session_config.rerank_enabled) + fusion_strategy = _coerce_fusion_strategy(session_config.hybrid_fusion_strategy) parent_resolver = getattr(self.vector_store, "resolve_parent_content", None) if retrieval_method == RetrievalMethod.HYBRID_SEARCH: semantic_results, keyword_results = await self._gather_hybrid_results( query, effective_top_k ) + # 先校验两路结果的形态,再合并。``_require_document_list`` 原先只在 + # 合并与 rerank **之后**运行,而 ``_merge_hybrid_results`` 已经对每个 + # 条目调了 ``.get(...)``——store 返回 ``[None]`` 时抛的是 + # ``AttributeError: 'NoneType' object has no attribute 'get'``, + # 而不是该校验承诺的可定位 ``TypeError``(附带是哪一路出的问题)。 + # 这也让「坏结果」的收场方式在三条检索路径之间保持一致。 + _require_document_list(semantic_results, "语义检索") + _require_document_list(keyword_results, "关键词检索") reranker = HybridReranker( session_config.vector_weight, session_config.keyword_weight, - fusion_strategy=session_config.hybrid_fusion_strategy, + fusion_strategy=fusion_strategy, ) ranked_results = reranker.rerank( _merge_hybrid_results(semantic_results, keyword_results) @@ -183,17 +718,33 @@ async def retrieve( else: ranked_results = await self._keyword_retrieve(query, effective_top_k) - if self._should_apply_score_threshold(session_config): + ranked_results = _require_document_list(ranked_results, "检索") + + if self._should_apply_score_threshold(session_config, fusion_strategy=fusion_strategy): ranked_results = [ - doc - for doc in ranked_results - if float(doc.get("score", 0) or 0) >= session_config.score_threshold + doc for doc in ranked_results if coerce_score(doc.get("score")) >= score_threshold ] ranked_results = _promote_parent_context(ranked_results, parent_resolver) ranked_results = _deduplicate_parent_documents(ranked_results) - ranked_results = await self._rerank_if_needed(query, ranked_results, session_config) - return ranked_results[: max(1, session_config.top_k)] + ranked_results = await self._rerank_if_needed( + query, ranked_results, session_config, rerank_enabled=rerank_enabled, top_k=top_k + ) + # 出口统一把 ``score`` 收敛成有限浮点。三条检索路径的来源不同: + # ``FaissStore`` 自己写的是 float,第三方 store 可能是数字字符串或 + # 其它形态;融合路径会重算 score,而**语义/关键词单路不会**。不收敛的话 + # 同一份结果会出现「阈值按 0.8 通过、界面显示 0.0000」,而排序、去重与 + # 展示又各自按不同规则解释它。这里让出口的契约只有一种形态。 + for document in ranked_results: + document["score"] = coerce_score(document.get("score")) + # 用**已收敛**的 ``top_k`` 而不是重读原属性:``retrieve`` 开头就把 + # ``session_config.top_k`` 收敛进了局部变量,这里重读等于绕过那道校验。 + # 该字段可被直写,而 ``max()`` 对非数值抛裸 ``TypeError``——实测并发场景下 + # (``retrieve`` 是 async,中间有多处 ``await``)在检索飞行中把 ``top_k`` + # 直写成 ``"abc"``,本行抛 + # ``TypeError: '>' not supported between instances of 'str' and 'int'``, + # 穿透调用方的 ``except ValueError`` 分类。 + return ranked_results[: max(1, top_k)] async def _gather_hybrid_results( self, query: str, top_k: int @@ -252,17 +803,89 @@ def _get_rerank_provider( provider_key: str, configurations: Mapping[str, Any] | None = None, ) -> RerankModel: - detail = configurations.get(provider_key) if configurations is not None else None + # key 形态必须在 ``.get()`` **之前**收敛:``provider_key`` 来自可直写的 + # ``session_config.active_rerank_configuration``,不可哈希的取值在 + # ``dict.get`` 处就抛裸 ``TypeError: unhashable type: 'list'``——早于 + # ``ModelProviderFactory`` 的守卫,工厂那一侧的修复覆盖不到这条路径。 + # 位数超限的整数在下面构造 ``cache_key`` 的元组里同样要过哈希,但那种 + # 情况 ``hash`` 不抛,真正的失败在下游消息插值处,一并在这里拦掉。 + if not isinstance(provider_key, str): + raise ValueError( + "active_rerank_configuration 必须是字符串," + f"但得到 {safe_repr(provider_key)}。 请检查 rerank 配置项。" + ) + # ``configurations`` 的形态同样要判:它来自可直写的 + # ``session_config.rerank_configurations``,非映射时 ``.get`` 抛裸 + # ``AttributeError: 'int' object has no attribute 'get'``。 + if configurations is not None and not isinstance(configurations, Mapping): + raise ValueError( + "rerank_configurations 必须是配置映射," + f"但得到 {type(configurations).__name__}。 请检查 rerank 配置项。" + ) + # 配置**取值**的形态同样要判,三个失败面: + # * detail 不提供 ``provider``/``model_name``(``None``/dict/int, + # 或属性被 ``del`` 掉)——``ModelProviderFactory.get_rerank_provider`` + # 里的 ``config.provider`` 抛裸 ``AttributeError``; + # * ``provider`` 非可哈希——它进 ``cache_key`` 元组,在 + # ``self._rerank_providers.get(...)`` 处抛裸 + # ``TypeError: unhashable type: 'list'``; + # * ``model_name`` 非可哈希——同上。 + # 来源是 ``session_config.rerank_configurations`` 的值,可直写。 + # + # 判据用 ``provider_key in configurations`` 而**不是** ``detail is not None``: + # 「key 不存在」与「key 存在但取值是 ``None``」是两件事。后者在工厂里走 + # ``configurations[key]`` 拿到 ``None``(键确实在),于是不报「未找到 key」, + # 直接在 ``config.provider`` 上抛裸 ``AttributeError``。只有按成员判定才能 + # 把两者分开:前者保持原有「未找到 key」的 ``ValueError``,后者是形态错误。 + detail: Any = None + provider_field = "" + if configurations is not None and provider_key in configurations: + detail = configurations[provider_key] + # 属性**存在性**先判:``getattr`` 的默认值只挡缺失、不挡类型, + # ``del detail.provider`` 兜出的空串会把「属性没了」伪装成 + # 「provider 名为空」,一路带到工厂再抛 ``AttributeError``。 + require_detail_attributes( + detail, + ("provider", "model_name"), + field="rerank_configurations", + key=provider_key, + role_label="Rerank", + ) + provider_field = require_provider_name( + detail.provider, label=f"rerank 配置 {provider_key} 的 provider" + ) + # ``model_name`` 同样进 ``cache_key`` 元组(``detail.model_name = + # ["m"]`` 实测直接成功,会在这里抛裸 ``TypeError: unhashable type``)。 + model_name = detail.model_name + if not _is_hashable(model_name): + raise ValueError( + f"rerank 配置 {provider_key} 的 model_name 必须是可哈希的取值," + f"但得到 {safe_repr(model_name)}。" + ) options = getattr(detail, "options", {}) if detail is not None else {} try: options_fingerprint = json.dumps( options or {}, sort_keys=True, default=str, separators=(",", ":") ) except (TypeError, ValueError): - options_fingerprint = repr(options) + # 兜底也必须用 ``safe_repr``:``repr(options)`` 抛的是**同一个** + # ``ValueError``(位数超限),于是这里成了「正要兜底时再抛一次」。 + # 可达性已确认:``ModelDetail.options`` 是 ``dict[str, Any]``, + # ``validate_secret_free_options`` 只查敏感键名、不做递归数值校验, + # 实测 ``ModelDetail(..., options={"x": 10**4301})`` 被接受,随后 + # ``rerank_enabled=True`` 时每次检索都在构造缓存键处崩。 + # + # ``json.dumps(default=str)`` 拦不住这类值:``default=`` 只对 + # **未知类型**生效,``int`` 是已知类型,直接走 ``int.__repr__``。 + # + # 这里不能直接用 ``safe_repr(options)``:它对渲染失败的**容器**只 + # 退回 ````,那会让所有含超大整数的 options 得到同一个缓存键, + # 不同配置静默复用同一个 provider 实例——比崩溃更糟。逐元素渲染既 + # 保住区分度,又不抛。 + options_fingerprint = _stable_fingerprint(options) cache_key = ( provider_key, - getattr(detail, "provider", ""), + provider_field, getattr(detail, "model_name", ""), options_fingerprint, ) @@ -275,9 +898,21 @@ def _get_rerank_provider( stale_keys = [ key for key in self._rerank_providers if key[0] == provider_key and key != cache_key ] + # 逐个关闭、失败不中断:``pop`` 已经把实例从缓存里摘掉,关闭失败 + # 的实例就此彻底失去引用——循环若在这里中断,后面每个 stale 实例 + # 都是「已摘除但从未关闭」的泄漏,而这条路径发生在**配置重载** + # 时(``config_menu`` 改完 rerank 配置后立刻检索),用户不会 + # 再有机会重试。契约与 ``clear_rerank_cache``/``close``/``aclose`` + # 一致:关闭全部,再报第一个错误。 + first_error: Exception | None = None for stale_key in stale_keys: stale_provider = self._rerank_providers.pop(stale_key) - close_resource_sync(stale_provider, "Rerank Provider") + try: + close_resource_sync(stale_provider, "Rerank Provider") + except Exception as exc: # noqa: BLE001 - close all resources before reporting + first_error = first_error or exc + if first_error is not None: + raise first_error if configurations is None: provider = ModelProviderFactory.get_rerank_provider(provider_key) else: @@ -286,71 +921,184 @@ def _get_rerank_provider( return provider def clear_rerank_cache(self) -> None: - """关闭并清空已缓存的 Rerank Provider。""" + """关闭并清空已缓存的 Rerank Provider。 + + 单个 Provider 关闭失败**不能**中断其余 Provider 的释放:本方法是 + ``close``/``aclose`` 的第一段,也是 ``RetrievalService`` 唯一的 + 重排资源出口。原实现直接调 ``close_resource_sync``,第一个抛异常的 + Provider 会让循环当场退出——后面所有 Provider 的传输层与凭证连接池 + 全部泄漏,而泄漏发生在**退出路径**上,用户不会再有机会重试。 + + 契约与四个 Provider 自己的 ``close``/``aclose`` 逐字对齐:先关闭全部 + 资源,再报出第一个错误(``src/providers/*.py`` 的同名实现里写作 + ``first_error = first_error or exc`` + ``raise first_error``)。 + 同一件「释放资源」的事,Provider 层已经是这个契约,服务层此前漏了。 + """ providers = list(self._rerank_providers.values()) self._rerank_providers.clear() closed: set[int] = set() + first_error: Exception | None = None for provider in providers: if id(provider) in closed: continue closed.add(id(provider)) - close_resource_sync(provider, "Rerank Provider") + try: + close_resource_sync(provider, "Rerank Provider") + except Exception as exc: # noqa: BLE001 - close all resources before reporting + first_error = first_error or exc + if first_error is not None: + raise first_error def close(self) -> None: - """释放重排缓存及 EmbeddingService 持有的 Provider。""" - self.clear_rerank_cache() + """释放重排缓存及 EmbeddingService 持有的 Provider。 + + 与 ``clear_rerank_cache`` 同一契约:重排 Provider 关闭失败时仍要继续 + 关闭 Embedding Provider,最后再报出第一个错误。原实现让 + ``clear_rerank_cache`` 的异常直接冒泡,Embedding Provider 的连接池 + 连同它的 SDK 客户端一并泄漏——而调用方(``Chatbot.close``)虽然接了 + 这个异常并记了日志,泄漏本身并不会因此被回收。 + """ + first_error: Exception | None = None + try: + self.clear_rerank_cache() + except Exception as exc: # noqa: BLE001 - close all resources before reporting + first_error = exc close_embedding = getattr(self.embedding_service, "close", None) if callable(close_embedding): - close_embedding() + try: + close_embedding() + except Exception as exc: # noqa: BLE001 - close all resources before reporting + first_error = first_error or exc + if first_error is not None: + raise first_error async def aclose(self) -> None: - """异步释放重排缓存及 EmbeddingService 持有的 Provider。""" + """异步释放重排缓存及 EmbeddingService 持有的 Provider。 + + 与 ``close`` 同一契约,且两处必须一致:``aclose`` 的循环此前同样在 + 第一个失败处中断,于是重排 Provider 的异步连接池与 Embedding Provider + 一并泄漏。调用点 ``src/retrieval/retriever.py:129`` 在 ``finally`` 里 + 裸 ``await`` 本方法——关闭失败会顶替掉一次**成功**的检索返回值,因此 + 这里更要紧的是「先把所有资源释放干净」,而不是提前中断。 + """ providers = list(self._rerank_providers.values()) self._rerank_providers.clear() closed: set[int] = set() + first_error: Exception | None = None for provider in providers: if id(provider) in closed: continue closed.add(id(provider)) - close = getattr(provider, "aclose", None) - if callable(close): - result = close() - if inspect.isawaitable(result): - await result - continue - close = getattr(provider, "close", None) - if callable(close): - result = close() - if inspect.isawaitable(result): - await result + try: + close = getattr(provider, "aclose", None) + if callable(close): + result = close() + if inspect.isawaitable(result): + await result + continue + close = getattr(provider, "close", None) + if callable(close): + result = close() + if inspect.isawaitable(result): + await result + except Exception as exc: # noqa: BLE001 - close all resources before reporting + first_error = first_error or exc close_embedding = getattr(self.embedding_service, "aclose", None) if not callable(close_embedding): close_embedding = getattr(self.embedding_service, "close", None) if callable(close_embedding): - result = close_embedding() - if inspect.isawaitable(result): - await result + try: + result = close_embedding() + if inspect.isawaitable(result): + await result + except Exception as exc: # noqa: BLE001 - close all resources before reporting + first_error = first_error or exc + if first_error is not None: + raise first_error async def _rerank_if_needed( - self, query: str, ranked_results: list[dict[str, Any]], session_config: SessionConfig + self, + query: str, + ranked_results: list[dict[str, Any]], + session_config: SessionConfig, + *, + rerank_enabled: bool | None = None, + top_k: int | None = None, ) -> list[dict[str, Any]]: - if not session_config.rerank_enabled or not ranked_results: + """按需重排。 + + ``rerank_enabled`` 由调用方传入**已收敛**的布尔:``retrieve`` 已经在 + 检索开始前校验过(早于任何检索工作,失败更快),这里再读一次原属性会 + 绕过那道校验——该字段可被直写。真值语义会把 ``None``/``0``/``[]`` + 静默当成「禁用」、把 ``"no"`` 当成「启用」(见 + ``_coerce_rerank_enabled``)。默认 ``None`` 时自行收敛,供库调用方直接 + 调本方法的场景使用。 + """ + # 上面那段契约此前只覆盖了「默认 ``None`` 时自行收敛」这一半,而 + # **调用方传入的取值**原样使用。库调用方正是本方法 docstring 点名的场景, + # 它没有 ``retrieve`` 开头那道收敛,于是同一个字段在这里分成两套判据: + # + # * ``rerank_enabled="no"`` —— 真值判断为 True,**真的去调 rerank + # provider**(实测触发建 provider 的整条链路,凭证缺失时以 + # ``ValueError: SILICONFLOW_API_KEY is required`` 收场),而 + # ``SessionConfig`` 对同一个值直接报「必须是布尔值」; + # * ``rerank_enabled=0``/``[]`` —— 恰好与 ``False`` 走同一分支, + # 不报错,但「能工作」靠的是真值语义的巧合,而不是判据。 + # + # 与 ``top_k`` 的处置完全一致:显式传入的取值同样要过一遍**同一组** + # 助手,而不是只判默认分支。``None`` 保留给「未传入」这一哨兵语义。 + if rerank_enabled is not None: + rerank_enabled = _coerce_rerank_enabled(rerank_enabled) + enabled = ( + _coerce_rerank_enabled(session_config.rerank_enabled) + if rerank_enabled is None + else rerank_enabled + ) + if not enabled or not ranked_results: return ranked_results + # 与 ``top_k``/``rerank_enabled`` 同一处置:本方法的 docstring 明确 + # 「供库调用方直接调本方法」,而库调用方不经过 ``retrieve`` 里那道 + # ``_require_document_list``。入参形态不受控,实测非列表取值的收场都不在 + # 契约内:``"abc"`` 是合法可迭代对象,逐**字符**进入下面的 + # ``document["score"] = ...`` 后抛 ``TypeError: 'str' object does not + # support item assignment``;``5``/``10**4301``/``object()`` 抛裸 + # ``TypeError: ... is not iterable``;``{"a": 1}`` 抛 ``KeyError: 0`` + # (字典迭代出的是键)。三种都不是 ``ValueError``,穿透调用方按 + # ``ValueError`` 建立的分类。判据与生产路径共用 ``_require_document_list``。 + ranked_results = _require_document_list(ranked_results, "重排") + rerank_provider = self._get_rerank_provider( session_config.active_rerank_configuration, session_config.rerank_configurations, ) - docs_to_rerank = [doc.get("page_content", "") for doc in ranked_results] + # 内容形态由 ``document_content`` 收敛:直接取 ``page_content`` 会把 + # ``None``/``42`` 这类值交给 provider,由它的 ``_validate_inputs`` 抛 + # 「documents 必须全部是字符串」——错误信息指向 provider 的参数,而真正 + # 的原因是上游 store 返回了形态异常的文档。在这里收敛更靠近原因。 + docs_to_rerank = [document_content(doc, "") for doc in ranked_results] # Keep the requested output bounded by the available candidates after # thresholding and parent de-duplication. - rerank_top_n = max(1, min(session_config.top_k, len(docs_to_rerank))) - reranked_indices, reranked_scores = await rerank_provider.arerank( - query, docs_to_rerank, top_n=rerank_top_n + # 与 ``rerank_enabled`` 同一处置:优先用调用方传入的**已收敛**取值。 + # 本方法的 docstring 明确「供库调用方直接调本方法」,而库调用方不经过 + # ``retrieve`` 开头那道收敛——实测直写 ``top_k="abc"``/``None``/``[1]`` + # 时 ``min()`` 抛裸 ``TypeError: '<' not supported between instances of + # 'int' and 'str'``,不是 ``ValueError``。 + # + # 但「优先用传入值」只覆盖了默认分支那一半:调用方**显式传入**的取值 + # 此前原样使用,于是同一个形参的两种来源判据不一致——不传时收敛、传了 + # 反而跳过。库调用方恰恰总是显式传(``retrieve`` 也传)。这里把显式取值 + # 也过一遍同一个 ``require_scale_field``,与 ``rerank_enabled`` 的处置 + # 逐字对齐;``None`` 保留给「未传入」这一哨兵语义。 + if top_k is not None: + top_k = require_scale_field(top_k, "top_k") + effective_top_k = ( + require_scale_field(session_config.top_k, "top_k") if top_k is None else top_k ) + rerank_top_n = max(1, min(effective_top_k, len(docs_to_rerank))) + rerank_result = await rerank_provider.arerank(query, docs_to_rerank, top_n=rerank_top_n) reranked_indices, reranked_scores = self._validate_rerank_output( - reranked_indices, - reranked_scores, + *_unpack_rerank_result(rerank_result), document_count=len(ranked_results), ) reranked_docs: list[dict[str, Any]] = [] @@ -392,6 +1140,12 @@ def _validate_rerank_output( seen_indices.add(index) if isinstance(score, bool): raise RuntimeError(f"Rerank provider 返回的 score 无效: index={index}。") + # ``float(10**400)`` 抛 ``OverflowError``——它不是 ``ValueError``, + # 因此下面这个 ``except`` 接不住。provider 返回值来自网络 JSON, + # 里面出现一个超大整数字面量就足以走到这里。先判双精度范围, + # 把它收敛成本方法承诺的 ``RuntimeError``。 + if isinstance(score, int) and not is_double_convertible(score): + raise RuntimeError(f"Rerank provider 返回的 score 超出双精度范围: index={index}。") try: normalized_score = float(score) except (TypeError, ValueError) as exc: @@ -403,8 +1157,38 @@ def _validate_rerank_output( return normalized_indices, validated_scores @staticmethod - def _should_apply_score_threshold(session_config: SessionConfig) -> bool: - return not ( - session_config.retrieval_method == RetrievalMethod.HYBRID_SEARCH - and session_config.hybrid_fusion_strategy == "rrf" + def _should_apply_score_threshold( + session_config: SessionConfig, *, fusion_strategy: str | None = None + ) -> bool: + """RRF 融合的分数是排名倒数之和,与阈值不同量纲,因此跳过阈值过滤。 + + ``fusion_strategy`` 优先用调用方传入的**已收敛**取值:``retrieve`` 已经 + 校验过。但兜底分支必须自己收敛,不能回落到原属性——本函数的判据是 + ``strategy == "rrf"``,而 ``"RRF"``/``" rrf "`` 走的是 RRF 融合却不等于 + 这个字面量,阈值过滤会被错误地套用在排名倒数的量纲上(正是 + ``_coerce_fusion_strategy`` 的 docstring 记录的那个缺陷)。原先的兜底 + 分支直接读原属性,等于把那条缺陷留在库调用方这条路径上(实测 + ``hybrid_fusion_strategy="RRF"`` 时返回 True)。 + + 但上面这条只修了**兜底分支**那一半:调用方显式传入的取值仍然原样参与 + ``== "rrf"`` 的比较,于是同一条缺陷换了个入口——实测 + ``_should_apply_score_threshold(cfg, fusion_strategy="RRF")`` 返回 ``True`` + 而默认分支返回 ``False``,同一个字段的两种来源给出**相反**的结论;传入 + ``["rrf"]``/``5`` 同样静默按「非 RRF」套上阈值。这是**静默的错误结果** + 而不是异常:分数是排名倒数之和,被阈值一过滤整批结果就没了,用户看到 + 「检索无结果」而配置里写着 RRF。修法与同文件 ``_rerank_if_needed`` 的 + ``top_k``/``rerank_enabled`` 逐字对齐:显式传入的取值也过一遍同一个 + ``_coerce_fusion_strategy``,``None`` 保留给「未传入」这一哨兵语义。 + + ``retrieval_method`` 同理收敛:与字面量比较对未知取值**不会报错**,而是 + 静默判定「不是混合检索」,于是阈值过滤照常套用。 + """ + if fusion_strategy is not None: + fusion_strategy = _coerce_fusion_strategy(fusion_strategy) + strategy = ( + fusion_strategy + if fusion_strategy is not None + else _coerce_fusion_strategy(session_config.hybrid_fusion_strategy) ) + method = require_retrieval_method(session_config.retrieval_method) + return not (method == RetrievalMethod.HYBRID_SEARCH and strategy == "rrf") diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py index 60f1f21..740113b 100644 --- a/src/ui/config_menu.py +++ b/src/ui/config_menu.py @@ -1,3 +1,5 @@ +import copy +from collections.abc import Callable, Mapping from typing import Any import questionary @@ -5,42 +7,174 @@ from rich.panel import Panel from ..utils.config import ( + _MAX_RETRIEVAL_CANDIDATES, _MAX_RETRIEVAL_MULTIPLIER, _MAX_RETRIEVAL_TOP_K, RetrievalMethod, + parse_decimal_int, + require_rerank_enabled, + require_retrieval_method, + require_scale_field, + safe_repr, ) from ..utils.log_manager import get_module_logger # 导入日志管理器 -from .display_utils import display_chat_config +from .display_utils import _render_value, display_chat_config, markup_text logger = get_module_logger(__name__) # 获取当前模块的日志器 console = Console() +def _rerank_menu_label(chat_config: dict[str, Any]) -> str: + """返回菜单里的 Rerank 状态文本;取值非法时显示「未知」。 + + 读取必须与写入侧同判据(``_validate_rerank_enabled`` 只认 ``bool``):真值 + 判断会把直写的 ``"false"`` 显示成「启用」,与服务层的 + ``_coerce_rerank_enabled`` 给出相反结论。 + + 但这里**不能**抛 ``ValueError``——``edit_retrieval_params`` 由 + ``launch_config_editor`` 经 ``asyncio.to_thread`` 调用,调用点只接 + ``(KeyboardInterrupt, EOFError)``,异常会终止整个会话。而菜单是用户唯一 + 还能改配置的入口(``display_chat_config`` 在上游会直接拒绝)。非法取值 + 显示「未知」,用户仍能进菜单把它改回布尔。 + """ + try: + enabled = require_rerank_enabled(chat_config["rerank_enabled"]) + except ValueError: + return "未知" + return "启用" if enabled else "禁用" + + +def _rerank_default(chat_config: dict[str, Any]) -> bool | None: + """读 ``rerank_enabled`` 作为 ``confirm`` 的默认项;取值非法时返回 ``None``。 + + ``questionary.confirm`` 在用户直接回车时把 ``default`` **原样**返回 + (``prompt_toolkit`` 的 ``set_answer`` 分支),因此 ``default="false"`` 会让 + 回车得到字符串 ``"false"``——写回配置后服务层报错,而界面上明明显示「是」。 + ``None`` 是 questionary 的合法默认(不预选),菜单照常可用。 + """ + try: + return require_rerank_enabled(chat_config["rerank_enabled"]) + except ValueError: + return None + + +def _top_k_ceiling(multiplier: int) -> int: + """返回当前倍率下 ``top_k`` 可用的上界;当前倍率非法时回落到单字段上界。 + + 真正送进 ``faiss_index.search()`` 的是 ``top_k × multiplier``(FAISS 既不 + 报错也不截断),因此单字段上界不够——UI 放行一个组合越界的值,会在应用 + 配置时抛 ``ValueError`` 并终止会话。 + """ + # 收敛后再算:``multiplier`` 是可直写字段,非法取值让 ``require_scale_field`` + # 抛 ``ValueError``,而菜单在主循环的 ``try`` 之外——内层只接 + # ``(KeyboardInterrupt, EOFError)``、外层只有 ``finally``,抛出去会以裸 + # traceback 终止整个会话(实测「倍率被直写成 ``"abc"``、用户选『调整 + # Top K』」即复现)。回落到单字段上界:上界放得比「组合判据」宽只会把 + # 校验推给应用配置时的 ``SessionConfig`` 守卫(那里会拒绝并提示),菜单 + # 仍可用;直接炸掉则用户连改回合法值的机会都没有。 + try: + multiplier = require_scale_field(multiplier, "retrieval_candidate_multiplier") + except ValueError: + return _MAX_RETRIEVAL_TOP_K + return min(_MAX_RETRIEVAL_TOP_K, _MAX_RETRIEVAL_CANDIDATES // max(1, multiplier)) + + +def _multiplier_ceiling(top_k: int) -> int: + """返回当前 ``top_k`` 下候选倍率可用的上界;当前 ``top_k`` 非法时同上回落。""" + # 与 ``_top_k_ceiling`` 同因:``top_k`` 同样可直写。 + try: + top_k = require_scale_field(top_k, "top_k") + except ValueError: + return _MAX_RETRIEVAL_MULTIPLIER + return min(_MAX_RETRIEVAL_MULTIPLIER, _MAX_RETRIEVAL_CANDIDATES // max(1, top_k)) + + +def _bounded_int_validator(ceiling: int) -> Callable[[str], bool]: + """构造 questionary 的整数输入校验器,接受 ``1`` 到 ``ceiling``。 + + 解析一律走 ``parse_decimal_int``,**校验器本身不抛任何异常**。这一点是硬要求: + questionary 的 ``build_validator`` 只把 ``ValidationError`` 转成「输入无效」 + 提示,其余异常会一路冒到 prompt_toolkit 的事件循环之外——用户按下回车后看到 + ``Unhandled exception in event loop`` 加一段 traceback,输入框卡死。实测两种 + 输入都会造成这种收场:``²``(``isdigit()`` 为真但 ``int()`` 抛错)与 + ``"9" * 4301``(合法的十进制整数,但位数超过 ``sys.get_int_max_str_digits()``)。 + """ + + def validate(text: str) -> bool: + parsed = parse_decimal_int(text) + return parsed is not None and 1 <= parsed <= ceiling + + return validate + + def edit_retrieval_params(chat_config: dict[str, Any]) -> None: """编辑检索相关参数。""" logger.info("进入检索参数编辑菜单。") while True: - current_method = chat_config["retrieval_method"].value + # 与 ``display_chat_config`` 同因:直读 ``.value`` 对可直写的非枚举取值抛裸 + # ``AttributeError``,菜单渲染不出来。收敛后 ``default=`` 也拿到规范取值。 + # ``require_retrieval_method`` 对**非法取值**抛 ``ValueError``(在 + # ``KeyboardInterrupt/EOFError`` 之外),会让整个编辑器会话终止——与 + # ``_rerank_menu_label`` 同一契约:菜单是用户唯一还能改回合法值的入口, + # 不能把它炸掉。非法取值显示「未知」,questionary 拿不到 ``default`` + # 时传 ``None``(不预选,仍是合法参数)。 + try: + current_method = require_retrieval_method(chat_config["retrieval_method"]).value + except ValueError: + current_method = None + # ``None`` 直接进 f-string 会渲染成 ``当前: None``——与下一项 + # ``_rerank_menu_label`` 对同一类非法取值刻意显示的「未知」不一致。 + # 菜单文案统一为「未知」,用户仍能看出这一项有问题并把它改回来。 + if current_method is None: + current_method = "未知" + current_top_k = chat_config["top_k"] - current_rerank = "启用" if chat_config["rerank_enabled"] else "禁用" + # 与 ``display_chat_config`` 同因:真值判断对可直写的取值会给出与服务层 + # 相反的结论(``"false"`` 显示成「启用」)。菜单侧不抛错,非法取值显示 + # 「未知」——见 ``_rerank_menu_label``。 + current_rerank = _rerank_menu_label(chat_config) + # 权重同样要经 ``safe_repr``:``SessionConfig`` 是公开类、普通属性没有 + # ``__setattr__`` 守卫,嵌入方或携带超大整数的 pickle 快照可以直写 + # ``cfg.vector_weight = 10**4301``,随后这里与下面三处的裸插值都会抛 + # ``ValueError: Exceeds the limit``——菜单与提示串根本渲染不出来。 current_weights = ( - f"向量: {chat_config['vector_weight']} / 关键词: {chat_config['keyword_weight']}" + f"向量: {safe_repr(chat_config['vector_weight'])}" + f" / 关键词: {safe_repr(chat_config['keyword_weight'])}" + ) + # 这里**不能**用 ``safe_repr``:它给字符串加引号(``'rrf'`` 而非 ``rrf``), + # 会改掉正常取值的显示形态。``_render_value`` 才是同一张表在 + # ``display_utils`` 里用的渲染器——先 ``str()``,只在撞上整数位数上限时 + # 降级成量级描述。``SessionConfig`` 的普通属性没有 ``__setattr__`` 守卫, + # 嵌入方或携带超大整数的 pickle 快照可以直写 + # ``cfg.hybrid_fusion_strategy = 10**4301``(或 ``["RRF"]``/``None``): + # 裸 ``.upper()`` 对非字符串抛 ``AttributeError``,冲穿聊天主循环的 + # ``except ValueError`` 分类;``str(10**4301)`` 则抛 ``ValueError: + # Exceeds the limit``,菜单根本渲染不出来。 + raw_fusion_strategy = _render_value(chat_config["hybrid_fusion_strategy"]) + # 非法取值(直写 ``None``/非字符串渲染成量级描述)与 method/rerank 项 + # 统一为「未知」——``NONE`` 这类大写形态对用户没有指向性。合法融合 + # 策略是 ``rrf``/``weighted``(判据与 ``SessionConfig._validate_fusion_strategy`` + # 及 questionary Choice value 一致——此前误写 ``weight``,合法默认 + # ``weighted`` 被显示成「未知」,实测复现),二者照常显示。 + current_fusion_strategy = ( + raw_fusion_strategy if raw_fusion_strategy.lower() in ("rrf", "weighted") else "未知" ) - current_fusion_strategy = chat_config["hybrid_fusion_strategy"].upper() current_candidate_multiplier = chat_config["retrieval_candidate_multiplier"] choice = questionary.select( "选择要调整的检索参数:", choices=[ questionary.Choice(f"1. 检索模式 (当前: {current_method})", value="method"), - questionary.Choice(f"2. 检索数量 Top K (当前: {current_top_k})", value="top_k"), + questionary.Choice( + f"2. 检索数量 Top K (当前: {safe_repr(current_top_k)})", value="top_k" + ), questionary.Choice(f"3. Rerank重排 (当前: {current_rerank})", value="rerank"), questionary.Choice(f"4. 混合搜索权重 (当前: {current_weights})", value="weights"), questionary.Choice( f"5. 混合融合策略 (当前: {current_fusion_strategy})", value="fusion" ), questionary.Choice( - f"6. 候选过量招募倍率 (当前: {current_candidate_multiplier})", + f"6. 候选过量招募倍率 (当前: {safe_repr(current_candidate_multiplier)})", value="candidate_multiplier", ), questionary.Separator(), @@ -54,42 +188,71 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: break if choice == "method": + # ``default`` 必须是候选集合内的值:非法 ``retrieval_method`` 时 + # ``current_method`` 是「未知」占位串,直接传给 questionary 会在 + # **构造期**抛 ``ValueError: Invalid 'default' value``(实测), + # 整个编辑器退出——method 分支是唯一能写 ``retrieval_method`` 的 + # 入口,恰是该字段唯一还能被修回合法值的路径。与 + # ``_normalized_fusion_default`` 同构:不在候选集合时传 ``None`` + # (不预选,仍是合法参数)。 + method_choices = [ + questionary.Choice("全文检索", value=RetrievalMethod.FULL_TEXT_SEARCH.value), + questionary.Choice("向量检索", value=RetrievalMethod.SEMANTIC_SEARCH.value), + questionary.Choice( + "混合检索 (全文+向量)", value=RetrievalMethod.HYBRID_SEARCH.value + ), + ] + method_values = {item.value for item in method_choices} new_method = questionary.select( "选择新的检索模式:", - choices=[ - questionary.Choice("全文检索", value=RetrievalMethod.FULL_TEXT_SEARCH.value), - questionary.Choice("向量检索", value=RetrievalMethod.SEMANTIC_SEARCH.value), - questionary.Choice( - "混合检索 (全文+向量)", value=RetrievalMethod.HYBRID_SEARCH.value - ), - ], - default=current_method, + choices=method_choices, + default=current_method if current_method in method_values else None, ).ask() if new_method: chat_config["retrieval_method"] = RetrievalMethod(new_method) - console.print(f"[green]检索模式已更新为: {new_method}[/green]") + console.print(markup_text("[green]检索模式已更新为: {}[/green]", new_method)) logger.info(f"检索模式已更新为: {new_method}") elif choice == "top_k": - # 上界与 SessionConfig/Settings 的守卫一致:此处不挡,非法值会一路 - # 走到 faiss_index.search(),FAISS 既不报错也不截断。 + # 上界必须与 SessionConfig/Settings 的守卫一致,且要连**组合**一起判: + # 真正送进 faiss_index.search() 的是 top_k × multiplier(FAISS 既不 + # 报错也不截断)。这里只按单字段上界放行的话,用户设成 2000 会在 + # 应用配置时抛 ValueError——而那个异常落在 chat 主循环之外,会以裸 + # traceback 终止整个会话。所以校验里把当前倍率算进去。 + top_k_ceiling = _top_k_ceiling(chat_config["retrieval_candidate_multiplier"]) new_top_k = questionary.text( - f"输入新的Top K值 (1-{_MAX_RETRIEVAL_TOP_K}, 当前: {current_top_k}):", - validate=lambda text: text.isdigit() and 1 <= int(text) <= _MAX_RETRIEVAL_TOP_K, - default=str(current_top_k), + f"输入新的Top K值 (1-{safe_repr(top_k_ceiling)}, 当前: {safe_repr(current_top_k)}):", + validate=_bounded_int_validator(top_k_ceiling), + # ``str`` 而不是裸取值:位数超限的整数连 ``str()`` 都抛,而 + # ``default=`` 的取值同样要经过渲染。 + default=safe_repr(current_top_k), ).ask() if new_top_k: - chat_config["top_k"] = int(new_top_k) - console.print(f"[green]Top K 已更新为: {new_top_k}[/green]") + chat_config["top_k"] = parse_decimal_int(new_top_k) + console.print(markup_text("[green]Top K 已更新为: {}[/green]", new_top_k)) logger.info(f"Top K 已更新为: {new_top_k}") elif choice == "rerank": + # 同一收敛也用于 ``default=``,见 ``_rerank_default``。 + # + # ``default=None`` 是 questionary 的**合法**取值,只是它的类型标注 + # (``default: bool = True``)没写出来:``prompt_toolkit`` 的 + # ``set_answer`` 分支是 ``if status["answer"] is None: status["answer"] + # = default``——``None`` 正是它内部的「尚未作答」哨兵,用户直接回车 + # 时返回 ``None``,下面的 ``if new_rerank is not None`` 因而跳过写回。 + # 实测 ``questionary.confirm("x", default=None)`` 正常构造。标注与行为 + # 不符,故在此显式忽略。 new_rerank = questionary.confirm( - "是否启用Rerank重排?", default=chat_config["rerank_enabled"] + "是否启用Rerank重排?", + default=_rerank_default(chat_config), # type: ignore[arg-type] ).ask() if new_rerank is not None: chat_config["rerank_enabled"] = new_rerank - console.print(f"[green]Rerank已更新为: {'启用' if new_rerank else '禁用'}[/green]") + console.print( + markup_text( + "[green]Rerank已更新为: {}[/green]", "启用" if new_rerank else "禁用" + ) + ) logger.info(f"Rerank已更新为: {'启用' if new_rerank else '禁用'}") elif choice == "weights": @@ -105,21 +268,38 @@ def is_float_between_0_and_1(text): return False new_vector_weight_str = questionary.text( - f"输入新的向量权重 (0.0-1.0, 当前: {chat_config['vector_weight']}):", + f"输入新的向量权重 (0.0-1.0, 当前: {safe_repr(chat_config['vector_weight'])}):", validate=is_float_between_0_and_1, - default=str(chat_config["vector_weight"]), + default=safe_repr(chat_config["vector_weight"]), ).ask() if new_vector_weight_str: new_vector_weight = float(new_vector_weight_str) - new_keyword_weight = 1.0 - new_vector_weight - chat_config["vector_weight"] = new_vector_weight - chat_config["keyword_weight"] = round(new_keyword_weight, 2) + new_keyword_weight = round(1.0 - new_vector_weight, 2) + # 两条写回有**顺序陷阱**:``SessionConfig``/编辑器共享的组合守卫 + # 「vector_weight 与 keyword_weight 不能同时为 0」按中间态判, + # 固定先写 vector 会拒绝合法终态——默认 (1.0, 0.0) 下用户把 + # 向量权重改成 0(切纯关键词检索)实测报「不能同时为 0」,而 + # 反序写通过。先写**非零侧**使中间态永远不落入双 0: + # (v=0, k=1) 先写 keyword、(v=1, k=0) 先写 vector,其余两侧 + # 均非 0 顺序无关。 + if new_vector_weight == 0.0: + chat_config["keyword_weight"] = new_keyword_weight + chat_config["vector_weight"] = new_vector_weight + else: + chat_config["vector_weight"] = new_vector_weight + chat_config["keyword_weight"] = new_keyword_weight console.print( - f"[green]混合搜索权重已更新为 -> 向量: {chat_config['vector_weight']}, 关键词: {chat_config['keyword_weight']}[/green]" + markup_text( + "[green]混合搜索权重已更新为 -> 向量: {}, 关键词: {}[/green]", + chat_config["vector_weight"], + chat_config["keyword_weight"], + ) ) logger.info( - f"混合搜索权重已更新为 -> 向量: {chat_config['vector_weight']}, 关键词: {chat_config['keyword_weight']}" + "混合搜索权重已更新为 -> 向量: %s, 关键词: %s", + safe_repr(chat_config["vector_weight"]), + safe_repr(chat_config["keyword_weight"]), ) elif choice == "fusion": @@ -133,35 +313,135 @@ def is_float_between_0_and_1(text): questionary.Choice("RRF (倒数排名融合)", value="rrf"), questionary.Choice("Weighted (分值加权)", value="weighted"), ], - default=chat_config["hybrid_fusion_strategy"], + default=_normalized_fusion_default(chat_config["hybrid_fusion_strategy"]), ).ask() if new_strategy: chat_config["hybrid_fusion_strategy"] = new_strategy - console.print(f"[green]混合融合策略已更新为: {new_strategy.upper()}[/green]") + console.print( + markup_text("[green]混合融合策略已更新为: {}[/green]", new_strategy.upper()) + ) logger.info(f"混合融合策略已更新为: {new_strategy.upper()}") elif choice == "candidate_multiplier": + # 同 top_k:上界要连当前 top_k 一起算,否则「两个字段各自合法、 + # 乘积越界」会在应用配置时抛错并终止会话。 + multiplier_ceiling = _multiplier_ceiling(chat_config["top_k"]) new_multiplier = questionary.text( - f"输入候选过量招募倍率 (1-{_MAX_RETRIEVAL_MULTIPLIER}, 当前: {current_candidate_multiplier}):", - validate=lambda text: ( - text.isdigit() and 1 <= int(text) <= _MAX_RETRIEVAL_MULTIPLIER - ), - default=str(current_candidate_multiplier), + f"输入候选过量招募倍率 (1-{safe_repr(multiplier_ceiling)}, 当前: {safe_repr(current_candidate_multiplier)}):", + validate=_bounded_int_validator(multiplier_ceiling), + # 与同函数 top_k 分支的 ``default=safe_repr(current_top_k)`` 保持 + # 一致:``str()`` 对位数超限的整数抛 ``ValueError: Exceeds the + # limit``,菜单在这一步直接报错;``safe_repr`` 退回量级描述, + # 校验器随后会把它作为非法输入拒掉,用户仍能改。 + default=safe_repr(current_candidate_multiplier), ).ask() if new_multiplier: - chat_config["retrieval_candidate_multiplier"] = int(new_multiplier) - console.print(f"[green]候选过量招募倍率已更新为: {new_multiplier}[/green]") + chat_config["retrieval_candidate_multiplier"] = parse_decimal_int(new_multiplier) + console.print( + markup_text("[green]候选过量招募倍率已更新为: {}[/green]", new_multiplier) + ) logger.info(f"候选过量招募倍率已更新为: {new_multiplier}") # 检索参数的更改不需要重载任何模型 +def _configuration_keys(configurations: Any, role_label: str) -> list[Any]: + """取配置映射的键列表;取值不是映射时给出带指引的 ``ValueError``。 + + ``SessionConfig`` 的 ``_VALIDATORS`` 明确不登记 ``*_configurations`` + (「按原样写入,由使用方负责其内部结构」),嵌入方可以把 + ``chat_config["llm_configurations"]`` 直写成 ``5``。原先直接写 + ``list(chat_config["llm_configurations"].keys())``,在那里抛裸 + ``AttributeError: 'int' object has no attribute 'keys'``——异常类型与 + 「配置项形态错了」无关,且不是 ``ValueError``,会以裸 traceback 终止会话。 + 判据与 ``display_utils._require_model_detail`` 一致。 + + **只取键、不校验值**:菜单要把「值形态坏了」的配置项也列出来,否则用户 + 没有入口去切换掉它(`display_chat_config` 在上游读值时会直接拒绝,菜单 + 是唯一还能改配置的地方)。值的形态由使用方在真正读取时判。 + """ + if not isinstance(configurations, Mapping): + raise ValueError( + f"{role_label}配置必须是映射,但得到 {type(configurations).__name__}。" + " 请检查 config.toml 里的 *_configurations 配置项。" + ) + return list(configurations.keys()) + + +def _configuration_choices(configurations: Any, role_label: str) -> list[questionary.Choice]: + """把配置映射的键列表包成 questionary 候选。 + + 与 ``_configuration_keys`` 同一处取值、同一取舍(只判容器、不判取值), + 但**必须**包成 ``Choice``:``questionary.select`` 对每个候选调 + ``choice.get(...)``,只有 ``Choice`` 对象有该方法,原始字符串之所以能工作 + 是因为 questionary 内部对 ``str`` 有特例——非字符串键(``int``/``tuple``/ + ``None``)一律抛裸 ``AttributeError: 'int' object has no attribute 'get'``。 + 而 ``SessionConfig._VALIDATORS`` 明确不登记 ``*_configurations``,嵌入方或 + 携带异常取值的 pickle 快照可以放进任意可哈希键。 + + 标题走 ``_render_value``:位数超限的整数键连 ``str()`` 都抛 + ``ValueError: Exceeds the limit``,菜单根本渲染不出来。``value`` 保留**原始 + 键**——切换时写回的必须是它,``default=`` 也要拿原始键去候选集合里对。 + + 候选为空时 questionary 自己抛 ``ValueError: A list of choices needs to be + provided.``——那是「配置里一个模型都没有」的真实情况,交给它报出比在这里 + 编一个假选项更诚实。 + """ + return [ + questionary.Choice(title=_render_value(key), value=key) + for key in _configuration_keys(configurations, role_label) + ] + + +def _choice_default(choices: list[questionary.Choice], active: Any) -> Any: + """返回可交给 ``questionary.select(default=...)`` 的取值。 + + ``default`` 必须**存在于候选集合里**,否则 questionary 抛 + ``ValueError: Invalid `default` value passed``——激活项被删掉、或它本身是 + 位数超限的整数时都会命中,而菜单是用户唯一还能改配置的入口。不在候选里就 + 退回 ``None``:那是 questionary 内部的「尚未作答」哨兵(见 + ``edit_retrieval_params`` 里 ``default=None`` 的说明),合法且不抛。 + """ + for choice in choices: + if choice.value == active: + return active + return None + + +def _normalized_fusion_default(value: Any) -> str | None: + """把融合策略收敛成候选集合里的字面量,收敛不了时返回 ``None``。 + + 候选只有 ``"rrf"``/``"weighted"`` 两个字面量,而 ``default=`` 拿的是 + ``chat_config["hybrid_fusion_strategy"]`` 的**原始**取值——``SessionConfig`` + 的普通属性没有 ``__setattr__`` 守卫,嵌入方或携带异常取值的 pickle 快照可以 + 直写该字段。实测三种收场都不在契约内:``"RRF"``/``" rrf "``(大小写/空白 + 未归一)与 ``1`` 让 questionary 抛 ``ValueError: Invalid 'default' value + passed``,位数超限的整数则抛 ``ValueError: Exceeds the limit``。 + + 归一化口径必须与运行期一致,取 ``_coerce_fusion_strategy``(它同时服务 + ``RetrievalService`` 的读取路径,把 ``"RRF"``/``" rrf "`` 都收敛成 + ``"rrf"``)。收敛后仍不在候选集合里(例如 ``"bogus"``)就退回 ``None``: + questionary 的「未作答」哨兵,合法且不抛,用户仍能选一个合法值。 + """ + from ..services.retrieval_service import _coerce_fusion_strategy + + try: + normalized = _coerce_fusion_strategy(value) + except ValueError: + return None + return normalized if normalized in {"rrf", "weighted"} else None + + def edit_model_params(chat_config: dict[str, Any]) -> bool: """编辑模型相关参数。""" logger.info("进入模型参数编辑菜单。") llm_changed = False while True: - current_llm = chat_config["active_llm_configuration"] - current_rerank = chat_config["active_rerank_configuration"] + # 与 ``edit_retrieval_params`` 同因:``active_*_configuration`` 可被直写, + # 裸插值对位数超限的整数抛 ``ValueError: Exceeds the limit``,菜单渲染不出来。 + active_llm = chat_config["active_llm_configuration"] + active_rerank = chat_config["active_rerank_configuration"] + current_llm = _render_value(active_llm) + current_rerank = _render_value(active_rerank) choice = questionary.select( "选择要切换的模型:", @@ -181,26 +461,34 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: break if choice == "llm": - llm_options = list(chat_config["llm_configurations"].keys()) + llm_options = _configuration_choices(chat_config["llm_configurations"], "LLM") new_llm = questionary.select( - "选择新的LLM配置:", choices=llm_options, default=current_llm + "选择新的LLM配置:", + choices=llm_options, + default=_choice_default(llm_options, active_llm), ).ask() - if new_llm and new_llm != current_llm: + # 与**原始**键比较,不与渲染后的 ``current_llm`` 比较:非字符串键 + # (或位数超限的整数键)渲染成量级描述后永不等于自身,会让「选中 + # 同一个配置」被误判成切换、白白触发一次 LLM 重载。 + if new_llm is not None and new_llm != active_llm: chat_config["active_llm_configuration"] = new_llm - console.print(f"[green]LLM配置已切换为: {new_llm}[/green]") - logger.info(f"LLM配置已切换为: {new_llm}") + console.print(markup_text("[green]LLM配置已切换为: {}[/green]", new_llm)) + logger.info("LLM配置已切换为: %s", new_llm) llm_changed = True + continue - elif choice == "rerank": - rerank_options = list(chat_config["rerank_configurations"].keys()) + if choice == "rerank": + rerank_options = _configuration_choices(chat_config["rerank_configurations"], "Rerank") new_rerank = questionary.select( - "选择新的Rerank配置:", choices=rerank_options, default=current_rerank + "选择新的Rerank配置:", + choices=rerank_options, + default=_choice_default(rerank_options, active_rerank), ).ask() - if new_rerank: + if new_rerank is not None: chat_config["active_rerank_configuration"] = new_rerank - console.print(f"[green]Rerank已切换为: {new_rerank}[/green]") - logger.info(f"Rerank已切换为: {new_rerank}") - # Rerank模型是按需加载的,所以不需要标记状态 + console.print(markup_text("[green]Rerank已切换为: {}[/green]", new_rerank)) + logger.info("Rerank已切换为: %s", new_rerank) + continue return llm_changed @@ -215,8 +503,44 @@ def launch_config_editor(chat_config: dict[str, Any]) -> tuple[bool, dict[str, A Panel("进入配置模式...", title="[yellow]配置编辑器[/yellow]", border_style="yellow") ) - llm_needs_reload = False + state = {"llm_needs_reload": False} + + # 编辑器全程**原地**写 ``chat_config``(``editable_config`` 是 ``/config`` + # 入口浅拷贝出来、随后会被 ``apply_config_update`` 应用到 bot 的同一份 + # 字典)。EOF 收敛承诺「按原样保存返回」,但 EOF 前已确认写入的每一项 + # (Top K、切换的 LLM 等)都留在这份字典里并随返回生效——实测「Top K + # 输入 7 → EOF」返回 top_k=7 且提示「按原样」。因此入口先快照,EOF 时 + # 还原;这同时解决 reload 标志问题:EOF 前若切换过 LLM,标志尚未置位 + # (只在 ``edit_model_params`` 正常走完才置位),还原后残留的 LM 键不再 + # 以「已切换但未重载」的形态继续服务。 + snapshot = copy.deepcopy(chat_config) + + try: + _run_config_editor_loop(chat_config, state) + except EOFError: + # ``questionary`` 的 ``.ask()`` 只捕 ``KeyboardInterrupt``(Ctrl+C 返回 + # ``None`` → 各层循环退一层);**Ctrl+D/EOF 抛 ``EOFError`` 直接穿透** + # (实测 /dev/null 与 \x04 均复现)。它经 ``asyncio.to_thread`` 到达 + # 聊天主循环的 ``except (KeyboardInterrupt, EOFError): break``—— + # 在最深的 Top K 输入框按一次 Ctrl+D 会**终止整个聊天会话**,与 + # Ctrl+C 的「退一层」语义不对称。EOF 中断按「放弃全部编辑、保存返回」 + # 收敛:还原快照使承诺与写回事实一致。 + chat_config.clear() + chat_config.update(snapshot) + state["llm_needs_reload"] = False + console.print(markup_text("[yellow]输入已结束(EOF),配置编辑器按原样保存返回。[/yellow]")) + logger.info("配置编辑器因 EOF 提前退出,已还原 EOF 前的全部修改。") + return state["llm_needs_reload"], chat_config + + +def _run_config_editor_loop(chat_config: dict[str, Any], state: dict[str, bool]) -> None: + """配置编辑器主循环(``launch_config_editor`` 的主体)。 + + 独立成函数是为了让 ``launch_config_editor`` 在外层统一收敛 ``EOFError``: + 各层 ``while True`` 只判 ``choice is None``(Ctrl+C 语义),EOF 从任何一层 + 的 ``.ask()`` 抛出都会沿调用栈到达这里。 + """ while True: choice = questionary.select( "主菜单: 请选择要执行的操作", @@ -249,12 +573,11 @@ def launch_config_editor(chat_config: dict[str, Any]) -> tuple[bool, dict[str, A logger.debug("用户选择切换模型。") # 如果LLM发生变化,标记需要重载 if edit_model_params(chat_config): - llm_needs_reload = True + state["llm_needs_reload"] = True elif "3." in choice: logger.debug("用户选择查看当前完整配置。") display_chat_config(console, chat_config) questionary.press_any_key_to_continue("按任意键返回主菜单...").ask() logger.debug("用户已查看配置并返回。") - logger.info(f"配置编辑器完成,LLM需要重载: {llm_needs_reload}") - return llm_needs_reload, chat_config + logger.info(f"配置编辑器完成,LLM需要重载: {state['llm_needs_reload']}") diff --git a/src/ui/display_utils.py b/src/ui/display_utils.py index 94cd978..adbd672 100644 --- a/src/ui/display_utils.py +++ b/src/ui/display_utils.py @@ -1,30 +1,349 @@ +import math import os +import re +from collections.abc import Mapping from typing import Any from rich.console import Console, Group from rich.panel import Panel from rich.table import Table +from rich.text import Text -from ..utils.config import ROOT_DIR, get_settings # 导入 get_settings 函数 +from ..utils.config import ( # 导入 get_settings 函数 + ROOT_DIR, + get_settings, + require_detail_attributes, + require_provider_name, + require_rerank_enabled, + require_retrieval_method, + safe_repr, +) from ..utils.log_manager import get_module_logger # 导入日志管理器 +from ..utils.security import redact_sensitive_text logger = get_module_logger(__name__) # 获取当前模块的日志器 # 全局UI宽度定义 CONSOLE_WIDTH = 120 +# 模板里的占位符。用 NUL 而不是 ``{}`` 直接进 ``Text.from_markup``:NUL 不可能 +# 出现在任何正常的模板字面量里,替换后也不会被 rich 的标记语法解释。 +_MARKUP_PLACEHOLDER = "\x00" + +# 与 ``rich.markup`` 的标记识别保持一致(``((\\*)\[([a-z#/@][^[]*?)])``), +# 用于判断占位符是否落在标记参数内部。 +_MARKUP_TAG_RE = re.compile(r"((\\*)\[([a-z#/@][^[]*?)])") + + +def markup_text(template: str, *values: object) -> Text: + """把不可信的值填进带标记的模板,得到一段安全的 ``Text``。 + + ``console.print(str)``、``Table.add_row(str)``、``Panel(str)`` 都会把字符串 + 按 rich **标记**解析(``Console(markup=True)`` 是默认值),而本项目的这些 + 位置里插着知识库文档正文、用户输入与异常消息。直接用 f-string 拼有两类后果: + + 1. **崩溃**。正文里出现字面量 ``[/]``、``[/color]``、``[/if]`` 时 + ``rich.markup.render`` 抛 ``MarkupError``。实测知识库文档里 + 「匹配斜杠用 [/] 表示」这一行即可触发。真正致命的是异常处理走的也是同一 + 个通道:``safe_exception_text()`` 拼出的消息里带着 ``[/]``,于是内层 + ``except`` 抛、``main()`` 的兜底 ``except`` 也抛、``run_cli`` 的兜底 + ``except`` 再抛——三层兜底全部失效,程序以裸 traceback 退出。 + 2. **静默改写**。``[b]粗体[/b]`` 的标记被吃掉,markdown 链接 + ``[https://x](https://x)`` 丢掉方括号部分。实测真实知识库 3038 行里有 + 12 行渲染后被改写——用户看到的与检索到的不一致。 + + 这里让**模板**继续走标记解析(样式照旧生效),而**值**通过 ``Text.append`` + 原样追加,不经过标记解析。值会继承它在模板里所处位置的样式。 + + ``{}`` 是占位符;模板与实参数量不符是编码错误,显式报错而不是错位拼接。 + + Args: + template: 含 ``{}`` 占位符的 rich 标记模板。 + *values: 按顺序填入占位符的不可信值。 + + Returns: + Text: 可直接交给 ``console.print`` / ``Table.add_row`` 的文本。 + """ + # 模板形态先判:本函数是**公开**的,而下面第一行就对 ``template`` 做成员测试。 + # 非字符串取值在那里抛的是裸 ``TypeError: argument of type 'int' is not + # iterable``(``None``/``int``)、``expected string or bytes-like object`` + # (list/tuple/dict)或 ``a bytes-like object is required``(bytes)——三种 + # 消息都不指向「模板写错了」,也不是 ``ValueError``。而本函数对**值**参数早已 + # 用 ``_render_value`` 完整收敛、对占位符数量不符与 NUL 也都抛 ``ValueError``: + # 同一个「入参形态非法」的处置,模板这一半此前漏了。 + if not isinstance(template, str): + raise ValueError(f"标记模板必须是字符串,但得到 {safe_repr(template)}。") + if _MARKUP_PLACEHOLDER in template: + raise ValueError("标记模板不得包含 NUL 字符。") + # 占位符落在标记**参数**内部(``[link={}]``)时,值会被并进标记语法本身: + # ``[link=https://x]点这里[/link]`` 渲染成 ``点这里https://x``(链接文本与 + # URL 粘连、样式也丢了),``[bold {}]`` 更是直接抛 MarkupError。这类模板 + # 是编码错误——值要出现在**文本**位置,不能出现在标记参数里。 + for _match in _MARKUP_TAG_RE.finditer(template): + # 奇数的前导反斜杠表示 rich 把 ``[`` 当作**字面量**(``\[example {}]`` + # 解析成 ``[example <值>]``),此时占位符不在任何标记参数内部,照常接受。 + # 正则的 ``(\\*)`` 捕获组拿到的正是这段反斜杠——按 rich ``_parse`` 的 + # 规则(``divmod(len(escapes), 2)[1]``)判奇偶。 + if len(_match.group(2)) % 2 == 1: + continue + if "{}" in _match.group(3): + raise ValueError( + f"标记模板的占位符不能出现在标记参数内部: {template!r}。 值只能放在标记的文本位置。" + ) + + filled = template + for _ in values: + if "{}" not in filled: + raise ValueError(f"模板占位符少于实参数量: {template!r}") + filled = filled.replace("{}", _MARKUP_PLACEHOLDER, 1) + if "{}" in filled: + raise ValueError(f"模板占位符多于实参数量: {template!r}") + + # 先把整段模板交给 rich 解析(标记照常生效),占位符作为 NUL 留在解析结果里, + # 再按 NUL 的位置把值插回去。 + # + # 不能切成若干段分别处理:``split`` 会把占位符那一个字符**丢掉**,而标记解析 + # 出的样式恰恰只挂在它身上(``[bold cyan]\x00[/bold cyan]`` 的 span 是 + # ``(0, 1, bold cyan)``)。切段后右侧是空串、没有样式,于是 + # ``[bold magenta]{}[/bold magenta]``——仓库里 13 处都是这个形状——渲染出来 + # 完全没有样式,且不报错。反向的错位同样会出:靠「已拼接结果末字符」兜底会 + # 让 ``[bold]x[/bold]{}`` 把值染成粗体,而值在标记之外。 + parsed = Text.from_markup(filled) + placeholder_offsets = [ + index for index, character in enumerate(parsed.plain) if character == _MARKUP_PLACEHOLDER + ] + + result = Text() + cursor = 0 + for position, value in zip(placeholder_offsets, values): + if position > cursor: + # 切片会带上这一段的样式(``Text.divide`` 按 span 重新落位)。 + result.append_text(parsed[cursor:position]) + # 样式取自占位符**自身**覆盖的 span;``Text.stylize`` 只接受 str/Style, + # 这里原样透传,不拼接、不合并——与 ``from_markup`` 的叠加方式一致。 + styles = [span.style for span in parsed.spans if span.start <= position < span.end] + start = len(result) + # 值经 ``_render_value`` 而不是裸 ``str()``:本函数的 ``values`` 是 + # ``object``,调用点既有字符串(``safe_exception_text``)也有 int/float + # (``config_menu`` 的权重与倍率),而 ``str(10**4301)`` 撞上 CPython 的 + # ``sys.get_int_max_str_digits()``(默认 4300)抛 + # ``ValueError: Exceeds the limit``。后果与 ``_path_text`` 那处同源: + # 正要显示「倍率已更新为 …」的那一步自己抛错,用户看到的是整数转换 + # 错误。``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫, + # 嵌入方或携带超大整数的 pickle 快照可以直写这些字段(见 + # ``_require_finite_number`` 的 docstring)。 + result.append(_render_value(value)) + for style in styles: + result.stylize(style, start, len(result)) + cursor = position + 1 + if cursor < len(parsed.plain): + result.append_text(parsed[cursor:]) + return result + + +def _render_value(value: Any) -> str: + """把任意取值渲染成可显示的字符串,位数超限时退回 ``safe_repr`` 的量级描述。 + + ``str()`` 对位数超过 ``sys.get_int_max_str_digits()``(默认 4300)的整数抛 + ``ValueError``。本函数的用途是**显示**一个值,不是拿它做算术或路径运算, + 因此降级成量级描述是可接受的——比抛异常好,也比整段数字刷屏好。 + + 与 ``_path_text`` 的区别只在取舍:那里**不能**对正常取值用 ``safe_repr`` + (会加引号、会在 80 字符处截断,破坏路径换算),所以只在 ``str()`` 真的 + 失败时才降级;这里同样只在失败时降级,保持正常取值的显示形态不变 + (``3`` 显示成 ``3`` 而不是 ``'3'``)。 + """ + try: + return str(value) + except (ValueError, RuntimeError, RecursionError): + return safe_repr(value) + + +def _path_text(value: Any) -> str: + """把路径取值收敛成 ``str``,位数超限时退回 ``safe_repr`` 的量级描述。 + + ``str()`` 对位数超过 ``sys.get_int_max_str_digits()``(默认 4300)的整数抛 + ``ValueError``,而 ``get_relative_path`` 的兜底分支存在的唯一理由就是不再抛。 + + **不能**直接对正常路径用 ``safe_repr``:它会加一对引号、并在 80 字符处截断, + 而本函数的用途是把绝对路径换算成相对路径——带引号的前缀永远匹配不到 + ``ROOT_DIR``,``os.path.relpath`` 于是原样返回那条绝对路径(相对路径功能 + 完全失效);截断更会让用户看不到文件名。因此只在 ``str()`` 真的失败时才 + 降级,正常路径走原样的 ``str()``。 + """ + try: + return str(value) + except (ValueError, RuntimeError, RecursionError): + return safe_repr(value) + def get_relative_path(absolute_path: str) -> str: """将绝对路径转换为相对于项目根目录的路径,方便显示。""" + # ``absolute_path`` 的实际来源是 ``document_source(document)``,而文档元数据 + # 来自磁盘上的快照/legacy pkl(pickle 是二进制格式,不受 CPython 十进制位数 + # 限制)。实测传入 4301 位整数时 ``str()`` 抛 ``ValueError``,而下面 + # ``except ValueError`` 里的 f-string **用同一个值再插值一次**,把刚接住的 + # 异常原样重抛——兜底分支抛在展示检索结果的最后一环,会把一次成功的检索变成 + # 「LLM 异步生成出错」。因此取值、日志插值与返回值都经 ``_path_text``。 + text = _path_text(absolute_path) try: - # 使用 os.path.relpath 计算相对路径 - relative_path = os.path.relpath(str(absolute_path), str(ROOT_DIR)) - logger.debug(f"将绝对路径 '{absolute_path}' 转换为相对路径: '{relative_path}'") + relative_path = os.path.relpath(text, str(ROOT_DIR)) + logger.debug(f"将绝对路径 '{text}' 转换为相对路径: '{relative_path}'") return relative_path except ValueError: # 如果路径不在同一驱动器上(例如Windows),则返回原始路径 - logger.warning(f"无法将路径 '{absolute_path}' 转换为相对路径,返回原始路径。") - return str(absolute_path) + logger.warning(f"无法将路径 '{text}' 转换为相对路径,返回原始路径。") + return text + + +def document_content(document: Any, default: str = "N/A") -> str: + """取出文档内容,形态异常时退回 ``default``。 + + ``display_results`` 与 ``Chatbot._display_retrieved_docs`` 都直接对检索结果做 + ``.replace(...)``/切片,而检索结果可以来自任何 ``VectorStoreBase`` 实现—— + ``FaissStore`` 会在载入期校验并归一化,但**第三方 store 不会**,而抽象基类 + 正是为替换实现而存在的。实测一个返回 ``{"page_content": None}`` 的 store 能让 + 两个展示函数分别抛 ``AttributeError`` 与 ``TypeError``。 + + 「显示检索结果」是错误处理之后的最后一环,它本身不该成为新的失败点。 + """ + if not isinstance(document, Mapping): + return default + content = document.get("page_content") + return content if isinstance(content, str) else default + + +def document_source(document: Any, default: str = "N/A") -> str: + """取出文档来源,形态异常时退回 ``default``。理由同 ``document_content``。""" + if not isinstance(document, Mapping): + return default + metadata = document.get("metadata") + if not isinstance(metadata, Mapping): + return default + source = metadata.get("source") + return source if isinstance(source, str) else default + + +def coerce_score(value: Any, default: float = 0.0) -> float: + """把分数字段收敛成**有限**浮点,形态异常时退回 ``default``。 + + 这是全项目唯一的分数收敛规则,展示层(``document_score``)与检索层 + (``retrieval_service``)共用。两处曾各自实现,判据还不一致: + 展示层拒绝数字字符串、检索层接受它,于是一份 ``score="0.8"`` 的第三方结果 + 会被检索层按 0.8 通过分数阈值、却由界面显示成 ``0.0000``——用户看到 + 「0.0000 排在最前面」,而它恰恰是通过了 0.5 阈值的那些。 + + 逐条规则与理由: + + * ``bool`` 退回默认值。``True`` 会算成 ``1.0``,看起来像一个真实得分,而它 + 几乎必然是上游把标志位写进了分数字段。 + * 数字字符串**照旧转换**。旧实现是 ``float(x or 0)``,接受 ``"0.5"``;一律 + 拒绝会让返回字符串得分的 store 被静默降为 0,再被分数阈值全部过滤—— + 从「能用」变成「静默返回空结果」。 + * ``nan``/``inf`` 退回默认值。``nan`` 不抛错却会静默破坏排序:融合里 + ``max()``/``min()`` 遇到它双双返回 ``nan``,每个文档的归一化结果都成了 + ``nan``,而 ``sorted`` 对 ``nan`` 的比较恒为 False,该文档保持原相对顺序、 + **留在首位**。``inf`` 同理,``max()`` 取到它之后其余文档全被压成 0。 + * 映射、列表等其它类型退回默认值——它们转换不了,且在展示层格式化时会抛错。 + """ + if isinstance(value, bool): + return default + # numpy 标量(``np.float32`` 等)不是 ``float`` 子类,但 ``float()`` 能无损 + # 转换它们。第三方 store 的 score 常是 numpy 标量,只判 ``(int, float)`` 会把 + # 真实分数静默归零——既影响排序又影响分数阈值。 + # ``__float__`` duck-typing 之后必须实测 ``float()``,不能直接采信: + # ``is_double_convertible`` 对未知类型返回 ``False``,会漏掉 numpy 标量。 + if isinstance(value, (int, float)) or hasattr(value, "__float__"): + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return default + elif isinstance(value, str): + try: + number = float(value) + except ValueError: + return default + else: + return default + return number if math.isfinite(number) else default + + +def document_score(document: Any, default: float = 0.0) -> float: + """取出文档得分,形态异常时退回 ``default``(规则见 ``coerce_score``)。""" + if not isinstance(document, Mapping): + return default + return coerce_score(document.get("score"), default) + + +def _require_model_detail(configurations: Any, key: Any, role_label: str) -> Any: + """按 key 取模型详情,缺失或取值不可用时抛带指引的 ``ValueError``。 + + 原先直接写 ``configurations[key]``,而 ``default_llm_provider``/ + ``default_rerank_provider``/``active_*_configuration`` **没有任何 + validator**(``Settings`` 里只是普通 ``str``,``SessionConfig`` 的 + ``_VALIDATORS`` 也没登记它们)。实测 ``default_llm_provider = + "not-a-provider"``(打错字)时,调用点 ``chat/core.py`` 的 + ``start_chat_session_async`` 在任何提示符之前就抛裸 ``KeyError``——用户刚 + 看到「知识快照加载成功」,紧接着会话终止。同一个 key 走 + ``ModelProviderFactory._get_model_detail`` 得到的是 + ``ValueError("在…配置中未找到key: …")``,错误分类本就该一致。 + + 成员判定只证明**键**在,不证明**值**可用:``{"k": None}`` 的键确实存在, + 于是不会走上面那条分支,``None`` 被原样返回给三个调用点,而它们紧接着都读 + ``..._model_details.provider``/``.model_name``——实测 + ``chat_config["rerank_configurations"] = {"siliconflow": None}`` 时在此抛裸 + ``AttributeError``(不是 ``ValueError``)。取值来源与键同源,都是可直写的 + 配置映射,因此在这里一并收敛。 + """ + if not isinstance(configurations, Mapping): + raise ValueError( + f"在{role_label}配置中未找到key: {safe_repr(key)}。可选值: 无。" + " 请检查 config.toml 里的 default_*_provider 与 *_configurations 是否对应。" + ) + # 成员判断要接住 ``TypeError``:``key not in mapping`` 对**不可哈希**的取值抛 + # 裸 ``TypeError: unhashable type: 'list'``,它不是 ``ValueError``。本函数在 + # ``start_chat_session_async`` 里于任何提示符**之前**被调用,因此实测 + # ``active_rerank_configuration = ["siliconflow"]`` 时用户看到的是裸 traceback + # ——刚显示完启动横幅,会话随即终止。判据与 ``ModelProviderFactory + # ._get_model_detail`` 的同名守卫一致(本函数的 docstring 正是以「同一个 key + # 走工厂得到 ``ValueError``」为理由建立契约的,两处必须同判据)。 + # + # 只收不可哈希这一类,**不**要求 key 是字符串:本函数的职责是**显示**,而 + # 可哈希的非字符串键在映射里能正常取到值(既有用例 + # ``test_display_chat_config_renders_values_beyond_the_conversion_limit`` + # 明确要求位数超限的整数键可渲染)。工厂侧要求字符串是因为它要拿 key 去建 + # provider,两处的接受面本就不同;契约一致的是**异常分类**。 + try: + present = key in configurations + except TypeError as exc: + raise ValueError( + f"{role_label}配置的 key 必须是可哈希的取值,但得到 {safe_repr(key)}。" + " 请检查 config.toml 里的 default_*_provider 与 *_configurations 是否对应。" + ) from exc + if not present: + # 先渲染成字符串再排序:``sorted(configurations)`` 直接比较**原始键**, + # 而键来自可直写的 ``llm_configurations``/``rerank_configurations`` + # (``SessionConfig`` 普通属性没有 ``__setattr__`` 守卫,嵌入方或携带 + # 异常取值的 pickle 快照可以放任意可哈希键)。混合类型键(``{1: .., + # "b": ..}``)在排序时抛裸 ``TypeError: '<' not supported between + # instances of 'str' and 'int'``——而本函数在 + # ``start_chat_session_async`` 里于任何提示符**之前**被调用,会话随即 + # 终止。这条消息的用途正是「告诉用户可选值是什么」,不该在拼消息时崩。 + # 渲染走 ``safe_repr``:位数超限的整数键连 ``str()`` 都抛。 + available = ", ".join(sorted(safe_repr(item) for item in configurations)) + raise ValueError( + f"在{role_label}配置中未找到key: {safe_repr(key)}。可选值: {available}。" + " 请检查 config.toml 里的 default_*_provider 与 *_configurations 是否对应。" + ) + detail = configurations[key] + require_detail_attributes( + detail, + ("provider", "model_name"), + field=f"{role_label}配置", + key=key, + role_label=role_label, + ) + return detail def display_chat_config(console: Console, chat_config: dict[str, Any]): @@ -44,37 +363,92 @@ def mask_api_key(key: str | None) -> str: ) chat_table.add_column(style="cyan") chat_table.add_column(style="bold white") - chat_table.add_row("检索策略:", chat_config["retrieval_method"].value) + # 这一整张表的**单元格**都来自 ``config.toml``,而表格单元格与 + # ``console.print`` 一样按标记解析。其中 ``model_name`` 与配置键名 + # (``active_llm_configuration`` 等)都没有白名单:工厂只校验 ``provider`` + # (``_provider_map`` 白名单),其余原样透传。实测 ``model_name = "gemini[/]"`` + # 时 ``reload_llm()`` 返回 True(模型确实建起来了),紧接着本函数抛 + # ``MarkupError``——聊天会话在启动横幅这一步就崩。 + # + # 值一律经 ``Text()`` 原样呈现,样式由 ``add_column(style=...)`` 提供。 + # 直读 ``.value`` 会在可直写的非枚举取值上抛裸 ``AttributeError``,而本函数在 + # ``start_chat_session_async`` 里于任何提示符**之前**被调用——会话在启动横幅 + # 这一步就终止。与同一张表的其它单元格同因(普通属性没有 ``__setattr__`` 守卫)。 + chat_table.add_row( + "检索策略:", + Text(require_retrieval_method(chat_config["retrieval_method"]).value), + ) chat_table.add_row( "混合搜索权重 (向量/关键词):", - f"{chat_config['vector_weight']} / {chat_config['keyword_weight']}", + Text( + f"{_render_value(chat_config['vector_weight'])}" + f" / {_render_value(chat_config['keyword_weight'])}" + ), ) - chat_table.add_row("混合融合策略:", chat_config["hybrid_fusion_strategy"].upper()) - chat_table.add_row("候选过量招募倍率:", str(chat_config["retrieval_candidate_multiplier"])) + # 与同一张表的其它单元格一致走 ``_render_value``:裸 ``str()`` 对位数超限的 + # 整数抛 ``ValueError: Exceeds the limit``,聊天会话在启动横幅这一步就终止。 + chat_table.add_row( + "混合融合策略:", Text(_render_value(chat_config["hybrid_fusion_strategy"]).upper()) + ) + chat_table.add_row( + "候选过量招募倍率:", Text(_render_value(chat_config["retrieval_candidate_multiplier"])) + ) + # ``rerank_enabled`` 与同一张表的 ``retrieval_method`` 同因:``SessionConfig`` + # 是公开类、普通属性没有 ``__setattr__`` 守卫,直写 ``"false"``/``"no"``/``0`` + # 之后真值判断会给出与服务层**相反**的结论——实测直写 ``"false"`` 时这张表显示 + # 「启用」并渲染 Rerank 模型段落,而 ``RetrievalService._coerce_rerank_enabled`` + # 对同一个值直接报错。写入侧(``_validate_rerank_enabled``)只认 ``bool``, + # 读取侧必须同判据。 + rerank_enabled = require_rerank_enabled(chat_config["rerank_enabled"]) chat_table.add_row( "Rerank重排:", - "[bold green]启用[/bold green]" - if chat_config["rerank_enabled"] - else "[bold red]禁用[/bold red]", + markup_text("[bold green]启用[/bold green]") + if rerank_enabled + else markup_text("[bold red]禁用[/bold red]"), ) - if chat_config["rerank_enabled"]: + if rerank_enabled: active_rerank = chat_config["active_rerank_configuration"] - rerank_model_details = chat_config["rerank_configurations"][active_rerank] + rerank_model_details = _require_model_detail( + chat_config["rerank_configurations"], active_rerank, "Rerank" + ) + # 这一格与同一张表的权重/top_k 同因(见下方注释):``active_*`` 与 + # ``model_name`` 都可被直写,裸 f-string 对位数超限的整数抛 + # ``ValueError: Exceeds the limit``;``provider`` 还可能是非字符串, + # 在 ``provider.lower()`` 处抛裸 ``AttributeError``。全部经 + # ``_render_value`` / ``require_provider_name`` 收敛。 + rerank_provider = require_provider_name( + rerank_model_details.provider, label="Rerank provider" + ) chat_table.add_row( "激活的Rerank模型:", - f"{active_rerank} ({rerank_model_details.provider}: {rerank_model_details.model_name})", + Text( + f"{_render_value(active_rerank)} " + f"({rerank_provider}: {_render_value(rerank_model_details.model_name)})" + ), ) - logger.debug(f"Rerank模型已启用,激活模型: {active_rerank}") + # 惰性 ``%s`` 而不是 f-string:后者在 DEBUG 关闭时也**已经求值**, + # 裸插值对位数超限的整数照样抛 ``ValueError``。 + logger.debug("Rerank模型已启用,激活模型: %s", active_rerank) else: logger.debug("Rerank模型未启用。") - chat_table.add_row("返回文档数 (top_k):", str(chat_config["top_k"])) - chat_table.add_row("语义搜索阈值:", str(chat_config["score_threshold"])) + # 这三个字段与上面的权重同理:``SessionConfig`` 是公开类、普通属性没有 + # ``__setattr__`` 守卫,嵌入方或携带超大整数的 pickle 快照可以直写, + # 而裸 ``str()`` 对位数超限的整数抛 ``ValueError: Exceeds the limit``。 + # 本函数在 ``start_chat_session_async`` 里于任何提示符**之前**被调用, + # 于是会话在「知识快照加载成功」之后立刻终止,用户看到的是整数转换错误。 + chat_table.add_row("返回文档数 (top_k):", Text(_render_value(chat_config["top_k"]))) + chat_table.add_row("语义搜索阈值:", Text(_render_value(chat_config["score_threshold"]))) active_llm = chat_config["active_llm_configuration"] - llm_model_details = chat_config["llm_configurations"][active_llm] + llm_model_details = _require_model_detail(chat_config["llm_configurations"], active_llm, "LLM") + llm_provider = require_provider_name(llm_model_details.provider, label="LLM provider") chat_table.add_row( - "激活的LLM:", f"{active_llm} ({llm_model_details.provider}: {llm_model_details.model_name})" + "激活的LLM:", + Text( + f"{_render_value(active_llm)} " + f"({llm_provider}: {_render_value(llm_model_details.model_name)})" + ), ) - logger.debug(f"激活的LLM: {active_llm}") + logger.debug("激活的LLM: %s", active_llm) # --- API与URL配置 --- # API密钥和URL仍然从全局的、不可变的settings对象中读取,因为它们不应该在运行时被修改。 @@ -88,11 +462,16 @@ def mask_api_key(key: str | None) -> str: api_table.add_column(style="bold white") # 仅显示与激活的LLM和Rerank相关的配置 active_providers = {llm_model_details.provider} - if chat_config["rerank_enabled"]: + if rerank_enabled: + # 与上面那处同源:这里是同一个裸索引的第二份拷贝,上面改了这里也要改, + # 否则「rerank 配置缺失」只在表格渲染阶段被拦住、到了 API Key 段落又 + # 抛一次裸 ``KeyError``。 active_providers.add( - chat_config["rerank_configurations"][ - chat_config["active_rerank_configuration"] - ].provider + _require_model_detail( + chat_config["rerank_configurations"], + chat_config["active_rerank_configuration"], + "Rerank", + ).provider ) current_settings = get_settings() # 获取当前配置 @@ -100,18 +479,29 @@ def mask_api_key(key: str | None) -> str: key_name = f"{provider_name.lower()}_api_key" api_key = getattr(current_settings, key_name, None) if api_key: - api_table.add_row(f"{key_name.upper()}:", mask_api_key(api_key)) - logger.debug(f"显示提供商 '{provider_name}' 的 API Key。") + # 行标签由 provider 名拼出。provider 有 ``_provider_map`` 白名单, + # 但仍按字面呈现——标签里的 ``[`` 会被解析。 + api_table.add_row(Text(f"{key_name.upper()}:"), mask_api_key(api_key)) + logger.debug("显示提供商 '%s' 的 API Key。", provider_name) else: - logger.debug(f"提供商 '{provider_name}' 的 API Key 未设置。") + logger.debug("提供商 '%s' 的 API Key 未设置。", provider_name) url_key_name = f"{provider_name.lower()}_base_url" api_url = getattr(current_settings, url_key_name, None) if api_url: - api_table.add_row(f"{url_key_name.upper()}:", str(api_url) or "[dim]未设置[/dim]") - logger.debug(f"显示提供商 '{provider_name}' 的 Base URL: {api_url}") + # Base URL 可以内嵌凭证(``https://user:token@gateway/v1``)—— + # 这是自建网关的常见写法,而开发者文档也建议把 key 放进 URL。 + # 日志侧由 RedactingFormatter 兜底,终端这一路没有,之前会把 + # 完整凭证打到屏幕上(也会留在终端回滚与截图里)。这里先脱敏再显示。 + # Base URL 来自 config.toml/.env,未经校验——IPv6 字面量本身就带 + # 方括号(``http://[::1]:8080/v1``),含 ``[/]`` 形态的取值会让表格 + # 渲染抛 MarkupError。脱敏解决的是凭证泄漏,不解决标记解析。 + api_table.add_row( + Text(f"{url_key_name.upper()}:"), Text(redact_sensitive_text(str(api_url))) + ) + logger.debug("显示提供商 '%s' 的 Base URL。", provider_name) else: - logger.debug(f"提供商 '{provider_name}' 的 Base URL 未设置。") + logger.debug("提供商 '%s' 的 Base URL 未设置。", provider_name) console.print( Panel( diff --git a/src/utils/cleanup.py b/src/utils/cleanup.py index 334daba..d43103b 100644 --- a/src/utils/cleanup.py +++ b/src/utils/cleanup.py @@ -1,16 +1,52 @@ import atexit import os import shutil +from collections.abc import Iterable from pathlib import Path -from .config import get_settings # 导入 get_settings 函数 +from .config import ( # 导入 get_settings 函数 + CONFIG_TOML_PATH, + ENV_FILE_PATH, + ROOT_DIR, + get_settings, +) from .log_manager import get_module_logger # 导入日志管理器 -from .security import redact_sensitive_text +from .security import safe_exception_text logger = get_module_logger(__name__) # 获取当前模块的日志器 -def _reject_unsafe_cache_dir(cache_dir: str) -> str | None: +def _protected_app_paths() -> list[Path]: + """返回应用自身依赖的、绝不能被当作缓存删掉的路径。 + + 只挡住 ``/``、家目录与系统目录是不够的:``cache_path`` 也可以指向应用 + 自己的目录。实测冻结产物中把 ``cache_path`` 配成 ``"."``(解析为应用根 + 目录),退出钩子会把整个安装目录删掉——``.env``(内含 API Key)、 + ``knowledge_base/``(用户放进去的源文档)与 ``config.toml`` 一并消失, + 且无法恢复。``"data"``、``"knowledge_base"``、``"data/logs"`` 同理: + 它们分别覆盖快照目录、源文档与日志目录。 + + 因此判定不依赖目录名,而是拿应用真实使用的路径来比对。 + """ + candidates = [ROOT_DIR, CONFIG_TOML_PATH, ENV_FILE_PATH] + settings = get_settings() + # ``pkl_path`` 是 legacy 知识库文件(用户投入的原始数据),此前漏在清单外: + # 把 ``cache_path`` 指向 ``data``、同时让 knowledge_base_path/snapshot_root/ + # log_path 都落在 ``data`` 之外时,``data`` 不覆盖任何受保护路径, + # ``_reject_unsafe_cache_dir`` 放行,退出清理随即 ``rmtree("data")`` —— + # 连带删掉 ``data/employee_kb.pkl``(实测复现)。它与 ``knowledge_base_path`` + # 同属「用户数据」,必须一并保护。 + for name in ("knowledge_base_path", "pkl_path", "snapshot_root", "log_path"): + value = getattr(settings, name, None) + if value: + candidates.append(Path(value)) + return candidates + + +def _reject_unsafe_cache_dir( + cache_dir: str, + protected_paths: Iterable[Path] | None = None, +) -> str | None: """判断 cache 目录是否可安全删除;不可删时返回原因,可删返回 None。 ``cache_path`` 是用户可配置项,而 ``shutil.rmtree`` 的后果不可逆:实测 @@ -18,17 +54,43 @@ def _reject_unsafe_cache_dir(cache_dir: str) -> str | None: 随后 ``cleanup_temp_files()`` 会把整个目录删掉——它已在 atexit 注册,每次 程序正常退出都会执行。 - 这里只做最低限度的常识判断,不试图猜用户意图:根目录、家目录、以及常见的 - 系统目录一律不删,并把判定结果写进日志。 + 这里只做最低限度的常识判断,不试图猜用户意图:根目录、家目录、常见系统 + 目录,以及**应用自身依赖的路径**一律不删,并把判定结果写进日志。 """ if not cache_dir: return "cache_path 为空" - resolved = Path(cache_dir).resolve() + try: + resolved = Path(cache_dir).resolve() + except (OSError, RuntimeError): + # ``resolve()`` 对符号链接环抛 ``RuntimeError: Symlink loop from ...``。 + # 这一行此前没有守卫(下面遍历 ``protected_paths`` 的那轮有),于是 + # ``cache_path`` 指向链接环时本函数整体抛异常,而它在 atexit 里被调用 + # ——用户看到 ``Exception ignored in atexit callback`` 加 traceback, + # **清理完全没做**。链接环不是可安全删除的目标(它甚至不是目录), + # 因此与「不可删」同一收场,返回原因而不是抛。 + return f"{cache_dir} 无法解析(符号链接环或权限问题)" if resolved == Path(resolved.anchor): return f"{resolved} 是文件系统根目录" - home = Path.home().resolve() - if resolved == home: + try: + home = Path.home().resolve() + except (OSError, RuntimeError): + home = None + if home is not None and resolved == home: return f"{resolved} 是当前用户的家目录" + # 清单原先只有 ``/etc``/``/usr``/``/bin``/``/sbin``/``/var`` 与三个 + # macOS 目录,**漏掉了同样属于系统级、且删除后果不可逆的一批**。实测放行的 + # 包括: + # + # * ``/tmp``(在 macOS 上解析成 ``/private/tmp``)——整个系统临时目录; + # * ``/Users``——macOS 上**全部用户家目录的父目录**,``rmtree`` 会删掉 + # 机器上每个账户的家目录;``/home`` 在 Linux 上同理; + # * ``/opt``——本文件的 docstring 自己就把「安装在 ``/opt``」列为场景, + # 而它的父目录同样不该被删; + # * ``/dev``(设备节点)、``/proc``、``/sys``(内核虚拟文件系统)。 + # + # 判据与上面几项一致:**相等**才算命中,因此 ``/Volumes/MyDrive/cache``、 + # ``/opt/myapp/cache`` 这类正常缓存位置不受影响(本函数从不过度拦截用户 + # 有意指定的缓存目录,见下方「目录名不是 .cache 时只警告不阻断」)。 protected_roots = [ Path("/etc"), Path("/usr"), @@ -38,10 +100,51 @@ def _reject_unsafe_cache_dir(cache_dir: str) -> str | None: Path("/System"), Path("/Library"), Path("/Applications"), + # 系统级目录(此前遗漏) + # ``nosec B108``:bandit 的 hardcoded_tmp_directory 规则按 + # ``context.string_val.startswith("/tmp")`` 匹配字面量,认不出这是 + # 「禁止删除的目录清单」而不是「用固定路径建临时文件」——本行既不 + # 创建也不写入任何文件,语义与 CWE-377 恰好相反。它是清单里唯一以 + # 临时目录前缀开头的项,因此只有这一处需要标注。 + Path("/tmp"), # nosec B108 - 禁止删除清单项,非临时文件用法 + Path("/Users"), + Path("/home"), + Path("/opt"), + Path("/boot"), + Path("/dev"), + Path("/proc"), + Path("/sys"), + Path("/run"), + Path("/srv"), + Path("/mnt"), + Path("/media"), + Path("/root"), + # macOS 的外接卷挂载点;只挡挂载点本身,卷内的目录照常可用。 + Path("/Volumes"), + # macOS 上 ``/etc``/``/var``/``/tmp`` 都指向它下面,挡一层总入口。 + Path("/private"), ] for protected in protected_roots: if resolved == protected.resolve(): return f"{resolved} 是系统目录" + + # 应用自身的路径:相等(cache_path = ".")或位于其下(cache_path = "data") + # 都意味着删除会波及配置、源文档或已建好的知识快照。 + if protected_paths is None: + protected_paths = _protected_app_paths() + for protected in protected_paths: + try: + protected_resolved = Path(protected).resolve() + except (OSError, RuntimeError): + # ``RuntimeError`` 也要接:``Path.resolve()`` 对符号链接环抛 + # ``RuntimeError: Symlink loop from ...``,而 ``except OSError`` + # 接不住它——实测一条配置里的链接环让本函数整体抛异常,退出钩子 + # 以「Exception ignored in atexit callback」收场,清理**完全没做**。 + # 本注释原写「跳过该项,不阻断判定」,只有接住才成立。 + continue + if protected_resolved == resolved or protected_resolved.is_relative_to(resolved): + return f"{resolved} 覆盖了应用自身路径 {protected_resolved}" + # 目录名不是 .cache 时只警告不阻断:用户可能有意把缓存放到别处 # (例如 config.toml 里写 cache_path = "data/cache")。 return None @@ -79,7 +182,7 @@ def cleanup_temp_files(): shutil.rmtree(cache_dir) logger.info(f"已成功删除缓存目录: {cache_dir}") except OSError as e: - error_text = redact_sensitive_text(str(e)) + error_text = safe_exception_text(e) logger.exception( "删除缓存目录 %s 时出错: %s", cache_dir, diff --git a/src/utils/config.py b/src/utils/config.py index abe7bda..1fd39cf 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -46,8 +46,11 @@ def resolve_app_root() -> Path: ROOT_DIR = resolve_app_root() -# 配置文件路径 +# 配置文件路径。两者都以 ``ROOT_DIR`` 为基准,不用相对路径——相对路径由 +# pydantic-settings 按**进程当前工作目录**解析,而冻结产物的可执行文件目录 +# 与调用者 CWD 通常不同,会让用户放在安装目录旁的配置被静默忽略。 CONFIG_TOML_PATH = ROOT_DIR / "config.toml" +ENV_FILE_PATH = ROOT_DIR / ".env" class RetrievalMethod(str, Enum): @@ -117,7 +120,33 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: if isinstance(value, ModelProtocol): return value - normalized = str(value).strip().lower().replace("-", "_") + # ``str(value)`` 自己会抛:``str(10**4301)`` 撞上 CPython 的整数位数上限 + # (``sys.get_int_max_str_digits()``,默认 4300),于是「协议不支持」的 + # 消息变成 ``Exceeds the limit (4300 digits)``。``protocol`` 是 + # ``config.toml`` 的字段,TOML 整数无上界,用户写错一个类型就会看到这句 + # 与协议毫无关系的话。先判类型,非字符串直接给带字段名的消息。 + # ``str(value)`` 自己会抛:``str(10**4301)`` 撞上 CPython 的整数位数上限 + # (``sys.get_int_max_str_digits()``,默认 4300),于是「协议不支持」的 + # 消息变成 ``Exceeds the limit (4300 digits)``。``protocol`` 是 + # ``config.toml`` 的字段,TOML 整数无上界,用户写错一个类型就会看到这句 + # 与协议毫无关系的话。先判类型,非字符串直接给带字段名的消息。 + # ``str(value)`` 对位数超限的整数自己会抛(``str(10**4301)`` 撞上 + # CPython 的 ``sys.get_int_max_str_digits()``,默认 4300),于是「协议 + # 不支持」的消息变成 ``Exceeds the limit (4300 digits)``。先判类型, + # 非字符串直接给带字段名的消息。 + # + # 这条守卫是**纵深防御**而非配置可达缺陷:``protocol`` 确实来自 + # ``config.toml``,但 ``tomllib`` 解析 4301 位的整数时自己就抛 + # ``ValueError``(``get_settings`` 已把它包成「配置解析失败」),位数在 + # 上限内的非字符串(``5``/``["responses"]``/``true``)原本就能得到正确 + # 消息。真正能触达的是 Python API 调用方 + # (``ModelDetail(protocol=10**4301)``),以及未来若换掉 TOML 解析器的 + # 情形。加它的理由是与同文件其它 validator 的判据保持一致——此前 + # ``log_level``/路径字段都已显式判类型,只有这里漏了。 + if not isinstance(value, str): + supported = ", ".join(protocol.value for protocol in ModelProtocol) + raise ValueError(f"不支持的模型协议: {safe_repr(value)}。可选值: {supported}") + normalized = value.strip().lower().replace("-", "_") aliases = { "chat": ModelProtocol.CHAT_COMPLETIONS, "completion": ModelProtocol.CHAT_COMPLETIONS, @@ -136,7 +165,7 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: return alias if alias is not None else ModelProtocol(normalized) except ValueError as exc: supported = ", ".join(protocol.value for protocol in ModelProtocol) - raise ValueError(f"不支持的模型协议: {value}。可选值: {supported}") from exc + raise ValueError(f"不支持的模型协议: {safe_repr(value)}。可选值: {supported}") from exc # ================================================================= @@ -147,8 +176,30 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: # 检索规模的上界。不是洁癖:``effective_top_k = chat_top_k * # retrieval_candidate_multiplier`` 直通 ``faiss_index.search()``,FAISS 既不报错 # 也不截断。实测 ``chat_top_k=10**9`` 单次查询分配约 3.6 GB 数组、RSS 涨 -# 10.3 GB、耗时 8.4 秒。10_000 远超任何真实场景(默认 top_k=5 × multiplier=3)。 -_MAX_RETRIEVAL_TOP_K = 10_000 +# 10.3 GB、耗时 8.4 秒。 +# 单字段上界与乘积上界必须一致:``top_k`` 单独就不该超过候选总规模,否则 +# 「字段校验通过、组合校验拒绝」会让用户看到自相矛盾的提示。候选总规模取 +# 2_000——实测 dim=1536、k=2000 时峰值 RSS 约 330 MB,而 k=100_000 涨到 +# 1.6 GB。默认值是 5 × 3 = 15,留出约 130 倍余量。 +# 向量维度的上界。``local-hash`` 的维度直接来自用户可写的 ``model_name`` +# 数字后缀(``local-hash-256`` -> 256),而 provider 会**先按该维度分配出完整 +# 向量**再返回:实测配成 ``local-hash-100000000`` 时单次 ``embed_texts(["测试"])`` +# 耗时 136 秒、峰值内存 3.9 GB,按真实知识库 115 个分块算 ``np.vstack`` 需要约 +# 4.3 GB,进程会被 OOM 杀掉而不是得到一条可读的错误。校验必须发生在分配**之前**, +# 因此 provider 侧也要用这个上界。 +# +# 现有各家的最大维度是 3072(text-embedding-3-large),65536 留了 20 倍余量, +# 而 65536 维的 float32 单块只有 256 KB。 +_MAX_EMBEDDING_DIMENSION = 65_536 + +_MAX_RETRIEVAL_TOP_K = 2_000 +# 候选规模的上界。单看两个字段各自的上界是不够的——真正送进 +# ``faiss_index.search()`` 的是**乘积** ``chat_top_k * +# retrieval_candidate_multiplier``(见 ``RetrievalService.retrieve``),而 +# FAISS 对超过 ``ntotal`` 的 k 既不报错也不截断,按 k 全量分配。实测 +# dim=1536、k=10**6 时峰值 RSS 涨约 35 GB。默认值是 5 × 3 = 15,这里留出 +# 约 130 倍余量,覆盖「提高 top_k 并同时放大召回候选」的正常调优。 +_MAX_RETRIEVAL_CANDIDATES = 2_000 # 日志保留天数与分片/批处理规模的上界。两者原来都无上界,实测 # ``log_retention_days=10**400``、``kb_chunk_size=10**400`` 均被接受, # 直到真正参与运算(日期减法、内存申请)才炸。 @@ -165,16 +216,372 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: _DECIMAL_LITERAL_PATTERN = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") -def _parse_int_literal(text: str) -> int | None: - """严格解析十进制整数字面量,不做科学计数法/下划线/正号的宽松放行。 +def parse_decimal_int(text: str) -> int | None: + """解析十进制整数字面量;解析不了返回 ``None``,**绝不抛异常**。 + + 只接受十进制数字(可带前导负号),不做科学计数法/下划线/正号的宽松放行: + ``int("1_0") == 10``、``int("+7") == 7`` 这类「安静的错值」正是本项目反复 + 踩到的模式,宁愿在这里明确拒绝。 + + ``int()`` 会抛 ``ValueError`` 的情形有**两**类,都必须收敛成 ``None``: - 只接受 ``[0-9]+``(可带前导负号)。``"1_0"`` 被静默读成 10 这类安静的 - 成功正是本项目反复踩到的模式,所以宁愿在这里明确拒绝。 + 1. 非十进制数字字符。判据用 ``str.isdecimal()`` 而不是 ``str.isdigit()``—— + 后者对 128 个码位为真(``²`` ``①`` ``፩`` ``₅`` …)却让 ``int()`` 抛错。 + 2. 位数超过 ``sys.get_int_max_str_digits()``(CPython 默认 4300 位)。 + ``"9" * 4301`` 是**合法**的十进制整数,``isdecimal()`` 也为真,但 + ``int()`` 照样抛 ``ValueError: Exceeds the limit (4300 digits)``。 + + 只堵住第 1 类是不够的:调用方拿到的仍然是一个可能冒到 prompt_toolkit + 事件循环或错误分类逻辑里的异常。这里用 ``try/except`` 一次性覆盖两类, + 不需要硬编码位数上界——``int()`` 抛 ``ValueError`` 本身就是判据。 + + 返回值是 ``int | None``,调用方据此分支,不必再包一层 ``try``。 + """ + if not isinstance(text, str): + return None + candidate = text.strip() + digits = candidate.removeprefix("-") + if not digits or not digits.isdecimal(): + return None + try: + return int(candidate) + except ValueError: + return None + + +# ``float()`` 与 ``math.isfinite()`` 对超出 C double 范围的整数都会先尝试转换, +# 失败时抛 ``OverflowError: int too large to convert to float``。 +# +# ``OverflowError`` 继承 ``ArithmeticError``,**不是** ``ValueError``。本项目 +# 的数值校验统一以 ``ValueError`` 作为契约(``Settings`` 的 validator、 +# ``SessionConfig`` 的 ``__setitem__``、各 provider 的 options 校验、 +# ``get_settings`` 的 ``except ValueError`` 兜底、questionary 的 validator), +# 因此 ``OverflowError`` 会穿透**全部**既有守卫,冒到解释器默认 handler。 +# +# 判据不能写成 ``math.isfinite(x)``:它自己就会抛 ``OverflowError``(实测 +# ``math.isfinite(10**400)`` 抛错),拿它当守卫等于没有守卫。这里先把 +# 「是不是浮点」与「绝对值是否超出双精度」分开判,两者都过了才安全地调 +# ``float()``。用 ``sys.float_info.max`` 做上界而不是硬编码字面量,避免在 +# 极端平台上与 C double 的实际范围脱节。 +_FLOAT_RANGE_BOUND = sys.float_info.max + + +def is_double_convertible(value: Any) -> bool: + """``value`` 能否安全地转成 C double(即 ``float(value)`` 不会抛异常)。 + + 对 ``int`` 判绝对值上界,对 ``float`` 判有限性,其余类型一律返回 ``False`` + ——它们要么转换不了(``str``/``None``/容器),要么是 ``bool``(``bool`` 是 + ``int`` 的子类,``float(True) == 1.0`` 会静默变成 1.0,调用方必须显式拒绝)。 """ - digits = text.removeprefix("-") - if digits and all(char in "0123456789" for char in digits): - return int(text) - return None + if isinstance(value, bool): + return False + if isinstance(value, float): + return math.isfinite(value) + if isinstance(value, int): + return -_FLOAT_RANGE_BOUND <= value <= _FLOAT_RANGE_BOUND + return False + + +def safe_repr(value: Any, *, limit: int = 80) -> str: + """``repr(value)`` 的安全版本,供错误消息使用。 + + ``repr`` 自己会抛异常:CPython 的 ``sys.get_int_max_str_digits()``(默认 + 4300)让 ``repr(10**4301)`` 抛 ``ValueError: Exceeds the limit (4300 + digits)``。校验代码把它拼进消息时,用户看到的是「整数转字符串超限」, + 而真实原因是「数值非法」——错误消息反过来把异常类型也换掉了 + (``ValueError`` 变 ``ValueError``,但内容与字段毫无关系)。 + + 超长输出也一并截断:``repr(10**400)`` 是 401 个字符,一条校验消息不该 + 刷屏。截断只影响显示,判据在调用方,因此不影响正确性。 + """ + try: + text = repr(value) + except Exception: # noqa: BLE001 - 最后一道展示边界,任何 repr 异常都收敛 + # 常见情形是整数的十进制位数超过 ``sys.get_int_max_str_digits()`` + # (``ValueError``)——``bit_length()`` 不经过十进制转换,可以安全地 + # 给出量级。但 ``repr`` 是任意对象的自定义方法,``__repr__`` 抛 + # ``KeyError``/``TypeError`` 等任何异常都可能(pickle 快照/嵌入方可 + # 注入任意键对象):本函数是 209 处调用点的「最后一道展示边界」, + # 自己再抛就是裸 traceback——比消息难看更糟,捕获面因此放宽到 + # ``Exception``,与 ``_key_text``/``_display_repr`` 的终态判据一致。 + if isinstance(value, int): + digits = value.bit_length() * 30103 // 100000 + 1 # log10(2) ≈ 0.30103 + return f"<整数,约 {digits} 位十进制>" + return f"<{type(value).__name__}>" + if len(text) > limit: + return text[: limit - 3] + "..." + return text + + +_DETAIL_ATTRIBUTE_MISSING = object() + + +def require_detail_attributes( + detail: Any, + attributes: tuple[str, ...], + *, + field: str, + key: Any, + role_label: str, +) -> None: + """校验模型配置的**取值**具备使用方要读的属性,否则抛可读的 ``ValueError``。 + + ``llm_configurations``/``rerank_configurations`` 声明为 + ``dict[str, ModelDetail]``,但 ``SessionConfig`` 的 ``_VALIDATORS`` 明确不登记 + 它们(注释写明「按原样写入,由使用方负责其内部结构」)。于是 ``key in + mapping`` 只证明**键**在,不证明**值**可用:``{"k": None}`` 的键确实存在, + 取值被原样交给使用方,调用方的 ``config.provider`` 抛裸 ``AttributeError`` + ——它不是 ``ValueError``,会穿透调用方按 ``ValueError`` 做的错误分类。 + + 判据用**属性存在性**而不是 ``isinstance(detail, ModelDetail)``:使用方只要求 + 取值提供这几个属性,任何具备它们的对象都能正常工作(仓库里的测试就用 + ``SimpleNamespace`` 构造等价配置)。按具体类型收窄会把可用的配置一并拒掉。 + + 属性存在但取值为 ``None`` 不在这里判——``None`` 是否能用作 provider 名、 + 模型名由各使用方按自己的判据处理。 + """ + for attribute in attributes: + if getattr(detail, attribute, _DETAIL_ATTRIBUTE_MISSING) is _DETAIL_ATTRIBUTE_MISSING: + raise ValueError( + f"{field} 中 {safe_repr(key)} 的取值必须是提供 {attribute} 属性的模型配置," + f"但得到 {type(detail).__name__}。 请检查 {role_label} 的当前配置项。" + ) + + +def require_retrieval_method(value: Any, *, label: str = "retrieval_method") -> RetrievalMethod: + """把检索模式收敛成 ``RetrievalMethod`` 成员,非法取值给出带指引的 ``ValueError``。 + + ``retrieval_method`` 声明为 ``RetrievalMethod``,但 ``SessionConfig`` 是公开类、 + 普通属性没有 ``__setattr__`` 守卫,嵌入方(或携带异常取值的 pickle 快照)可以 + 把它直写成任何形态。此前有三个取用点各自假设它是枚举成员: + + * ``retrieval_service.retrieve`` 用 ``==`` 判分支,且**没有任何形态收敛**——它是 + 该函数里唯一没有 ``_coerce_*`` 助手的字段(``top_k``/候选倍率/ + ``score_threshold``/``rerank_enabled``/``hybrid_fusion_strategy`` 都有)。 + 实测 ``cfg.retrieval_method = "hybrid"``/``None``/``5`` 时静默落进 else + 分支,变成**纯关键词检索**;只有恰好等于 ``"向量检索"`` 的取值才走语义检索。 + 用户看到的是「混合检索配好了但只召回关键词命中」,而不是一条配置错误。 + * ``display_chat_config`` 读 ``.value``——它在 ``start_chat_session_async`` 里于 + 任何提示符**之前**被调用,直写 ``5``/``None`` 时抛裸 + ``AttributeError: 'int' object has no attribute 'value'``,会话在启动横幅 + 这一步就终止。 + * ``config_menu.edit_retrieval_params`` 读同一个 ``.value``,菜单渲染不出来。 + + 接受三类形态,判据是**属性存在性**而不是具体类型(与 ``require_detail_attributes`` + 同一约定——使用方只要求取值提供 ``.value``,任何具备它的对象都能正常工作): + + * ``RetrievalMethod`` 成员; + * 它的**字符串取值**(``"混合检索"``)——本项目的既有契约,``config.toml`` 的 + ``chat_retrieval_method`` 与 ``Settings`` 走的就是这条路径,而 + ``RetrievalMethod`` 本身是 ``str`` 枚举,两种形态在 ``==`` 下等价; + * 任何提供 ``.value`` 的等价替身。仓库里的测试用 ``SimpleNamespace(value="混合检索")`` + 构造这种替身,按类型收窄会把它们一并拒掉。 + + 不做大小写/别名归一:``"hybrid"`` 不是合法取值,静默接受它等于把「配置写错了」 + 变成「检索行为悄悄变了」。 + + ``Enum`` 查找对不可哈希取值抛 ``ValueError``(``['a']``、``{'a': 1}`` 实测), + 但位数超限的整数抛的 ``ValueError`` 消息是 ``Exceeds the limit (4300 digits)`` + ——与「检索模式不合法」无关,因此先做一次形态收敛,把消息统一成带可选值的形态。 + """ + if isinstance(value, RetrievalMethod): + return value + candidate: Any = value + if not isinstance(candidate, str): + # ``getattr`` 而非 ``.value``:非字符串取值(``5``/``None``/列表)没有该 + # 属性,直接取会抛裸 ``AttributeError``。替身对象的 ``.value`` 也可能是 + # property(可以抛),因此整体包在 ``try`` 里。 + try: + candidate = getattr(value, "value", _DETAIL_ATTRIBUTE_MISSING) + except Exception: # noqa: BLE001 - 取值不可用等同于「不是合法的检索模式」 + candidate = _DETAIL_ATTRIBUTE_MISSING + if isinstance(candidate, str): + try: + return RetrievalMethod(candidate) + except ValueError: + pass + allowed = ", ".join(member.value for member in RetrievalMethod) + raise ValueError(f"{label} 必须是 {allowed} 之一,但得到 {safe_repr(value)}。") + + +def require_scale_field(value: Any, label: str) -> int: + """把规模字段(``top_k``/``retrieval_candidate_multiplier``)收敛成正整数。 + + ``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫,嵌入方或携带 + 超大整数的 pickle 快照可以把这两个字段直写成任何形态。两个读取方此前各自 + 假设它是整数: + + * ``RetrievalService.retrieve`` 直接算 ``max(1, top_k) * max(1, multiplier)``, + ``max()`` 对非数值抛裸 ``TypeError: '>' not supported between instances of + 'NoneType' and 'int'``。它是**生产入口**(``Chatbot.chat_async`` 每次提问 + 都调它),契约是返回文档列表或给出可读错误。 + * ``config_menu._top_k_ceiling`` / ``_multiplier_ceiling`` 读**另一个**字段算 + 动态上界(``min(_MAX_RETRIEVAL_TOP_K, _MAX_RETRIEVAL_CANDIDATES // max(1, m))``), + 同样在 ``max()`` 处抛裸 ``TypeError``。菜单在主循环的 ``try`` 之外——内层 + 只接 ``(KeyboardInterrupt, EOFError)``、外层只有 ``finally``——因此这会以 + 裸 traceback 终止整个会话,用户丢失对话。实测「倍率被直写成 ``"abc"``、 + 用户选『调整 Top K』」即复现。 + + 判据上移到这里而不是留在 ``retrieval_service``:``config_menu`` 已经依赖本 + 模块(``_MAX_RETRIEVAL_*``、``parse_decimal_int``、``safe_repr``),而反向 + 依赖(UI 去 import 服务层私有函数)会把层次关系弄反。 + + 收敛语义与 ``retrieve`` 的 ``max(1, ...)`` 一致:非正数视为 1(「至少要取 + 一条」),这与 ``__setitem__`` 的 1 下界不冲突——那里是拒绝写入,这里是读取 + 已有状态时的容错。``bool`` 显式拒绝:``max(1, True)`` 会静默变成 1,而它几乎 + 必然是上游把标志位写进了规模字段。 + """ + if isinstance(value, bool): + raise ValueError(f"{label} 必须是整数,但得到布尔值 {safe_repr(value)}。") + if isinstance(value, int): + # 位数上界:``top_k × multiplier`` 要送进 ``faiss_index.search()``,而 + # FAISS 既不报错也不截断,按 k 分配结果数组。``10**4301`` 这类取值在 + # 乘积处还会撞上双精度转换,收场各不相同。 + if value > _MAX_RETRIEVAL_CANDIDATES: + raise ValueError( + f"{label} 超出合理范围({safe_repr(value)})," + f"上界 {_MAX_RETRIEVAL_CANDIDATES}。请检查该配置项。" + ) + return max(1, value) + raise ValueError(f"{label} 必须是整数,但得到 {safe_repr(value)}。") + + +def _require_text(value: Any, label: str) -> str: + """确认取值是字符串并原样返回,否则给出带字段名的 ``ValueError``。""" + if not isinstance(value, str): + raise ValueError(f"{label} 必须是字符串,但得到 {safe_repr(value)}。") + return value + + +def require_provider_name(value: Any, *, label: str = "provider") -> str: + """确认 ``provider`` 是字符串并原样返回,否则给出带字段名的 ``ValueError``。 + + ``ModelDetail.provider`` 声明为 ``str``,但 ``SessionConfig.llm_configurations`` + /``rerank_configurations`` 的 ``_VALIDATORS`` 明确不登记(「按原样写入,由 + 使用方负责其内部结构」),嵌入方可以往映射里放任意取值。三个使用方此前各自 + 假设它是字符串: + + * ``display_chat_config`` 拿它拼 ``f"{provider.lower()}_api_key"`` —— 实测 + ``provider=None``/``5`` 抛裸 ``AttributeError: 'NoneType' object has no + attribute 'lower'``;``provider=["openai"]`` 更早一步,在 + ``active_providers = {llm_model_details.provider}`` 这个**集合**里就抛 + ``TypeError: unhashable type: 'list'``。本函数在 + ``start_chat_session_async`` 里于任何提示符**之前**被调用,会话在启动横幅 + 这一步就终止。 + * ``RetrievalService._get_rerank_provider`` 已经内联了同一判据(provider 进 + ``cache_key`` 元组),这里抽成共享实现,两处不再各写一份。 + * ``ModelProviderFactory._get_model_detail`` 把取值原样交给三个 getter,而 + getter 的 ``logger.info(f"...{provider_name}...")`` 对位数超限的整数抛 + ``ValueError: Exceeds the limit``,``_create_provider`` 的字典查找对不可哈希 + 取值抛裸 ``TypeError``——诊断入口(``_provider_info_for_role``)早已按本判据 + 收敛,建 provider 这条路径此前漏了。 + + 判据只要求「是字符串」,不查 ``_provider_map`` 白名单:白名单是**工厂**的 + 职责(``_get_provider_class`` 会给「不支持的模型提供商」),而展示层与缓存键 + 只需要一个可哈希、可拼串的取值。在展示层提前套白名单会让「配置里有个尚未 + 支持的 provider」从「启动时列出配置」变成「启动时崩溃」——那是更差的收场。 + """ + return _require_text(value, label) + + +def require_model_name(value: Any, *, label: str = "model_name") -> str: + """确认 ``model_name`` 是字符串并原样返回,否则给出带字段名的 ``ValueError``。 + + ``ModelDetail.model_name`` 声明为 ``str``,但取值来源与 ``provider`` 同源 + (``_VALIDATORS`` 不登记 ``*_configurations``,属性赋值也没有 ``__setattr__`` + 守卫,实测 ``detail.model_name = ["m"]`` 直接成功),而它全程没有形态校验: + ``local-hash`` 的 ``_parse_dimension`` 直接 ``.rsplit()``(非字符串抛裸 + ``AttributeError``),其它 provider 把它透传给 SDK;三个 getter 的 + ``logger.info(f"...模型: {model_name}")`` 对位数超限的整数抛 + ``ValueError: Exceeds the limit``。判据与 ``require_provider_name`` 一致。 + """ + return _require_text(value, label) + + +def require_rerank_enabled(value: Any, *, label: str = "rerank_enabled") -> bool: + """确认 ``rerank_enabled`` 是真正的布尔值并原样返回,否则抛 ``ValueError``。 + + ``SessionConfig._validate_rerank_enabled`` 早已要求 ``bool``(测试 + ``tests/runtime/test_contracts.py`` 里就有 ``("rerank_enabled", "yes")`` 这一 + 行),但那只管**写入**侧。``SessionConfig`` 是公开类、普通属性没有 + ``__setattr__`` 守卫,直写 ``"false"``/``"no"``/``0`` 之后,读取侧此前走的是 + 真值判断,于是同一个字段分成两套判据: + + * ``RetrievalService._coerce_rerank_enabled`` 明确拒绝(``ValueError``); + * UI 的 5 个读取点全部用真值判断——实测直写 ``"false"`` 时配置表显示 + **「启用」**、菜单显示「当前: 启用」、``questionary.confirm`` 的默认项是 + 「是」,而服务层对同一个值直接报错。用户看到的是与配置意图**相反**的结论。 + + 判据与 ``_coerce_rerank_enabled``/``_validate_rerank_enabled`` 完全一致 + (只认 ``bool``,不认 ``0``/``1``/``"yes"``):真值语义正是这条缺陷的根因, + 读取侧不能再用它。 + """ + if not isinstance(value, bool): + raise ValueError(f"{label} 必须是布尔值,但得到 {safe_repr(value)}。") + return value + + +def require_chat_temperature(value: Any) -> float: + """把 ``chat_temperature`` 收敛成 ``0.0`` 到 ``1.0`` 的有限浮点,非法取值给出带字段名的 ``ValueError``。 + + ``chat_temperature`` 有**完整**的校验器——``Settings.validate_chat_temperature`` + 与 ``SessionConfig._validate_chat_temperature`` 各自一份,判据一致(拒 ``bool``、 + 非数值、非有限、负数、超出双精度的整数、以及大于 ``1.0`` 的取值)。但两个 + **读取点**都直读原属性,而 ``SessionConfig`` 是公开类、普通属性没有 + ``__setattr__`` 守卫: + + * ``ChatService.generate_reply``:``session_config.chat_temperature`` 直接进 + ``CompletionRequest``,再原样成为 SDK 的 kwargs。实测 ``"abc"``/``None``/ + ``[0.5]``/``True``/``-1`` 全部**静默**通过——而它们正是 + ``SessionConfig["chat_temperature"] = ...`` 会明确拒绝的取值。同一个字段 + 「写得进就合法、直写就非法」两种口径。非法值一路到服务端,用户看到的是 + 接口返回的参数错误(或更糟:``-1`` 被某些网关当默认值接受),而不是一条 + 本地配置错误。 + * ``Chatbot.chat_async`` 的兜底分支(``chat_service`` 缺失时)读 + ``self.session_config["chat_temperature"]``,同一个值。 + + 上界 ``1.0`` 必须在这里也判:契约由 ``Settings.validate_chat_temperature`` 与 + ``config.toml.example`` 的注释共同钉在 ``0.0``–``1.0``,而本函数此前只判了 + ``>= 0``。于是同一个 ``1.5`` 写进 ``config.toml`` 被 ``Settings`` 明确拒绝, + 而经 ``SessionConfig(...)`` 构造器、属性直写或配置菜单却一路进请求 kwargs—— + 两条入口对同一取值给相反结论,正是本函数要消除的那类分歧。 + + 收敛语义与两处校验器一致(返回 ``float``,落在 ``[0.0, 1.0]``)。与 + ``_validate_chat_temperature`` 的分工:那里是配置边界的**拒绝**,这里是运行期 + 读取可直写字段时的**收敛**——两者共用同一判据,措辞也一致。 + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"chat_temperature 必须是数值,但得到 {safe_repr(value)}。") + if not is_double_convertible(value): + raise ValueError(f"chat_temperature 必须在 0.0 到 1.0 之间,但得到 {safe_repr(value)}。") + number = float(value) + if not math.isfinite(number) or not 0.0 <= number <= 1.0: + raise ValueError(f"chat_temperature 必须在 0.0 到 1.0 之间,但得到 {safe_repr(value)}。") + return number + + +def to_finite_float(value: Any) -> float | None: + """把数值收敛成有限 ``float``;不能安全转换时返回 ``None``,**绝不抛异常**。 + + 接受 ``float()`` 原本接受的三类输入:``int``(含超出双精度范围的,此时返回 + ``None`` 而不是抛 ``OverflowError``)、``float``(非有限返回 ``None``)、以及 + 数字字符串。字符串必须继续接受——调用方此前走的就是 ``float(v)``, + ``.env``/TOML 来源本来就以字符串形态到达。 + + 返回 ``None`` 的语义是「这个值不能当有限浮点用」,调用方据此抛自己的 + ``ValueError``(各处的字段名与措辞不同,消息由调用方决定)。 + """ + if isinstance(value, bool): + return None + if isinstance(value, str): + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + if not is_double_convertible(value): + return None + return float(value) def _coerce_number(value: Any, field_label: str) -> int | float: @@ -191,22 +598,22 @@ def _coerce_number(value: Any, field_label: str) -> int | float: ``inf`` 这类「成功解析出的非法值」,不如直接在这里当解析失败处理)。 """ if isinstance(value, bool): - raise ValueError(f"{field_label} 不能是布尔值 {value!r}。") + raise ValueError(f"{field_label} 不能是布尔值 {safe_repr(value)}。") if isinstance(value, (int, float)): return value if isinstance(value, str): text = value.strip() - parsed_int = _parse_int_literal(text) + parsed_int = parse_decimal_int(text) if parsed_int is not None: return parsed_int if not _DECIMAL_LITERAL_PATTERN.fullmatch(text): - raise ValueError(f"无法把 {field_label} 的 {value!r} 转换为数值。") + raise ValueError(f"无法把 {field_label} 的 {safe_repr(value)} 转换为数值。") try: number = float(text) except ValueError as exc: - raise ValueError(f"无法把 {field_label} 的 {value!r} 转换为数值。") from exc + raise ValueError(f"无法把 {field_label} 的 {safe_repr(value)} 转换为数值。") from exc if not math.isfinite(number): - raise ValueError(f"{field_label} 必须是有限数值,但得到 {value!r}。") + raise ValueError(f"{field_label} 必须是有限数值,但得到 {safe_repr(value)}。") return number raise ValueError(f"{field_label} 必须是数值,但得到 {type(value).__name__}。") @@ -393,9 +800,11 @@ def validate_chat_score_threshold(cls, value: Any) -> float: # 它不是 ``ValidationError``,会穿透 ``get_settings`` 的 ``except`` 变成 # 未脱敏的 traceback。范围判断对任意大的 int 都能给出正常结论。 if not isinstance(number, (int, float)) or isinstance(number, bool): - raise ValueError(f"chat_score_threshold 必须是数值,但得到 {value!r}。") + raise ValueError(f"chat_score_threshold 必须是数值,但得到 {safe_repr(value)}。") if not 0 <= number <= 1: - raise ValueError(f"chat_score_threshold 必须在 0 到 1 之间,但得到 {value!r}。") + raise ValueError( + f"chat_score_threshold 必须在 0 到 1 之间,但得到 {safe_repr(value)}。" + ) return float(number) @field_validator("log_retention_days", mode="before") @@ -440,18 +849,45 @@ def validate_positive_sizes(cls, value: Any) -> int: @field_validator("kb_chunk_overlap", "kb_child_chunk_overlap", mode="before") @classmethod def validate_non_negative_overlap(cls, value: Any) -> int: + """overlap 必须落在 ``[0, _MAX_KB_CHUNK_TOKENS)``。 + + 上界与 ``kb_chunk_size`` 同源而不是各写一个数:overlap 的语义是「相邻 + 分片重叠的 token 数」,它必须**小于** chunk_size,而 chunk_size 的上界 + 就是 ``_MAX_KB_CHUNK_TOKENS``,因此这里取同一个常量作上界。 + + 缺上界是**配置可达**的:``config.toml`` 写 + ``kb_child_chunk_overlap = 10**400``(401 位,``tomllib`` 照常解析)会被 + ``Settings`` 静默接受(实测),配置加载成功,直到分片期才在 + ``RecursiveTextSplitter._validate_hierarchical_sizes`` 抛错——报错现场 + 离写错的配置很远,而且消息里会打出整个 401 位数字。上界把这类错误拉回 + 配置加载期,与同文件其它数值字段(``kb_chunk_size``/``chat_top_k``/ + ``retrieval_candidate_multiplier``)一致。 + """ number = _coerce_number(value, "kb_chunk_overlap/kb_child_chunk_overlap") if isinstance(number, float) and not number.is_integer(): raise ValueError("kb_chunk_overlap/kb_child_chunk_overlap 必须是整数。") number = int(number) if number < 0: raise ValueError("kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数。") + if number >= _MAX_KB_CHUNK_TOKENS: + raise ValueError( + "kb_chunk_overlap/kb_child_chunk_overlap 必须小于 " + f"{_MAX_KB_CHUNK_TOKENS}(分片重叠必须小于分片上限)。" + ) return number @field_validator("chat_vector_weight", "chat_keyword_weight", mode="before") @classmethod def validate_hybrid_weights(cls, value: Any) -> float: - number = float(_coerce_number(value, "chat_vector_weight/chat_keyword_weight")) + # ``float(10**400)`` 抛 ``OverflowError``(**不是** ``ValueError``), + # 会穿透 ``get_settings`` 的全部三个 handler。``to_finite_float`` 内部 + # 先判双精度范围,越界时返回 ``None``。 + number = to_finite_float(_coerce_number(value, "chat_vector_weight/chat_keyword_weight")) + if number is None: + raise ValueError( + "chat_vector_weight/chat_keyword_weight 必须是有限数值," + f"但得到 {safe_repr(value)}。" + ) if not 0 <= number <= 1: raise ValueError("chat_vector_weight/chat_keyword_weight 必须在 0 到 1 之间。") return number @@ -463,35 +899,62 @@ def validate_retrieval_size_bounds(self) -> "Settings": raise ValueError( f"retrieval_candidate_multiplier 必须不超过 {_MAX_RETRIEVAL_MULTIPLIER}。" ) + # 两个字段各自合法,乘积仍可能失控:10_000 × 100 = 10**6 会让 FAISS + # 按 100 万条分配(实测 dim=1536 时峰值 RSS 约 35 GB)。约束必须落在 + # 乘积上——这才是真正送进 search() 的值。 + candidates = self.chat_top_k * self.retrieval_candidate_multiplier + if candidates > _MAX_RETRIEVAL_CANDIDATES: + raise ValueError( + f"chat_top_k × retrieval_candidate_multiplier 不能超过 " + f"{_MAX_RETRIEVAL_CANDIDATES},但得到 {candidates}。" + " 两者相乘才是实际送入向量检索的候选规模,过大会一次性申请巨量内存。" + ) return self @model_validator(mode="after") def validate_overlap_smaller_than_size(self) -> "Settings": - """overlap 必须严格小于 size,否则分片无法推进。 + """overlap 必须严格小于 size,且不得超过 size 的一半。 ``chunk_size == chunk_overlap`` 会让切分器无法前进(得到空分片或 死循环),且这一约束无法用单字段 validator 表达。 + + 比例上界(``overlap <= size / 2``)拦的是另一类失败:LangChain 的 + ``RecursiveCharacterTextSplitter`` 只在 ``overlap > size`` 时报错, + **接近** size 时静默接受,而每个分片只前进 ``size - overlap`` 个字符, + 冗余随比例急剧放大。实测一篇 15000 字符的文档: + + ========== ========== ========== + overlap 分片数 放大倍数 + ========== ========== ========== + 10% 14 1.1x + 50% 23 1.9x + 80% 56 4.7x + 99% 1651 137.6x + 99.9% 13751 1145.9x + ========== ========== ========== + + 这些分片全部进入 embedding 与 FAISS 写入,是一次「配置看着正常、代价 + 上千倍」的静默放大。50% 是主流 RAG 实践的通行上界(它同时保证每个 + 分片至少有一半是新内容),且实测在该点上放大仅 1.9x。 """ if self.kb_chunk_overlap >= self.kb_chunk_size: raise ValueError("kb_chunk_overlap 必须小于 kb_chunk_size。") - if self.kb_child_chunk_overlap >= self.kb_child_chunk_size: - raise ValueError("kb_child_chunk_overlap 必须小于 kb_child_chunk_size。") - return self - - @model_validator(mode="after") - def validate_child_chunk_size_not_larger_than_parent(self) -> "Settings": - """子分片不得大于父分片,否则层级结构静默退化为一层。 - - 实测 ``kb_chunk_size=300, kb_child_chunk_size=1500`` 被接受后,父块数 - 从 2 涨到 10(每个父块只产出 1 个子块),「先粗后细」的父子检索意图 - 失效,且没有任何报错。同属无法用单字段 validator 表达的约束。 - """ - # 相等同样要拒:实测 parent=child=300 时不同父块数 == 分块数(15/15), - # 即每个父块只产出一个子块,层级与 child>parent 一样退化为一层。 - if self.kb_child_chunk_size >= self.kb_chunk_size: + if self.kb_chunk_overlap * 2 > self.kb_chunk_size: raise ValueError( - "kb_child_chunk_size 必须小于 kb_chunk_size(子分片不得大于或等于父分片)。" + "kb_chunk_overlap 不能超过 kb_chunk_size 的一半" + f"(得到 overlap={self.kb_chunk_overlap}、size={self.kb_chunk_size})。" + " 重叠比例过高会让分片器几乎无法推进,产出成百上千倍的冗余分片。" ) + # 子分片的两条关系(``child_overlap < child_size``、 + # ``child_size < chunk_size``)**不在这里判**:它们只在层级模式下有 + # 意义,而模式是分片时的运行时参数(``build(splitter_structure_mode)``/ + # CLI 的 ``--mode``),``Settings`` 看不到。无条件拒绝会让一组 + # 「standard 下完全合法」的取值连配置都加载不了——用户只想用 standard, + # 却必须先改掉用不到的字段(实测 ``kb_chunk_size=100, + # kb_child_chunk_size=300`` 被拒,而 standard 模式根本不构造子分片器)。 + # + # 判定移到 ``RecursiveTextSplitter._validate_hierarchical_sizes()``, + # 那里模式已知、且与真正使用这些参数的位置一致。 return self @model_validator(mode="after") @@ -511,8 +974,18 @@ def validate_hybrid_weights_not_both_zero(self) -> "Settings": @field_validator("log_level", mode="before") @classmethod - def validate_log_level(cls, v: str) -> str: - """验证日志级别是否有效。""" + def validate_log_level(cls, v: Any) -> str: + """验证日志级别是否有效。 + + ``config.toml`` 的 TOML 值可以是任意类型,因此必须先确认是字符串再调 + ``.upper()``:原实现直接 ``v.upper()``,``log_level = 123`` 抛的是裸 + ``AttributeError: 'int' object has no attribute 'upper'``——它不是 + ``ValueError`` 的子类,会穿透 ``get_settings`` 的全部三个 handler。这个 + 字段尤其要紧:它在**模块导入期**就被 ``log_manager.get_module_logger`` + 读取,早于任何入口的 ``try``,连脱敏边界都还没建立。 + """ + if not isinstance(v, str): + raise ValueError(f"log_level 必须是字符串,但得到 {safe_repr(v)}。") valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] if v.upper() not in valid_levels: raise ValueError(f"无效的日志级别: {v}. 必须是 {', '.join(valid_levels)} 中的一个。") @@ -541,11 +1014,13 @@ def validate_chat_temperature(cls, v: Any) -> float: """验证聊天温度在 0.0 到 1.0 之间。""" # ``bool`` 是 ``int`` 的子类,``float(True) == 1.0`` 会被静默接受。 if isinstance(v, bool): - raise ValueError(f"聊天温度必须是数字,不能是布尔值 {v!r}。") - try: - value = float(v) - except (ValueError, TypeError) as exc: - raise ValueError(f"无法将聊天温度 '{v}' 转换为数字。") from exc + raise ValueError(f"聊天温度必须是数字,不能是布尔值 {safe_repr(v)}。") + # ``float(10**400)`` 抛的是 ``OverflowError``——它**不是** ``ValueError``, + # 因此下面这个 ``except`` 接不住,裸异常会穿透 ``get_settings`` 的 + # 全部三个 handler。判据用 ``to_finite_float``(内部先判双精度范围)。 + value = to_finite_float(v) + if value is None: + raise ValueError(f"无法将聊天温度 {safe_repr(v)} 转换为数字。") if not (0.0 <= value <= 1.0): raise ValueError(f"聊天温度必须在 0.0 到 1.0 之间,但得到 {value}。") @@ -613,14 +1088,43 @@ def validate_retrieval_candidate_multiplier(cls, v: Any) -> int: """验证检索候选过量招募倍率。""" # ``bool`` 是 ``int`` 的子类,``int(True) == 1`` 会被静默接受。 if isinstance(v, bool): - raise ValueError(f"检索候选倍率必须是整数,不能是布尔值 {v!r}。") + raise ValueError(f"检索候选倍率必须是整数,不能是布尔值 {safe_repr(v)}。") + # ``config.toml`` 写 ``retrieval_candidate_multiplier = inf``(合法 + # TOML,``tomllib`` 原样解析成 ``float('inf')``)必须给出「必须是整数, + # 但得到 inf」这种能直接定位到字段的消息。这条前置守卫先于下面的 + # ``is_integer`` 收敛给出该消息;实测删掉它行为不变——``inf``/``nan`` + # 的 ``is_integer()`` 同样返回 ``False``,会落到同一条消息——保留它是 + # 因为同文件的 ``chat_top_k``/``log_retention_days``/``kb_chunk_size`` + # 都有等价的前置守卫,判据要对齐。 + # + # 其它非有限数值类型(``Decimal('Infinity')`` 等,只能从 Python 调用方 + # 传入)不是 ``float``,走不进这条守卫;但它们也到不了下面的 ``int()`` + # ——``_coerce_number`` 的类型兜底会先给出「必须是数值,但得到 Decimal」。 + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"检索候选倍率必须是整数,但得到 {safe_repr(v)}。") + # 与同文件的 ``chat_top_k``/``log_retention_days``/``kb_chunk_size`` + # 用同一套解析:走 ``_coerce_number``(内部用 ``parse_decimal_int``, + # 只认纯十进制字面量)。此前这里直接 ``int(v)``,与其它整数字段**判据 + # 不一致**,两条后果都实测复现: + # + # * ``1.9`` 被静默截断成 ``1``(``chat_top_k=1.9`` 会被拒绝); + # * ``"1_0"`` 被 ``int()`` 接受为 10、``"+7"`` 被接受为 7——而 + # ``parse_decimal_int`` 的 docstring 明确写着这两类「安静的错值」 + # 「正是本项目反复踩到的模式,宁愿在这里明确拒绝」。 + number = _coerce_number(v, "检索候选倍率") + if isinstance(number, float) and not number.is_integer(): + raise ValueError(f"检索候选倍率必须是整数,但得到 {safe_repr(v)}。") + # ``number`` 已是 ``int`` 或整值 ``float``,``int()`` 对它们不会失败; + # 唯一能走到 ``except`` 的是覆写了 ``__int__`` 的数值子类(``int``/ + # ``float`` 的派生类,只能从 Python 调用方传入),它可能抛 + # ``OverflowError``/``ValueError``/``TypeError`` 中任意一种。 try: - value = int(v) - except (ValueError, TypeError) as exc: - raise ValueError(f"无法将检索候选倍率 '{v}' 转换为整数。") from exc + value = int(number) + except (ValueError, TypeError, OverflowError) as exc: + raise ValueError(f"无法将检索候选倍率 '{safe_repr(v)}' 转换为整数。") from exc if value < 1: - raise ValueError(f"检索候选倍率必须大于等于 1,但得到 {value}。") + raise ValueError(f"检索候选倍率必须大于等于 1,但得到 {safe_repr(value)}。") return value @field_validator("kb_splitter_separators", mode="before") @@ -671,14 +1175,65 @@ def validate_retrieval_method(cls, v: Any) -> Any: "knowledge_base_path", "pkl_path", "snapshot_root", "log_path", "cache_path", mode="before" ) @classmethod - def resolve_path(cls, v: str) -> str: - """将相对路径解析为绝对路径。""" - if not v: - return v + def resolve_path(cls, v: Any, info: ValidationInfo) -> str: + """将相对路径解析为绝对路径。 + + 非字符串必须显式拒绝:``Path(123)`` 抛的是裸 + ``TypeError: expected str, bytes or os.PathLike object, not int``, + 而 ``TypeError`` **不是** ``ValueError``,会穿透 ``get_settings`` 的全部 + 三个 handler,用户看到的是解释器默认 handler 打出的多帧裸 traceback + (实测 23 帧),其中没有任何字段名。 + """ + if not isinstance(v, str): + raise ValueError(f"{info.field_name} 必须是字符串,但得到 {safe_repr(v)}。") + # 空值必须显式拒绝,不能原样返回。此处原为 ``if not v: return v``,而 + # ``Path("")`` 在求值期等价于 ``Path(".")``——**运行时的当前工作目录**。 + # 两个后果: + # + # 1. 信任根扩到 CWD。``FaissStore.load_snapshot`` 拿 ``snapshot_root`` 当 + # pickle 的来源信任根(``AGENTS.md`` 要求快照只从本项目生成的文件 + # 加载),实测 ``SNAPSHOT_ROOT=""`` 时信任根落在仓库根目录,在其中放 + # 一个带 ``ACTIVE_SNAPSHOT`` 标记的目录即可让加载被接受,边界等于 + # 不存在。 + # 2. 行为随 CWD 漂移。默认值(如 ``data/kb``)走下面的 ``ROOT_DIR`` 分支、 + # 不随 CWD 变,而空值原样返回后由 ``Path()`` 在求值期绑定到 CWD——同一 + # 份配置在仓库内与仓库外运行会指向不同目录。 + # + # 纯空白一并拒绝:``Path(" ")`` 会解析成一个名为空格的**真实目录**, + # 没有任何合法用途。路径字段的默认值都是非空相对路径,用户「不想设」时 + # 应删掉该键回落默认值,而不是写成空串。 + if not v.strip(): + raise ValueError( + f"{info.field_name} 不能为空。" + " 空值会解析成当前工作目录,使路径随运行位置漂移;" + "如需使用默认值,请删除该配置项而不是置空。" + ) + # NUL 字节也必须显式拒绝:``os.makedirs``/``open`` 对它抛 + # ``ValueError: embedded null byte``——**不是** ``OSError``,因此下游那些 + # 「目录不可写就降级」的 ``except OSError`` 全部接不住。实测 + # ``config.toml`` 写 ``log_path = "/tmp/a\u0000b"``(TOML 的 ``\u0000`` + # 转义)时,``cleanup.py`` 在**模块导入期**调用 ``get_module_logger``, + # 程序连 ``run_cli`` 都到不了就崩。路径里出现 NUL 没有任何合法用途。 + if "\x00" in v: + raise ValueError(f"{info.field_name} 不能包含 NUL 字节。") path = Path(v) if path.is_absolute(): return str(path) - return str((ROOT_DIR / v).resolve()) + # ``resolve()`` 对符号链接环抛 ``RuntimeError: Symlink loop from ...``, + # 它不是 ``ValueError``,也不是 ``OSError``——``get_settings`` 已有的四个 + # handler 一个都接不住,而 ``get_settings`` 在 **import 期**就被调用 + # (``log_manager.get_module_logger``),早于任何入口的 ``try``。实测 + # ``data`` 是符号链接环(``data -> data``)时 ``import main`` 直接以裸 + # traceback 终止,与同文件其它「配置写错」的脱敏收场不一致。收敛判据与 + # ``utils.security.resolve_within``、``cleanup._reject_unsafe_cache_dir`` + # 一致:``(OSError, RuntimeError)`` 统一成带字段名的 ``ValueError``。 + try: + return str((ROOT_DIR / v).resolve()) + except (OSError, RuntimeError) as exc: + raise ValueError( + f"{info.field_name} 无法解析({type(exc).__name__}: {exc})。" + " 路径可能包含符号链接环或过长的间接层,请检查该配置项。" + ) from exc @classmethod def settings_customise_sources( @@ -699,7 +1254,8 @@ def settings_customise_sources( ) model_config = SettingsConfigDict( - env_file=".env", + # 见 ``ENV_FILE_PATH`` 的说明:与 ``CONFIG_TOML_PATH`` 同源。 + env_file=str(ENV_FILE_PATH), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", @@ -707,6 +1263,12 @@ def settings_customise_sources( ) +# ``options`` 的取值是原样交给 SDK/服务端的用户载荷(``response_format`` 的 +# JSON Schema、``extra_body`` 的内容等),键名属于用户,不能按项目词汇小写化。 +# 见 ``load_toml_config`` 里 ``normalize_mapping`` 的说明。 +_OPAQUE_VALUE_CONTAINERS = frozenset({"options"}) + + def load_toml_config() -> dict[str, Any]: """ 从全局 CONFIG_TOML_PATH 路径加载 config.toml 文件配置。 @@ -717,33 +1279,59 @@ def load_toml_config() -> dict[str, Any]: with CONFIG_TOML_PATH.open("rb") as f: raw_config = tomllib.load(f) - def normalize_mapping(value: Any, preserve_keys: set[str] | None = None) -> Any: + def normalize_mapping( + value: Any, preserve_keys: set[str] | None = None, *, opaque: bool = False + ) -> Any: + """把 TOML 的键名归一成项目的小写词汇,但**不碰用户载荷**。 + + 归一化的理由是配置项名大小写不敏感(``CHAT_TOP_K``/``chat_top_k`` 等价), + 而这条理由**不适用于取值**:``options`` 里的 ``response_format``、 + ``extra_body`` 是原样交给 SDK/服务端的 JSON,键名属于用户。 + 此前每一层嵌套字典的键都被小写化,实测 + ``required = ["OrderId"]`` 与 ``properties = { OrderId = ... }`` 被拆成 + ``["OrderId"]`` 与 ``["orderid"]``——schema 的 required 与 properties + 不再对应,结构化输出静默失效。 + + * ``opaque=True``:整个子树原样保留键名(进入用户载荷之后)。 + * ``preserve_keys``:只保留**本层**键名(``llm_configurations`` 下的条目名 + 由用户命名、别处按名查找),条目内部的字段名仍按项目词汇归一。 + * ``options``:本层键名是项目/SDK 的字段词汇(大小写不敏感,照旧归一, + 于是 ``MAX_TOKENS`` 仍然有效),其下的取值是用户载荷,原样保留。 + """ if not isinstance(value, dict): return value preserved = preserve_keys or set() normalized: dict[str, Any] = {} for raw_key, raw_value in value.items(): - key = str(raw_key).lower() - if key in preserved and isinstance(raw_value, dict): + key = str(raw_key) if opaque else str(raw_key).lower() + if not isinstance(raw_value, dict): + if isinstance(raw_value, list): + normalized[key] = [normalize_mapping(item, opaque=opaque) for item in raw_value] + else: + normalized[key] = raw_value + continue + + if opaque: + normalized[key] = { + str(item_key): normalize_mapping(item_value, opaque=True) + for item_key, item_value in raw_value.items() + } + elif key in preserved: normalized[key] = { str(item_key): normalize_mapping(item_value) for item_key, item_value in raw_value.items() } - continue - - if isinstance(raw_value, dict): + elif key in _OPAQUE_VALUE_CONTAINERS: + normalized[key] = { + str(item_key).lower(): normalize_mapping(item_value, opaque=True) + for item_key, item_value in raw_value.items() + } + else: normalized[key] = { str(item_key).lower(): normalize_mapping(item_value) for item_key, item_value in raw_value.items() } - continue - - if isinstance(raw_value, list): - normalized[key] = [normalize_mapping(item) for item in raw_value] - continue - - normalized[key] = raw_value return normalized raw_config = normalize_mapping( @@ -867,6 +1455,44 @@ def get_settings() -> Settings: # 实测 ``KB_SPLITTER_SEPARATORS='###'``(非 JSON 形态)走的正是这条路, # 用户看到的是解释器默认 handler 打出的多屏 traceback,而不是脱敏消息。 raise ValueError(f"配置解析失败 - {redact_sensitive_text(str(exc))}") from None + except tomllib.TOMLDecodeError as exc: + # ``config.toml`` 语法错走的是 ``load_toml_config`` 里的 ``tomllib.load``, + # pydantic-settings 把该异常原样放出来。``TOMLDecodeError`` 继承 + # ``ValueError``,但既不是 ``ValidationError`` 也不是 ``SettingsError``, + # 上面两个分支都接不住——实测 import 期(``get_module_logger`` -> + # ``get_settings``)直接抛裸 traceback,35 帧全打在终端上,早于任何入口 + # 的 ``try``。作为对照,同一份文件里字段**取值**非法时得到的是 11 帧的 + # 脱敏消息。同属「配置写错」,收场方式不该有两种。 + # + # 消息里保留行号与列号:用户靠它定位是哪一行写坏了。tomllib 的消息只含 + # 位置与原因,不回显该行的内容,因此不涉及凭证回显;仍走一次脱敏以防 + # 上游改动,成本可忽略。 + raise ValueError(f"配置解析失败 - {redact_sensitive_text(str(exc))}") from None + except RecursionError as exc: + # ``tomllib`` 对**深层嵌套**的表/数组会递归解析,超过解释器递归上限时抛 + # ``RecursionError``。它继承 ``RuntimeError`` 而**不是** ``ValueError``, + # 因此上面四个 handler 一个都接不住——实测 ``config.toml`` 写 + # ``a = [[[[...]]]]``(深度 500 即触发)后 ``import main`` 直接以 + # 2990 帧裸 traceback 终止,早于任何入口的 ``try``。 + # + # 与下面 ``except ValueError`` 同属「配置写错」的收场,消息里带上原因: + # 用户需要知道是嵌套层级而不是取值出的问题。 + raise ValueError( + f"配置解析失败 - 嵌套层级过深({redact_sensitive_text(str(exc))});" + "请检查 config.toml 中是否存在过深的表或数组嵌套。" + ) from None + except ValueError as exc: + # ``tomllib`` 解析 ``config.toml`` 时还有一类失败**不是** + # ``TOMLDecodeError``:整数位数超过 ``sys.get_int_max_str_digits()`` + # (CPython 默认 4300 位)时,``_parser`` 内部直接 ``int()`` 并让 + # ``ValueError`` 穿出来。实测 ``chat_top_k = "9" * 4301`` 得到的是 + # 36 帧裸 traceback,消息只有 "Exceeds the limit (4300 digits)..."—— + # 既没有「配置解析失败」前缀,也没说是哪个文件写坏了,而同样属于 + # 「配置写错」的语法错是 5 帧带前缀的消息。这一支兜住它。 + # + # 放在最后:``ValidationError``/``SettingsError``/``TOMLDecodeError`` + # 都继承 ``ValueError``,顺序颠倒会让前面的分支永远接不到。 + raise ValueError(f"配置解析失败 - {redact_sensitive_text(str(exc))}") from None def _redacted_settings_error(exc: ValidationError) -> str: @@ -874,13 +1500,25 @@ def _redacted_settings_error(exc: ValidationError) -> str: 直接重抛 ``ValidationError`` 会让 pydantic 再包一层,原始报告的 ``input_value`` 会以嵌套形式重复出现;这里逐条取 ``loc`` 与 ``msg`` - 重建,只保留定位信息和校验原因,并把值整体交给 ``redact_sensitive_text``。 + 重建,只保留定位信息和校验原因。``msg`` 与 ``input`` **两者**都交给 + ``redact_sensitive_text``:validator 的消息里常常回显原始取值, + 只脱敏 ``input`` 会留下明文出口。 """ lines = [] for error in exc.errors(): location = ".".join(str(part) for part in error.get("loc", ())) - message = str(error.get("msg", "校验失败")) - value = redact_sensitive_text(str(error.get("input"))) + # ``msg`` 也必须脱敏:validator 普遍把**原始取值**写进消息 + # (``f"无效的日志级别: {value}"``),因此只处理 ``input`` 会留下一条 + # 明文出口。实测 ``CHAT_TEMPERATURE=sk-live-...`` 时消息里原样带着该 + # 凭证,而这条消息经 ``get_settings`` 的 handler 抛出后进终端与日志 + # ——正是本函数 docstring 声称要挡住的那一类。 + message = redact_sensitive_text(str(error.get("msg", "校验失败"))) + # 用 ``safe_repr`` 而不是 ``str()``:``input`` 是 pydantic 记下的原始 + # 输入值,可以是任意大的整数。实测 ``Settings(chat_top_k=10**4301)`` + # (库调用方与测试都走这条构造路径)让**报告校验失败**这一步自己抛 + # ``ValueError: Exceeds the limit``——用户看到的是位数上限错误,而真正 + # 的问题是 ``chat_top_k`` 越界。 + value = redact_sensitive_text(safe_repr(error.get("input"))) lines.append(f"{location}: {message} (input={value})" if location else f"{message}") return "配置校验失败 - " + "; ".join(lines) if lines else "配置校验失败" diff --git a/src/utils/log_manager.py b/src/utils/log_manager.py index d9bfc36..999c848 100644 --- a/src/utils/log_manager.py +++ b/src/utils/log_manager.py @@ -7,6 +7,43 @@ from src.utils.config import get_settings from src.utils.security import redact_sensitive_text +# 日志目录不可写时,降级 warning 只打一次。 +# +# ``get_module_logger`` 被 20 多个模块在 import 期调用,每个模块都会走一遍 +# 文件 handler 的创建。若各自打一条,只读安装下用户会看到一长串重复的 +# 「无法写入日志目录」,把真正的问题埋掉。 +_DEGRADED_WARNING_EMITTED = False + +# 本应用写出的日志文件名前缀。写入(``_build_rotating_handler``)与清理 +# (``cleanup_old_logs``)共用同一份定义,避免清理规则与命名规则各自演化。 +APP_LOG_PREFIX = "app_log" +CHAT_LOG_PREFIX = "chat_log" +# 清理认 ``<前缀>_.log`` 以及它经 ``RotatingFileHandler`` 轮转出来的 +# 备份 ``<前缀>_.log.<序号>``。 +# +# 备份后缀是必需的,不是锦上添花:写入侧用 ``RotatingFileHandler(maxBytes=1MB, +# backupCount=5)``,单个文件超过 1 MB 就轮转出 ``.log.1`` ~ ``.log.5``。旧模式 +# 以 ``$`` 结尾,那些备份**一个都不匹配**,于是「清理旧日志」只删掉主文件, +# 把绝大部分体积留在盘上——实测造一批 30 天前的日志(主文件 + 5 个备份), +# 清理后仍剩 83% 的字节。而 ``log_path`` 是用户可配置项,指向磁盘时这份残留 +# 会一直累积。 +# +# 后缀限定为 ``.`` + 数字:``RotatingFileHandler`` 的备份名就是 +# ``baseFilename + "." + 序号``(``doRollover`` 里 ``dfn = self.rotation_filename( +# self.baseFilename + "." + str(i))``),不会产生其它形态。 +_LOG_FILENAME_PATTERN = re.compile( + rf"^(?:{APP_LOG_PREFIX}|{CHAT_LOG_PREFIX})_(\d{{4}}-\d{{2}}-\d{{2}})\.log(?:\.\d+)?$" +) + + +def _warn_once(logger: logging.Logger, message: str, *args: object) -> None: + """只在首次调用时发出降级 warning。""" + global _DEGRADED_WARNING_EMITTED + if _DEGRADED_WARNING_EMITTED: + return + _DEGRADED_WARNING_EMITTED = True + logger.warning(message, *args) + class RedactingFormatter(logging.Formatter): """在格式化完整 traceback 后脱敏,避免异常链泄漏凭证。 @@ -45,9 +82,6 @@ def get_chat_logger() -> logging.Logger: # 避免重复添加处理器 if not logger.handlers: current_settings = get_settings() - log_dir = current_settings.log_path - if not os.path.exists(log_dir): - os.makedirs(log_dir) # 控制台处理器 console_handler = logging.StreamHandler() @@ -56,17 +90,18 @@ def get_chat_logger() -> logging.Logger: logger.addHandler(console_handler) # 文件处理器 (每天一个文件,最大 1MB,保留 5 个文件) - log_file_name = f"chat_log_{datetime.now().astimezone().strftime('%Y-%m-%d')}.log" - file_path = os.path.join(log_dir, log_file_name) - file_handler = RotatingFileHandler( - file_path, - maxBytes=1 * 1024 * 1024, # 1 MB - backupCount=5, - encoding="utf-8", - ) - file_formatter = RedactingFormatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) + # 与 ``get_module_logger`` 同理:日志目录不可写时降级为仅控制台, + # 不因诊断能力缺失而中断查询。 + try: + file_handler = _build_rotating_handler(current_settings.log_path, CHAT_LOG_PREFIX) + except (OSError, ValueError) as exc: + # ``ValueError`` 也要接:``os.makedirs`` 对含 NUL 字节的路径抛 + # ``ValueError: embedded null byte``(**不是** ``OSError``)。配置层的 + # ``resolve_path`` 现在已拒绝 NUL,这里是纵深防御——路径字段有六个, + # 任何一条来源将来都可能再引入这个形态。 + _warn_once(logger, "无法写入日志目录,本次运行的日志仅输出到控制台: %s", exc) + else: + logger.addHandler(file_handler) return logger @@ -83,9 +118,6 @@ def get_module_logger(name: str) -> logging.Logger: # 避免重复添加处理器 if not logger.handlers: current_settings = get_settings() - log_dir = current_settings.log_path - if not os.path.exists(log_dir): - os.makedirs(log_dir) # 控制台处理器 console_handler = logging.StreamHandler() @@ -96,23 +128,52 @@ def get_module_logger(name: str) -> logging.Logger: logger.addHandler(console_handler) # 文件处理器 (每天一个文件,最大 1MB,保留 5 个文件) - log_file_name = f"app_log_{datetime.now().astimezone().strftime('%Y-%m-%d')}.log" - file_path = os.path.join(log_dir, log_file_name) - file_handler = RotatingFileHandler( - file_path, - maxBytes=1 * 1024 * 1024, # 1 MB - backupCount=5, - encoding="utf-8", - ) - file_formatter = RedactingFormatter( - "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" - ) - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) + # + # 本函数在模块导入期就会被调用(``src/utils/cleanup.py`` 顶层的 + # ``get_module_logger(__name__)``),而默认的 ``log_path`` 解析到 + # 应用根目录之下。安装在 ``/opt``、``/Applications``、 + # ``C:\Program Files`` 这类只读位置时,``makedirs`` 会抛 + # ``PermissionError``,把整个进程挡在 import 阶段——连入口的脱敏 + # 边界都还没建立。日志属于可选的诊断能力,不可写时降级为仅控制台, + # 而不是让程序无法启动。 + try: + file_handler = _build_rotating_handler( + current_settings.log_path, APP_LOG_PREFIX, with_location=True + ) + except (OSError, ValueError) as exc: + # ``ValueError`` 也要接:``os.makedirs`` 对含 NUL 字节的路径抛 + # ``ValueError: embedded null byte``(**不是** ``OSError``),因此 + # 只接 ``OSError`` 时这条降级路径会被绕过——而本函数在**模块导入期** + # 就被 ``cleanup.py`` 调用,异常会让 ``import main`` 直接失败。 + # 配置层的 ``resolve_path`` 现在已拒绝 NUL,这里是纵深防御。 + _warn_once(logger, "无法写入日志目录,本次运行仅输出到控制台: %s", exc) + else: + logger.addHandler(file_handler) return logger +def _build_rotating_handler(log_dir: str, prefix: str, *, with_location: bool = False): + """构建按天滚动的文件处理器;目录不可创建/不可写时抛 ``OSError``。 + + 含 NUL 字节的路径抛的是 ``ValueError``(不是 ``OSError``),调用方两者都接。 + """ + os.makedirs(log_dir, exist_ok=True) + log_file_name = f"{prefix}_{datetime.now().astimezone().strftime('%Y-%m-%d')}.log" + file_handler = RotatingFileHandler( + os.path.join(log_dir, log_file_name), + maxBytes=1 * 1024 * 1024, # 1 MB + backupCount=5, + encoding="utf-8", + ) + if with_location: + pattern = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" + else: + pattern = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + file_handler.setFormatter(RedactingFormatter(pattern)) + return file_handler + + def cleanup_old_logs(): """ 根据配置的保留天数清理旧的日志文件。 @@ -121,31 +182,37 @@ def cleanup_old_logs(): log_dir = current_settings.log_path log_retention_days = current_settings.log_retention_days - if not os.path.exists(log_dir): + # ``exists`` 对**文件**也返回 True,于是下面 ``os.listdir`` 抛 + # ``NotADirectoryError``。``log_path`` 是用户可配置项,指向一个已存在的 + # 普通文件完全可能(写入侧对此的处置是降级为仅控制台,见 + # ``_build_rotating_handler`` 的 ``except (OSError, ValueError)``),而本函数 + # 由退出钩子调用——异常会让程序在退出路径上以裸 traceback 终止。清理不是 + # 必须完成的能力,形态不对就跳过。 + if not os.path.isdir(log_dir): return now = datetime.now().astimezone() - # 获取所有日志文件 - log_files = [f for f in os.listdir(log_dir) if f.endswith(".log")] - - for filename in log_files: + # 只清理本应用写出的日志。 + # + # 这里原先匹配「目录下所有 *.log,且文件名含任意 YYYY-MM-DD」,而 + # ``log_path`` 是用户可配置项:把它指向共享目录(``/var/log``、某个 + # 挂载的日志盘)时,退出钩子会连带删掉别的程序与用户的日志——实测 + # ``nginx_access_2020-01-01.log``、``postgres_2020-01-01.log`` 乃至 + # ``backup_2020-01-01.log`` 都被删掉,且 ``rmtree`` 之外的这个删除同样 + # 不可逆。现在要求文件名严格等于本应用的命名形态 + # (``app_log_<日期>.log`` / ``chat_log_<日期>.log``),与写入侧同源。 + for filename in os.listdir(log_dir): + match = _LOG_FILENAME_PATTERN.match(filename) + if not match: + # 别的程序或用户的文件:不是本应用的清理对象,也不值得每个都告警。 + continue file_path = os.path.join(log_dir, filename) try: - # 从文件名中解析日期,例如 "chat_log_2023-10-26.log" 或 "app_log_2023-10-26.log" - match = re.search(r"(\d{4}-\d{2}-\d{2})", filename) - if match: - file_date_str = match.group(1) - file_date = datetime.strptime(file_date_str, "%Y-%m-%d").replace(tzinfo=now.tzinfo) - - if (now - file_date).days > log_retention_days: - os.remove(file_path) - get_module_logger(__name__).info(f"已删除旧日志文件: {filename}") - else: - # 如果文件名不符合日期模式,也记录一下,但不删除 - get_module_logger(__name__).warning( - f"日志文件名不符合日期模式,跳过清理: {filename}" - ) + file_date = datetime.strptime(match.group(1), "%Y-%m-%d").replace(tzinfo=now.tzinfo) + if (now - file_date).days > log_retention_days: + os.remove(file_path) + get_module_logger(__name__).info(f"已删除旧日志文件: {filename}") except Exception: # noqa: BLE001 - cleanup continues independently per file get_module_logger(__name__).exception("清理日志文件 %s 时出错", filename) diff --git a/src/utils/security.py b/src/utils/security.py index c7a3eb7..6eb8987 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -96,6 +96,13 @@ # ``密钥sk-...`` 两侧都不成立,而国产网关(SiliconFlow、Ark、DashScope)的 # 错误消息正是中文,密钥会整串落进日志。 _BEARER_TEXT_RE = re.compile(r"(?i)(?`` 是 SDK 异常回显的常见形态。 +# 要求前置 ``authorization`` 键名(大小写不敏感、可选 ``proxy-`` 前缀), +# 否则 ``basic understanding`` 这类普通文本会被误伤。 +_BASIC_AUTH_TEXT_RE = re.compile( + r"(?i)(? str: 没有分隔符时,游程可能是凭证的可见片段(``sk-abc***xyz``),不能整段 留下;但敏感键名(``token``/``myApiKey``)要保留,让 - ``_KEY_VALUE_TEXT_RE`` 接续处理 ``sk-abc***token: `` 这类写法。 + ``_CREDENTIAL_KEY_VALUE_RE`` 接续处理 ``sk-abc***token: `` 这类写法。 """ # 键名游程由正则的 ``([A-Za-z0-9_.*-]*)`` 捕获(``*``/``.``/``…`` 等掩码 # 占位符不在其中),整段判断是否为敏感键:``token``/``myApiKey`` 命中并保留, @@ -307,7 +314,7 @@ def _redact_masked_credential(match: re.Match[str]) -> str: # 把 ``sk-abc***`` 切碎。 run = match.group(1) # 键值尾部:游程是键名,后面的值同样属于凭证的可见尾部,一并脱敏。 - # 这里不能只保留键名交给 ``_KEY_VALUE_TEXT_RE``——键名可能不是敏感键 + # 这里不能只保留键名交给 ``_CREDENTIAL_KEY_VALUE_RE``——键名可能不是敏感键 # (``xxxtoken``/``oauth``),那条规则不会接手,值就明文落进日志。 if match.group(2) is not None: return "[REDACTED]" + run + "=[REDACTED]" @@ -320,22 +327,139 @@ def _redact_masked_credential(match: re.Match[str]) -> str: _GOOGLE_API_KEY_RE = re.compile(r"(? str: + """把任意取值渲染成字符串,位数超限时退回量级描述。 + + 本模块**不能**导入 ``config.safe_repr``:``config`` 反过来从本模块导入 + ``redact_sensitive_text`` 与 ``validate_secret_free_options``,跨模块引用会 + 形成循环导入。判据与 ``safe_repr`` 一致(位数超限时用 ``bit_length()`` 给 + 出量级),改一处时两处都要看。 + """ + if isinstance(value, str): + return value + try: + return str(value) + except (ValueError, RuntimeError, RecursionError): + if isinstance(value, int): + digits = value.bit_length() * 30103 // 100000 + 1 # log10(2) ≈ 0.30103 + return f"<整数,约 {digits} 位十进制>" + return f"<{type(value).__name__}>" + + +def _require_string_keys(mapping: Mapping[Any, Any], label: str) -> None: + """校验映射的**顶层**键都是字符串。 + + 非字符串键本身不是凭证,但它会让下游的 ``str(key)`` 与 ``sorted(...)`` 崩在 + 与字段无关的地方:``str(10**4301)`` 抛 + ``ValueError: Exceeds the limit``(类型对、内容错),``", ".join(sorted(...))`` + 对普通整数键抛裸 ``TypeError: sequence item 0: expected str instance``。 + ``ModelDetail.options`` 声明为 ``dict[str, Any]``,pydantic 会强制这一形态, + 因此**配置来源**到不了这里;但 provider 是**公开类**——嵌入方可以直接 + ``JinaProvider("m", {1: "x"})``(本仓测试就这么构造)。各校验器的其余判据都 + 按 ``ValueError`` 收敛,这里必须对齐。 + + 嵌套层级的同类判据在 ``find_sensitive_option_paths`` 的递归里,两处共用 + ``_display_repr`` 渲染越界整数。 + """ + for key in mapping: + if not isinstance(key, str): + raise ValueError( + f"{label} 的键必须是字符串,但得到 {_display_repr(key)}。" + " 请检查配置里的 options 表(TOML 表的键本就是字符串," + "非字符串键通常来自直接构造 Provider 的库调用方)。" + ) def redact_sensitive_text(value: Any) -> str: """脱敏异常、日志和请求错误中的常见凭证表示。""" if not isinstance(value, str): - value = str(value) + # 非字符串(调用方有传异常、URL、映射的),而 pickle 是二进制格式、 + # 不受十进制位数限制,畸形快照能带进超限整数这类值。 + # 循环导入限制(不能导入 ``config.safe_repr``)由 ``_display_repr`` 统一 + # 承担,这里不再内联同一份判据。 + value = _display_repr(value) # 掩码形态必须最先处理:``_BEARER_TEXT_RE`` 会先吃掉 ``Bearer sk-abc`` # 的可见前缀,使后续的 ``sk-`` 锚点失效,尾部掩码片段就会残留。 redacted = _MASKED_CREDENTIAL_RE.sub(_redact_masked_credential, value) redacted = _BEARER_TEXT_RE.sub("Bearer [REDACTED]", redacted) + redacted = _BASIC_AUTH_TEXT_RE.sub("Basic [REDACTED]", redacted) redacted = _CREDENTIAL_KEY_VALUE_RE.sub(_redact_credential_pair, redacted) redacted = _SHORT_CREDENTIAL_KEY_RE.sub(r"\1=[REDACTED]", redacted) redacted = _URL_USERINFO_RE.sub(r"\1[REDACTED]:[REDACTED]@", redacted) redacted = _URL_QUERY_SECRET_RE.sub(r"\1[REDACTED]", redacted) redacted = _OPENAI_KEY_RE.sub("[REDACTED]", redacted) - return _GOOGLE_API_KEY_RE.sub("[REDACTED]", redacted) + redacted = _GOOGLE_API_KEY_RE.sub("[REDACTED]", redacted) + redacted = _AK_SK_PREFIXED_KEY_RE.sub("[REDACTED]", redacted) + redacted = _SELF_ATTESTING_PREFIX_RE.sub("[REDACTED]", redacted) + # PEM 块放最后:它的正文是多行 base64,前面几条规则可能已经改写了其中的 + # 片段(例如正文里恰好出现 ``sk-`` 形态),但 BEGIN/END 标记不受影响, + # 整块仍能被吃掉。放前面则会把已被部分脱敏的块再匹配一次,结果相同但多 + # 一次全串扫描。 + return _PEM_PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", redacted) def safe_exception_text(exc: BaseException) -> str: @@ -344,8 +468,43 @@ def safe_exception_text(exc: BaseException) -> str: 终端与日志是凭证最容易泄漏的出口——SDK 的异常消息常回显请求头或 URL。所有面向用户的异常输出都应经过这里,而不是直接 ``str(exc)`` 或把异常对象交给 ``console.print`` / ``logger.error``。 + + ``str(exc)`` 自己会抛,必须兜住:``str(10**4301)`` 撞上 CPython 的整数 + 位数上限(``sys.get_int_max_str_digits()``,默认 4300)抛 ``ValueError``。 + 本函数是**最后一道展示边界**,它在 22 处 ``except`` 里被调用——它再抛, + 用户看到的就是解释器默认 handler 打出的裸 traceback,连脱敏都没有, + 比「消息不好看」严重得多。 + + 这类值确实可达:``pickle`` 是二进制格式,不受十进制位数限制,实测 + ``pickle.loads(pickle.dumps(10**4301))`` 正常往返,而本项目加载 pickle + 知识快照。JSON 与 TOML 两条路径送不进来(解析器自己就抛),所以此前没被 + 发现;快照里的畸形值可以。 + + 兜底不用 ``repr``(同样会抛),用 ``safe_repr``:它对位数超限的整数退回 + ``bit_length()`` 给出量级,既保住了「是哪个字段」的信息,也不再抛。 """ - message = str(exc).strip() + try: + message = str(exc).strip() + except Exception: # noqa: BLE001 - 展示边界不得再抛 + # 只有异常自身的 args 含位数超限的整数会走到这里。 + # + # 逐个渲染 args 而不是把整个元组交给 ``safe_repr``:``repr(tuple)`` 会 + # 递归到元素上,对含超大整数的元组抛错,``safe_repr`` 兜住后只能退回 + # ````,把「是哪个值出的问题」也丢掉了。逐元素渲染既保住信息, + # 又因为每个元素单独兜底而不会抛。 + # + # 局部导入 ``safe_repr``:``config`` 在模块层导入本模块 + # (``from src.utils.security import redact_sensitive_text, ...``), + # 模块层反向导入会成环。这里只在异常兜底分支执行,代价可忽略。 + from src.utils.config import safe_repr + + rendered = ", ".join(safe_repr(item) for item in exc.args) if exc.args else "" + if not rendered: + return type(exc).__name__ + # 兜底分支**同样**是展示边界,必须脱敏:异常 args 里的凭证(如 + # ``Authorization: Basic ``)若不过 ``redact_sensitive_text`` + # 就会明文落进日志——与主分支的收场不一致。 + return f"{type(exc).__name__}: {redact_sensitive_text(rendered)[:240]}" if not message: return type(exc).__name__ return f"{type(exc).__name__}: {redact_sensitive_text(message)[:240]}" @@ -383,6 +542,19 @@ def _model_dump_mapping(value: Any) -> Mapping[str, Any] | None: Pydantic 型 SDK 配置对象(例如 ``google.genai.types.HttpOptions``)不是 ``Mapping``,只遍历普通字典会让其中的 headers/base URL 绕过配置边界。 仅使用 SDK 明确提供的 ``model_dump``,无法读取时保持原有兼容行为。 + + 返回 ``None`` 的语义是「**这个值检视不了**」,不是「这个值是安全的」—— + 调用方 ``find_sensitive_option_paths`` 会就此跳过它。这是**有意的 + fail-open**,与边界对其它不可检视值的处理一致:没有 ``model_dump`` 的对象、 + 任意 ``object()``、``bytes`` 同样被跳过(实测四者都返回 ``[]``)。收紧成 + fail-closed 会让所有非 pydantic 的第三方配置对象(``httpx.Client``、 + ``openai.OpenAI`` 都属此类)连同其中的普通配置一起被拒,那是行为破坏而非 + 加固。 + + 此处原先的注释写「third-party model_dump must fail closed」,与代码相反; + 那句话描述的是**意图**(不要因为读不到就当成已检查),而实现选择的是 + 「读不到就不检查、也不拒绝」。注释已按实际行为改写,避免下一个人照着 + 「fail closed」去推断边界强度。 """ model_dump = getattr(value, "model_dump", None) if not callable(model_dump): @@ -392,9 +564,9 @@ def _model_dump_mapping(value: Any) -> Mapping[str, Any] | None: except TypeError: try: dumped = model_dump() - except Exception: # noqa: BLE001 - third-party model_dump must fail closed + except Exception: # noqa: BLE001 - 读不到就不检查,见 docstring 的 fail-open 说明 return None - except Exception: # noqa: BLE001 - third-party model_dump must fail closed + except Exception: # noqa: BLE001 - 读不到就不检查,见 docstring 的 fail-open 说明 return None if dumped is value or not isinstance(dumped, Mapping): return None @@ -402,8 +574,16 @@ def _model_dump_mapping(value: Any) -> Mapping[str, Any] | None: def normalize_option_key(key: Any) -> str: - """将配置键规范化为大小写无关、分隔符无关的形式。""" - return "".join(char for char in str(key).lower() if char.isalnum()) + """将配置键规范化为大小写无关、分隔符无关的形式。 + + 非字符串键走 ``_display_repr``:``str(10**4301)`` 撞上 CPython 的整数位数 + 上限抛 ``ValueError: Exceeds the limit``。本函数在配置边界的**每一层**键上 + 调用(``find_sensitive_option_paths`` 的递归、``_is_credential_key``), + 也在文本脱敏的正则回调里调用,因此对不可 ``str()`` 的键必须降级而不是崩。 + 归一化结果对非字符串键没有语义(调用方另有 ``_require_string_keys`` 判据), + 只要不抛即可。 + """ + return "".join(char for char in _display_repr(key).lower() if char.isalnum()) def _is_credential_key(key: Any) -> bool: @@ -423,7 +603,7 @@ def _is_credential_key(key: Any) -> bool: part.lower() for part in re.split( r"[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", - str(key), + _display_repr(key), ) if part ] @@ -510,17 +690,26 @@ def find_sensitive_option_paths( options: Mapping[str, Any], *, allowed_containers: Collection[str] = (), + label: str = "Provider options", ) -> list[str]: """递归查找凭证、连接覆盖和 URL 中的凭证路径。 ``allowed_containers`` 只用于供应商明确支持的安全容器(例如 Google ``http_options.headers``);容器内的键和值仍会继续递归检查。 + + 每一层的键都必须是字符串:本函数是**唯一的**递归点,四个兄弟校验器都经由 + 它扫描,因此判据放在这里才能覆盖嵌套层级。此前判据只在 + ``validate_secret_free_options`` 的顶层循环里,嵌套的 ``{10**4301: "x"}`` + 会在这里的 ``str(key)`` 上抛 ``ValueError: Exceeds the limit``(类型对、内容 + 与字段无关),而普通的 ``{1: "x"}`` 则静默通过、随后在调用方的 + ``", ".join(sorted(...))`` 处抛裸 ``TypeError``。 """ found: list[str] = [] allowed = {normalize_option_key(value) for value in allowed_containers} def visit(value: Any, path: str = "") -> None: if isinstance(value, Mapping): + _require_string_keys(value, label if not path else f"{label} 的 {path}") for key, nested in value.items(): current = f"{path}.{key}" if path else str(key) if is_sensitive_option_key(key) and normalize_option_key(key) not in allowed: @@ -556,7 +745,13 @@ def validate_secret_free_options( return {} if not isinstance(options, Mapping): raise ValueError(f"{provider} options 必须是对象。") - found = find_sensitive_option_paths(options, allowed_containers=allowed_containers) + # 键形态判据(顶层与嵌套)统一由 ``find_sensitive_option_paths`` 承担: + # 它是四个校验器共用的递归点,判据放在那里才覆盖每一层。 + found = find_sensitive_option_paths( + options, + allowed_containers=allowed_containers, + label=f"{provider} options", + ) if found: raise ValueError( f"{provider} options 不允许包含凭证或请求头/query 配置: " + ", ".join(found) @@ -589,7 +784,7 @@ def validate_secret_free_request_overrides( continue if not isinstance(value, Mapping): raise ValueError(f"{provider} {field_name} 必须是对象。") - found = find_sensitive_option_paths(value) + found = find_sensitive_option_paths(value, label=f"{provider} {field_name}") if found: raise ValueError( f"{provider} 请求头/query 不允许包含凭证: {field_name}." + ", ".join(found) @@ -609,7 +804,7 @@ def validate_secret_free_payload( return {} if not isinstance(value, Mapping): raise ValueError(f"{provider} {field_name} 必须是对象。") - found = find_sensitive_option_paths(value) + found = find_sensitive_option_paths(value, label=f"{provider} {field_name}") if found: raise ValueError(f"{provider} {field_name} 不允许包含凭证或连接字段: " + ", ".join(found)) return _copy_nested(dict(value)) @@ -629,6 +824,9 @@ def validate_secret_free_resource_kwargs( return {} if not isinstance(kwargs, Mapping): raise ValueError(f"{provider} 资源参数必须是对象。") + # 顶层键单独判:下面的扫描器只收到 ``generic_kwargs``(已按扩展键名过滤), + # 非字符串键若恰好归一化成扩展键名就会绕过那一层的判据。 + _require_string_keys(kwargs, f"{provider} 资源参数") sanitized = _copy_nested(dict(kwargs)) # SDK client/resource methods also expose client-level overrides such as @@ -643,6 +841,7 @@ def validate_secret_free_resource_kwargs( found = find_sensitive_option_paths( generic_kwargs, allowed_containers=_SAFE_RESOURCE_PARAMETER_CONTAINERS, + label=f"{provider} 资源参数", ) if found: raise ValueError( @@ -709,6 +908,7 @@ def validate_secret_free_resource_args( find_sensitive_option_paths( {f"args[{index}]": value}, allowed_containers=_SAFE_RESOURCE_PARAMETER_CONTAINERS, + label=f"{provider} 资源位置参数", ) ) if found: @@ -735,8 +935,31 @@ def resolve_within( (``root/link -> /etc`` 这类逃逸)。``resolve()`` 用默认的 ``strict=False``:目标可以尚不存在,此处只判断归属,存在性由调用方另行校验。 """ - resolved_root = Path(root).resolve() - resolved_candidate = Path(candidate).resolve() + # ``Path.resolve()`` 对符号链接环抛 ``RuntimeError: Symlink loop from ...``, + # 它**不是** ``ValueError``,会穿透本函数的契约与调用方的 ``except ValueError`` + # ——``ensure_trusted_source`` 只接 ``ValueError``,于是一条「拒绝加载」的消息 + # 变成 pathlib 的内部错误。``strict=False`` 不覆盖这种情况:环是**解析期**的 + # 失败,与目标是否存在无关。这里统一收敛成 ``ValueError``。 + # + # ``Path(...)`` 对非路径取值抛的 ``TypeError``(``None``/``int``/``list``: + # ``expected str, bytes or os.PathLike object, not ...``)同样要收敛:它不是 + # ``ValueError``,且消息里没有字段名,而本函数的两个调用方 + # (``ensure_trusted_source`` 与 ``FaissStore.load_snapshot``)都按 + # ``ValueError`` 分类。形态判据放在构造之前,让消息带上是哪一个参数错了。 + for role, value in (("路径", candidate), ("受信根", root)): + if not isinstance(value, (str, os.PathLike)): + raise ValueError( + f"{label} 的{role}必须是字符串或路径,但得到 {type(value).__name__}。" + " 请检查该路径来源(通常来自 RunConfig 的路径字段)。" + ) + try: + resolved_root = Path(root).resolve() + resolved_candidate = Path(candidate).resolve() + except (OSError, RuntimeError) as exc: + raise ValueError( + f"{label} 无法解析({type(exc).__name__}: {exc})。" + " 路径可能包含符号链接环或过长的间接层,请检查配置。" + ) from exc if resolved_candidate != resolved_root and not resolved_candidate.is_relative_to(resolved_root): raise ValueError( f"{label} 必须位于受信根目录内。" @@ -756,20 +979,74 @@ def ensure_trusted_source( ``trusted_roots`` 为 ``None`` 或空集合时一律拒绝——调用方没有声明任何 信任来源时,不能退化成「任意路径都可加载」,否则这个边界等于不存在。 + + ``trusted_roots`` 是**可迭代**,而 ``str``/``bytes``/``bytearray`` 也是 + 合法的可迭代:直接 ``for root in trusted_roots`` 会把一个字符串信任根拆成 + 单字符根。实测 ``trusted_roots="/etc"`` 拆出 ``"/"``(解析为文件系统根), + 于是**任意绝对路径都被接受**——``/anywhere/evil.pkl`` 也返回成功,正好绕开 + 「不导入不可信来源的 .pkl」这条边界。这与同模块对 ``Sequence`` 参数的既有 + 判据一致(``normalize_messages``/``_require_documents`` 都显式排除 + ``str``/``bytes``/``bytearray``),必须同样拦下。 + + 每个根还要能构成 ``Path``:``Path(None)``/``Path(5)``/``Path([...])`` 抛 + 裸 ``TypeError: expected str, bytes or os.PathLike object, not ...``——它 + 不是 ``ValueError``,会穿透 ``load`` 调用方按 ``ValueError`` 建立的错误 + 分类,消息里也没有字段名。收敛成带 label 的 ``ValueError``,与 + ``resolve_within`` 对候选路径的同类形态同判据。 """ - roots = [Path(root) for root in trusted_roots] if trusted_roots else [] + if isinstance(trusted_roots, (str, bytes, bytearray)): + raise ValueError( + f"{label} 的受信来源必须是路径序列,但得到 {type(trusted_roots).__name__}。" + " 字符串会被逐字符拆成信任根(其中 ``/`` 会放行任意绝对路径)," + "请传入路径列表,例如 [snapshot_root]。" + ) + # 非可迭代的形态也要在这里拦下:下面直接 ``for root in trusted_roots``,对 + # ``5``/``True``/``object()`` 抛裸 ``TypeError: 'int' object is not + # iterable``——不是本函数承诺的带 label 的 ``ValueError``,而调用方 + # ``FaissStore.load`` 按 ``ValueError`` 分类。``str``/``bytes`` 已在上一条 + # 显式拦下(它们**是**可迭代,但会被逐字符拆开,是另一种失败),这里补的是 + # 「根本不是序列」这一类。可达路径:``load`` 是 ``VectorStoreBase`` 的契约 + # 方法,嵌入方直接调用时把 ``trusted_paths`` 写错形态即命中。 + if trusted_roots is not None and not isinstance(trusted_roots, Iterable): + raise ValueError( + f"{label} 的受信来源必须是路径序列,但得到 {type(trusted_roots).__name__}。" + " 请传入路径列表,例如 [snapshot_root]。" + ) + roots: list[Path] = [] + if trusted_roots is not None: + for root in trusted_roots: + try: + roots.append(Path(root)) + except TypeError as exc: + raise ValueError( + f"{label} 的受信来源必须是路径,但得到 {type(root).__name__}。" + " 请传入路径列表(通常来自 RunConfig 的 snapshot_root 或" + " legacy_pkl_path)。" + ) from exc if not roots: raise ValueError( f"缺少 {label} 的受信来源声明,已拒绝加载。" " 请在调用处显式传入 trusted_paths/trusted_roots(通常来自 RunConfig 的" " snapshot_root 或 legacy_pkl_path)。" ) - errors: list[str] = [] for root in roots: try: return resolve_within(candidate, root, label=label) - except ValueError as exc: - errors.append(str(exc)) + except ValueError: + # 逐个根试探,全部落空后由下面的消息统一报告:单条 ``resolve_within`` + # 的消息只提一个根,而这里的结论是「一个都没命中」,把根列表完整 + # 列出来更有用。收集各条子消息再拼进去会让消息变长却不增加信息量。 + continue + # ``candidate`` 的形态先在这里判一次,不能只依赖 ``resolve_within``:后者对 + # 非路径取值抛的 ``ValueError`` 会被上面的 ``except ValueError: continue`` + # 吞掉,于是全部根都「没命中」,最后落到下面那条消息里的 ``Path(candidate)`` + # ——那里对 ``None``/``int``/``list`` 抛的是裸 ``TypeError``。本函数的契约 + # 是带 label 的 ``ValueError``(调用方 ``FaissStore.load`` 按它分类)。 + if not isinstance(candidate, (str, os.PathLike)): + raise ValueError( + f"{label} 的路径必须是字符串或路径,但得到 {type(candidate).__name__}。" + " 请检查该路径来源(通常来自 RunConfig 的 legacy_pkl_path)。" + ) raise ValueError( f"{label} 不在任何受信路径内。路径={Path(candidate)}。" f" 受信路径={[str(root) for root in roots]}。" diff --git a/tests/chat/test_core.py b/tests/chat/test_core.py index 92440dd..70c4042 100644 --- a/tests/chat/test_core.py +++ b/tests/chat/test_core.py @@ -1,6 +1,11 @@ import asyncio +import subprocess +import sys +from pathlib import Path from types import SimpleNamespace +from unittest.mock import MagicMock +import pytest from rich.console import Console from src.chat.core import Chatbot, start_chat_session_async @@ -9,6 +14,8 @@ from src.utils.log_manager import RedactingFormatter from src.utils.security import safe_exception_text +REPO_ROOT = Path(__file__).resolve().parents[2] + def test_safe_exception_text_redacts_credentials(): text = safe_exception_text(RuntimeError("Authorization: Bearer sk-secret-token")) @@ -138,6 +145,83 @@ async def fake_to_thread(func, *args, **kwargs): assert len(config_calls) == 1 +def test_start_chat_session_async_survives_a_rejected_config_update(monkeypatch): + """``/config`` 应用失败时必须留在会话里,而不是终止整个聊天。 + + 内层 ``try`` 只接 ``(KeyboardInterrupt, EOFError)``,而 ``SessionConfig`` 的 + 校验器会拒绝非法取值(``ValueError``)。配置编辑器的动态上界与 + ``SessionConfig`` 的守卫是两套独立实现,任一侧演化就会命中这条路径——实测 + 编辑器返回「非法字段 + 非法值」时异常穿透到 ``finally``,用户被丢回主菜单 + 并丢失会话。同文件的 ``chat_async`` 对异常是恢复的(打印后继续下一轮), + 这里与之对齐。 + """ + prompts = [] + + class RejectingChatbot(FakeChatbot): + def apply_config_update(self, updated_config, llm_needs_reload): + raise ValueError("retrieval_method 必须是 向量检索, 全文检索, 混合检索 之一。") + + class RecordingPromptSession(FakePromptSession): + async def prompt_async(self, *args, **kwargs): + value = await super().prompt_async(*args, **kwargs) + prompts.append(value) + return value + + monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: RecordingPromptSession(["/config", "/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "src.chat.core.launch_config_editor", + lambda chat_config: (False, {**chat_config, "retrieval_method": "坏模式"}), + ) + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr("src.chat.core.asyncio.to_thread", fake_to_thread) + + # 会话必须走完(回到提示符并正常退出),而不是抛异常终止。 + asyncio.run(start_chat_session_async()) + + # 两个输入都被读到,说明被拒之后确实回到了提示符。 + assert prompts == ["/config", "/quit"] + + +def test_start_chat_session_async_reports_rejected_config_without_credentials(monkeypatch): + """被拒的配置错误也要走脱敏出口。""" + + class RejectingChatbot(FakeChatbot): + def apply_config_update(self, updated_config, llm_needs_reload): + raise ValueError("非法取值: sk-live-abcdef123456") + + monkeypatch.setattr("src.chat.core.Chatbot", RejectingChatbot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/config", "/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "src.chat.core.launch_config_editor", + lambda chat_config: (False, chat_config), + ) + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr("src.chat.core.asyncio.to_thread", fake_to_thread) + + console = Console(record=True, width=120, force_terminal=False) + monkeypatch.setattr("src.chat.core.Console", lambda: console) + + asyncio.run(start_chat_session_async()) + + rendered = console.export_text() + assert "sk-live-abcdef123456" not in rendered + + def test_start_chat_session_async_skips_empty_input(monkeypatch): chat_calls = [] @@ -202,6 +286,10 @@ async def consume(): assert retrieval_queries == ["清除浏览器缓存的操作步骤"] assert chunks == ["回答"] + # 走 LLM 异步生成分支时必须**流式**请求:stream 标志是这一分支的契约 + # (与 ChatService.generate_reply 同判据),反向成 False 后整段回复在 + # provider 内部一次性缓冲再返回,逐块 yield 名存实亡。 + assert model_calls[0]["stream"] is True assert "temperature" not in model_calls[0] @@ -428,3 +516,853 @@ def test_redacting_formatter_scrubs_cached_traceback_for_later_handlers(): assert "sk-FAKE0000TRACEBACK0000FAKE0000" not in redacting.stream.getvalue() assert "sk-FAKE0000TRACEBACK0000FAKE0000" not in plain.stream.getvalue() + + +# ── 回归:chat_config setter 收 dict 时必须是原子的 ── + + +def test_chat_config_setter_is_atomic_on_invalid_field(): + """批量更新里任一字段非法时,整批都不应生效。 + + 直接循环写 ``self.session_config`` 不是原子的:``{"top_k": 42, + "retrieval_method": "不存在"}`` 会留下「top_k 已改成 42、检索模式还是 + 旧的」这种改了一半的状态,而调用方只看到一个异常,以为整批都没生效。 + """ + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + bot = object.__new__(Chatbot) + bot.session_config = build_session_config(Settings(_env_file=None)) + before = bot.session_config.to_dict() + + with pytest.raises(ValueError, match="retrieval_method"): + bot.chat_config = {"top_k": 42, "retrieval_method": "不存在的模式"} + + assert bot.session_config.to_dict() == before, "配置处于改了一半的状态" + + +def test_chat_config_setter_applies_a_valid_batch(): + """收紧不能误伤:合法批量更新必须整体生效。""" + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + bot = object.__new__(Chatbot) + bot.session_config = build_session_config(Settings(_env_file=None)) + + bot.chat_config = {"top_k": 42, "rerank_enabled": True} + + assert bot.session_config["top_k"] == 42 + assert bot.session_config["rerank_enabled"] is True + + +# ── 回归:聊天路径的终端输出不得被文档内容里的标记形态打断 ── +# +# ``console.print(str)``/``Table.add_row(str)`` 的字符串会被 rich 按标记解析。 +# 检索到的文档正文、来源路径、意图文本都可能含字面量 ``[/]``(知识库文档里 +# 实测存在「匹配斜杠用 [/] 表示」这类写法),修复前会让聊天会话以 +# ``MarkupError`` 崩溃——而该异常在 ``chat_async`` 的 except 里再次走同一个 +# 渲染通道,于是「报告错误」本身也抛,最终以裸 traceback 终止会话。 + + +class _SilentLogger: + """吞掉所有日志调用,只让测试关心终端输出。""" + + def info(self, *_args, **_kwargs): + return None + + def error(self, *_args, **_kwargs): + return None + + def warning(self, *_args, **_kwargs): + return None + + def exception(self, *_args, **_kwargs): + return None + + +def _silent_bot(buffer): + bot = object.__new__(Chatbot) + bot.console = Console(file=buffer, width=200, force_terminal=False, no_color=True) + bot.logger = _SilentLogger() + bot.session_config = {"chat_temperature": 0.7} + return bot + + +def test_display_retrieved_docs_survives_markup_like_content(monkeypatch): + """预览与来源含标记形态时必须照常渲染,且内容原样显示。""" + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + bot._display_retrieved_docs( + [ + { + "page_content": "配置说明:匹配斜杠用 [/] 表示。", + "metadata": {"source": "/kb/[/]说明.md"}, + "score": 0.9, + } + ] + ) + + assert "[/]" in buffer.getvalue() + + +def test_chat_async_survives_markup_like_intent(monkeypatch): + """意图文本含标记形态时,输出与错误处理都不得抛错。""" + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + async def fake_identify(_query): + return "斜杠 [/] 的用法" + + async def fake_retrieve(_query): + return [] + + class Model: + async def ainvoke(self, *_args, **_kwargs): + yield "回答" + + bot.llm_model = Model() + bot.chat_service = None + # 直接赋普通函数:实例属性不做绑定,调用时只收到查询本身。 + bot._identify_intent_async = fake_identify + bot._retrieve_knowledge_async = fake_retrieve + + async def consume(): + return [chunk async for chunk in bot.chat_async("斜杠怎么用")] + + chunks = asyncio.run(consume()) + + assert chunks == ["回答"] + assert "斜杠 [/] 的用法" in buffer.getvalue() + + +def _inline_branch_bot(documents, captured): + """构造走 ``chat_async`` 内联分支(``chat_service=None``)的 bot。 + + 该分支只在 ``chat_service`` 缺失而 ``llm_model`` 仍在时进入——例如 + ``reload_llm`` 之外的库调用方手工装配,或依赖被替换后的降级路径。它不是 + 死代码:``test_chat_async_survives_markup_like_intent`` 同样走这一支。 + """ + from io import StringIO + + bot = _silent_bot(StringIO()) + + class Model: + async def ainvoke(self, **kwargs): + captured["prompt"] = kwargs.get("prompt") + yield "回答" + + async def fake_identify(query): + return query + + async def fake_retrieve(_query): + return documents + + bot.llm_model = Model() + bot.chat_service = None + bot._identify_intent_async = fake_identify + bot._retrieve_knowledge_async = fake_retrieve + return bot + + +def test_chat_async_inline_branch_does_not_feed_malformed_content_to_the_model(): + """内联分支必须与 ``ChatService._build_prompt`` 收敛同一条判据。 + + ``_build_prompt`` 对检索结果里的文档字段已经收敛(``page_content=None`` + 时整条跳过,绝不把字面量 ``内容: None`` 喂给模型),而 ``chat_async`` 的 + 内联分支把整份 ``retrieved_docs`` 直接 f-string 进 prompt——实测 + ``page_content=None`` 生成 + ``知识: [{'page_content': None, ...}]``,模型会把字面量 ``None`` 当成 + 一条真实知识。检索结果可以来自任何 ``VectorStoreBase`` 实现,第三方 store + 不会做载入期归一化。 + """ + captured = {} + bot = _inline_branch_bot([{"page_content": None, "metadata": {"source": "a.md"}}], captured) + + async def consume(): + return [chunk async for chunk in bot.chat_async("问题")] + + chunks = asyncio.run(consume()) + + assert chunks == ["回答"] + assert "None" not in captured["prompt"] + + +def test_chat_async_inline_branch_survives_oversized_score(): + """位数超限的分数字段不得让整次问答失败。 + + 内联分支 f-string 整个 ``retrieved_docs``;文档里的 ``score=10**4301`` + 会抛裸 ``ValueError: Exceeds the limit``,``chat_async`` 捕获后只回一句 + 「抱歉,处理请求时遇到错误」——用户看到的是莫名其妙的失败,而不是那条 + 知识被正常跳过。 + """ + captured = {} + bot = _inline_branch_bot( + [{"page_content": "正文", "metadata": {"source": "a.md"}, "score": 10**4301}], + captured, + ) + + async def consume(): + return [chunk async for chunk in bot.chat_async("问题")] + + chunks = asyncio.run(consume()) + + assert chunks == ["回答"] + assert "正文" in captured["prompt"] + + +def test_chat_async_error_path_survives_markup_like_exception(monkeypatch): + """异常消息含标记形态时,错误提示必须能打出来(级联失效的回归)。""" + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + async def failing_identify(_query): + raise RuntimeError("bad tag [/] in message") + + bot._identify_intent_async = failing_identify + + async def consume(): + return [chunk async for chunk in bot.chat_async("问题")] + + chunks = asyncio.run(consume()) + + assert chunks == ["抱歉,处理请求时遇到错误。"] + rendered = buffer.getvalue() + # 断言的是**原异常**而不是任意异常:签名写错会抛 TypeError,那条路径 + # 同样会打印「LLM 异步生成出错」,测试会因为错误的原因变绿。 + assert "bad tag [/] in message" in rendered + + +@pytest.mark.parametrize( + "document", + [ + {"page_content": None, "metadata": {"source": "a.md"}, "score": 0.5}, + {"page_content": ["x"], "metadata": {"source": "a.md"}, "score": 0.5}, + {"page_content": "x", "metadata": None, "score": 0.5}, + {"page_content": "x", "metadata": ["a"], "score": 0.5}, + {"page_content": "x", "metadata": {"source": ["a"]}, "score": 0.5}, + {"page_content": "x", "metadata": {"source": "a.md"}, "score": None}, + {"page_content": "x", "metadata": {"source": "a.md"}, "score": "0.5"}, + "不是映射", + None, + ], +) +def test_display_retrieved_docs_survives_malformed_documents(document): + """字段形态异常时聊天侧的检索详情也必须照常渲染。 + + 检索结果可以来自任何 ``VectorStoreBase`` 实现——``FaissStore`` 会在载入期 + 校验并归一化,第三方 store 不会,而抽象基类正是为替换实现而存在的。 + """ + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + bot._display_retrieved_docs([document]) + + assert buffer.getvalue() + + +# ── 回归:``_display_retrieved_docs`` 的顶层容器形态 ── +# +# 与召回测试侧的 ``display_results`` 同判据——两者消费的是检索管线输出的同一种 +# 值,而那一侧已有这条守卫,这一侧此前漏了。实测非列表取值两种收场都不在契约内: +# * ``"abc"`` 是合法可迭代对象,逐**字符**迭代后每个字符经 ``document_*`` 助手 +# 收敛成占位符,渲染出一张 3 行来源与预览都是空串的伪结果表格(用户看到 +# 「检索详情」里有 3 条命中,而实际返回的是字符串); +# * ``5``/``True`` 抛裸 ``TypeError: 'int' object is not iterable``,而本方法在 +# ``chat_async`` 的 ``try`` 内,异常会被兜成「LLM 异步生成出错」——一次成功 +# 检索的展示失败被显示成模型故障。 + + +@pytest.mark.parametrize( + "docs", + [ + pytest.param("abc", id="str"), + pytest.param(5, id="int"), + pytest.param(True, id="bool"), + pytest.param({"a": 1}, id="dict"), + pytest.param(None, id="none"), + ], +) +def test_display_retrieved_docs_treats_non_list_as_empty(docs): + """非列表的 ``docs`` 一律按「无相关文档」收敛,不得渲染伪结果或裸抛。""" + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + bot._display_retrieved_docs(docs) + + output = buffer.getvalue() + assert "无相关文档" in output + # ``"abc"`` 修复前会渲染出一张 3 行的伪表格;这里断言表格标题不出现。 + assert "检索详情" not in output + + +def test_display_retrieved_docs_still_renders_a_normal_list(): + """收紧不能误伤:正常文档列表照常逐条渲染。""" + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + bot._display_retrieved_docs( + [{"page_content": "正文", "metadata": {"source": "a.md"}, "score": 0.5}] + ) + + output = buffer.getvalue() + assert "检索详情" in output + assert "a.md" in output + + +# ── 回归:批量更新要按**最终值**校验,而不是逐个对旧值校验 ── +# +# ``chat_config`` 的 setter 在副本上逐个写字段,但每个字段的校验读的是**副本当时 +# 的兄弟字段值**——即尚未被这批更新覆盖的旧值。于是同一个合法终态能否被接受, +# 取决于 dict 的遍历顺序: +# +# 初始 (top_k=5, mult=3),目标 (top_k=2000, mult=1),乘积 2000 完全合法。 +# 先写 top_k -> 用旧的 mult=3 校验,乘积 6000 -> 拒绝。 +# 先写 mult -> 用旧的 top_k=5 校验,随后 top_k 用新 mult=1 校验 -> 接受。 +# +# 这不是理论问题:配置编辑器的动态上界会**放行**这两个字段(先设 mult=1 时上界 +# 是 100,再设 top_k=2000 时上界正好 2000),而 ``SessionConfig.to_dict()`` 的键 +# 顺序把 ``retrieval_candidate_multiplier`` 排在 ``top_k`` **之前**——正好是能 +# 通过的那个顺序;但嵌入方传入自己的 dict、或将来调整字段顺序,就会变成 +# 「UI 放行、应用时报错并终止会话」。 + + +def test_chat_config_setter_validates_against_the_final_state(): + """合法终态必须被接受,且与 dict 的键顺序无关。""" + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + for label, payload in [ + ("top_k 在前", {"top_k": 2000, "retrieval_candidate_multiplier": 1}), + ("mult 在前", {"retrieval_candidate_multiplier": 1, "top_k": 2000}), + ]: + bot = object.__new__(Chatbot) + bot.session_config = build_session_config(Settings(_env_file=None)) + bot.session_config["top_k"] = 5 + bot.session_config["retrieval_candidate_multiplier"] = 3 + + bot.chat_config = payload + + assert bot.session_config["top_k"] == 2000, label + assert bot.session_config["retrieval_candidate_multiplier"] == 1, label + + +def test_chat_config_setter_still_rejects_an_illegal_final_state(): + """收紧不能误伤:终态本身越界时,任何键顺序都要拒绝,且不留痕。""" + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + for label, payload in [ + ("top_k 在前", {"top_k": 2000, "retrieval_candidate_multiplier": 100}), + ("mult 在前", {"retrieval_candidate_multiplier": 100, "top_k": 2000}), + ]: + bot = object.__new__(Chatbot) + bot.session_config = build_session_config(Settings(_env_file=None)) + bot.session_config["top_k"] = 5 + bot.session_config["retrieval_candidate_multiplier"] = 3 + before = bot.session_config.to_dict() + + with pytest.raises(ValueError, match="不能超过"): + bot.chat_config = payload + + assert bot.session_config.to_dict() == before, f"{label}: 配置处于改了一半的状态" + + +# ── 「服务尚未初始化」三条判据的覆盖缺口 ── +# +# 三条判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,经真实入口): +# ``reload_llm``/``apply_config_update_async``(260)在 ``retrieval_service is +# None`` 时不再报错,而是拿 ``None`` 去构造 ``ChatService``——崩在构造器内部, +# 消息与真实原因(服务没装配好)无关;``chat_async``(332)在 ``chat_service`` +# 为 ``None`` 时静默走完整个生成器,用户拿到空回答而不是「聊天服务尚未初始化」。 +def test_reload_llm_reports_missing_retrieval_service(monkeypatch): + """``retrieval_service`` 未初始化时必须报错,不能拿 None 去构造 ChatService。""" + bot = object.__new__(Chatbot) + bot.console = Console() + bot.session_config = SimpleNamespace(active_llm_configuration="new-model") + bot.retrieval_service = None + bot.llm_model = None + bot.chat_service = None + + class _Model: + pass + + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", + lambda _llm_key, _configurations=None: _Model(), + ) + + assert bot.reload_llm() is False + + +def test_chat_async_reports_missing_chat_service(monkeypatch): + """``chat_service`` 与 ``llm_model`` 都未初始化时用户必须收到显式错误。 + + 删除守卫后生成器**什么都不产出**就结束——用户拿到空回答,与「模型真的 + 回答为空」无法区分;守卫在场时异常被外层 ``except`` 收敛成致歉文本。 + """ + bot = object.__new__(Chatbot) + bot.console = Console() + bot.session_config = { + "chat_temperature": 0.7, + "active_llm_configuration": "openai", + "llm_configurations": {}, + } + bot.chat_service = None + bot.llm_model = None + bot.retrieval_service = None + bot.logger = MagicMock() + + async def _drain(): + return [chunk async for chunk in bot.chat_async("你好")] + + chunks = asyncio.run(_drain()) + + assert chunks, "两个服务都缺失时必须产出显式错误文本,不能是空回答" + + +def test_apply_config_update_async_reports_missing_retrieval_service(monkeypatch): + """异步配置更新路径上 ``retrieval_service`` 未初始化也必须报错。 + + 与 ``test_reload_llm_reports_missing_retrieval_service`` 是**两条独立路径** + (同步 ``reload_llm`` 与 ``apply_config_update_async`` 各有自己的判据), + 既有用例只覆盖了后者且 ``retrieval_service`` 非 ``None``。 + """ + + class _Model: + pass + + class DummySessionConfig: + active_llm_configuration = "new-model" + + def __setitem__(self, key, value): + setattr(self, key, value) + + messages = [] + bot = object.__new__(Chatbot) + bot.console = SimpleNamespace(print=lambda message: messages.append(str(message))) + bot.logger = SimpleNamespace(warning=lambda *_args, **_kwargs: None) + bot.session_config = DummySessionConfig() + bot.retrieval_service = None + bot.llm_model = object() + bot.chat_service = object() + + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", + lambda *_args, **_kwargs: _Model(), + ) + + asyncio.run(bot.apply_config_update_async({"active_llm_configuration": "new-model"}, True)) + + assert any("LLM 切换失败" in message for message in messages) + + +# --- 否定变异(not / not in / is not)扫描的覆盖缺口 ------------------------- +# +# 把守卫里的 ``is not`` 换成 ``is``、``not`` 去掉后全量仍绿的存活点。判据是 +# 「只换掉其中一个 ``is not``」——同步分支大多有测试,异步兄弟方法没有。 + + +class _ClosableModel: + def __init__(self, name="m"): + self.name = name + self.closed = False + + def close(self): + self.closed = True + + +def _disposal_bot(model): + """构造一个只够跑 ``reload_llm``/``apply_config_update_async`` 的 Chatbot。""" + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + bot = object.__new__(Chatbot) + bot.console = SimpleNamespace(print=lambda *_args, **_kwargs: None) + bot.logger = SimpleNamespace(warning=lambda *_args, **_kwargs: None) + bot.session_config = build_session_config(Settings(_env_file=None)) + bot.session_config.active_llm_configuration = "new-model" + bot.retrieval_service = object() + bot.llm_model = model + bot.chat_service = object() + return bot + + +def test_chatbot_aclose_disposes_llm_model(): + """``aclose()`` 释放 LLM 模型(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 ``_adispose_model`` 永不执行,异步会话退出时模型 + 不被关闭——连接池泄漏。同步 ``close()`` 有测试,异步兄弟方法此前没有。 + """ + llm = _ClosableModel() + retrieval = SimpleNamespace(aclose=lambda: None) + bot = object.__new__(Chatbot) + bot.llm_model = llm + bot.chat_service = object() + bot.retrieval_service = retrieval + bot.logger = SimpleNamespace(warning=lambda *_args, **_kwargs: None) + + asyncio.run(bot.aclose()) + + assert llm.closed is True + assert bot.llm_model is None + + +def test_reload_llm_disposes_previous_model_only_when_replaced(monkeypatch): + """``reload_llm`` 只在**换成了不同对象**时释放旧模型。 + + 137 行有两个 ``is not``:换第一个(``is None``)后不同对象的旧模型不再 + 被释放;换第二个(``is``)后工厂返回同一对象时会把刚装上的模型关掉。 + """ + # (a) 工厂返回**同一对象**:不得释放 + same = _ClosableModel() + bot = _disposal_bot(same) + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", lambda *_a, **_k: same + ) + assert bot.reload_llm() is True + assert same.closed is False + + # (b) 换成**不同对象**:必须释放旧模型 + old, new = _ClosableModel("old"), _ClosableModel("new") + bot = _disposal_bot(old) + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", lambda *_a, **_k: new + ) + assert bot.reload_llm() is True + assert old.closed is True + assert new.closed is False + assert bot.llm_model is new + + +def test_reload_llm_failure_disposes_only_uninstalled_model(monkeypatch): + """``reload_llm`` 失败时释放**未装上**的新模型,保留在用的旧模型。 + + ``new_model is not self.llm_model`` 换成 ``is`` 后新模型不再被释放—— + 初始化失败留下的 Provider 泄漏连接池。 + """ + old, new = _ClosableModel("old"), _ClosableModel("new") + bot = _disposal_bot(old) + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", lambda *_a, **_k: new + ) + monkeypatch.setattr( + "src.chat.core.ChatService", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("service init failed")), + ) + + assert bot.reload_llm() is False + assert new.closed is True + assert old.closed is False + assert bot.llm_model is old + + +def test_apply_config_update_async_disposes_previous_model_only_when_replaced(monkeypatch): + """异步配置更新与同步 ``reload_llm`` 同判据(两个 ``is not`` 都要判)。 + + 264 行的两个 ``is not`` 此前都无测试:换成 ``is None`` 后旧模型不释放; + 换成 ``is`` 后工厂返回同一对象时把在用的模型关掉。 + """ + # (a) 工厂返回**同一对象**:不得释放 + same = _ClosableModel() + bot = _disposal_bot(same) + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", lambda *_a, **_k: same + ) + asyncio.run(bot.apply_config_update_async({"active_llm_configuration": "new-model"}, True)) + assert same.closed is False + + # (b) 换成**不同对象**:必须释放旧模型 + old, new = _ClosableModel("old"), _ClosableModel("new") + bot = _disposal_bot(old) + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", lambda *_a, **_k: new + ) + asyncio.run(bot.apply_config_update_async({"active_llm_configuration": "new-model"}, True)) + assert old.closed is True + assert new.closed is False + assert bot.llm_model is new + + +def test_apply_config_update_async_rolls_back_session_config_on_failure(monkeypatch): + """异步重载失败时回滚 ``session_config``(``is not None`` 不是 ``is None``)。 + + ``llm_needs_reload`` 为真时 ``previous_config`` 必非 ``None``(假值已在 + 前面提前返回),因此 ``is None`` 变异后回滚永不执行——用户看到「已保留 + 当前模型配置」,实际留下的是失败的那份配置。 + """ + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + bot = _disposal_bot(_ClosableModel("old")) + bot.session_config = build_session_config(Settings(_env_file=None)) + bot.session_config.active_llm_configuration = "old-model" + monkeypatch.setattr( + "src.chat.core.ModelProviderFactory.get_llm_provider", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("reload failed")), + ) + + asyncio.run(bot.apply_config_update_async({"active_llm_configuration": "new-model"}, True)) + + assert bot.session_config.active_llm_configuration == "old-model" + + +def test_apply_config_update_async_passes_llm_configurations_when_present(monkeypatch): + """``llm_configurations`` 存在时传给工厂(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后走单参分支,用户的模型定义(provider/model_name)被 + 整个丢掉。 + """ + from src.runtime.contracts import build_session_config + from src.utils.config import Settings + + bot = _disposal_bot(_ClosableModel("old")) + bot.session_config = build_session_config(Settings(_env_file=None)) + bot.session_config.active_llm_configuration = "old-model" + bot.session_config.llm_configurations = { + "new-model": ModelDetail(provider="openai", model_name="gpt-4o") + } + received = [] + + def _factory(*args, **_kwargs): + received.append(args) + return _ClosableModel("new") + + monkeypatch.setattr("src.chat.core.ModelProviderFactory.get_llm_provider", _factory) + + asyncio.run(bot.apply_config_update_async({"active_llm_configuration": "new-model"}, True)) + + assert received == [ + ("new-model", {"new-model": ModelDetail(provider="openai", model_name="gpt-4o")}) + ] + + +def test_importing_chat_core_does_not_start_interactive_session(): + """import ``src.chat.core`` 不得触发 ``__main__`` 分支(``==`` 不是 ``!=``)。 + + ``if __name__ == "__main__"`` 的 ``==``→``!=`` 变异后守卫常真:任何 import + 都会把模块当作主脚本执行(实测子进程直接进入会话,打出「客服已就绪」与 + 「感谢使用,再见!」)——所有 import 该模块的调用方(含本仓库其它测试) + 都会被拖进交互式会话。 + """ + result = subprocess.run( + [sys.executable, "-c", "import src.chat.core"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, output + assert "客服已就绪" not in output + assert "感谢使用,再见!" not in output + + +def test_display_retrieved_docs_renders_the_column_header_row(): + """检索详情表必须渲染**列头**(``show_header=True``)。 + + ``show_header`` 反向成 ``False`` 后 rich 不再输出列头行,表格只剩数据行: + 第一列的数字与第三列的小数失去列名(``来源``/``预览``/``得分``), + 用户看到三列中间夹着省略号却无从判断哪列是分数。 + """ + from io import StringIO + + buffer = StringIO() + bot = _silent_bot(buffer) + + bot._display_retrieved_docs( + [{"page_content": "正文", "metadata": {"source": "a.md"}, "score": 0.5}] + ) + + output = buffer.getvalue() + for column in ("来源", "预览", "得分"): + assert column in output, f"列头 {column!r} 未渲染:{output!r}" + + +class _SurvivalBot(FakeChatbot): + """带真实 ``SessionConfig`` 的替身,rerank 段为空表。""" + + def __init__(self, _console: Console): + self.llm_model = object() + self.chat_config = SessionConfig( + retrieval_method=RetrievalMethod.HYBRID_SEARCH, + vector_weight=0.3, + keyword_weight=0.7, + hybrid_fusion_strategy="rrf", + retrieval_candidate_multiplier=3, + rerank_enabled=False, + top_k=5, + score_threshold=0.4, + active_llm_configuration="old-model", + active_rerank_configuration="siliconflow", + llm_configurations={"old-model": ModelDetail(provider="openai", model_name="gpt-4o")}, + rerank_configurations={}, + chat_temperature=0.7, + ) + + def apply_config_update(self, updated_config, _llm_needs_reload): + for key, item in updated_config.items(): + self.chat_config[key] = item + + def reload_llm(self): + return False + + async def chat_async(self, _user_input): + if False: + yield "" + + +def test_start_chat_session_async_survives_config_display_failure(monkeypatch): + """配置已生效后,回显抛 ``ValueError`` 也必须让会话存活。 + + 实测路径(真实 ``SessionConfig`` + 真实 ``display_chat_config``):rerank 段 + 为空表时,编辑器保存 ``rerank_enabled=True`` 通过 ``set_many`` 校验、apply + 「成功」,随后 ``display_chat_config`` 在 ``chat/core.py`` 的回显调用点抛 + ``ValueError: 在Rerank配置中未找到key: ...``。修复前该调用点在恢复 ``try`` + 之外,异常穿透内层只接 ``(KeyboardInterrupt, EOFError)`` 的 ``try``—— + 配置已生效、会话却被终止,用户丢失全部对话,直接违反同块开头声明的 + 「配置应用失败不能终止整个会话」契约。 + """ + from src.ui.display_utils import display_chat_config as real_display + + monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/config", "/quit"]), + ) + # 启动横幅里的回显不炸(rerank_enabled=False 时不读 rerank 段), + # 只有 /config 保存后的回显要炸——用计数器区分两次调用。 + display_calls = [] + + def exploding_display(_console, chat_config): + display_calls.append(1) + if len(display_calls) > 1: + raise ValueError("在Rerank配置中未找到key: 'siliconflow'") + real_display(_console, chat_config) + + monkeypatch.setattr("src.chat.core.display_chat_config", exploding_display) + monkeypatch.setattr( + "src.chat.core.launch_config_editor", + lambda chat_config: (False, {"rerank_enabled": True}), + ) + + asyncio.run(start_chat_session_async()) + + # 启动回显 + /config 后的回显都被调用过——后者抛了异常而会话仍走到 /quit。 + assert len(display_calls) == 2 + + +def test_start_chat_session_async_survives_config_editor_failure(monkeypatch): + """编辑器自身抛 ``ValueError``(空候选/配置段非映射)也必须让会话存活。""" + monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/config", "/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) + + def exploding_launch(_chat_config): + raise ValueError("A list of choices needs to be provided.") + + monkeypatch.setattr("src.chat.core.launch_config_editor", exploding_launch) + + asyncio.run(start_chat_session_async()) + + +def test_start_chat_session_async_survives_failing_teardown(monkeypatch): + """``finally`` 里的 ``aclose`` 抛异常时不得顶替正常收尾。 + + 替身 ``aclose`` 是受支持的契约(同函数用 ``getattr`` 探测它);裸 ``await`` + 会让替身的错误顶替「再见」输出并向上穿透到菜单兜底(实测复现)。 + 释放失败只记录,会话仍正常收尾。 + """ + + class ExplodingTeardownBot(FakeChatbot): + last_instance = None + + def __init__(self, console): + super().__init__(console) + ExplodingTeardownBot.last_instance = self + self.closed = False + + async def aclose(self): + self.closed = True + raise RuntimeError("替身的 aclose 失败") + + monkeypatch.setattr("src.chat.core.Chatbot", ExplodingTeardownBot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_args, **_kwargs: None) + + asyncio.run(start_chat_session_async()) + + assert ExplodingTeardownBot.last_instance.closed is True + + +def test_start_chat_session_survives_banner_display_failure(monkeypatch): + """启动横幅回显失败(rerank 启用而段为空)只报黄字,会话照常启动。 + + 修复前 ``display_chat_config`` 在任何提示符之前裸抛 ``ValueError``, + 以「程序运行期间发生错误」打回主菜单——可修复的配置问题被顶成程序级 + 错误,与 ``/config`` 路径对同一类错误的恢复处理不对称。 + """ + from src.ui.display_utils import display_chat_config as real_display + + def exploding_display(_console, chat_config): + real_display(_console, chat_config) + raise ValueError("在Rerank配置中未找到key: 'siliconflow'") + + monkeypatch.setattr("src.chat.core.Chatbot", _SurvivalBot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", exploding_display) + + asyncio.run(start_chat_session_async()) + + +def test_ready_line_survives_none_chat_config(monkeypatch): + """替身 bot 的 ``chat_config=None`` 在就绪行抛 ``TypeError``——必须在捕获面内。 + + 实测 ``bot.chat_config["active_llm_configuration"]`` 对 ``None`` 抛 + ``TypeError: 'NoneType' object is not subscriptable``,不在 + ``(ValueError, KeyError)`` 捕获面——注释声称要堵的形态恰好漏接。 + """ + + class NoneConfigBot(FakeChatbot): + def __init__(self, console): + self.chat_config = None + self.llm_model = object() + + async def chat_async(self, _user_input): + if False: + yield "" + + monkeypatch.setattr("src.chat.core.Chatbot", NoneConfigBot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/quit"]), + ) + monkeypatch.setattr( + "src.chat.core.display_chat_config", + lambda *_args, **_kwargs: None, + ) + + asyncio.run(start_chat_session_async()) diff --git a/tests/chat/test_guard_regressions.py b/tests/chat/test_guard_regressions.py new file mode 100644 index 0000000..7e2d126 --- /dev/null +++ b/tests/chat/test_guard_regressions.py @@ -0,0 +1,124 @@ +"""「吞掉型 except」变异扫描的对话循环覆盖缺口。""" + +import asyncio + +from src.chat.core import start_chat_session_async +from tests.chat.test_core import FakeChatbot + + +class _PromptSession: + def __init__(self, responses): + self._responses = iter(responses) + + async def prompt_async(self, *_args, **_kwargs): + item = next(self._responses) + if isinstance(item, BaseException): + raise item + return item + + +def test_chat_session_breaks_cleanly_on_interrupt(monkeypatch): + """流式渲染期间 Ctrl-C 干净退出,不作为异常抛出。 + + 删除吞掉后 ``KeyboardInterrupt`` 冒泡——退出对话变成一次崩溃 + (``start_chat_session_async`` 的 ``finally`` 仍会关闭,但调用方收到 + KeyboardInterrupt)。 + """ + from src.chat import core as chat_core + + monkeypatch.setattr(chat_core, "Chatbot", FakeChatbot) + monkeypatch.setattr( + chat_core, + "PromptSession", + lambda: _PromptSession([KeyboardInterrupt(), "/quit"]), + ) + monkeypatch.setattr(chat_core, "display_chat_config", lambda *_a, **_k: None) + asyncio.run(start_chat_session_async()) + + +# --- except 元组收窄:第二类型(KeyError / EOFError)必须被触发 ------------- + + +def test_start_chat_session_survives_keyerror_from_config_update(monkeypatch): + """``apply_config_update`` 抛 ``KeyError`` 时留在会话里,而不是终止。 + + 内层 ``except (ValueError, KeyError)`` 的 ``KeyError`` 此前无测试触发—— + 收窄成 ``except ValueError`` 后,配置更新里的缺键异常穿透到 ``finally``, + 用户被丢出会话。 + """ + import asyncio + + from tests.chat.test_core import FakeChatbot, FakePromptSession + + class KeyErrorChatbot(FakeChatbot): + def apply_config_update(self, updated_config, llm_needs_reload): + raise KeyError("active_llm_configuration") + + monkeypatch.setattr("src.chat.core.Chatbot", KeyErrorChatbot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/config", "/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) + monkeypatch.setattr( + "src.chat.core.launch_config_editor", + lambda chat_config: (False, dict(chat_config)), + ) + # 不抛异常即表示会话存活到 /quit。 + asyncio.run(__import__("src.chat.core", fromlist=["x"]).start_chat_session_async()) + + +def test_start_chat_session_exits_cleanly_on_eof(monkeypatch): + """``prompt_async`` 抛 ``EOFError`` 时干净退出,而不是冒泡。 + + 收窄成 ``except KeyboardInterrupt`` 后 ``EOFError`` 穿透——非交互输入 + (管道、CI)下会话以未捕获异常终止。 + """ + import asyncio + + from tests.chat.test_core import FakeChatbot, FakePromptSession + + class EofSession(FakePromptSession): + async def prompt_async(self, *args, **kwargs): + raise EOFError() + + monkeypatch.setattr("src.chat.core.Chatbot", FakeChatbot) + monkeypatch.setattr("src.chat.core.PromptSession", lambda: EofSession([])) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) + asyncio.run(__import__("src.chat.core", fromlist=["x"]).start_chat_session_async()) + + +# --- 否定变异:``if not callable(apply_sync)`` 去掉 not ---------------------- + + +def test_start_chat_session_survives_bot_without_config_update_methods(monkeypatch): + """bot 两个配置更新方法都没有时报错并留在会话里,而不是终止。 + + ``if not callable(apply_sync)`` 去掉 ``not`` 后,缺方法的替身走 + ``apply_sync(...)`` → 裸 ``TypeError``;它不落在 ``except (ValueError, + KeyError)`` 里,会一路穿透到 ``finally``,用户被丢出会话。 + """ + from tests.chat.test_core import FakeChatbot, FakePromptSession + + class NoUpdateChatbot(FakeChatbot): + apply_config_update = None + apply_config_update_async = None + + def __getattribute__(self, name): + # 两个方法都不存在:走 ``getattr(bot, ..., None)`` 的 None 分支。 + if name in {"apply_config_update", "apply_config_update_async"}: + return None + return super().__getattribute__(name) + + monkeypatch.setattr("src.chat.core.Chatbot", NoUpdateChatbot) + monkeypatch.setattr( + "src.chat.core.PromptSession", + lambda: FakePromptSession(["/config", "/quit"]), + ) + monkeypatch.setattr("src.chat.core.display_chat_config", lambda *_a, **_k: None) + monkeypatch.setattr( + "src.chat.core.launch_config_editor", + lambda chat_config: (False, dict(chat_config)), + ) + # 不抛异常即表示会话存活到 /quit。 + asyncio.run(__import__("src.chat.core", fromlist=["x"]).start_chat_session_async()) diff --git a/tests/etl/test_hierarchical_splitter.py b/tests/etl/test_hierarchical_splitter.py index 653445b..6cb6469 100644 --- a/tests/etl/test_hierarchical_splitter.py +++ b/tests/etl/test_hierarchical_splitter.py @@ -1,5 +1,8 @@ +import logging from types import SimpleNamespace +import pytest + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter from src.models.document import Document @@ -117,19 +120,32 @@ def test_splitter_output_is_unchanged_when_separator_matches(monkeypatch): assert max(len(encoder.encode(chunk.content)) for chunk in chunks) == 252 -def test_strip_leading_punctuation_does_not_delete_content(): - """开头标点只能被规范化,不能被删除。 - - 旧实现 ``re.sub(r"^[\\s.。]+", "", text).strip()`` 直接删掉开头连续的句号; - 当分隔符本身就是句号时,LangChain 的 ``keep_separator=True`` 会把上一句的句号 - 留在下一个分片开头,于是那个句号被永久丢弃。 +@pytest.mark.parametrize( + ("text", "expected"), + [ + pytest.param("。第7句内容", "。第7句内容", id="leading-ideographic-period"), + pytest.param("。。开头两句", "。。开头两句", id="two-leading-periods"), + pytest.param("...文本", "...文本", id="leading-ascii-dots"), + pytest.param(" \n 正常文本", "正常文本", id="leading-whitespace"), + pytest.param("开头就是正文", "开头就是正文", id="no-leading-noise"), + ], +) +def test_strip_leading_punctuation_keeps_every_punctuation_character(text, expected): + r"""开头标点必须**原样保留**,只有空白被规范化。 + + 两版实现都没做到这件事:最初是 ``re.sub(r"^[\s.。]+", "", text).strip()`` + 直接删除;随后改成「替换为等长空格」看似修好了,实则与旧实现**逐字符等价** + ——被替换的字符集恰好等于紧随其后的 ``strip()`` 会删掉的字符集,替换出来的 + 空格立刻又被删掉(实测 3 万条随机输入零差异)。而 ``。`` 属于正文:当分隔符 + 本身就是 ``。`` 时,LangChain 的 ``keep_separator=True`` 会把它留在下一个 + 分片开头,删掉它就是丢掉用户内容。 + + 这条断言把 ``。`` 也算进期望值——旧版本刻意跳过了这个字符( + ``if char == "。": continue``),于是断言对被测缺陷零区分度。 """ splitter = RecursiveTextSplitter(mode="char") - assert splitter._strip_leading_punctuation("。第7句内容") == "第7句内容" - assert splitter._strip_leading_punctuation("。。开头两句") == "开头两句" - assert splitter._strip_leading_punctuation(" \n 正常文本") == "正常文本" - assert splitter._strip_leading_punctuation("开头就是正文") == "开头就是正文" + assert splitter._strip_leading_punctuation(text) == expected def test_splitter_keeps_all_content_when_punctuation_is_the_separator(monkeypatch): @@ -149,11 +165,101 @@ def test_splitter_keeps_all_content_when_punctuation_is_the_separator(monkeypatc _restore(settings, original) assert chunks, "不得产出 0 个块" + # 断言**逐字相等**而不是「每个字符都能在某个分片里找到」:后者是 + # 子序列判定,丢掉重复的句号照样通过——旧版本甚至刻意写 + # ``if char == "。": continue`` 把唯一会丢的那个字符排除在外,对被测缺陷 + # 零区分度。分片按顺序拼接必须还原原文(本用例 overlap=0)。 covered = "".join(chunk.content for chunk in chunks) - for char in body: - if char == "。": - continue - assert char in covered, f"字符 {char!r} 在所有分片中都找不到" + assert covered == body, f"正文被改写:原文 {len(body)} 字符,分片合计 {len(covered)} 字符" + + +# ── 回归:单篇文档实际产出 0 块时必须留下痕迹 ── +# +# 标准分支原先记的是 ``len(langchain_chunks)``(**过滤前**的数量):一份被整段 +# 判空的文档会显示「生成 8 个块」而实际贡献 0 个——日志反过来掩盖了问题。而 +# 「原文非空却产出 0 块」这种静默丢数据此前连一行日志都没有,用户会把「检索不到」 +# 误当成检索质量问题。 + + +class _RecordHandler(logging.Handler): + def __init__(self): + super().__init__() + self.records: list[tuple[str, str]] = [] + + def emit(self, record): + self.records.append((record.levelname, record.getMessage())) + + def messages(self, level: str) -> list[str]: + return [message for name, message in self.records if name == level] + + +@pytest.fixture +def splitter_log_records(): + """捕获分片器 logger 的记录。""" + import logging as logging_module + + import src.etl.splitters.recursive_text_splitter as module + + handler = _RecordHandler() + module.logger.addHandler(handler) + previous_level = module.logger.level + module.logger.setLevel(logging_module.DEBUG) + try: + yield handler + finally: + module.logger.removeHandler(handler) + module.logger.setLevel(previous_level) + + +def test_splitter_logs_actual_chunk_count_not_pre_filter_count(monkeypatch, splitter_log_records): + """日志必须报**实际收录**的块数,而不是过滤前的数量。 + + ``chunk_size=1`` 时 LangChain 逐字符切分,一段连续的空白会产出多个「纯空白」 + 的块,它们随后被 ``if not chunk_content: continue`` 滤掉。实测 + ``"a" + "\t"*5 + "b"`` 在 ``chunk_size=1`` 下过滤前 7 个、实际收录 2 个—— + 日志若退回 ``len(langchain_chunks)`` 就会报「生成 7 个块」。 + """ + splitter, settings, original = _splitter_with( + kb_chunk_size=1, kb_chunk_overlap=0, kb_splitter_separators=["###"] + ) + try: + chunks = splitter.split( + [Document(content="a" + "\t" * 5 + "b", metadata={"source": "count.md"})] + ) + finally: + _restore(settings, original) + + assert len(chunks) == 2, f"夹具前提变了:实际收录 {len(chunks)} 个块" + + debug_messages = [m for m in splitter_log_records.messages("DEBUG") if "count.md" in m] + assert debug_messages, "单篇文档完成时必须留下日志" + + # 日志里的数字必须等于该文档实际贡献的块数。 + import re as re_module + + reported = int(re_module.search(r"生成 (\d+) 个块", debug_messages[-1]).group(1)) + assert reported == len(chunks) + + +def test_splitter_reports_an_empty_document_as_empty(monkeypatch, splitter_log_records): + """清洗后为空的文档必须记成「跳过」而不是「生成 0 个块」。 + + 空文件与纯空白文档是正常输入(清洗器会把它们折叠成空串)。日志要能让用户 + 一眼看出「这份文档没内容」,而不是给一个会让人去找分片器 bug 的「0 个块」。 + """ + splitter, settings, original = _splitter_with( + kb_chunk_size=120, kb_chunk_overlap=0, kb_splitter_separators=["###"] + ) + try: + chunks = splitter.split([Document(content=" \n\t ", metadata={"source": "empty.md"})]) + finally: + _restore(settings, original) + + assert chunks == [] + messages = [m for m in splitter_log_records.messages("DEBUG") if "empty.md" in m] + assert messages, "空文档也要留下日志" + assert "清洗后为空" in messages[-1] + assert not [m for m in splitter_log_records.messages("WARNING") if "empty.md" in m] def test_splitter_keeps_punctuation_only_document(monkeypatch): @@ -170,3 +276,170 @@ def test_splitter_keeps_punctuation_only_document(monkeypatch): assert chunks assert any("正文" in chunk.content for chunk in chunks) + + +# ── 回归:尺寸参数的越界消息必须可读 ── +# +# ``_require_optional_size`` 的**类型**分支早就用 ``safe_repr``,**范围**分支 +# 却是裸 ``{value}``——同一参数、同一份消息,只有一处防住了位数超限。 +# 实测 ``parent_chunk_size=-(10**4301)`` 时,正要报「必须大于等于 1」的那一步 +# 自己抛 ``ValueError: Exceeds the limit (4300 digits) for integer string +# conversion``:用户拿到的是 CPython 的整数转换错误,与尺寸参数毫无关系。 +# +# 只有**负的**超限整数会走到范围分支(``value < minimum`` 成立),因此两个方向 +# 都要断言——只测正数会让这条守卫永远绿着。 +@pytest.mark.parametrize( + ("value", "label"), + [ + (-(10**4301), "parent_chunk_size"), + (-(10**4301), "child_chunk_overlap"), + (0, "parent_chunk_size"), + (-1, "child_chunk_size"), + ], + ids=["超限负-parent", "超限负-child", "零", "负一"], +) +def test_splitter_size_rejection_message_is_readable(value, label): + with pytest.raises(ValueError) as excinfo: + RecursiveTextSplitter(**{label: value}) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message + assert label in message + assert "大于等于" in message + + +def test_splitter_size_rejection_keeps_small_values_verbatim(): + """收紧不能误伤:上限内的非法取值照旧原样显示,不要被量级描述替换。""" + with pytest.raises(ValueError) as excinfo: + RecursiveTextSplitter(parent_chunk_size=-1) + + assert "但得到 -1" in str(excinfo.value) + + +def test_splitter_accepts_the_largest_legal_size(): + """反向守卫:合法的大取值(位数远在上限内)照常接受。""" + splitter = RecursiveTextSplitter(parent_chunk_size=10**6, parent_chunk_overlap=0) + assert splitter.parent_chunk_size == 10**6 + + +def test_splitter_logs_effective_parent_chunk_size(splitter_log_records): + """日志报**生效**的 ``chunk_size``(``is not None`` 不是 ``is None``)。 + + 显式给出 ``parent_chunk_size`` 时不得回落到 settings 的默认值:``is None`` + 变异后日志报 ``chunk_size=1500``(settings 默认),而实际用的是 333—— + 排查分块效果时日志给出的数字与真实行为相反。 + """ + splitter = RecursiveTextSplitter(parent_chunk_size=333) + + ready = [m for m in splitter_log_records.messages("INFO") if "文本分割器已就绪" in m] + assert ready, "初始化后必须留下就绪日志" + assert "chunk_size=333" in ready[-1] + assert splitter.text_splitter._chunk_size == 333 + + +# ── 回归:分隔符被当成正则 / 日志阶段词与结构模式不一致(boolc 扫描存活点)── +# +# 四个布尔字面量互换后全量 4269 条仍全绿(boolc 变异扫描实测): +# * 189/334 ``is_separator_regex=False`` → ``True``:``kb_splitter_separators`` +# 是**字面量**分隔符(默认 ``["###"]``),正则模式下 ``***`` 被 ``re`` 当成 +# 「重复前一项」而抛裸 ``re.error: nothing to repeat``,用户看到的是正则语法 +# 错误而不是「分隔符配置」;``|`` 则从字面竖线变成「空串或空串」的交替,切点 +# 完全错位。 +# * 452/458 ``hierarchical`` 标志:它唯一的作用是选择日志里的阶段词 +# (``层级分割``/``分割``)。反向之后标准分支的日志写成「层级分割完成」, +# 层级分支的日志把「层级分割」写成「分割」——按日志定位「哪条分支产出的块」 +# 正是排查分块问题时唯一的线索。 + + +def test_splitter_treats_separators_as_literals_not_regex(): + """分隔符表按**字面量**切分:``***`` 不得被当成正则而抛 ``re.error``。 + + 正则模式下 ``***`` 的 ``*`` 是量词、前面没有可重复项,LangChain 的 + ``re.search(separator_, text)`` 直接抛 ``re.error: nothing to repeat at + position 0``——异常类型与「分隔符配置」毫无关联。 + """ + splitter, settings, original = _splitter_with( + mode="char", + kb_chunk_size=60, + kb_chunk_overlap=0, + kb_splitter_separators=["***"], + ) + try: + chunks = splitter.split( + [ + Document( + content="***" + "内容" * 20 + "***" + "内容" * 20, metadata={"source": "s.md"} + ) + ] + ) + finally: + _restore(settings, original) + + assert splitter.text_splitter._is_separator_regex is False + assert len(chunks) >= 2, "字面量分隔符必须真的切出多块" + + +def test_child_splitter_treats_separators_as_literals_not_regex(): + """层级模式的**子**分片器同样按字面量切分。 + + ``_build_child_splitter`` 是另一处构造点,只改父分片器会留下同族缺口: + 正则模式下子分片阶段抛同一个 ``re.error``,父分片成功而子分片失败。 + """ + splitter, settings, original = _splitter_with( + mode="char", + structure_mode="hierarchical", + kb_chunk_size=60, + kb_chunk_overlap=0, + kb_child_chunk_size=30, + kb_child_chunk_overlap=0, + kb_splitter_separators=["***"], + ) + try: + chunks = splitter.split( + [ + Document( + content="***" + "内容" * 20 + "***" + "内容" * 20, metadata={"source": "c.md"} + ) + ] + ) + finally: + _restore(settings, original) + + assert splitter._build_child_splitter()._is_separator_regex is False + assert chunks, "子分片必须产出块" + + +def test_splitter_log_stage_word_matches_structure_mode(splitter_log_records): + """单篇文档的完成日志必须报**该分支**的阶段词。 + + 标准分支写「分割完成」,层级分支写「层级分割完成」。两处的 ``hierarchical`` + 标志是这句日志唯一的来源:反向之后标准分支报「层级分割完成」、层级分支报 + 「分割完成」,而「这批块是哪条分支产出的」正是排查分块问题的唯一线索。 + """ + for structure_mode, expected, unexpected in ( + ("standard", "分割完成", "层级分割完成"), + ("hierarchical", "层级分割完成", "分割"), + ): + splitter, settings, original = _splitter_with( + mode="char", + structure_mode=structure_mode, + kb_chunk_size=60, + kb_chunk_overlap=0, + kb_child_chunk_size=30, + kb_child_chunk_overlap=0, + kb_splitter_separators=["###"], + ) + try: + splitter.split([Document(content="###" + "内容" * 30, metadata={"source": "stage.md"})]) + finally: + _restore(settings, original) + + messages = [m for m in splitter_log_records.messages("DEBUG") if "stage.md" in m] + assert messages, f"{structure_mode} 分支必须留下单篇文档日志" + # 断言**整句**(来源 + 阶段词 + 逗号):「层级分割完成」包含「分割完成」 + # 这个子串,只看子串无法区分两条分支。 + if expected == "层级分割完成": + assert f"'stage.md' {expected}," in messages[-1], f"阶段词错误:{messages[-1]!r}" + else: + assert f"'stage.md' {expected}," in messages[-1], f"阶段词错误:{messages[-1]!r}" + assert "层级分割" not in messages[-1], f"标准分支报了对侧阶段词:{messages[-1]!r}" diff --git a/tests/etl/test_pipeline.py b/tests/etl/test_pipeline.py index 5fae5e8..d969cad 100644 --- a/tests/etl/test_pipeline.py +++ b/tests/etl/test_pipeline.py @@ -172,3 +172,564 @@ def test_recursive_text_splitter(): current_settings.kb_chunk_size = original_chunk_size current_settings.kb_chunk_overlap = original_chunk_overlap current_settings.kb_splitter_separators = original_separators + + +# ── 回归:清洗器不得用会被用户内容命中的哨兵 ── + + +@pytest.mark.parametrize( + "text", + [ + "本文档说明 __DOUBLE_NEWLINE_PLACEHOLDER__ 的用途。", + "A\n\nB __DOUBLE_NEWLINE_PLACEHOLDER__ C\n\nD", + "__DOUBLE_NEWLINE_PLACEHOLDER____DOUBLE_NEWLINE_PLACEHOLDER__", + ], +) +def test_basic_cleaner_does_not_rewrite_content_that_looks_like_its_sentinel(text): + """用户内容里出现旧哨兵字面量时不得被改写。 + + 原实现硬编码 ``"__DOUBLE_NEWLINE_PLACEHOLDER__"`` 作为双换行的临时替身, + 与用户内容碰撞:文档里出现同样的字符串会被改写成双换行,而整段由它构成的 + 内容会被抹成空串(实测两个该字面量相连 -> 清洗后得到 ``""``)。内容是用户 + 数据,不该被实现细节改写。 + """ + cleaner = BasicCleaner() + + cleaned = cleaner.clean([Document(content=text, metadata={})]) + + assert cleaned[0].content == text.strip() + + +# 复杂度判据的规模与阈值。基准取 20k 而不是 2k:2k 的耗时约 0.1ms,已接近 +# ``time.perf_counter()`` 的可靠量级,机器负载的抖动会让比值从 ~50 跳到 ~900 +# (实测同一台机器上 8 次采样出现 967、598 两个越界值),是一条会间歇误报的 +# 断言。20k 的基准约 0.7ms,采样稳定得多。 +_SENTINEL_BASE_COUNT = 20_000 +_SENTINEL_LARGE_COUNT = 200_000 +# 每档取多次采样的**最小值**:最小值逼近「无干扰」耗时,而最大值会把 GC 与 +# 调度抖动算进去。 +_SENTINEL_REPEATS = 5 +# 规模放大 10 倍:线性实现实测比值 7.8~11.5,平方实现实测 700。取 60 作为判据 +# ——给线性留约 5 倍余量,同时把平方增长稳稳判红(离阈值还有 12 倍)。 +_SENTINEL_MAX_RATIO = 60 + + +def test_basic_cleaner_sentinel_length_is_computed_in_one_pass(): + """哨兵长度必须一次算够——逐字符增长是 O(n²),能把建库挂死。 + + 原写法 ``while placeholder in content: placeholder += "\x00"`` 每次 ``in`` + 都是一次全串扫描、每次 ``+=`` 又让哨兵长一位,于是 N 个 NUL 要扫 N 次。 + 实测 20 万个 NUL 耗时 19.8 秒(旧的字面量哨兵是 O(1)),100 万 NUL 按平方 + 外推约 1300 秒。 + + 触发输入很现实:零填充的 md(写入中断/预分配/误拷的二进制残片)或 + Windows 用户存成 UTF-16LE 的纯 ASCII 文档——``read_text(encoding="utf-8")`` + 对两者**都不报错**,因此 ``Pipeline._process_file`` 的解码失败分支拦不住, + 用户只看到「向量化处理」卡死数分钟且无任何提示。 + + 这里断言的是**复杂度**而不是绝对耗时:单纯卡一个秒数阈值会在慢机器上误报, + 而平方增长(10 倍规模 → 100 倍耗时)无论如何都超出线性量级。 + """ + import time + + cleaner = BasicCleaner() + + def clean_duration(count: int) -> float: + content = "\x00" * count + start = time.perf_counter() + cleaner.clean([Document(content=content, metadata={})]) + return time.perf_counter() - start + + def best_duration(count: int) -> float: + # 预热一次,避免把首次调用的 import/编译开销算进基准。 + clean_duration(1_000) + return max(min(clean_duration(count) for _ in range(_SENTINEL_REPEATS)), 1e-5) + + small = best_duration(_SENTINEL_BASE_COUNT) + large = best_duration(_SENTINEL_LARGE_COUNT) + + assert large / small < _SENTINEL_MAX_RATIO, ( + f"{_SENTINEL_LARGE_COUNT} 个 NUL 耗时 {large:.3f}s," + f"而 {_SENTINEL_BASE_COUNT} 个耗时 {small:.3f}s" + f"(比值 {large / small:.0f},阈值 {_SENTINEL_MAX_RATIO})" + "——哨兵长度可能又退回逐字符增长。" + ) + + +@pytest.mark.parametrize( + "text", + [ + pytest.param("a\x00\x00\x00\n\nb", id="nul-run-then-paragraph-break"), + pytest.param("a\n\n\x00\x00\x00b", id="paragraph-break-then-nul-run"), + pytest.param("a\x00\n\n\x00b", id="nul-runs-on-both-sides"), + pytest.param("a\x00\n\nb", id="single-nul"), + pytest.param("\x00\x00\n\n", id="nul-run-and-break-only"), + ], +) +def test_basic_cleaner_keeps_paragraph_breaks_adjacent_to_nul_runs(text): + """双换行紧邻 NUL 游程时,正文必须逐字保留、双换行仍还原成 ``\n\n``。 + + 哨兵用「NUL 游程 + 1」这种多字符形态时,边界上是歧义的:内容 + ``"a\x00\x00\x00\n\nb"`` 换出 4 个 NUL 后与原有 3 个 NUL 连成 7 个, + ``str.replace`` 取最左匹配、只吃掉前 4 个,剩下 3 个 NUL 被留在双换行 + **之后**——实测得到 ``'a\n\n\x00\x00\x00b'``,用户内容被静默改写。 + 单个且内容中不存在的字符不会有这种歧义。 + """ + cleaner = BasicCleaner() + + cleaned = cleaner.clean([Document(content=text, metadata={})]) + + assert cleaned[0].content == text.strip() + + +@pytest.mark.parametrize( + "text", + [ + pytest.param("a\x01\n\nb", id="soh"), + pytest.param("a\x02\n\nb", id="stx"), + pytest.param("a\x00\x01\n\nb", id="soh-after-nul"), + ], +) +def test_basic_cleaner_sentinel_survives_hostile_control_characters(text): + """内容里已有候选哨兵字符时,必须另选一个不在内容里的。 + + ``\x00``/``\x01`` 都是「非空白且可能被用作哨兵」的字符。内容里已经出现 + 它们时,若仍拿它当哨兵,替换回双换行那一步会把用户自己的字符也改成 + ``\n\n``——正是「哨兵必须不在内容里」这条要求要挡住的情形。 + """ + cleaner = BasicCleaner() + + cleaned = cleaner.clean([Document(content=text, metadata={})]) + + assert cleaned[0].content == text.strip() + + +def test_basic_cleaner_sentinel_is_not_a_whitespace_character(): + r"""哨兵不能是空白字符,否则会被 ``re.sub(r"\s+", " ")`` 一起折叠掉。 + + 内容里塞满各种空白(含 Unicode 空白)时,第一个非空白候选必然是控制字符, + 双换行必须仍然还原。若哨兵退化成空格/制表符之类,双换行会被折叠成单个 + 空格,这条会变红。 + """ + cleaner = BasicCleaner() + + text = "\t\n\r \u00a0\u3000\u200b\n\n尾" + cleaned = cleaner.clean([Document(content=text, metadata={})]) + + assert "\n\n" in cleaned[0].content + + +def test_basic_cleaner_preserves_paragraph_breaks_and_collapses_whitespace(): + """收紧不能误伤:双换行保留、连续空白折叠、首尾去空白的既有行为不变。""" + cleaner = BasicCleaner() + + cleaned = cleaner.clean([Document(content="你好 世界 \n\n\n 再见. ", metadata={})]) + + assert cleaned[0].content == "你好 世界 \n\n 再见." + + +# ── 回归:子分片尺寸关系只在**层级模式**下校验 ── +# +# 这两条约束原先写在 ``Settings`` 的 model_validator 里,但**模式不在 Settings +# 里**——它是分片时的运行时参数(``build(splitter_structure_mode)``/CLI 的 +# ``--mode``)。无条件校验的后果是一组「standard 下完全合法」的取值连配置都 +# 加载不了:实测 ``kb_chunk_size=100, kb_child_chunk_size=300`` 被 +# ``ValidationError`` 拒绝,而 standard 模式只调用普通分片器 +# (``_split_standard_documents``),子分片器的参数根本不参与。 +# +# 判定现在放在 ``RecursiveTextSplitter._validate_hierarchical_sizes()``:层级 +# 分支一定会构造子分片器,非层级分支一定不会,因此校验的正是真正会用到的参数。 + + +def _settings_with(monkeypatch, **overrides): + """返回一份按 overrides 覆盖过的 settings 替身,供分片器读取。""" + real = get_settings() + # 用 ``model_fields`` 而不是 ``dir()``:后者会连带访问 ``model_computed_fields`` + # 等 pydantic 内部属性,触发 PydanticDeprecatedSince211 警告。 + values = {name: getattr(real, name) for name in type(real).model_fields} + values.update(overrides) + stub = type("_StubSettings", (), values)() + monkeypatch.setattr("src.etl.splitters.recursive_text_splitter.get_settings", lambda: stub) + return stub + + +@pytest.mark.parametrize( + ("label", "overrides"), + [ + ("子分片大于父分片", {"kb_chunk_size": 300, "kb_child_chunk_size": 1500}), + ("子分片等于父分片", {"kb_chunk_size": 300, "kb_child_chunk_size": 300}), + ( + "子分片 overlap 不小于 size", + {"kb_chunk_size": 300, "kb_child_chunk_size": 100, "kb_child_chunk_overlap": 100}, + ), + ], +) +def test_hierarchical_mode_rejects_degenerate_child_sizes(monkeypatch, label, overrides): + """层级模式下必须拒绝会让层级静默退化(或无法推进)的子分片尺寸。""" + _settings_with(monkeypatch, **overrides) + splitter = RecursiveTextSplitter(structure_mode="hierarchical") + + with pytest.raises(ValueError, match="kb_child"): + splitter.split([Document(content="正文" * 300, metadata={"source": "a.md"})]) + + +@pytest.mark.parametrize( + ("label", "bad_value"), + [ + ("整数", 10**4301), + ("列表", ["standard"]), + ("字典", {"mode": "standard"}), + ("集合", {"standard"}), + ], + ids=["huge-int", "list", "dict", "set"], +) +def test_splitter_rejects_illegal_structure_mode_with_value_error(label, bad_value): + """非法 ``structure_mode`` 必须报 ``ValueError``,不能漏出 ``TypeError``/位数上限。 + + 两条独立缺陷,同一个入口:集合成员判断对不可哈希的值抛 + ``TypeError: unhashable type``;消息里插值 ``{structure_mode}`` 对位数超限的 + 整数抛 ``ValueError: Exceeds the limit``(``str(10**4301)``)。后者尤其误导 + ——用户看到的是整数转换问题,而真实原因是模式名写错了。 + + 这个入口的用户可达路径是 ``embed_knowledge_base.py`` 的 ``--mode``(argparse + 有 ``choices`` 拦着)与直接调用分片器的库使用者,因此按库契约要求 + ``ValueError``。 + """ + with pytest.raises(ValueError) as excinfo: + RecursiveTextSplitter(structure_mode=bad_value) + + message = str(excinfo.value) + assert "不支持的结构模式" in message, f"{label}: {message}" + assert "Exceeds the limit" not in message, f"{label}: {message}" + + +# ── 回归:分割模式与尺寸参数的形态必须报 ValueError ── +# +# ``mode`` 原先完全没有校验,直接赋值后在 ``_get_length_function`` 里只判 +# ``== "token"``:任何拼错/大小写不符/非字符串的取值都**静默退回 char 模式** +# (实测 ``"Token"``/``"tokens"``/``None``/``["token"]`` 全部得到 char 分片)。 +# ``chunk_size=1500`` 于是意味着 1500 **字符**而不是 1500 token,中文下每 token +# 约一字,分片规模与配置意图不符且无任何提示——违反 AGENTS.md「不用静默回退或 +# 占位结果掩盖失败」。 +# +# 尺寸参数原先只做比较,不做类型检查:``child_chunk_size='300'`` 构造成功、到 +# split 时才抛 ``TypeError: '>=' not supported between instances of 'int' and +# 'str'``,而契约是同文件的 ``ValueError``。 + + +@pytest.mark.parametrize( + ("label", "bad_value"), + [ + ("大小写不符", "Token"), + ("拼写错误", "tokens"), + ("None", None), + ("列表", ["token"]), + ("整数", 123), + ("位数超限整数", 10**4301), + ], + ids=["capitalized", "plural", "none", "list", "int", "huge-int"], +) +def test_splitter_rejects_illegal_mode_with_value_error(label, bad_value): + """非法 ``mode`` 必须报 ``ValueError``,不能静默退回 char 模式。""" + with pytest.raises(ValueError) as excinfo: + RecursiveTextSplitter(mode=bad_value) + + message = str(excinfo.value) + assert "不支持的分割模式" in message, f"{label}: {message}" + assert "Exceeds the limit" not in message, f"{label}: {message}" + + +def test_splitter_accepts_valid_modes(): + """收紧不能误伤:两个合法模式照常构造,且分片行为确实不同。""" + text = "这是一段中文测试文本,用于观察分片行为。" * 60 + + # overlap 必须一并给出:settings 的默认 overlap(150)大于这里的 + # parent_chunk_size,LangChain 会在构造期就拒绝。 + def chunks_for(mode: str) -> list: + splitter = RecursiveTextSplitter(mode=mode, parent_chunk_size=100, parent_chunk_overlap=10) + return splitter.split([Document(content=text, metadata={"source": "a.md"})]) + + token_chunks = chunks_for("token") + char_chunks = chunks_for("char") + + assert token_chunks and char_chunks + # 模式确实生效(若两者相同,说明 mode 又被忽略了)。 + assert len(token_chunks) != len(char_chunks) + + +@pytest.mark.parametrize( + ("label", "kwargs"), + [ + ("size 是数字字符串", {"child_chunk_size": "300"}), + ("overlap 是列表", {"child_chunk_overlap": ["x"]}), + ("parent size 是数字字符串", {"parent_chunk_size": "1500"}), + ("parent overlap 是布尔值", {"parent_chunk_overlap": True}), + ("size 是 None 之外的假值", {"child_chunk_size": 0}), + ], + ids=["size-str", "overlap-list", "parent-str", "overlap-bool", "size-zero"], +) +def test_splitter_rejects_malformed_size_arguments(label, kwargs): + """尺寸参数形态非法时必须在**构造期**报 ``ValueError``。 + + 原先这些值被静默接受,直到 ``split()`` 里做比较时才抛 ``TypeError``—— + 错误现场离写错的调用很远,且 ``TypeError`` 不是本模块的契约类型。 + """ + with pytest.raises(ValueError) as excinfo: + RecursiveTextSplitter(structure_mode="hierarchical", **kwargs) + + assert "必须是整数" in str(excinfo.value) or "必须大于等于" in str(excinfo.value), label + + +def test_splitter_accepts_valid_size_arguments(): + """收紧不能误伤:合法尺寸(含 None 表示用 settings)照常构造。""" + splitter = RecursiveTextSplitter( + structure_mode="hierarchical", + parent_chunk_size=500, + parent_chunk_overlap=50, + child_chunk_size=100, + child_chunk_overlap=10, + ) + + assert splitter.parent_chunk_size == 500 + assert splitter.child_chunk_overlap == 10 + + defaults = RecursiveTextSplitter(structure_mode="hierarchical") + assert defaults.parent_chunk_size is None + + +def test_splitter_rejects_oversized_separator_table(monkeypatch): + """分隔符表过长必须在构造期报 ``ValueError``,而不是到切分时抛 ``RecursionError``。 + + LangChain 的 ``_split_text`` 按分隔符表**递归**,每层消耗一项。实测 1500 项 + 抛 ``RecursionError: maximum recursion depth exceeded``——它不是 + ``ValueError``,穿透调用方的错误分类与脱敏。表来自用户可写的 + ``kb_splitter_separators``,``Settings`` 的 validator 只做 ``str -> list`` + 拆分、没有长度上界。 + """ + _settings_with(monkeypatch, kb_splitter_separators=[f"sep{i}" for i in range(1500)]) + + with pytest.raises(ValueError, match="分隔符表最多"): + RecursiveTextSplitter(structure_mode="standard") + + +def test_splitter_accepts_a_reasonable_separator_table(monkeypatch): + """收紧不能误伤:真实规模的表照常工作。""" + _settings_with(monkeypatch, kb_splitter_separators=["###", "\n", " ", ""]) + + chunks = RecursiveTextSplitter(structure_mode="standard").split( + [Document(content="a" * 2000, metadata={"source": "a.md"})] + ) + + assert chunks + + +def test_splitter_accepts_valid_structure_modes(): + """收紧不能误伤:两个合法模式照常构造。""" + for mode in ("standard", "hierarchical"): + assert RecursiveTextSplitter(structure_mode=mode).structure_mode == mode + + +def test_hierarchical_overlap_message_survives_an_unprintable_integer(): + """越界消息本身不得抛异常——它是唯一会插值 ``child_overlap`` 的地方。 + + 构造器参数是 ``Settings`` 之外的第二条入口:``RecursiveTextSplitter`` + 的 ``child_chunk_overlap`` 直接来自调用方,``Settings`` 的上界拦不住它。 + 实测修复前 ``child_chunk_overlap=10**4301`` 时,本该报「overlap 必须小于 + size」的消息自己插值这个整数,用户看到的是 + ``ValueError: Exceeds the limit (4300 digits)``——与重叠参数毫无关系, + 而且 ``Exceeds the limit`` 会让人以为是整数转换的 bug。 + + ``repr(10**4301)`` 抛 ``ValueError`` 是因为 ``sys.get_int_max_str_digits()`` + 默认 4300 位;判据用 ``safe_repr`` 兜住。 + """ + splitter = RecursiveTextSplitter(structure_mode="hierarchical", child_chunk_overlap=10**4301) + + with pytest.raises(ValueError) as excinfo: + splitter.split([Document(content="正文" * 300, metadata={"source": "a.md"})]) + + message = str(excinfo.value) + assert "kb_child_chunk_overlap" in message, message + assert "Exceeds the limit" not in message, message + + +def test_standard_mode_ignores_unused_child_sizes(monkeypatch): + """收紧不能误伤:standard 模式不使用子分片器,那组取值必须照常可用。 + + 这是本组问题的**用户可见**后果:只想用 standard 的用户,原先必须先改掉 + 用不到的 ``kb_child_*`` 字段才能加载配置。 + """ + _settings_with(monkeypatch, kb_chunk_size=300, kb_child_chunk_size=1500) + splitter = RecursiveTextSplitter(structure_mode="standard") + + chunks = splitter.split([Document(content="正文" * 300, metadata={"source": "a.md"})]) + + assert chunks, "standard 模式必须照常产出分片" + + +def test_hierarchical_mode_accepts_valid_child_sizes(monkeypatch): + """收紧不能误伤:合法的层级尺寸照常工作。""" + _settings_with( + monkeypatch, kb_chunk_size=300, kb_child_chunk_size=100, kb_child_chunk_overlap=10 + ) + splitter = RecursiveTextSplitter(structure_mode="hierarchical") + + chunks = splitter.split([Document(content="正文" * 300, metadata={"source": "a.md"})]) + + assert chunks + assert splitter.parent_documents, "层级模式必须产出父块" + + +# ── 基础清洗器与分片器的容器形态判据 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测): +# * ``basic_cleaner`` 的 ``documents`` 容器/元素形态 → 删掉 43 后 ``5`` 变成裸 +# ``TypeError: 'int' object is not iterable``(不是本模块承诺的 ``ValueError``); +# * ``_require_separators`` 的 135(非列表)→ 同样退化成裸 ``TypeError``; +# 138(元素非字符串)→ **静默接受** ``["a", 5]``,后续按分隔符切分时 +# 才以形态各异的错误崩掉(或更糟:静默产出错误的分片)。 +# +# 既有用例只喂合法输入(``["document"]`` 之类),两条判据从未被反向覆盖。 +def test_basic_cleaner_rejects_non_sequence_documents(): + """``documents`` 非序列必须给带字段名的 ``ValueError``,不是裸 ``TypeError``。""" + import pytest + + from src.etl.cleaners.basic_cleaner import BasicCleaner + + with pytest.raises(ValueError, match="documents 必须是文档列表"): + BasicCleaner().clean(5) + + +def test_basic_cleaner_rejects_non_document_entries(): + """``documents`` 的元素必须是 ``Document``,不能静默丢弃不合规条目。""" + import pytest + + from src.etl.cleaners.basic_cleaner import BasicCleaner + + with pytest.raises(ValueError, match=r"documents\[1\] 必须是 Document"): + BasicCleaner().clean([Document(content="ok", metadata={}), "not-a-document"]) + + +def test_recursive_text_splitter_requires_list_of_string_separators(): + """分隔符表必须是非空字符串的列表:容器与元素两条判据都要生效。""" + import pytest + + with pytest.raises(ValueError, match="分隔符表必须是列表"): + RecursiveTextSplitter._require_separators(5) + + +def test_recursive_text_splitter_rejects_non_string_separator_entry(): + """元素非字符串必须报错,不能静默接受后产出错误的分片。""" + import pytest + + with pytest.raises(ValueError, match="每一项都必须是字符串"): + RecursiveTextSplitter._require_separators(["\n\n", 5]) + + +# ── 回归:ETL 日志插值不得因 metadata 取值形态而崩 ── +# +# ``Document.metadata`` 声明为 ``dict[str, Any]``,取值不受控。``str(10**4301)`` +# 撞 CPython 的整数位数上限(默认 4300 位)抛 ``ValueError: Exceeds the limit``, +# 而 ETL 的日志行直接把它插进 f-string——一条日志/一次分片让建库主路径崩掉, +# 报出的还是与「来源字段形态异常」无关的整数转换错误。 + + +class _PassthroughExtractor(MarkdownExtractor): + pass + + +def _passthrough_pipeline() -> Pipeline: + """构造一个各阶段都不改数据的管线,单独暴露 ``process`` 的日志插值。""" + from src.etl.cleaners.base import BaseCleaner + from src.etl.splitters.base import BaseSplitter + + class _Cleaner(BaseCleaner): + def clean(self, documents): + return documents + + class _Splitter(BaseSplitter): + def split(self, documents): + return documents + + return Pipeline(_PassthroughExtractor(), _Cleaner(), _Splitter()) + + +def test_pipeline_process_survives_oversized_integer_source(): + """``metadata['source']`` 是位数超限整数时,日志插值不得让 ``process`` 崩。 + + 删除守卫后 ``logger.info(f"开始处理文档: {source}")`` 抛 + ``ValueError: Exceeds the limit (4300 digits)``——建库主路径失败,且错误 + 与「来源字段形态异常」毫无关系。 + """ + document = Document(content="正文", metadata={"source": 10**4301}) + + chunks = _passthrough_pipeline().process(document) + + assert len(chunks) == 1 + + +def test_pipeline_process_survives_non_string_source(): + """``source`` 是非字符串形态(列表)时同样不得崩。""" + document = Document(content="正文", metadata={"source": ["a.md"]}) + + chunks = _passthrough_pipeline().process(document) + + assert len(chunks) == 1 + + +@pytest.mark.parametrize( + "entry", + [10**4301, ["a.md"], {"path": "a.md"}, 42], + ids=["huge-int", "list", "dict", "int"], +) +def test_splitter_survives_malformed_source_in_logs(entry): + """分片器两条路径的 ``source`` 日志插值都不得因形态异常而崩。""" + document = Document(content="正文内容" * 50, metadata={"source": entry}) + + assert RecursiveTextSplitter().split([document]) + assert RecursiveTextSplitter(structure_mode="hierarchical").split_hierarchical([document]) + + +def test_splitter_keeps_string_source_verbatim_in_logs(caplog): + """正常字符串来源的日志输出逐字不变(守卫只在非字符串时兜底)。""" + import logging + + with caplog.at_level(logging.DEBUG, logger="src.etl.splitters.recursive_text_splitter"): + RecursiveTextSplitter().split( + [Document(content="正文内容" * 50, metadata={"source": "a.md"})] + ) + + assert "正在分割文档: a.md" in caplog.text + assert "文档 'a.md' 分割完成" in caplog.text + + +def test_document_repr_survives_oversized_integer_metadata(): + """``Document`` 的 ``__str__``/``__repr__`` 必须对任何 metadata 都能渲染。 + + ``__repr__`` 是 pytest 失败报告、日志与调试器的**兜底渲染**;``metadata`` + 里一个位数超过 ``sys.get_int_max_str_digits()`` 的整数就让整条 f-string 抛 + ``ValueError: Exceeds the limit``,报错现场反而什么都看不到(实测 pytest 把 + 失败对象显示成 ``<[ValueError(...) raised in repr()] Document object at ...>``)。 + """ + document = Document(content="正文", metadata={"source": 10**4301}) + + assert repr(document) + assert str(document) + + +def test_document_repr_keeps_normal_metadata_verbatim(): + """正常 metadata 的渲染逐字不变(降级只在渲染真的失败时发生)。""" + metadata = {"source": "knowledge_base/sample.md", "chunk_index": 0, "token_count": 320} + document = Document(content="正文", metadata=metadata) + + assert str(document) == f"Document(content='正文...', metadata={metadata})" + + +@pytest.mark.parametrize( + "metadata", + [{"source": 10**4301}, {"source": ["a.md"]}, {"source": 42}, {"source": {"p": 1}}], + ids=["huge-int", "list", "int", "dict"], +) +def test_document_repr_survives_malformed_metadata(metadata): + """非字符串 metadata 取值同样不得让 ``repr`` 抛。""" + assert repr(Document(content="正文", metadata=metadata)) diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index 1b96133..c1e65a5 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -177,7 +177,7 @@ def test_get_llm_provider_success(): def test_get_llm_provider_not_found(): """测试获取不存在的 LLM 提供商时抛出 ValueError""" - with pytest.raises(ValueError, match="在LLM配置中未找到key: non_existent_key"): + with pytest.raises(ValueError, match="在LLM配置中未找到key: 'non_existent_key'"): ModelProviderFactory.get_llm_provider("non_existent_key") @@ -234,7 +234,7 @@ def test_get_embedding_provider_success(): def test_get_embedding_provider_not_found(): """测试获取不存在的 Embedding 提供商时抛出 ValueError""" - with pytest.raises(ValueError, match="在Embedding配置中未找到key: non_existent_key"): + with pytest.raises(ValueError, match="在Embedding配置中未找到key: 'non_existent_key'"): ModelProviderFactory.get_embedding_provider("non_existent_key") @@ -283,7 +283,7 @@ def test_get_rerank_provider_siliconflow_mapping(): def test_get_rerank_provider_not_found(): """测试获取不存在的 Rerank 提供商时抛出 ValueError""" - with pytest.raises(ValueError, match="在Rerank配置中未找到key: non_existent_key"): + with pytest.raises(ValueError, match="在Rerank配置中未找到key: 'non_existent_key'"): ModelProviderFactory.get_rerank_provider("non_existent_key") @@ -296,3 +296,492 @@ def test_unsupported_provider_type(): """测试 _get_provider_class 方法处理不支持的提供商名称""" with pytest.raises(ValueError, match="不支持的模型提供商: unsupported_provider"): ModelProviderFactory._get_provider_class("unsupported_provider") + + +def test_unsupported_provider_type_real_guard_not_mock(): + """``test_unsupported_provider_type`` 断言的是 autouse 替身 ``mock_get_provider_class``, + 真实守卫 ``factory.py:218`` 从未执行。这里直接调原始类的 ``__wrapped__`` 钉住真守卫, + 避免替身掩盖 ``raise`` 被删/反转的回归。""" + real = _ORIGINAL_FACTORY._get_provider_class.__wrapped__ + with pytest.raises(ValueError, match="不支持的模型提供商: unsupported_provider"): + real("unsupported_provider") + + +# ── 回归:provider key 的形态校验 ── +# +# ``_get_model_detail`` 直接写 ``if provider_key not in configurations``,随后 +# ``configurations[provider_key]`` 与错误消息里的 f-string 插值。三条失败路径都 +# 与「配置里没有这个 key」无关: +# ``key not in dict`` 对不可哈希的取值抛 ``TypeError: unhashable type: 'list'`` +# (它不是 ``ValueError``,穿透 ``Chatbot.reload_llm`` 的 +# ``except Exception`` 分类与上游的 ``except ValueError``); +# ``f"...{provider_key}"`` 对位数超限的整数抛 +# ``ValueError: Exceeds the limit (4300 digits)``——类型对,内容与字段无关。 +# +# 同一个判据在 ``src/runtime/contracts.py`` 的 ``_require_known_field`` 里已经 +# 认领过(同样的 ``key not in ...`` + f-string 组合),provider 工厂漏了。 +# key 来源是 ``SessionConfig.active_llm_configuration`` 等可直写属性。 + + +@pytest.mark.parametrize("bad_key", [[1], {1: 2}, {1, 2}], ids=["list", "dict", "set"]) +def test_get_llm_provider_rejects_unhashable_key_readably(bad_key): + """不可哈希的 key 必须给 ``ValueError``,不是裸 ``TypeError: unhashable type``。""" + with pytest.raises(ValueError, match="llm_configurations"): + ModelProviderFactory.get_llm_provider(bad_key, {}) + + +@pytest.mark.parametrize("bad_key", [10**4301, -(10**4301)], ids=["huge", "negative-huge"]) +def test_get_llm_provider_rejects_oversized_key_readably(bad_key): + """位数超限的 key 必须给出字段名,而不是整数转换超限。""" + with pytest.raises(ValueError) as excinfo: + ModelProviderFactory.get_llm_provider(bad_key, {}) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "llm_configurations" in message + + +@pytest.mark.parametrize("bad_key", [[1], 10**4301], ids=["list", "huge-int"]) +def test_get_embedding_provider_rejects_malformed_key_readably(bad_key): + """Embedding 与 Rerank 走同一 helper,判据必须一致。""" + with pytest.raises(ValueError, match="embedding_configurations"): + ModelProviderFactory.get_embedding_provider(bad_key, {}) + + +def test_get_llm_provider_still_reports_missing_key(): + """收紧不能误伤:合法的字符串 key 照常给出原有消息。""" + with pytest.raises(ValueError, match="在LLM配置中未找到key: 'nope'"): + ModelProviderFactory.get_llm_provider("nope", {}) + + +# ── 回归:provider 配置**取值**的形态校验 ── +# +# `_get_model_detail` 只做 `provider_key not in configurations` 的**成员**判定, +# 然后把 `configurations[provider_key]` 原样返回。键在、值不可用(`None`/dict/ +# int)时不会走「未找到key」分支,`None` 一路带到三个入口的 `config.provider`, +# 在那里抛裸 `AttributeError`——它不是 `ValueError`,穿透 +# `Chatbot.reload_llm` 的 `except ValueError` 分类。 +# 值来源是 `SessionConfig.llm_configurations` 等可直写映射。 + + +@pytest.mark.parametrize( + "bad_detail", [None, 5, "x", {"provider": "deepseek"}], ids=["None", "int", "str", "dict"] +) +def test_get_llm_provider_rejects_non_detail_value_readably(bad_detail): + """配置取值不是 `ModelDetail` 时必须给 ``ValueError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="llm_configurations"): + ModelProviderFactory.get_llm_provider("k", {"k": bad_detail}) + + +def test_get_embedding_provider_rejects_non_detail_value_readably(): + """Embedding 与 LLM 走同一 helper,判据必须一致。""" + with pytest.raises(ValueError, match="embedding_configurations"): + ModelProviderFactory.get_embedding_provider("k", {"k": None}) + + +def test_get_rerank_provider_rejects_non_detail_value_readably(): + """Rerank 同上。""" + with pytest.raises(ValueError, match="rerank_configurations"): + ModelProviderFactory.get_rerank_provider("k", {"k": None}) + + +def test_get_llm_provider_still_accepts_valid_explicit_configuration(): + """收紧不能误伤:合法的 ``ModelDetail`` 照常返回实例。""" + configurations = {"k": ModelDetail(provider="mock_llm", model_name="m")} + + provider = ModelProviderFactory.get_llm_provider("k", configurations) + + assert provider.model_name == "m" + + +# ── 回归:provider 配置**容器**形态的校验 ── +# +# `_get_model_detail` 直接写 `provider_key not in configurations`。取值不可迭代 +# 时抛裸 `TypeError: argument of type 'int' is not iterable`;是字符串时走**子串** +# 判定(`"k" in "k"` 为真),随后 `"k"["k"]` 抛 +# `TypeError: string indices must be integers`。两者都不是 `ValueError`。 +# 取值来自可直写的 `SessionConfig.llm_configurations`,经 `Chatbot.reload_llm` +# 原样传到这里。 + + +@pytest.mark.parametrize( + "bad_configurations", [5, "x", 10**4301, object()], ids=["int", "str", "huge-int", "object"] +) +def test_get_llm_provider_rejects_non_mapping_configurations_readably(bad_configurations): + """配置容器不是映射时必须给 ``ValueError``,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="llm_configurations"): + ModelProviderFactory.get_llm_provider("k", bad_configurations) + + +def test_get_embedding_and_rerank_providers_share_the_mapping_guard(): + """Embedding/Rerank 走同一 helper,判据必须一致。""" + with pytest.raises(ValueError, match="embedding_configurations"): + ModelProviderFactory.get_embedding_provider("k", 5) + with pytest.raises(ValueError, match="rerank_configurations"): + ModelProviderFactory.get_rerank_provider("k", 5) + + +def test_get_llm_provider_still_accepts_a_plain_dict(): + """收紧不能误伤:普通 ``dict`` 仍是合法映射。""" + configurations = {"k": ModelDetail(provider="mock_llm", model_name="m")} + + provider = ModelProviderFactory.get_llm_provider("k", configurations) + + assert provider.model_name == "m" + + +# ── 回归:provider 配置**取值内部字段**的形态校验 ── +# +# `_get_model_detail` 只做键/容器/取值形态的校验,取值的**内部字段** +# (`provider`/`model_name`)原样交给 `_create_provider`。而 `_create_provider` +# 会把 `provider_name` 拿去 `_role_aliases.get(...)`/`_provider_map.get(...)` +# 做字典查找、`_get_provider_class` 做成员判定、并在「不支持的模型提供商」消息里 +# f-string 插值;`model_name` 则原样透传给 provider 构造器(`local-hash` 直接 +# `.rsplit()`)。 +# +# 判据只做了一半:`832abf0` 给**诊断**入口 `_provider_info_for_role` 加了 +# `provider_name` 形态守卫,展示层 `display_chat_config` 与 +# `RetrievalService._get_rerank_provider` 也各自用 `require_provider_name` 收敛, +# 唯独**真正建 provider** 的 `_create_provider` 与三个 getter 漏了。同一个 +# `provider=["a"]`:走诊断入口得到带指引的 `ValueError`,走 getter 得到裸 +# `TypeError: unhashable type: 'list'`。 +# +# 可达路径:`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫, +# `llm_configurations`/`embedding_configurations`/`rerank_configurations` +# 的 `_VALIDATORS` 明确不登记(「按原样写入,由使用方负责其内部结构」),嵌入方 +# 可以往映射里放任意取值的 detail;`Chatbot.reload_llm` 把它原样传到这里。 +# `ModelDetail` 本身会拒绝非字符串 `provider`,但属性赋值没有 `__setattr__` 守卫 +# (实测 `detail.provider = ["a"]` 直接成功),且 `SimpleNamespace` 替身同样能提供 +# 这些属性(`require_detail_attributes` 只要求属性**存在**)。 + + +def _detail_with(**overrides: Any) -> ModelDetail: + """构造一个 `ModelDetail` 再直写字段,绕过 pydantic 的构造期校验。""" + detail = ModelDetail(provider="mock_llm", model_name="m") + for field, value in overrides.items(): + setattr(detail, field, value) + return detail + + +@pytest.mark.parametrize( + "bad_provider", + [["a"], {"k": 1}, 10**4301], + ids=["list", "dict", "huge-int"], +) +def test_get_llm_provider_rejects_malformed_provider_field_readably(bad_provider): + """`detail.provider` 形态非法时必须给 ``ValueError``,不是裸 ``TypeError``/整数转换错。""" + configurations = {"k": _detail_with(provider=bad_provider)} + + with pytest.raises(ValueError) as excinfo: + ModelProviderFactory.get_llm_provider("k", configurations) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "provider" in message + + +def test_embedding_and_rerank_getters_share_the_provider_field_guard(): + """Embedding/Rerank 走同一 helper,判据必须一致。""" + for getter, field in ( + (ModelProviderFactory.get_embedding_provider, "embedding_configurations"), + (ModelProviderFactory.get_rerank_provider, "rerank_configurations"), + ): + with pytest.raises(ValueError, match="provider"): + getter("k", {"k": _detail_with(provider=["a"])}) + + +@pytest.mark.parametrize( + "bad_model_name", + [["m"], {"m": 1}, None, 5, 10**4301], + ids=["list", "dict", "none", "int", "huge-int"], +) +def test_get_embedding_provider_rejects_malformed_model_name_readably(bad_model_name): + """`detail.model_name` 形态非法时必须给 ``ValueError``,不是裸 ``AttributeError``。 + + `model_name` 全程没有形态校验:`local-hash` 的 `_parse_dimension` 直接 + `.rsplit()`(非字符串抛裸 `AttributeError`),其它 provider 把它交给 SDK。 + 位数超限的整数还会在日志 f-string 与错误消息里抛 `ValueError: Exceeds the limit`。 + """ + configurations = {"k": _detail_with(provider="local-hash", model_name=bad_model_name)} + + with pytest.raises(ValueError) as excinfo: + ModelProviderFactory.get_embedding_provider("k", configurations) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "model_name" in message + + +def test_getters_still_accept_valid_details(): + """收紧不能误伤:合法的 provider/model_name 照常建出实例。""" + provider = ModelProviderFactory.get_llm_provider( + "k", {"k": ModelDetail(provider="mock_llm", model_name="m")} + ) + + assert provider.model_name == "m" + + +# ── 回归:`_get_provider_class` 的白名单守卫 ── +# +# 本文件 `patch_settings` 是 `autouse=True`,它把 `_get_provider_class` +# **整体换成** `mock_get_provider_class`(含一份自己的「不支持的模型提供商」 +# 分支)。于是 `test_unsupported_provider_type` 断言的消息来自替身,真实守卫 +# (`factory.py` 里 `if provider_name not in _provider_map: raise ValueError`) +# 在整套测试里从未被执行——把它的 `raise` 换成 `pass`,3160 条测试全绿。 +# +# 这个判据的价值在于它是**唯一**的白名单执行者:`require_provider_name` 的注释 +# 写明「白名单是工厂的职责(`_get_provider_class` 会给『不支持的模型提供商』)」, +# 而 `_create_provider` 在 `_provider_map.get(...)` 返回 `None` 时会落到这里 +# (`factory.py:327`)。删掉守卫后,`get_llm_provider`(经 `_create_provider`)与 +# 诊断入口直调 `_get_provider_class` 都改抛裸 `KeyError: 'unknown'`,不再带指引。 +# +# fixture 替换的是**重新导入**出的那个类;模块顶部导入的 `_ORIGINAL_FACTORY` 仍是 +# 未被打补丁的原始类,用它才能钉住源码里那份实现。 +_ORIGINAL_FACTORY = ModelProviderFactory + + +def test_get_provider_class_enforces_the_provider_map_whitelist(): + """未知渠道名必须给带指引的 ``ValueError``,不能是裸 ``KeyError``。""" + real = _ORIGINAL_FACTORY._get_provider_class.__wrapped__ + + with pytest.raises(ValueError) as excinfo: + real("definitely_not_a_provider") + + assert "不支持的模型提供商: definitely_not_a_provider" in str(excinfo.value) + + +def test_create_provider_rejects_unknown_provider_with_value_error(): + """真正建 provider 的路径(`_create_provider`)同样收敛成 ``ValueError``。""" + with pytest.raises(ValueError) as excinfo: + _ORIGINAL_FACTORY._create_provider( + "definitely_not_a_provider", "m", "llm", LargeLanguageModel + ) + + assert "不支持的模型提供商: definitely_not_a_provider" in str(excinfo.value) + + +# ── 回归:`_create_provider` 协议/能力两条守卫 ── +# +# 同一函数内四条守卫只有一条被钉住:`test_factory_rejects_options_for_provider_without_options_parameter` +# 覆盖了「构造函数不支持 options」(342 行),而 315/329 两条在整套测试里从未执行 +# (`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3186 条全绿, +# 且没有任何测试断言这两条消息)。删除后行为确实改变(实测): +# * 315 行 `protocol` 归一化为空(`protocol=""`)→ 从 `ValueError: provider +# protocol 不能为空。` 变成裸 `AttributeError: 'NoneType' object has no +# attribute 'value'`(把带字段名的 `ValueError` 换成了别的异常); +# * 329 行 `issubclass` 失败 → **静默返回**一个不是 `expected_type` 子类的 +# 实例(`local-hash` 传 `expected_type=LargeLanguageModel` 时拿到 +# `LocalHashEmbeddingProvider`),类型契约被无声破坏。 +# 两条都经公开 getter 可达(`config.protocol` 与 `config.provider` 由 +# `ModelDetail` 承载,`_detail_with` 直写字段即可构造)。用 `_ORIGINAL_FACTORY` +# 是因为 `patch_settings` 把真实 `_get_provider_class` 换成了替身,只有原始类能 +# 走到 `_provider_map` 里的真实 provider 信息。 +# +# 同一函数的 326 行(协议不在该 provider 的 `protocols` 白名单内)经甄别是**等价 +# 变异**,故不补测试:删除后仍是 `ValueError`、结论不变——OpenAI 兼容渠道落到适配器 +# 自己的 `_normalize_protocol`(消息换成「OpenAI 兼容渠道不支持 X 协议」), +# google/anthropic 落到 360 行(「构造函数不支持 protocol=X」)。三种收场都拒绝且 +# 都带指引,没有静默接受,不满足「行为确实改变」的判据。 +def test_create_provider_rejects_empty_protocol_readably(): + """`protocol=""` 归一化为空时必须给带字段名的 ``ValueError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError) as excinfo: + _ORIGINAL_FACTORY._create_provider("openai", "m", "llm", LargeLanguageModel, protocol="") + + assert "protocol" in str(excinfo.value) and "不能为空" in str(excinfo.value) + + +def test_create_provider_rejects_class_that_is_not_the_expected_type(): + """provider 类不是 `expected_type` 子类时必须 ``TypeError``,不能静默返回。 + + `local-hash` 是纯 embedding 渠道(`LocalHashEmbeddingProvider`),把它按 + `expected_type=LargeLanguageModel` 取用时,能力表那一关(role 在 + `capabilities` 内)放行,随后 `issubclass` 判据必须拦住——否则调用方拿到 + 一个不是 `LargeLanguageModel` 的实例,类型契约被无声破坏。 + """ + with pytest.raises(TypeError) as excinfo: + _ORIGINAL_FACTORY._create_provider("local-hash", "m", "embedding", LargeLanguageModel) + + assert "需要实现 LargeLanguageModel" in str(excinfo.value) + + +# ── 回归:`_provider_info_for_role` 与诊断入口的六条守卫 ── +# +# 这些判据在整套测试里从未执行(行覆盖证实;把它们的 `raise` 换成 `pass` 后 +# 全量 3471 条仍全绿)。删除后行为确实改变(实测,经公开诊断入口 +# `capabilities`/`capability_report`/`protocol_status` 与公开 getter): +# * 236 模块导入失败 → 删掉 ``except ImportError`` 的 ``raise`` 后 +# ``ProviderClass`` 未赋值,函数**静默返回 None**,调用方拿到 ``None`` 并在 +# 后面某处才崩; +# * 244 模块里找不到 provider 类 → 同上,静默返回 ``None``; +# * 277 渠道名不在 ``_provider_map`` → 带指引的 ``ValueError: 不支持的模型 +# 提供商: nope`` 变成裸 ``AttributeError: 'NoneType' object has no attribute +# 'get'``(``info.get(...)``),诊断界面拿到的是崩溃而不是「渠道名写错了」; +# * 280 角色不在该渠道的 ``capabilities`` 内 → **静默返回**该渠道的 provider +# 信息(实测返回 ``local-hash`` 的 embedding 实现),角色契约被无声破坏; +# * 412 ``server_verified_protocols`` 非字符串非序列 → 裸 ``TypeError: 'int' +# object is not iterable``(``for value in configured``); +# * 431 登记了未知协议名 → ``normalized_protocol`` 未赋值,漏出裸 +# ``UnboundLocalError: cannot access local variable 'normalized_protocol'``; +# * 554 ``role=None`` 且渠道名无法推断出任何角色 → 消息从「无法为提供商 nope +# 推断模型角色,请显式传入 role。」退化成「模型角色必须是字符串,但得到 +# None。」——把「渠道名不存在」说成「角色名不是字符串」。 +# +# 同批的 360 行经甄别是**死代码**,不补测试:它的条件 +# (``protocol_value not in provider_info["protocols"]``)是 326 行那条的严格 +# 子集,326 先命中,因此这条分支在现有配置下不可达。 +# +# 用 `_ORIGINAL_FACTORY` 的原因同上:``patch_settings`` 替换的是重新导入的 +# 那个类,只有原始类能走到真实的 ``_provider_map`` 与 provider 信息。 + + +def test_provider_info_for_role_rejects_unknown_provider(): + """渠道名不在 ``_provider_map`` 时必须给带指引的 ``ValueError``。""" + with pytest.raises(ValueError) as excinfo: + _ORIGINAL_FACTORY._provider_info_for_role("nope", "llm") + + assert "不支持的模型提供商: nope" in str(excinfo.value) + + +def test_provider_info_for_role_rejects_unsupported_capability(): + """角色不在该渠道 ``capabilities`` 内必须报错。 + + ``local-hash`` 是纯 embedding 渠道;按 ``llm`` 角色查询时判据必须拦住, + 否则调用方拿到 embedding 实现,角色契约被无声破坏。 + """ + with pytest.raises(TypeError) as excinfo: + _ORIGINAL_FACTORY._provider_info_for_role("local-hash", "llm") + + assert "不支持 llm 能力" in str(excinfo.value) + + +@pytest.mark.parametrize( + "options", + [ + {"server_verified_protocols": 5}, + {"server_verified_protocols": {"responses": 1}}, + ], + ids=["int", "dict"], +) +def test_runtime_verified_protocols_rejects_non_sequence_container(options): + """``server_verified_protocols`` 必须是字符串或协议序列。 + + 映射是 ``Sequence`` 的反面:``for value in {"responses": 1}`` 会迭代出键 + 而不是报错,因此这里要显式排除。 + """ + with pytest.raises(ValueError) as excinfo: + _ORIGINAL_FACTORY._runtime_verified_protocols(options, frozenset({"responses"}), "qwen") + + assert "server_verified_protocols 必须是字符串或协议序列" in str(excinfo.value) + + +def test_runtime_verified_protocols_rejects_unknown_protocol(): + """登记了未知协议名必须给带可选值列表的消息。 + + 删除守卫后 ``normalized_protocol`` 从未赋值,漏出裸 ``UnboundLocalError``。 + """ + with pytest.raises(ValueError) as excinfo: + _ORIGINAL_FACTORY._runtime_verified_protocols( + {"server_verified_protocols": ["nope"]}, frozenset({"responses"}), "qwen" + ) + + message = str(excinfo.value) + assert "不支持的 server_verified_protocols 协议: nope" in message + assert "可选值" in message + + +def test_runtime_verified_protocols_accepts_protocol_in_supported_set(): + """协议**在**提供商支持列表内时必须接受——这是上面那条拒绝守卫的另一半。 + + 既有用例只覆盖「协议不在白名单 → 拒绝」(``["nope"]``),从未走 ``and`` 的 + 假分支(``normalized_protocol in supported_protocols`` → 不 raise)。把判据 + ``supported_protocols is not None and normalized_protocol not in supported_protocols`` + 的 ``and`` 互换为 ``or`` 后,「协议在白名单内」也会 raise——合法配置被误拒, + 而全量套件仍绿(实测)。这条把「白名单内必须接受」钉死。 + """ + result = _ORIGINAL_FACTORY._runtime_verified_protocols( + {"server_verified_protocols": ["responses"]}, frozenset({"responses"}), "qwen" + ) + assert result == frozenset({"responses"}) + + +def test_runtime_verified_protocols_accepts_any_protocol_when_set_is_none(): + """``supported_protocols`` 为 ``None`` 时不施加白名单限制。 + + ``None`` 是「该提供商未声明限制」的语义;``and`` 的 ``is not None`` 分支 + 被短接,任何合法协议名都应通过。 + """ + result = _ORIGINAL_FACTORY._runtime_verified_protocols( + {"server_verified_protocols": ["responses"]}, None, "qwen" + ) + assert result == frozenset({"responses"}) + + +def test_protocol_status_requires_explicit_role_for_unknown_provider(): + """``role=None`` 且渠道名不存在时必须说「无法推断角色」,而不是「角色不是字符串」。""" + with pytest.raises(ValueError) as excinfo: + _ORIGINAL_FACTORY.protocol_status("nope", "chat_completions") + + assert "推断模型角色" in str(excinfo.value) + + +@pytest.mark.parametrize( + ("module", "class_name", "expected"), + [ + ("src.providers.nonexistent_module", "X", ImportError), + ("src.providers.jina", "NoSuchProviderClass", AttributeError), + ], + ids=["missing-module", "missing-class"], +) +def test_get_provider_class_reports_import_failures_readably(module, class_name, expected): + """动态导入失败必须报成带模块名的异常,不能静默返回 ``None``。 + + 删除守卫后函数在 ``except`` 里走空,落到函数末尾隐式返回 ``None``——调用方 + 拿到 ``None`` 后会在更远的地方崩,错误现场与真正的病因分开了。 + """ + real = _ORIGINAL_FACTORY._get_provider_class.__wrapped__ + saved = _ORIGINAL_FACTORY._provider_map["local-hash"] + _ORIGINAL_FACTORY._provider_map["local-hash"] = { + "module": module, + "class": class_name, + "capabilities": {"embedding"}, + } + try: + with pytest.raises(expected) as excinfo: + real("local-hash") + finally: + _ORIGINAL_FACTORY._provider_map["local-hash"] = saved + + message = str(excinfo.value) + assert module in message or class_name in message + + +# ── `get_llm_provider` 的 `except` 末尾裸 `raise` ── +# +# `get_llm_provider` 的 `except Exception` 块在记日志之后以裸 `raise` 重抛。这条 +# 判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它换成 `pass` 后全量 3698 +# 条仍全绿)。删除后行为确实改变(实测):`_create_provider` 抛出的异常被 +# **静默吞掉**,`get_llm_provider` 返回 `None`——调用方拿到 `None` 当 provider 用, +# 崩在更远处;而三个兄弟 getter(`get_embedding_provider`/`get_rerank_provider`) +# 的同一条重抛都已被覆盖,只有这一条没有(sibling 缺口)。 +# +# 用真实入口:`provider` 填一个 `_provider_map` 里没有的名字,`_create_provider` +# 会抛带指引的 `ValueError`,本条判据负责让它继续向上传播。 +def test_get_llm_provider_propagates_creation_failure(): + """`_create_provider` 失败时 `get_llm_provider` 必须向上抛,不能返回 `None`。""" + configurations = {"k": ModelDetail(provider="nonexistent", model_name="m")} + + with pytest.raises(ValueError, match="不支持的模型提供商"): + ModelProviderFactory.get_llm_provider("k", configurations) + + +def test_get_model_detail_available_keys_survive_huge_int_key(): + """可选值拼接必须经 ``safe_repr``:超限整数键不得让守卫自崩。 + + configations 是可直写映射,pickle 快照可携带 ``10**4301`` 这样的键—— + 裸 ``str(k)`` 在拼消息时抛 ``Exceeds the limit``,用户看到的语义从 + 「未找到key」错位成整数转换失败。``display_utils._require_model_detail`` + 同场景已用 ``safe_repr`` 正常降级,两侧对齐。 + """ + huge = 10**4301 + with pytest.raises(ValueError, match="可选值: <整数,约 4302 位十进制>"): + ModelProviderFactory._get_model_detail("x", {huge: "v"}, "llm_configurations", "LLM") diff --git a/tests/providers/test_failure_contracts.py b/tests/providers/test_failure_contracts.py index 451543b..76be741 100644 --- a/tests/providers/test_failure_contracts.py +++ b/tests/providers/test_failure_contracts.py @@ -1,18 +1,31 @@ import asyncio +import base64 import importlib +import math +import struct from types import SimpleNamespace from unittest.mock import MagicMock +import openai import pytest import src.providers.factory as factory_module from src.providers.__base__.model_provider import ( CompletionRequest, LargeLanguageModel, + StreamEventError, + TextEmbeddingModel, + _responses_content_part, + is_retryable_error, iterate_async, + normalize_client_retries, + normalize_embedding_vector, raise_for_stream_error_event, + require_token_count, retry_async_stream, + retry_async_stream_context, retry_sync_stream, + retry_sync_stream_context, ) from src.providers.anthropic import AnthropicProvider from src.providers.factory import ModelProviderFactory @@ -179,6 +192,53 @@ def test_openai_compatible_provider_normalizes_hyphenated_settings(monkeypatch): assert provider._base_url == "http://localhost:1234/v1" +# ── 回归:空的 base_url 必须与「未配置」同义,而不是把请求打到相对路径 ── +# +# ``openai_compatible.__init__`` 用 ``getattr(settings, "_base_url")`` +# 取值,随后只判 ``if provider != "openai" and not self._base_url``——**openai +# 被显式排除在外**(它允许回落到 SDK 自带的默认地址)。而字段的默认值非空、 +# ``.env`` 里又有 ``OPENAI_API_BASE``,于是「显式置空」这条路径没人管: +# 实测 ``OPENAI_API_BASE=""`` 时 ``self._base_url`` 是空串(不是 ``None``), +# 判断 ``is None`` 的回落分支不触发,``openai.OpenAI(base_url="")`` 得到 +# ``URL('')``,请求 URL 变成相对路径 ``chat/completions``——没有主机名, +# 失败信息与「地址配错」毫无关系。 +# +# 判据要覆盖三个取值:``""``(显式置空)、``" "``(空白,SDK 会把它编码成 +# ``%20%20%20/``)、以及 ``None``(真正未配置,应当回落 SDK 默认)。只判 +# ``is None`` 会漏掉前两者,只判 falsy 又会把 ``None`` 一并拒掉。 +@pytest.mark.parametrize("blank_value", ["", " "]) +def test_openai_compatible_provider_rejects_blank_base_url(monkeypatch, blank_value): + fake_settings = SimpleNamespace( + openai_api_key="token", + openai_api_base=blank_value, + ) + monkeypatch.setattr("src.providers.openai_compatible.get_settings", lambda: fake_settings) + + with pytest.raises(ValueError) as excinfo: + OpenAICompatibleProvider("demo", "openai") + + message = str(excinfo.value) + assert "openai_api_base" in message + # 只查键名的话,一个把任意异常都包成同一句话的实现也能通过;消息必须 + # 指出「空值」这一真实原因。 + assert "空" in message + + +def test_openai_compatible_provider_still_defaults_when_base_url_is_unset(monkeypatch): + """反向守卫:``None`` 是「未配置」,必须继续回落 SDK 默认地址。 + + 把判据写成「一律拒绝空值」会连这条一起拒掉,而 ``openai`` 渠道依赖它。 + """ + fake_settings = SimpleNamespace(openai_api_key="token", openai_api_base=None) + monkeypatch.setattr("src.providers.openai_compatible.get_settings", lambda: fake_settings) + + provider = OpenAICompatibleProvider("demo", "openai") + + # 与「不传 base_url」的 SDK 客户端逐字节一致:``openai.base_url`` 这个模块级 + # 常量是 ``None``(不是默认地址),拿它作基准的话未修复的实现也能通过。 + assert provider._get_client().base_url == openai.OpenAI(api_key="token").base_url + + def test_openai_compatible_provider_forwards_tools(monkeypatch): fake_settings = SimpleNamespace( deepseek_api_key="token", deepseek_base_url="http://example.com" @@ -475,3 +535,937 @@ async def consume_one(): asyncio.run(consume_one()) assert closed == [True] + + +# ── 回归:错误分类不得被「看起来像数字」的 Unicode 码位打断 ── +# +# ``is_retryable_error`` 与 ``raise_for_stream_error_event`` 都用 ``str.isdigit()`` +# 判断 SDK/网关给的 status 是不是数字串。``isdigit()`` 对 128 个 Unicode 码位 +# 为真(``²`` ``①`` ``፩`` ``₅`` …)而 ``int()`` 对它们一律抛 ``ValueError``—— +# 于是一个形态奇怪的 status 值会让**错误分类本身**崩掉:本该重试的 5xx 变成 +# 一条解析异常,本该报出的流错误也变成一条解析异常。 +# +# 全码位穷举:``isdigit()`` 为真的 788 个码位里 128 个 ``int()`` 抛错; +# ``isdecimal()`` 为真的 660 个码位 ``int()`` 全部可解析。 + + +@pytest.mark.parametrize("status", ["²", "①", "፹", "₅", "4²0"]) +def test_is_retryable_error_survives_unparseable_digit_status(status): + """status 是「看起来像数字但 ``int()`` 解析不了」时必须给出布尔判断。""" + error = SimpleNamespace(status_code=status) + + assert is_retryable_error(error) is False + + +@pytest.mark.parametrize( + "status,expected", + [("503", True), ("429", True), ("404", False), ("200", False)], +) +def test_is_retryable_error_still_classifies_numeric_status(status, expected): + """收紧不能误伤:正常数字串的分类结果不得改变。""" + error = SimpleNamespace(status_code=status) + + assert is_retryable_error(error) is expected + + +@pytest.mark.parametrize("status", ["²", "①", "₅"]) +def test_stream_error_event_survives_unparseable_digit_status(status): + """流错误事件的 status 解析不了时必须照常报出流错误,而不是解析异常。""" + event = {"type": "error", "error": {"message": "overloaded", "status_code": status}} + + with pytest.raises(StreamEventError, match="overloaded") as excinfo: + raise_for_stream_error_event(event, "Test") + + assert excinfo.value.status_code is None + + +def test_stream_error_event_still_extracts_numeric_status(): + """收紧不能误伤:正常数字串仍要提取成 status_code。""" + event = {"type": "error", "error": {"message": "overloaded", "status_code": "503"}} + + with pytest.raises(StreamEventError) as excinfo: + raise_for_stream_error_event(event, "Test") + + assert excinfo.value.status_code == 503 + + +@pytest.mark.parametrize("status", ["²", "①", "4²0"]) +def test_chat_status_is_error_survives_unparseable_digit_status(status): + """网关响应体里的 status 解析不了时,错误分类必须照常给出布尔判断。 + + ``_chat_status_is_error`` 是 ``_raise_for_chat_response_error`` 的一环, + 用于识别「HTTP 200 但响应体里写着失败」的兼容网关。它崩掉意味着这类网关 + 的业务错误被误报成空文本。 + """ + assert OpenAICompatibleProvider._chat_status_is_error(status) is False + + +def test_chat_status_is_error_still_classifies_numeric_status(): + """收紧不能误伤:正常数字串的分类结果不得改变。""" + assert OpenAICompatibleProvider._chat_status_is_error("500") is True + assert OpenAICompatibleProvider._chat_status_is_error("200") is False + assert OpenAICompatibleProvider._chat_status_is_error("error") is True + + +def _huge_digits(): + import sys + + return "9" * (sys.get_int_max_str_digits() + 1) + + +def test_is_retryable_error_survives_digit_strings_beyond_the_conversion_limit(): + """超长数字 status 必须给出布尔判断,而不是让重试判定抛 ``ValueError``。""" + error = SimpleNamespace(status_code=_huge_digits()) + + assert is_retryable_error(error) is False + + +def test_stream_error_event_survives_digit_strings_beyond_the_conversion_limit(): + """超长数字 status 必须照常报出流错误,而不是解析异常。""" + event = {"type": "error", "error": {"message": "overloaded", "status_code": _huge_digits()}} + + with pytest.raises(StreamEventError, match="overloaded") as excinfo: + raise_for_stream_error_event(event, "Test") + + assert excinfo.value.status_code is None + + +def test_chat_status_is_error_survives_digit_strings_beyond_the_conversion_limit(): + """超长数字 status 必须给出布尔判断,而不是让错误分类抛 ``ValueError``。""" + assert OpenAICompatibleProvider._chat_status_is_error(_huge_digits()) is False + + +# ── 回归:token 计数响应只判了 None,形态异常会抛与「token 计数」无关的异常 ── +# +# 八个方法(Anthropic 同步/异步 × 普通/Beta、Google 同步/异步、OpenAI Responses +# 同步/异步)此前都是「判 None 后直接 ``int(value)``」。那只挡住了缺字段: +# SDK 或兼容网关返回 ``"abc"``/``1.5``/``[1]``/位数超限的数字串时,``int()`` +# 抛裸 ``ValueError``/``TypeError``/位数超限错误,调用方拿不到是哪个字段出的 +# 问题。非负整数才是这份数据的真实契约——token 计数不可能为负,也不可能带小数。 + + +@pytest.mark.parametrize( + "value,label", + [ + (None, "缺字段"), + ("abc", "非数值字符串"), + ("1.5", "浮点字符串"), + ("9" * 5000, "位数超限的数字串"), + (1.5, "浮点数"), + (float("inf"), "无穷大"), + (float("nan"), "NaN"), + ([1], "列表"), + ({"a": 1}, "映射"), + (True, "布尔值"), + (-5, "负数"), + ], +) +def test_require_token_count_reports_malformed_values(value, label): + """形态异常必须报成带 provider 与字段名的 ``RuntimeError``。""" + with pytest.raises(RuntimeError) as excinfo: + require_token_count(value, "Test", "total_tokens") + + message = str(excinfo.value) + assert "Test" in message, label + assert "total_tokens" in message, label + + +def test_require_token_count_none_reports_missing_field(): + """``None`` 是「字段缺失」,不是「值不是整数」——两者指向不同的排查方向。 + + 删除首条守卫后 ``None`` 落进 else 分支,消息变成「不是整数(NoneType)」: + 丢了「响应里根本没有这个字段」这一语义,调用方会去查值损坏而不是查网关 + 响应形状。既有参数化用例对 ``None`` 只匹配 provider 与字段名,两条消息都 + 含它们,变异后仍然变绿(变异扫描证实),必须钉住「缺少」前缀。 + """ + with pytest.raises(RuntimeError, match="Test token count 响应缺少 total_tokens"): + require_token_count(None, "Test", "total_tokens") + + +@pytest.mark.parametrize( + "value,expected", + [(0, 0), (12, 12), (12.0, 12), ("12", 12), ("0", 0)], +) +def test_require_token_count_accepts_valid_counts(value, expected): + """收紧不能误伤:正常计数照常通过(含整数值的浮点与数字字符串)。""" + assert require_token_count(value, "Test", "total_tokens") == expected + + +def test_require_token_count_never_raises_a_non_runtime_error(): + """契约是「要么返回 int,要么抛 RuntimeError」,不能漏出 ``OverflowError``。 + + ``int(float("inf"))`` 抛的是 ``OverflowError``——它既不是 ``ValueError`` 也 + 不是 ``TypeError``,任何按后者写的处理都接不住。 + + ``10**400`` 与 ``"9" * 5000`` 走的是**正数**方向,负数方向另有 + ``test_require_token_count_negative_branch_survives_unprintable_integer`` + 覆盖:那条路径上的消息格式化自己也会抛,是本文件里唯一一处「抛异常时再抛 + 异常」的站点。 + """ + for value in [float("inf"), float("-inf"), float("nan"), 10**400, "9" * 5000]: + try: + require_token_count(value, "Test", "total_tokens") + except RuntimeError: + pass + except BaseException as exc: + raise AssertionError(f"{value!r} 抛出了 {type(exc).__name__}: {exc}") from exc + + +# ``repr(10**4301)`` 自己抛 ``ValueError: Exceeds the limit (4300 digits)`` +# (CPython 的 ``sys.get_int_max_str_digits()``,默认 4300)。错误消息里凡是 +# 直接插值 ``{numeric}`` 的地方,都会在**正要报告一个错误时**改抛另一个错误, +# 把契约里承诺的 ``RuntimeError`` 换成与字段无关的 ``ValueError``。 +# +# 这条路径只有**负数**能走到:正数在 ``numeric < 0`` 之前就返回了,因此 +# 上面那条「永不抛非 RuntimeError」的用例(探针全是正数)碰不到它。实测把 +# ``safe_repr(numeric)`` 换回 ``{numeric}`` 后本用例变红。 +def test_require_token_count_negative_branch_survives_unprintable_integer(): + """负数分支的消息格式化不得自己抛异常。""" + huge_negative = -(10**4301) + + with pytest.raises(RuntimeError) as excinfo: + require_token_count(huge_negative, "Test", "total_tokens") + + message = str(excinfo.value) + assert "Test" in message + assert "total_tokens" in message + assert "Exceeds the limit" not in message, message + + +# --------------------------------------------------------------------------- +# 超出双精度范围的整数 +# --------------------------------------------------------------------------- +# +# ``math.isfinite(x)`` 与 ``float(x)`` 对任意大的 Python int 都会先尝试转成 +# C double,超出 ``DBL_MAX``(约 1.8e308)时抛 ``OverflowError: int too large +# to convert to float``。``OverflowError`` 是 ``ArithmeticError`` 的子类, +# **不是** ``ValueError``,会穿透这些方法承诺的异常契约与调用方的 +# ``except ValueError``。 +# +# 输入可达:``config.toml`` 的整数是无上界的 TOML 整数,``tomllib`` 原样解析 +# 成 Python int;provider 的 ``options`` 又是文档指定的扩展入口。provider +# 返回值一侧同理——网络 JSON 里出现一个超大整数字面量就能走到。 +# +# 参数 id 必须显式给出:``10**4301`` 超过 ``sys.get_int_max_str_digits()``, +# pytest 自动生成 id 时调 ``str(val)`` 会自己抛 ``ValueError``,测试在收集 +# 阶段就崩掉。 +_BEYOND_DOUBLE_INTS = [ + pytest.param(10**400, id="400digits"), + pytest.param(-(10**400), id="negative-400digits"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), +] + + +@pytest.mark.parametrize("value", _BEYOND_DOUBLE_INTS) +def test_normalize_embedding_vector_rejects_ints_beyond_double_range(value): + """SDK 返回的向量里出现超大整数时,必须报成 ``ValueError`` 而不是 ``OverflowError``。""" + with pytest.raises(ValueError, match="超出双精度范围"): + normalize_embedding_vector([1.0, value]) + + +def test_normalize_embedding_vector_still_passes_through_non_finite_floats(): + """只收敛 ``OverflowError``:``inf``/``nan`` 的既有契约不变。 + + base64 分支(``struct.unpack``)本来就可能产出 ``inf``/``nan``,有限性由下游 + ``EmbeddingService`` 的 ``np.isfinite`` 检查统一负责。在这里另加一道拒绝会让 + 两个分支的契约不一致。 + """ + assert normalize_embedding_vector([float("inf"), 1.0]) == [float("inf"), 1.0] + assert math.isnan(normalize_embedding_vector([float("nan")])[0]) + + +# `normalize_embedding_vector` 的 base64/类型分支有三条判据在整套测试里从未执行 +# (`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3323 条仍然全绿)。 +# 既有用例只喂 list 形态(走 `isinstance(value, (list, tuple))` 分支),三条都绕开了。 +# 删除后行为确实改变(实测,全在 `src/providers/__base__/model_provider.py`): +# * 1397 非法 base64 → `encoded` 未赋值,抛 `UnboundLocalError`(不是 +# `ValueError`,穿透 `EmbeddingService` 的 `except ValueError`); +# * 1399 不支持的类型 → 同样抛 `UnboundLocalError`,把「SDK 返回了奇怪类型」 +# 说成一个与类型无关的名字错误; +# * 1402 空字节串 → **静默返回空列表**(一个零维 embedding 被当成合法结果); +# 长度非 4 的倍数 → 裸 `struct.error: unpack requires a buffer of 4 bytes`。 +def test_normalize_embedding_vector_rejects_invalid_base64(): + """非法 base64 必须报 ``ValueError``,不是 ``UnboundLocalError``。""" + with pytest.raises(ValueError, match="base64 内容无效"): + normalize_embedding_vector("!!!not-base64!!!") + + +@pytest.mark.parametrize("value", [5, None, {"a": 1}, object()]) +def test_normalize_embedding_vector_rejects_unsupported_types(value): + """SDK 返回不支持的类型时必须给带类型名的 ``TypeError``,不是 ``UnboundLocalError``。""" + with pytest.raises(TypeError, match="不支持的类型"): + normalize_embedding_vector(value) + + +@pytest.mark.parametrize( + "value", [b"", b"12345", b"\x00\x00\x00"], ids=["empty", "not-multiple-of-4", "three-bytes"] +) +def test_normalize_embedding_vector_rejects_non_float32_byte_lengths(value): + """字节长度不是 4 的倍数时必须 ``ValueError``,不能静默返回空列表或抛 ``struct.error``。""" + with pytest.raises(ValueError, match="不是有效的 float32 字节序列"): + normalize_embedding_vector(value) + + +def test_normalize_embedding_vector_accepts_valid_base64_and_bytes(): + """收紧不能误伤:合法的 base64 与字节串照常解出浮点列表。""" + encoded = base64.b64encode(struct.pack("<2f", 1.5, -2.5)).decode() + + assert normalize_embedding_vector(encoded) == [1.5, -2.5] + assert normalize_embedding_vector(struct.pack("<2f", 1.5, -2.5)) == [1.5, -2.5] + + +@pytest.mark.parametrize("value", _BEYOND_DOUBLE_INTS) +def test_responses_video_fps_beyond_double_range_is_explicit(value): + """Ark Responses 的 ``fps`` 超出双精度范围时必须是 ``ValueError``。""" + with pytest.raises(ValueError, match=r"fps 必须是有限数字"): + _responses_content_part( + { + "type": "input_video", + "video_url": {"url": "https://example.invalid/v.mp4"}, + "fps": value, + }, + "params", + provider="ark", + ) + + +# ── 回归:Ark Responses 失败出口的 message 形态 ── +# +# ``VolcengineProvider._raise_for_response_error`` 的 ``status == "failed"`` +# 分支写 ``raise RuntimeError(str(message))``,``message`` 是网关 JSON 的 +# ``error``(或它的 ``message`` 字段)。位数超限的整数让裸 ``str()`` 抛 +# ``ValueError: Exceeds the limit``——**正要报告网关错误**的那一步变成整数转换 +# 错误,而调用方按 ``RuntimeError`` 分类。同函数的 ``incomplete`` 分支与 +# ``openai_compatible._error_message`` 都已按 ``safe_repr`` 收敛,这一支漏了。 + + +@pytest.mark.parametrize( + "response", + [ + pytest.param({"status": "failed", "error": 10**4301}, id="huge-int"), + pytest.param({"status": "failed", "error": {"message": 10**4301}}, id="huge-int-message"), + ], +) +def test_ark_responses_failed_reports_malformed_error_readably(response): + """非字符串 ``error`` 不得让「报告网关错误」自己抛裸异常。""" + with pytest.raises(RuntimeError) as excinfo: + VolcengineProvider._raise_for_response_error(response) + + assert "Exceeds the limit" not in str(excinfo.value) + + +def test_ark_responses_failed_still_reports_string_error(): + """收紧不能误伤:字符串 ``error`` 仍原样出现在消息里。""" + with pytest.raises(RuntimeError, match="boom"): + VolcengineProvider._raise_for_response_error({"status": "failed", "error": "boom"}) + + +# ── 回归:流错误事件兜底用裸 ``code`` 插值 ── +# +# ``raise_for_stream_error_event`` 在 message 提取失败后回落到 +# ``field(error, "code") or f"{provider} 流返回错误"``。``code`` 来自网关 JSON, +# 形态不受控:位数超限的整数让 f-string 抛 ``ValueError: Exceeds the limit``—— +# 正要报出流错误的那一步变成整数转换错误,而调用方按 ``StreamEventError`` 分类。 +# 同函数提取 ``message`` 的那条路径只收字符串,这一支漏了。 + + +@pytest.mark.parametrize( + "code", + [pytest.param(10**4301, id="huge-int"), pytest.param(["a"], id="list")], +) +def test_stream_error_event_reports_malformed_code_readably(code): + """非字符串 ``code`` 不得让「报告流错误」自己抛裸异常。""" + event = {"type": "error", "error": {"code": code}} + + with pytest.raises(StreamEventError) as excinfo: + raise_for_stream_error_event(event, "Test") + + assert "Exceeds the limit" not in str(excinfo.value) + + +def test_stream_error_event_still_reports_string_code(): + """收紧不能误伤:字符串 ``code`` 仍原样出现在消息里。""" + event = {"type": "error", "error": {"code": "invalid_api_key"}} + + with pytest.raises(StreamEventError, match="invalid_api_key"): + raise_for_stream_error_event(event, "Test") + + +# ── 回归:错误分类不得被「形态奇怪的 status」打断(容器持大整数)── +# +# ``_chat_status_is_error`` 的 docstring 明确承诺「一个形态奇怪的 status 值不能 +# 让错误分类本身崩掉」,它也确实挡住了「字符串形态但位数超限」的取值。但收敛 +# 只做了一半:非 ``int`` 的非字符串走 ``str(status).strip().lower()``,而 +# ``str([10**4301])`` 自己抛 ``ValueError: Exceeds the limit``——于是 +# 「网关在响应体里返回了业务错误」变成一条整数转换错误,调用方的错误分类与日志 +# 脱敏都接不住。``status`` 来自网关 JSON,形态不受控。 + + +@pytest.mark.parametrize( + "status", + [ + pytest.param([10**4301], id="list-with-huge-int"), + pytest.param({"code": 10**4301}, id="dict-with-huge-int"), + ], +) +def test_chat_status_is_error_survives_container_with_huge_int(status): + """容器里含位数超限整数时,错误分类必须照常给出布尔判断。""" + assert OpenAICompatibleProvider._chat_status_is_error(status) is False + + +def test_chat_response_error_reports_container_status_readably(): + """从 ``_raise_for_chat_response_error`` 进入时同样不抛整数转换错误。""" + response = {"status": [10**4301], "choices": None} + + with pytest.raises(RuntimeError) as excinfo: + OpenAICompatibleProvider._raise_for_chat_response_error(response) + + assert "Exceeds the limit" not in str(excinfo.value) + + +# ── 回归:provider 构造器对非字符串 model_name 无形态守卫 ── +# +# 六个 provider 的 ``__init__`` 都把 ``model_name`` 原样存下、不做形态判据: +# * google / anthropic / jina / siliconflow_rerank / openai_compatible 在 +# ``logger.info(f"...模型: {model_name}")`` 处抛裸 +# ``ValueError: Exceeds the limit``(位数超限的整数),而该消息与「模型名 +# 写错了」无关;列表/``None`` 则静默通过,原样发给 SDK; +# * volcengine 连日志都没有,非字符串全程静默接受。 +# +# 这些类都是**公开类**,嵌入方可以绕过工厂直接构造(本仓测试大量这么用),而 +# 工厂路径已用 ``require_model_name`` 收敛(``cc10585``)。同一个 ``model_name`` +# 走工厂得到带指引的 ``ValueError``,直接构造却得到整数转换错误或静默接受—— +# 判据只做了一半。 + + +def _model_name_settings(): + return SimpleNamespace( + openai_api_key="k", + openai_api_base="https://api.openai.com/v1", + anthropic_api_key="k", + google_api_key="k", + jina_api_key="k", + siliconflow_api_key="k", + siliconflow_base_url="https://api.siliconflow.cn/v1", + ark_api_key="k", + volc_access_key="ak", + volc_secret_key="sk", + volc_base_url="https://ark.cn-beijing.volces.com/api/v3", + ) + + +def _constructible_providers(monkeypatch): + """构造六个 provider 的工厂,各自用最小的合法凭证设置。 + + provider **类**必须从本次新导入的模块取(而不是文件顶部的导入): + ``tests/providers/test_factory.py`` 会把 ``src.providers.*`` 从 ``sys.modules`` + 里删掉再重新导入,于是文件顶部那个类属于**旧**模块对象,而 + ``__init__`` 仍从旧模块字典取 ``get_settings``——patch 落在新模块上,类却读 + 旧模块,全量运行时读到真实配置(无 key)后抛 ``API_KEY is required``。单独跑 + 本文件不触发,只有全量运行才暴露。 + """ + settings = _model_name_settings() + cases = [ + ("google", "GoogleProvider", lambda cls, v: cls(v)), + ("anthropic", "AnthropicProvider", lambda cls, v: cls(v)), + ("jina", "JinaProvider", lambda cls, v: cls(v)), + ("siliconflow_rerank", "SiliconflowRerankProvider", lambda cls, v: cls(v)), + ("volcengine", "VolcengineProvider", lambda cls, v: cls(v)), + ( + "openai_compatible", + "OpenAICompatibleProvider", + lambda cls, v: cls(v, "openai", settings=settings), + ), + ] + factories = [] + for module_name, class_name, build in cases: + module = importlib.import_module(f"src.providers.{module_name}") + monkeypatch.setitem(module.__dict__, "get_settings", lambda: settings) + provider_class = getattr(module, class_name) + factories.append((class_name, lambda v, build=build, cls=provider_class: build(cls, v))) + return factories + + +@pytest.mark.parametrize( + "bad_name", + [pytest.param(10**4301, id="huge-int"), pytest.param(["m"], id="list")], +) +def test_provider_constructors_reject_non_string_model_name_readably(monkeypatch, bad_name): + """非字符串 model_name 必须给出带字段名的 ``ValueError``,不得静默接受。""" + for label, build in _constructible_providers(monkeypatch): + with pytest.raises(ValueError) as excinfo: + build(bad_name) + message = str(excinfo.value) + assert "Exceeds the limit" not in message, f"{label}: {message}" + assert "model_name" in message, f"{label}: {message}" + + +def test_provider_constructors_still_accept_string_model_names(monkeypatch): + """收紧不能误伤:字符串 model_name 照常构造。""" + for label, build in _constructible_providers(monkeypatch): + build("demo-model") + + +# ── 回归:OpenAI 兼容构造器的 ``provider`` 参数无形态守卫 ── +# +# 与 ``model_name`` 同一处、同一类:``__init__`` 里 ``provider.lower()`` 直接调用, +# 非字符串抛裸 ``AttributeError: 'list' object has no attribute 'lower'``。而 +# ``provider`` 随后要拼出 ``{provider}_api_key`` 字段名——本类的其余拒绝路径 +# (缺 key/缺 base_url/协议非法)都给带指引的 ``ValueError``。工厂路径已用 +# ``require_provider_name`` 收敛,直接构造这条路径漏了。 + + +@pytest.mark.parametrize( + "provider", + [ + pytest.param(10**4301, id="huge-int"), + pytest.param(["openai"], id="list"), + pytest.param(None, id="none"), + ], +) +def test_openai_compatible_rejects_non_string_provider_readably(provider): + """非字符串 provider 必须给出带字段名的 ``ValueError``。""" + settings = _model_name_settings() + + with pytest.raises(ValueError) as excinfo: + OpenAICompatibleProvider("demo", provider, settings=settings) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "provider" in message, message + + +def test_openai_compatible_still_accepts_string_provider(): + """收紧不能误伤:字符串 provider 照常构造。""" + settings = _model_name_settings() + + provider = OpenAICompatibleProvider("demo", "openai", settings=settings) + + assert provider._provider == "openai" + + +# ── 客户端重试次数、流形态与 embedding 结果判据 ── +# +# 三条判据在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` +# 后全量 3405 条仍全绿)。删除后行为确实改变(实测): +# * ``normalize_client_retries`` 的兜底分支(None/列表等既非 bool、str、int、 +# float 的取值)→ ``numeric`` 从未赋值,漏出裸 ``UnboundLocalError: +# cannot access local variable 'numeric'``,而调用方的错误分类按 +# ``ValueError`` 写; +# * ``iterate_async`` 的非迭代器分支 → **静默产出空序列**,SDK 返回了无法消费 +# 的对象却被当成「流正常结束」,模型答了什么内容完全丢失; +# * ``TextEmbeddingModel.embed_query``/``aembed_query`` 的空结果判据 → 裸 +# ``IndexError: list index out of range``,把「provider 没返回向量」说成 +# 「下标越界」。 + + +@pytest.mark.parametrize( + "value", + [None, [1], {"a": 1}, (1, 2), object()], + ids=["none", "list", "dict", "tuple", "object"], +) +def test_normalize_client_retries_rejects_non_numeric_types_readably(value): + """非数值形态必须报成 ``ValueError``,不能漏出 ``UnboundLocalError``。""" + with pytest.raises(ValueError, match="max_retries 必须是整数"): + normalize_client_retries(value, "Test") + + +@pytest.mark.parametrize("value", [3, 0, "3", 2.0]) +def test_normalize_client_retries_still_accepts_legal_values(value): + """反向守卫:合法取值(含十进制字符串与整值浮点)照常归一。""" + assert normalize_client_retries(value, "Test") == int(float(value)) + + +def test_iterate_async_rejects_non_iterable_stream_response(): + """SDK 流式响应既不是异步也不是同步迭代器时必须报错,不能静默返回空序列。""" + + async def consume(): + return [item async for item in iterate_async(42)] + + with pytest.raises(TypeError, match="既不是异步迭代器也不是同步迭代器"): + asyncio.run(consume()) + + +class _EmptyEmbedding(TextEmbeddingModel): + """返回空结果的 embedding provider——模拟 SDK 静默失败。""" + + def embed_documents(self, texts, **_kwargs): + return [] + + async def aembed_documents(self, texts, **_kwargs): + return [] + + +def test_embed_query_reports_empty_provider_result_readably(): + """provider 返回空列表必须报成 ``ValueError``,不能是裸 ``IndexError``。""" + with pytest.raises(ValueError, match="返回空结果"): + _EmptyEmbedding().embed_query("q") + + +def test_aembed_query_reports_empty_provider_result_readably(): + """异步入口同判据。""" + with pytest.raises(ValueError, match="返回空结果"): + asyncio.run(_EmptyEmbedding().aembed_query("q")) + + +@pytest.mark.parametrize("status", [400, "400"]) +def test_chat_status_is_error_includes_status_400_itself(status): + """400 本身算错误状态(``>= 400`` 不是 ``> 400``)。""" + assert OpenAICompatibleProvider._chat_status_is_error(status) is True + assert OpenAICompatibleProvider._chat_status_is_error(399) is False + + +# ── 回归:流关闭失败不得顶替流错误,也不得污染重试分类 ── +# +# ``iterate_async``/``retry_async_stream``/``retry_sync_stream`` 的 ``finally`` +# 都负责关闭迭代器。``finally`` 里抛出的异常会**替换**掉 ``try`` 中正在上抛的 +# 那个,于是「流中途断开」这类可重试错误被「关闭失败」这类不可重试错误顶替: +# tenacity 的 ``is_retryable_error`` 按类名判据(含 "connection")会把它当成 +# 可重试,实测一次 ``ConnectionError`` 只尝试了 1 次就放弃。 + + +class _StreamThatFailsOnClose: + """迭代中途抛可重试错误、关闭时抛不可重试错误的流。""" + + def __init__(self, iteration_error, close_error): + self._iteration_error = iteration_error + self._close_error = close_error + + def __aiter__(self): + return self + + async def __anext__(self): + raise self._iteration_error + + async def aclose(self): + raise self._close_error + + +def test_iterate_async_keeps_iteration_error_when_close_also_fails(): + """关闭异常不能顶替流消费异常——原始错误必须上抛。 + + 实测原实现抛出的是 ``关闭也失败``,``流中途断开`` 只留在 ``__context__`` + 里;调用方按异常类型分类(可重试/不可重试、是否报流错误)时拿到的是 + 关闭错误,真正的失败原因被掩盖。 + """ + stream = _StreamThatFailsOnClose( + iteration_error=ConnectionError("流中途断开"), + close_error=RuntimeError("关闭也失败"), + ) + + async def consume(): + async for _ in iterate_async(stream): + pass + + with pytest.raises(ConnectionError, match="流中途断开"): + asyncio.run(consume()) + + +def test_retry_async_stream_retries_retryable_error_when_close_fails(): + """关闭失败不得把可重试错误变成不可重试——重试次数必须保持。 + + 实测原实现只尝试 1 次:``finally`` 的 ``关闭失败(不可重试)`` 顶替了 + ``连接中断(可重试)``,tenacity 的类名判据(含 "connection")认不出它。 + """ + calls = [] + + async def factory(): + calls.append(1) + return _StreamThatFailsOnClose( + iteration_error=ConnectionError("连接中断(可重试)"), + close_error=ValueError("关闭失败(不可重试)"), + ) + + async def consume(): + async for _ in retry_async_stream(factory): + pass + + with pytest.raises(ConnectionError, match="连接中断(可重试)"): + asyncio.run(consume()) + + assert len(calls) == 3 + + +def test_retry_sync_stream_keeps_iteration_error_when_close_also_fails(): + """同步流同一契约:关闭异常不能顶替消费异常。 + + 迭代器必须自带 ``close``:``retry_sync_stream`` 关闭的是 ``iter(factory())`` + 的返回值,若工厂返回生成器,拿到的是生成器自带的 ``close``,本用例要 + 覆盖的关闭路径就永远不会被走到。 + """ + + class SyncStreamThatFailsOnClose: + def __init__(self): + self._emitted = False + + def __iter__(self): + return self + + def __next__(self): + if not self._emitted: + self._emitted = True + return "first" + raise ConnectionError("同步流中途断开") + + def close(self): + raise RuntimeError("同步关闭也失败") + + def consume(): + for _ in retry_sync_stream(lambda: SyncStreamThatFailsOnClose()): + pass + + with pytest.raises(ConnectionError, match="同步流中途断开"): + consume() + + +def test_retry_sync_stream_closes_underlying_response_not_only_wrapper(): + """``iter()`` 得到的是包装生成器时,必须关闭底层 SDK 响应对象。 + + openai/anthropic 的 ``Stream.__iter__`` 是生成器函数——``iter(stream)`` + 拿到的是包装生成器而非流本身,``close()`` 它只终止包装层, + ``response.close()`` 从不被调;消费方提前退出时连接释放退化为 GC 时机 + (实测复现,对照 async 侧经 ``iterate_async`` 是确定性关闭)。忠实复刻 + 该形态:``__iter__`` 用生成器函数,``close`` 记录在响应对象上。 + """ + + class FakeResponse: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + def __iter__(self): + # 与 openai.Stream.__iter__ 同构:生成器函数,不是 ``return self``。 + yield "item" + yield "item2" + + response = FakeResponse() + stream = retry_sync_stream(lambda: response) + assert next(stream) == "item" + stream.close() # 消费方提前退出 + + assert response.closed is True + + +def test_retry_sync_stream_validator_failure_keeps_original_error(): + """首帧校验失败时,``close`` 抛异常不得顶替原始异常(双向污染重试分类)。 + + 实测:首帧是可重试 429 事件且 ``close`` 抛错时,tenacity 只尝试 1 次—— + 原始可重试异常被不可重试的关闭异常顶替;首帧是不可重试 ``Boom`` 时反向 + 被重发 3 次。``open_and_peek`` 的清理必须吞掉自己的失败。 + """ + + from src.providers.__base__.model_provider import retry_sync_stream as retry + + class Boom(Exception): + """不可重试的原始异常。""" + + class CloseFail(Exception): + """替身 close 的异常——同样不可重试。""" + + class BoomStream: + closed = False + + def close(self): + self.closed = True + raise CloseFail("关闭失败") + + def __iter__(self): + return self + + def __next__(self): + raise Boom("原始错误") + + # 原始不可重试异常必须原样上抛——裸 close() 的替身场景下,关闭异常 + # 不得顶替它(反向污染重试分类:顶替成可重试类时会被重发 3 次)。 + stream = BoomStream() + with pytest.raises(Boom) as excinfo: + list(retry(lambda: stream, lambda _first: None)) + assert "原始错误" in str(excinfo.value) + assert stream.closed is True # 清理确实执行了 + + +def test_retry_sync_stream_validator_failure_closes_underlying_response(): + """建立阶段 validator 失败时也必须关底层 response,且每次重试都关。 + + openai/anthropic 的 ``Stream.__iter__`` 是生成器函数——包装形态下 + ``iterator.close()`` 只终止包装层。validator 拒绝正是 429 重试路径: + 修复前 3 次重试各泄漏一条未关闭连接(实测 ``[False, False, False]``)。 + """ + closed = [] + + class FakeResponse: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + def __iter__(self): + # 生成器函数形态,与 openai.Stream.__iter__ 同构——不是 return self。 + yield "first" + yield "second" + + def factory(): + response = FakeResponse() + closed.append(response) + return response + + def validator(_first): + raise ConnectionError("429") + + with pytest.raises(ConnectionError): + list(retry_sync_stream(factory, validator)) + + assert [response.closed for response in closed] == [True, True, True] + + +def test_retry_sync_stream_closes_response_even_when_iterator_close_raises(): + """iterator.close 抛错不得让 response 补刀被跳过(第三轮 F2)。 + + 两段 close 原先共用一个 try:iterator.close 抛错时 response.close 被 + 跳过——中途断开 + 包装生成器形态下每次泄漏一条底层连接(实测 log 无 + ``rclose``)。修成两段独立 try 后补刀必须执行;无在途异常时 iterator + 的清理异常仍按原契约冒出。 + """ + + class CloseFail: + pass + + class FakeResponse: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + def __iter__(self): + # 生成器函数形态——iter(self) 得到独立包装生成器。 + yield "a" + raise ConnectionError("中途断开") + + response = FakeResponse() + + def factory(): + return response + + stream = retry_sync_stream(factory) + # 让包装生成器的 close 抛错:手动包装 iter() 的产物。 + # retry_sync_stream 内部 iter(factory()) 拿到生成器,无法注入—— + # 改为直接验证「response.close 在 iterator.close 抛错后仍执行」的 + # 最小等价场景:monkeypatch generator close。 + # 简化:借助 iterator 抛 ConnectionError 进入 finally,检查 response 关闭。 + with pytest.raises(ConnectionError): + next(stream) + stream.throw(ConnectionError("外层断开")) + assert response.closed is True + + +def test_retry_sync_stream_context_enter_close_failure_keeps_original_error(): + """context 入口 ``__enter__`` 失败时 manager.close 抛错不得顶替原始异常(第三轮 F1)。 + + 实测:可重试 429 被 close 的 ``Boom`` 顶替后 tenacity 只尝试 1 次, + 重试链被斩断——与 ``open_and_peek`` 修掉的是同一形态。 + """ + + class Boom(Exception): + pass + + class FakeStatus(Exception): + status_code = 429 + + class Manager: + closed = False + + def __enter__(self): + raise FakeStatus("429") + + def close(self): + self.closed = True + raise Boom("close failed") + + attempts = [] + + def factory(): + attempts.append(1) + return Manager() + + with pytest.raises(FakeStatus) as excinfo, retry_sync_stream_context(factory): + pass + assert "429" in str(excinfo.value) + assert len(attempts) == 3 # 429 仍被重试满 + + +def test_retry_async_stream_context_enter_aclose_failure_keeps_original_error(): + """async context 入口同契约:``aclose`` 抛错不顶替原始 429。""" + + class Boom(Exception): + pass + + class FakeStatus(Exception): + status_code = 429 + + class AManager: + closed = False + + async def __aenter__(self): + raise FakeStatus("429") + + async def aclose(self): + self.closed = True + raise Boom("aclose failed") + + attempts = [] + + def factory(): + attempts.append(1) + return AManager() + + async def run(): + async with retry_async_stream_context(factory): + pass + + with pytest.raises(FakeStatus) as excinfo: + asyncio.run(run()) + assert "429" in str(excinfo.value) + assert len(attempts) == 3 + + +def test_retry_sync_stream_response_close_runs_even_when_iterator_close_raises(): + """正常耗尽 + iterator.close 抛错(无在途异常)时 response 补刀必须执行。 + + 第三轮的两段独立 try 修好了「iterator 抛错跳过补刀」的中途断开方向, + 但 re-raise(``not in_flight`` 时上抛 iterator 清理异常)会跳过顺序结构 + 中位于其后的 response 补刀——换方向复现同一泄漏(实测 log 只有 + ``iclose``)。补刀移入外层 finally 后无条件执行。 + """ + + class IteratorCloseFail: + def __iter__(self): + return self + + def __next__(self): + raise StopIteration + + def close(self): + raise RuntimeError("close boom") + + class FakeResponse: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + def __iter__(self): + return IteratorCloseFail() + + response = FakeResponse() + stream = retry_sync_stream(lambda: response) + + with pytest.raises(RuntimeError, match="close boom"): + for _ in stream: + pass + + assert response.closed is True diff --git a/tests/providers/test_google_genai.py b/tests/providers/test_google_genai.py index 660d953..45bbed2 100644 --- a/tests/providers/test_google_genai.py +++ b/tests/providers/test_google_genai.py @@ -59,6 +59,33 @@ def test_google_provider_uses_credentials_without_api_key(monkeypatch): client_class.assert_called_once_with(credentials=credentials, vertexai=True) +def test_google_provider_loads_credentials_file_and_forwards_it(monkeypatch): + """设置里的凭证文件路径加载出的凭证必须传给 SDK(``"credentials" not in client_options``)。 + + ``in`` 变异后仅在 ``client_options`` 已有 credentials 时才赋值——文件加载 + 出的凭证被静默丢弃,SDK 落回 ADC/API key 路径,而配置看起来完全正常。 + """ + credentials = object() + settings = MagicMock() + settings.google_api_key = None + settings.google_application_credentials = "/tmp/credentials.json" + monkeypatch.setattr(google_module, "get_settings", lambda: settings) + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + monkeypatch.setattr( + GoogleProvider, "_load_credentials_file", staticmethod(lambda path: credentials) + ) + with patch.object(google_module.genai, "Client") as client_class: + client_class.return_value = MagicMock() + provider = GoogleProvider( + model_name="gemini-1.5-flash", + options={"vertexai": True}, + ) + + provider._get_client() + + client_class.assert_called_once_with(credentials=credentials, vertexai=True) + + def test_google_provider_accepts_gemini_api_key_alias(monkeypatch): settings = SimpleNamespace(google_api_key=None, gemini_api_key="gemini-key") monkeypatch.setattr(google_module, "get_settings", lambda: settings) @@ -188,6 +215,9 @@ def test_google_get_client_requires_explicit_auth_without_key_or_vertex_mode(mon {"base_url": "https://proxy.invalid/v1?api_key=secret"}, {"base_url": "https://user:password@proxy.invalid/v1"}, {"httpx_client": object()}, + # 长键名尾缀凭证:``normalized_key`` 若经 ``safe_repr`` 规范化,80 字符 + # 截断会把超长键名的 ``apikey`` 后缀切掉,``endswith`` 判据失配而放行。 + {"headers": {("x-" + "tooling" * 15 + "apikey"): "secret"}}, ], ) def test_google_http_options_reject_credentials_or_custom_clients(http_options): @@ -242,6 +272,91 @@ def test_google_http_options_reject_connection_override_containers(): ) +class _ModelDumpRaisesTypeError: + """``model_dump(exclude_none=True)`` 抛 ``TypeError`` 的替身。 + + 真实 ``google-genai`` 的 HttpOptions 接受 ``exclude_none``;这里模拟的是 + SDK 签名变化或第三方对象只实现无参 ``model_dump`` 的情形。 + """ + + def model_dump(self, *args, **kwargs): + raise TypeError("model_dump() got an unexpected keyword argument 'exclude_none'") + + +class _ModelDumpRaisesOtherError: + def model_dump(self, *args, **kwargs): + raise KeyError("boom") + + +class _ModelDumpReturnsScalar: + def model_dump(self, *args, **kwargs): + return 5 + + +class _ModelDumpTakesNoArguments: + """无参 ``model_dump``:第一次带参调用抛 ``TypeError``,第二次成功。""" + + def model_dump(self, *args, **kwargs): + if args or kwargs: + raise TypeError("takes 1 positional argument but 2 were given") + return {"headers": {"X-Trace": "ok"}} + + +# ``_validate_http_options`` 有两条输入路径:Mapping(直接 ``dict()``)与 +# 对象(走 ``model_dump``)。两条路径上的形态判据在整套测试里从未执行—— +# 既有用例只喂合法 Mapping 或真实 ``types.HttpOptions``。把它们的 ``raise`` +# 换成 ``pass`` 后全量 3353 条仍全绿(只有 585 那条被 +# ``test_google_http_options_reject_nested_non_string_key`` 钉住)。 +@pytest.mark.parametrize( + "value", + [5, "abc", [1, 2], object()], + ids=["int", "str", "list", "object"], +) +def test_google_http_options_reject_non_mapping_without_model_dump(value): + """非 Mapping 且无 ``model_dump`` 必须给出可读消息。 + + 删除守卫后取值落到 ``model_dump`` 分支,得到「无法读取」——把一个**类型 + 错误**说成**读取失败**,照提示排查的人会去查权限而不是配置形态。 + """ + with pytest.raises(ValueError, match="必须是对象表或 google-genai HttpOptions"): + GoogleProvider._validate_http_options(value) + + +def test_google_http_options_reports_model_dump_failure_readably(): + """``model_dump`` 抛异常时必须收敛成 ``ValueError``,不能漏出 ``UnboundLocalError``。 + + 删除两条 ``except`` 里的守卫后,``payload`` 从未赋值,异常从 + 「无法读取」退化成裸 ``UnboundLocalError: cannot access local variable + 'payload'``——调用方按 ``ValueError`` 分类,接不住它。 + """ + with pytest.raises(ValueError, match="无法读取"): + GoogleProvider._validate_http_options(_ModelDumpRaisesOtherError()) + + +def test_google_http_options_falls_back_to_no_argument_model_dump(): + """带参 ``model_dump`` 抛 ``TypeError`` 时回退到无参调用,不能吞掉合法对象。 + + 回退成功仍走完整校验,因此这里是**反向守卫**:合法对象照常通过。 + """ + GoogleProvider._validate_http_options(_ModelDumpTakesNoArguments()) + + +def test_google_http_options_reports_dump_failure_after_fallback(): + """回退(无参)也失败时必须给「无法读取」,不能漏出 ``UnboundLocalError``。""" + with pytest.raises(ValueError, match="无法读取"): + GoogleProvider._validate_http_options(_ModelDumpRaisesTypeError()) + + +def test_google_http_options_reject_model_dump_returning_non_mapping(): + """``model_dump`` 返回非 Mapping 必须显式报错,而不是裸 ``TypeError``。 + + 删除守卫后 ``"timeout" in payload`` 对整数抛裸 + ``TypeError: argument of type 'int' is not iterable``。 + """ + with pytest.raises(ValueError, match="http_options 必须是对象"): + GoogleProvider._validate_http_options(_ModelDumpReturnsScalar()) + + def test_google_invoke_non_stream_refusal_is_explicit_error(): provider = GoogleProvider(model_name="gemini-1.5-flash") response = SimpleNamespace( @@ -442,6 +557,12 @@ def test_google_provider_invoke_non_stream(mock_settings, mock_genai_client): with patch.object(GoogleProvider, "_get_client", return_value=mock_genai_client): mock_response = MagicMock() mock_response.text = "Hello world" + # ``candidates`` 必须显式给成列表:真 SDK 的字段类型是 + # ``list[Candidate] | None``,而裸 ``MagicMock`` 的属性是一个可迭代的 + # ``MagicMock``(``iter()`` 返回空迭代器)——旧代码只做 ``or []`` 与迭代, + # 这种不真实形态恰好能通过。响应容器的形态守卫(google.py 的 + # ``_require_candidates``)会把它拒掉,因此夹具要写成真实形状。 + mock_response.candidates = [] mock_genai_client.models.generate_content.return_value = mock_response result = list(provider.invoke("test prompt", stream=False)) @@ -498,3 +619,193 @@ async def consume() -> None: ) asyncio.run(consume()) + + +# ── 回归:partial_args 的 json_path 索引位数超限时不得崩 ── +# +# ``_partial_arg_tokens`` 用 ``re.match(r"^\[(\d+)\]", rest)`` 取索引,再 +# ``int(indexed.group(1))``。``\d+`` 对任意长度的数字串都匹配,而 +# ``int("9" * 4301)`` 撞上 CPython 的 ``sys.get_int_max_str_digits()`` +# (默认 4300)抛 ``ValueError: Exceeds the limit``——异常类型是本函数契约里的 +# ``ValueError``,但**内容**是整数转换问题,与「json_path 不合法」无关,同函数 +# 另外两条拒绝分支都带着原始 ``json_path`` 与指引。 +# +# json_path 来自模型的流式 function call 分片(``partial_args``),形态不受控; +# 同一份响应里普通路径(``$.a``/``$[1]``)都能得到可读错误,只有这一支例外。 + + +@pytest.mark.parametrize( + "json_path", + ["$[" + "9" * 4301 + "]", "$[" + "9" * 5000 + "]"], + ids=["4301digits", "5000digits"], +) +def test_google_partial_args_rejects_oversized_index_readably(json_path): + """超长索引必须给出带 json_path 的 ``ValueError``,不是整数转换超限。""" + with pytest.raises(ValueError) as excinfo: + GoogleProvider._partial_arg_tokens(json_path) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "json_path" in message + + +def test_google_partial_args_still_accepts_normal_paths(): + """收紧不能误伤:普通 JSONPath 子集照常解析。""" + assert GoogleProvider._partial_arg_tokens("$.a[0].b") == ["a", 0, "b"] + assert GoogleProvider._partial_arg_tokens("$['k'][2]") == ["k", 2] + assert GoogleProvider._partial_arg_tokens("$") == [] + + +# ── 回归:嵌套路径的中间容器类型由**下一个** token 决定 ── +# +# ``_partial_args_to_mapping`` 在写入中途遇到「尚未创建的中间容器」时,用 +# ``isinstance(tokens[position + 1], int)`` 决定该填 ``[]`` 还是 ``{}``——路径 +# 下一段是数组下标就建 list,是对象键就建 dict。这条判据此前**从未被任何测试 +# 触达**:既有用例的 json_path 全是单段(``$.a[0]``/``$.country``),走到那 +# 一步时 ``position`` 已是最后一个 token(``last=True``),根本不会读 +# ``tokens[position + 1]``;只有 ``$.a[0][1]``/``$.a[0].b`` 这类**至少三段** +# 的路径才经过它。把 ``position + 1`` 变异成 ``position - 1``(读上一段而非 +# 下一段的类型)时整套测试仍全绿,而真实行为已经改变——实测 ``$.a[0][1]`` +# 从 ``{'a': [[None, 'x']]}`` 变成抛 ``ValueError``(把内层 list 建成了 dict, +# 写入时 ``isinstance(current, list)`` 判否)。 +def test_google_partial_args_nested_path_builds_container_from_next_token(): + """中间容器的类型看**下一段**路径:数组下标建 list,对象键建 dict。""" + # ``$.a[0][1]``:在 ``a[0]`` 处下一段是下标 ``1`` -> 该位置建 list。 + assert GoogleProvider._partial_args_to_mapping( + [{"json_path": "$.a[0][1]", "string_value": "x"}] + ) == {"a": [[None, "x"]]} + # ``$.a[0].b``:在 ``a[0]`` 处下一段是键 ``b`` -> 该位置建 dict。 + assert GoogleProvider._partial_args_to_mapping( + [{"json_path": "$.a[0].b", "string_value": "x"}] + ) == {"a": [{"b": "x"}]} + # 更深一层:``$.a[0][1][2]`` 每段都按紧随其后的 token 建对应容器。 + assert GoogleProvider._partial_args_to_mapping( + [{"json_path": "$.a[0][1][2]", "string_value": "x"}] + ) == {"a": [[None, [None, None, "x"]]]} + + +def test_google_partial_args_nested_index_after_existing_list(): + """中间容器已存在时按既有形态写入,不重建——区分「新建」与「复用」。""" + # 同一份 partial_args 里先建 ``a[0]`` 为 list,再往 ``a[0][1]`` 写。 + result = GoogleProvider._partial_args_to_mapping( + [ + {"json_path": "$.a[0][0]", "string_value": "first"}, + {"json_path": "$.a[0][1]", "string_value": "second"}, + ] + ) + assert result == {"a": [["first", "second"]]} + + +# ── 回归:Google 覆写的 embed_query 必须与基类保持同一兜底 ── +# +# ``TextEmbeddingModel`` 的默认 ``embed_query`` 先判 ``if not vectors`` 再取 +# ``[0]``,空结果得到的是带指引的 ``ValueError("Embedding provider 返回空结果。")``。 +# ``GoogleProvider`` 为了设 ``task_type`` 覆写了这两个方法,却把那条兜底丢了, +# 直接下标取 ``[0]``——provider 返回空列表时抛裸 +# ``IndexError: list index out of range``。 +# +# 空返回不是假想输入:``values`` 数量校验是按 ``len(texts)`` 做的,传空 ``texts`` +# 时 ``len(values) == len(texts) == 0`` 成立,方法正常返回 ``[]``。网关在截断 +# 响应、或返回空 ``embeddings`` 字段时也会走到同一形态。 +# +# ``IndexError`` 不是 ``ValueError``/``RuntimeError``,会穿透调用方按这两类做的 +# 错误分类与日志脱敏,且消息与「provider 返回了什么」毫无关系。 + + +class _EmptyEmbeddingGoogle(GoogleProvider): + """绕过网络:让底层 ``embed_documents`` 返回空结果。""" + + def __init__(self): + self._model_name = "m" + + def embed_documents(self, texts, **kwargs): + self.seen_kwargs = kwargs + return [] + + async def aembed_documents(self, texts, **kwargs): + self.seen_kwargs = kwargs + return [] + + +def test_google_embed_query_reports_empty_result_as_value_error(): + """空结果必须是 ``ValueError``,不是裸 ``IndexError``。""" + provider = _EmptyEmbeddingGoogle() + + with pytest.raises(ValueError, match="空结果"): + provider.embed_query("查询文本") + + +def test_google_aembed_query_reports_empty_result_as_value_error(): + """异步路径同判据。""" + provider = _EmptyEmbeddingGoogle() + + with pytest.raises(ValueError, match="空结果"): + asyncio.run(provider.aembed_query("查询文本")) + + +def test_google_embed_query_still_sets_retrieval_query_task_type(): + """收紧不能误伤:覆写存在的理由(``task_type``)必须保留。""" + provider = _EmptyEmbeddingGoogle() + + with pytest.raises(ValueError): + provider.embed_query("查询文本") + + assert provider.seen_kwargs["task_type"] == "RETRIEVAL_QUERY" + + +def test_google_aembed_query_still_sets_retrieval_query_task_type(): + provider = _EmptyEmbeddingGoogle() + + with pytest.raises(ValueError): + asyncio.run(provider.aembed_query("查询文本")) + + assert provider.seen_kwargs["task_type"] == "RETRIEVAL_QUERY" + + +def test_google_embed_query_returns_the_first_vector(): + """正常路径不受影响。""" + + class _One(_EmptyEmbeddingGoogle): + def embed_documents(self, texts, **kwargs): + self.seen_kwargs = kwargs + return [[0.5, 1.5]] + + async def aembed_documents(self, texts, **kwargs): + self.seen_kwargs = kwargs + return [[0.5, 1.5]] + + assert _One().embed_query("查询文本") == [0.5, 1.5] + + +# ── 回归:json_path 的拒绝消息自身用 ``!r`` 插值 ── +# +# ``_partial_arg_tokens`` 的两条拒绝分支写 ``f"...: {json_path!r}"``。判据是 +# ``isinstance(json_path, str)``,因此**非字符串**取值会走到第一条分支——而 +# ``repr(10**4301)`` 自己抛 ``ValueError: Exceeds the limit``:正要报出 +# 「json_path 无效」的那一步变成整数转换错误。上一条回归只覆盖了「字符串形态、 +# 索引位数超限」,这条覆盖「取值本身是位数超限的整数」。 +# +# 可达路径:``_partial_args_to_mapping`` 读模型流式分片的 ``json_path`` 字段, +# 形态不受控。 + + +@pytest.mark.parametrize( + "json_path", + [pytest.param(10**4301, id="huge-int"), pytest.param(["$"], id="list")], +) +def test_google_partial_args_rejects_malformed_json_path_readably(json_path): + """非字符串 ``json_path`` 不得让「报告无效路径」自己抛裸异常。""" + with pytest.raises(ValueError) as excinfo: + GoogleProvider._partial_arg_tokens(json_path) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "json_path" in message + + +def test_google_partial_args_mapping_surfaces_malformed_json_path_readably(): + """从公开入口进入时同样给出可读消息。""" + with pytest.raises(ValueError) as excinfo: + GoogleProvider._partial_args_to_mapping([{"json_path": 10**4301, "number_value": 1}]) + + assert "Exceeds the limit" not in str(excinfo.value) diff --git a/tests/providers/test_guard_coverage_regressions.py b/tests/providers/test_guard_coverage_regressions.py new file mode 100644 index 0000000..e85e9a5 --- /dev/null +++ b/tests/providers/test_guard_coverage_regressions.py @@ -0,0 +1,4855 @@ +"""补齐「删守卫后全量测试仍绿」的非 raise 守卫覆盖缺口(Provider 批)。 + +每一条用例对应一条经变异扫描存活的守卫:把它的 ``if`` 整体替换成 ``pass`` +后,3804 条测试全部通过——即整套测试没有任何断言能察觉该守卫消失。每条都 +经真实入口探测确认删除后行为确实改变(崩溃、静默取值或消息内容),再按 +**守卫存在时的行为**写成断言。详见 CHANGELOG 对应条目。 +""" + +import asyncio +import dataclasses +import inspect +from types import SimpleNamespace + +import pytest + +from src.providers.__base__ import model_provider as model_provider_module +from src.providers.__base__.model_provider import ( + CompletionRequest, + LargeLanguageModel, + StreamEvent, + _guard_provider_resource_method, + _responses_content_part, + _responses_has_content, + _responses_json_text, + content_to_text, + normalize_chat_messages, + normalize_embedding_vector, + normalize_messages, + normalize_responses_input, + normalize_tool_arguments, + retry_async_call, + retry_sync_call, +) +from src.providers.anthropic import AnthropicProvider +from src.providers.google import GoogleProvider +from src.providers.local_hash import LocalHashEmbeddingProvider +from src.providers.openai_compatible import OpenAICompatibleProvider +from src.providers.volcengine import VolcengineProvider + +# --- OpenAI 兼容渠道 ------------------------------------------------------- + + +def test_chat_stream_events_returns_usage_only_event_for_empty_choices(): + """``choices=[]`` 的收尾块(常见于 usage-only 块)不能索引 ``choices[0]``。 + + 删除守卫后 ``choices[0]`` 抛裸 ``IndexError: list index out of range``; + 真实网关在流的最后一块用空 ``choices`` 携带 usage,这是常规形态。 + """ + events = OpenAICompatibleProvider._chat_stream_events( + {"choices": [], "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}} + ) + assert [event.type for event in events] == ["usage"] + assert OpenAICompatibleProvider._chat_stream_events({"choices": []}) == [] + + +def test_chat_completion_events_does_not_flush_accumulator_on_non_finish(): + """非 finish 事件原样返回,不得提前冲刷累积中的工具调用。 + + 删除守卫后 text_delta 也会走进冲刷分支:未完成的工具调用被提前发出 + (或以 ``RuntimeError`` 中断流),且 ``completed`` 被污染使 finish 时 + 无法再补齐。 + """ + accumulator = {"call_1": {"name": "f", "arguments": "{}", "id": "call_1"}} + completed: set = set() + event = StreamEvent(type="text_delta", text="hi") + assert OpenAICompatibleProvider._chat_completion_events(event, accumulator, completed) == [ + event + ] + assert completed == set() + assert list(accumulator) == ["call_1"] + + +def test_responses_completion_event_ignores_non_tool_events(): + """只有 ``tool_call_completed`` 且带载荷的事件才参与合并。 + + 删除守卫后文本增量事件也会进入合并分支,在 ``None`` 上取 ``get`` 抛裸 + ``AttributeError: 'NoneType' object has no attribute 'get'``。 + """ + assert ( + OpenAICompatibleProvider._responses_completion_event( + StreamEvent(type="text_delta", text="hi"), {} + ) + is None + ) + assert ( + OpenAICompatibleProvider._responses_completion_event( + StreamEvent(type="tool_call_completed", tool_call=None), {} + ) + is None + ) + + +def test_extract_refusal_does_not_misreport_normal_text_as_refusal(): + """普通 ``output_text`` 内容不得被当成拒答文本返回。 + + 删除守卫后所有内容块都按「refusal 或 text」取值,正常回答会被当作拒答 + 上抛给调用方。 + """ + normal = { + "output": [{"type": "message", "content": [{"type": "output_text", "text": "正常回答"}]}] + } + assert OpenAICompatibleProvider._extract_refusal(normal) is None + refusal = { + "output": [{"type": "message", "content": [{"type": "refusal", "refusal": "我不行"}]}] + } + assert OpenAICompatibleProvider._extract_refusal(refusal) == "我不行" + + +def test_convert_responses_tool_choice_accepts_top_level_function_name(): + """Responses 的 function tool_choice 允许顶层 ``name``(无嵌套 ``function``)。 + + 删除守卫后这种形态落到「缺少 name」的 ``ValueError``,合法请求被拒绝。 + """ + choice = {"type": "function", "name": "get_weather"} + assert OpenAICompatibleProvider._convert_responses_tool_choice(choice) == choice + + +def test_convert_responses_format_passes_through_already_normalized_format(): + """已是 ``{"format": ...}`` 形态的 response_format 原样返回。 + + 删除守卫后抛「仅支持 json_schema 或 json_object」的 ``ValueError``。 + """ + fmt = {"format": {"type": "json_object"}} + assert OpenAICompatibleProvider._convert_responses_format(fmt) == fmt + + +def test_error_message_preserves_string_and_code_details(): + """错误消息提取:字符串错误与 ``code`` 字段都不能丢。 + + 删除 2649 的守卫后字符串错误退化成默认消息;删除 2658 的守卫后 + ``{"code": ...}`` 形态退化成默认消息——调用方看不到真实原因。 + """ + assert OpenAICompatibleProvider._error_message("rate limited", "默认消息") == "rate limited" + assert OpenAICompatibleProvider._error_message({"code": "E42"}, "默认消息") == "E42" + assert OpenAICompatibleProvider._error_message({}, "默认消息") == "默认消息" + + +def test_normalize_verified_protocols_none_returns_empty_set(): + """``server_verified_protocols`` 未配置(None)时返回空集。 + + 删除守卫后 None 落到「必须是字符串或协议序列」的 ``ValueError``, + 默认配置无法通过。 + """ + assert OpenAICompatibleProvider._normalize_verified_protocols(None) == frozenset() + + +def test_resource_capability_table_maps_responses_methods(): + """``retrieve_response`` 等 Responses 方法必须映射到 ``responses`` 能力。 + + 删除守卫后这些方法的能力映射变成 ``None``(未知方法),资源校验会在 + 调用期把它们当成未挂载资源拒绝。 + """ + assert ( + OpenAICompatibleProvider._resource_capability_for_method("retrieve_response") == "responses" + ) + assert ( + OpenAICompatibleProvider._resource_capability_for_method("cancel_response") == "responses" + ) + + +def test_response_tool_keys_skips_none_components(): + """键元组只包含实际存在的分量;全缺时返回空列表。 + + 删除守卫后返回 ``[("item_id", None), ("index", None), ("call_id", None)]``, + 合并逻辑会把这些「全 None」键当成真实键参与匹配。 + """ + assert OpenAICompatibleProvider._response_tool_keys(None) == [] + + +# --- Anthropic ------------------------------------------------------------- + + +def test_anthropic_convert_tool_choice_accepts_dict_auto_any_none(): + """``{"type": "auto"|"any"|"none"}`` 字典形态原样返回。 + + 删除守卫后这三种字典形态落到「必须是 auto、any、none、tool 或 function + 对象」的 ``ValueError``,只有字符串形态能通过。 + """ + for value in ("auto", "any", "none"): + assert AnthropicProvider._convert_tool_choice({"type": value}) == {"type": value} + + +def test_anthropic_top_p_at_compat_threshold_is_omitted(): + """不高于 0.99 的兼容 top_p 取值返回 None(省略字段)而不是抛错。 + + 删除守卫后 0.99/1.0 也抛「不支持该 top_p」,模型声明支持兼容值却无法 + 通过校验。 + """ + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-6" + assert provider._normalize_sampling_value("top_p", 0.99) is None + assert provider._normalize_sampling_value("top_p", 1.0) is None + with pytest.raises(ValueError, match="不支持该 top_p"): + provider._normalize_sampling_value("top_p", 0.5) + + +def test_anthropic_user_profile_fields_without_user_returns_inputs_unchanged(): + """``user=None`` 时不追加 user-profiles beta 头。 + + 删除守卫后即使没有 user 也会把 ``user-profiles-2026-08-18`` 塞进 + betas/headers,向服务端声明了一个没有主体的 beta 资源。 + """ + assert AnthropicProvider._user_profile_fields(None, None, None, beta=True) == (None, None) + headers, betas = AnthropicProvider._user_profile_fields("user_123", None, None, beta=True) + assert headers is None + assert betas == ["user-profiles-2026-08-18"] + + +def test_anthropic_stream_event_emits_usage_and_finish_events(): + """``message_delta`` 的 usage 与 ``message_stop`` 的 finish 都必须发出。 + + 删除 1411 的守卫后 usage 增量返回 None(usage 丢失);删除 1414 的守卫 + 后 message_stop 返回 None(流消费者永远等不到 finish)。 + """ + usage_event = AnthropicProvider._stream_event( + {"type": "message_delta", "usage": {"output_tokens": 7}}, "resp_1" + ) + assert usage_event is not None and usage_event.type == "usage" + assert usage_event.usage == {"output_tokens": 7} + finish_event = AnthropicProvider._stream_event({"type": "message_stop"}, "resp_1") + assert finish_event is not None and finish_event.type == "finish" + assert AnthropicProvider._stream_event({"type": "ping"}, "resp_1") is None + + +def test_anthropic_iter_invoke_stream_skips_non_text_events(): + """``ping``/``content_block_start`` 等非文本事件被跳过而不是解引用 None。 + + 删除守卫后 ``converted.type`` 在 ``None`` 上抛裸 ``AttributeError``—— + 真实 Anthropic 流的每个响应都含 ping 与 content_block_* 事件。 + """ + + class FakeStream: + def __iter__(self): + return iter( + [ + {"type": "ping"}, + {"type": "content_block_start", "index": 0}, + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "你好"}, + }, + {"type": "message_stop"}, + ] + ) + + assert list(AnthropicProvider._iter_invoke_stream_text(FakeStream())) == ["你好"] + + +# --- Google ---------------------------------------------------------------- + + +def test_google_client_vertex_mode_reads_test_double_attribute(): + """客户端替身上直接暴露的 ``vertexai`` 属性要被读到。 + + 删除守卫后返回 None(回退到配置推断),而配置推断与替身实际行为可能 + 不一致——本函数的用途正是优先读真实客户端。 + """ + + class FakeClient: + def __init__(self): + self.vertexai = True + + class PlainClient: + pass + + assert GoogleProvider._client_vertex_mode(FakeClient()) is True + assert GoogleProvider._client_vertex_mode(PlainClient()) is None + + +def test_google_token_count_system_prompt_prefers_message_system(): + """token 统计的 system prompt 优先取消息里的 system 内容。 + + 删除守卫后回退到 ``request.system_prompt``,两者同时存在时取错来源, + 统计的 token 数与实际发送的请求不一致。 + """ + from src.providers.__base__.model_provider import CompletionRequest + + both = CompletionRequest( + messages=[ + {"role": "system", "content": "来自消息"}, + {"role": "user", "content": "hi"}, + ], + system_prompt="来自请求", + ) + assert GoogleProvider._token_count_system_prompt(both) == "来自消息" + only_request = CompletionRequest( + messages=[{"role": "user", "content": "hi"}], system_prompt="只有请求" + ) + assert GoogleProvider._token_count_system_prompt(only_request) == "只有请求" + + +def test_google_http_options_for_request_returns_configured_options(): + """模型级 ``options.http_options`` 配置优先于重建的请求级配置。 + + 删除守卫后配置被静默丢弃,``timeout``/``extra_body`` 等模型级 HTTP + 配置不生效。 + """ + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-2.0-flash" + provider._options = {"http_options": {"timeout": 12345}} + assert provider._http_options_for_request() == {"timeout": 12345} + + +def test_google_contents_returns_prompt_directly_for_prompt_only_requests(): + """只给 prompt 的请求原样返回字符串,不经过消息归一化。 + + 删除守卫后返回值变成 ``Content`` 列表(类型变化),依赖字符串形态的 + 调用方行为改变。 + """ + assert GoogleProvider._contents(None, "你好", None) == "你好" + + +def test_google_contents_skips_system_messages(): + """``role=system`` 的消息不进入 contents(system 走独立通道)。 + + 删除守卫后 system 内容被并入 user 部件,模型收到重复的系统提示。 + """ + contents = GoogleProvider._contents( + [{"role": "system", "content": "系统提示"}, {"role": "user", "content": "hi"}], + None, + None, + ) + assert len(contents) == 1 + assert [part.text for part in contents[0].parts] == ["hi"] + + +def test_google_candidate_refusal_reads_block_reason_message(): + """``block_reason_message`` 是拒答文本的来源之一。 + + 删除守卫后该字段被忽略,只带 block_reason_message 的候选返回 None。 + """ + + class FakeCandidate: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + candidate = FakeCandidate(block_reason_message="内容被屏蔽") + assert GoogleProvider._candidate_refusal(candidate) == "内容被屏蔽" + + +def test_google_stream_chunk_terminal_detection_covers_both_shapes(): + """顶层 ``finish_reason`` 与 ``prompt_feedback.block_reason`` 都算终止。 + + 删除 2670 后带顶层 finish_reason 的块不再终止;删除 2673 后 + prompt_feedback 拦截的块不再终止——流消费方会继续等待永远不会来的事件。 + """ + + class FakeChunk: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + assert GoogleProvider._stream_chunk_has_terminal_event(FakeChunk(finish_reason="STOP")) is True + assert ( + GoogleProvider._stream_chunk_has_terminal_event( + FakeChunk(prompt_feedback=FakeChunk(block_reason="SAFETY")) + ) + is True + ) + assert GoogleProvider._stream_chunk_has_terminal_event(FakeChunk(text="hi")) is False + + +def test_google_close_without_client_is_noop(): + """未构造过客户端时 ``close``/``aclose`` 直接返回。 + + 删除守卫后 ``None.close()`` / ``None.aio`` 抛裸 ``AttributeError``; + 清理路径上的崩溃会打断退出流程。 + """ + provider = object.__new__(GoogleProvider) + provider._client = None + provider.close() + asyncio.run(provider.aclose()) + + +def test_google_partial_args_to_mapping_accepts_mapping(): + """``partial_args`` 已是 Mapping 时直接转 dict。 + + 删除守卫后 Mapping 落到「必须是列表」的 ``ValueError``,合法输入被拒。 + """ + assert GoogleProvider._partial_args_to_mapping({"a": 1}) == {"a": 1} + + +def test_google_coerce_debug_config_passes_through_none_and_non_mapping(): + """``debug_config`` 为 None 或非 Mapping 时原样返回。 + + 删除守卫后 ``for key in None`` / ``for key in 5`` 抛裸 ``TypeError``, + 未配置 debug 的默认路径直接崩溃。 + """ + assert GoogleProvider._coerce_debug_config(None) is None + assert GoogleProvider._coerce_debug_config(5) == 5 + + +# --- Volcengine(Ark)------------------------------------------------------ + + +def test_ark_responses_format_and_tool_choice_default_none(): + """``response_format=None`` 返回 ``{}``、``tool_choice=None`` 返回 None。 + + 删除守卫后 None 分别落到「必须是对象」与「必须是 auto、none、required + 或工具对象」的 ``ValueError``,未配置这两个字段的请求全部被拒。 + """ + assert VolcengineProvider._convert_responses_format(None) == {} + assert VolcengineProvider._convert_responses_tool_choice(None) is None + + +def test_ark_chat_stream_events_returns_usage_only_event_for_empty_choices(): + """Ark 流块的 ``choices=[]`` 形态同样不能索引 ``choices[0]``。 + + 删除守卫后抛裸 ``IndexError``。 + """ + events = VolcengineProvider._chat_stream_events( + {"choices": [], "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}} + ) + assert [event.type for event in events] == ["usage"] + assert VolcengineProvider._chat_stream_events({"choices": []}) == [] + + +# --- 基础层(__base__)----------------------------------------------------- + + +def test_responses_has_content_rejects_empty_sequence(): + """空序列不算「有内容」。 + + 删除守卫后空列表落到 ``return True``,空内容被当成有内容,调用方不再 + 走回退取值路径。 + """ + assert _responses_has_content("") is False + assert _responses_has_content([]) is False + assert _responses_has_content("x") is True + assert _responses_has_content([1]) is True + + +def test_responses_content_part_accepts_plain_string(): + """字符串部件转换为 ``input_text`` 块。 + + 删除守卫后字符串落到「必须是字符串或对象」的 ``ValueError``,最常见的 + 部件形态被拒绝。 + """ + assert _responses_content_part("hi", "loc") == {"type": "input_text", "text": "hi"} + with pytest.raises(ValueError, match="必须是字符串或对象"): + _responses_content_part(5, "loc") + + +def test_content_to_text_joins_sequence_parts(): + """序列内容拼接为文本而不是丢弃。 + + 删除守卫后列表内容返回空串——多部件消息的文本静默消失。 + """ + assert content_to_text([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]) == "ab" + assert content_to_text(["a", "b"]) == "ab" + assert content_to_text("hi") == "hi" + + +def test_responses_json_text_empty_handling(): + """``allow_empty=True`` 时空值/空串返回空串;缺省时 None 序列化为 ``{}``。 + + 删除 536 后 None+allow 变成 ``'{}'``;删除 540 后空串+allow 抛 + 「必须是有效 JSON」——两种形态的语义都被改变。 + """ + assert _responses_json_text(None, "loc", allow_empty=True) == "" + assert _responses_json_text("", "loc", allow_empty=True) == "" + assert _responses_json_text(None, "loc") == "{}" + with pytest.raises(ValueError, match="必须是有效 JSON"): + _responses_json_text("", "loc") + + +def test_guard_provider_resource_method_is_idempotent(): + """已包装过的方法再次包装时原样返回。 + + 删除守卫后每次调用都套一层新包装:参数校验被执行多次,且 + ``_resource_guarded`` 的同一性判断失效。 + """ + + def plain(a, b): + return (a, b) + + guarded = _guard_provider_resource_method(plain) + assert _guard_provider_resource_method(guarded) is guarded + # ``getattr(method, "_resource_guarded", False)`` 的默认值若反向成 ``True``, + # 未包装的方法也会被判为「已安装」——守卫被静默跳过,凭证与能力校验全部失效。 + assert getattr(guarded, "_resource_guarded", None) is True + + +def test_guard_provider_resource_method_is_idempotent_for_coroutines(): + """**协程**方法同样必须打上 ``_resource_guarded`` 标记并保持幂等。 + + ``_guard_provider_resource_method`` 对 ``inspect.iscoroutinefunction`` 走 + ``async_wrapper`` 分支,其 ``setattr(async_wrapper, "_resource_guarded", + True)`` 与同步分支的赋值是**两处独立**的字面量。上面那条用例用的是同步 + 函数,只覆盖同步分支;实测把异步分支的标记反向成 ``False`` 后全量套件仍 + 绿——异步资源入口每次被重复包装都会多套一层,``_resource_guarded`` 的 + 幂等判断对它们失效。 + """ + + async def plain(a, b): + return (a, b) + + guarded = _guard_provider_resource_method(plain) + assert getattr(guarded, "_resource_guarded", None) is True + assert _guard_provider_resource_method(guarded) is guarded + + +# --- 收口批:轮询、文件内容、消息转换、异步流与流终止去重 ------------------- + + +def test_openai_background_poll_returns_terminal_response_without_id(): + """终态响应没有 ``id`` 时原样返回(不进入轮询、也不需要 id)。 + + 删除守卫后终态响应落到「缺少 id,无法轮询」的 ``RuntimeError``——网关 + 返回 ``status=completed`` 但省略 id 时,本来已完成的响应被当成错误。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + terminal = {"status": "completed"} + assert provider._poll_background_response(terminal, {}) is terminal + assert asyncio.run(provider._poll_background_response_async(terminal, {})) is terminal + + +def test_openai_retrieve_file_content_uses_sdk_method_when_available(): + """SDK 提供 ``files.retrieve_content`` 时走 SDK 资源通道。 + + 删除守卫后直接落到 ``file_content``(SDK 无该资源时抛 + ``NotImplementedError``),已挂载的 SDK 方法被绕过。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + calls = [] + + def fake_call(method, args=(), kwargs=None, operation=""): + calls.append(operation) + return "sdk-result" + + async def fake_async_call(method, args=(), kwargs=None, operation=""): + calls.append(operation) + return "sdk-result" + + provider._call_sdk_resource = fake_call + provider._call_async_sdk_resource = fake_async_call + provider._get_client = lambda: _files_client() + provider._get_aclient = _files_client + assert provider.retrieve_file_content("file_1") == "sdk-result" + assert asyncio.run(provider.async_retrieve_file_content("file_1")) == "sdk-result" + assert calls == ["OpenAI 文件文本读取", "OpenAI 文件文本读取"] + + +def _files_client(): + import types + + return types.SimpleNamespace( + files=types.SimpleNamespace(retrieve_content=lambda *args, **kwargs: "sdk") + ) + + +def test_anthropic_convert_messages_skips_system_messages(): + """``role=system`` 的消息不进入 messages(system 走独立参数)。 + + 删除守卫后系统提示被转换成 user 消息,与 ``system`` 参数里的提示重复, + 模型收到两份系统指令。 + """ + converted = AnthropicProvider._convert_messages( + [{"role": "system", "content": "系统提示"}, {"role": "user", "content": "hi"}] + ) + assert converted == [{"role": "user", "content": "hi"}] + + +def test_anthropic_async_iter_invoke_stream_skips_non_text_events(): + """异步流同样跳过 ``ping`` 等非文本事件。 + + 删除守卫后 ``converted.type`` 在 ``None`` 上抛裸 ``AttributeError``。 + """ + + class AsyncStream: + def __aiter__(self): + async def events(): + yield {"type": "ping"} + yield { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "你好"}, + } + yield {"type": "message_stop"} + + return events() + + async def consume(): + return [text async for text in AnthropicProvider._aiter_invoke_stream_text(AsyncStream())] + + assert asyncio.run(consume()) == ["你好"] + + +def test_google_validate_http_options_allows_none_and_none_valued_sensitive_keys(): + """``http_options=None`` 通过校验;值为 None 的敏感键不算凭证。 + + 删除 502 的守卫后 None 落到「必须是对象表或 HttpOptions」的 + ``ValueError``;删除 590 的守卫后 ``{"api_key": None}`` 被判成携带凭证 + ——None 值没有可泄露的内容,占位形态应当放行。 + """ + GoogleProvider._validate_http_options(None) + GoogleProvider._validate_http_options({"api_key": None}) + with pytest.raises(ValueError, match="不允许包含凭证"): + GoogleProvider._validate_http_options({"api_key": "sk-1"}) + + +def test_ark_normalize_verified_protocols_none_returns_empty_set(): + """Ark 的 ``server_verified_protocols`` 未配置时返回空集。 + + 删除守卫后 None 落到「必须是字符串或协议序列」的 ``ValueError``。 + """ + assert VolcengineProvider._normalize_verified_protocols(None) == frozenset() + + +def _responses_stream_client(events): + import types + + class Responses: + def create(self, **request): + return iter(events) + + return types.SimpleNamespace(responses=Responses()) + + +def test_openai_responses_stream_ignores_events_after_terminal(): + """``response.completed`` 之后的事件被跳过(流排空阶段不重复发 finish)。 + + 删除守卫后重复的终止事件会再次转换成 finish 事件——SDK 排空阶段重复 + 送达的终止块让调用方收到两个 finish。 + """ + import types + + terminal = types.SimpleNamespace( + type="response.completed", response=types.SimpleNamespace(status="completed") + ) + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + provider._get_client = lambda: _responses_stream_client([terminal, terminal]) + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + assert [event.type for event in events].count("finish") == 1 + + +def test_ark_responses_stream_ignores_events_after_terminal(): + """Ark 的 Responses 流同样跳过终止后的事件。""" + import types + + terminal = types.SimpleNamespace( + type="response.completed", response=types.SimpleNamespace(status="completed") + ) + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + provider._provider = "volcengine" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + provider._get_client = lambda: _responses_stream_client([terminal, terminal]) + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + assert [event.type for event in events].count("finish") == 1 + + +def test_anthropic_async_stream_deduplicates_finish_events(): + """异步流的 message_delta(finish) 与 message_stop 只产生一个 finish。 + + 删除守卫后两个事件各转换一次 finish,调用方收到重复的终止事件。 + """ + + class AsyncStreamManager: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + def __aiter__(self): + async def events(): + yield SimpleNamespace( + type="message_delta", + delta=SimpleNamespace(stop_reason="end_turn"), + usage=SimpleNamespace(output_tokens=1), + ) + yield SimpleNamespace(type="message_stop") + + return events() + + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-test" + provider._options = {} + provider._get_aclient = lambda: SimpleNamespace( + messages=SimpleNamespace(stream=lambda **_kwargs: AsyncStreamManager()) + ) + + async def consume(): + return [event async for event in provider.astream_events(CompletionRequest(prompt="hello"))] + + events = asyncio.run(consume()) + assert [event.type for event in events].count("finish") == 1 + + +def test_google_stream_deduplicates_completed_tool_calls(): + """同一工具调用的完成事件只交付一次(重复块被跳过)。 + + 删除守卫后重复的 function_call 块再次交付 ``tool_call_completed``—— + 同一轮工具调用被执行两次。 + """ + import types + + function_call = types.SimpleNamespace( + id="call-1", name="lookup", args='{"id":1}', will_continue=False + ) + chunk = types.SimpleNamespace( + response_id="response-1", + candidates=[ + types.SimpleNamespace( + content=types.SimpleNamespace( + parts=[types.SimpleNamespace(function_call=function_call)] + ), + finish_reason=None, + ) + ], + ) + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini" + provider._options = {} + terminal = types.SimpleNamespace( + response_id="response-1", + candidates=[ + types.SimpleNamespace(content=types.SimpleNamespace(parts=[]), finish_reason="STOP") + ], + ) + provider._get_client = lambda: types.SimpleNamespace( + models=types.SimpleNamespace( + generate_content_stream=lambda **_kwargs: iter([chunk, chunk, terminal]) + ) + ) + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + completed = [event for event in events if event.type == "tool_call_completed"] + assert len(completed) == 1 + + +def _async_responses_client(events): + import types + + class Stream: + def __init__(self): + self._events = iter(events) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._events) + except StopIteration as exc: + raise StopAsyncIteration from exc + + async def close(self): + return None + + class Responses: + async def create(self, **_request): + return Stream() + + return types.SimpleNamespace(responses=Responses()) + + +def test_openai_async_responses_stream_ignores_events_after_terminal(): + """异步流的终止后事件同样被跳过。""" + import types + + terminal = types.SimpleNamespace( + type="response.completed", response=types.SimpleNamespace(status="completed") + ) + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + provider._get_aclient = lambda: _async_responses_client([terminal, terminal]) + + async def consume(): + return [event async for event in provider.astream_events(CompletionRequest(prompt="hi"))] + + events = asyncio.run(consume()) + assert [event.type for event in events].count("finish") == 1 + + +def test_ark_async_responses_stream_ignores_events_after_terminal(): + """Ark 异步流的终止后事件同样被跳过。""" + import types + + terminal = types.SimpleNamespace( + type="response.completed", response=types.SimpleNamespace(status="completed") + ) + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + provider._provider = "volcengine" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + provider._get_aclient = lambda: _async_responses_client([terminal, terminal]) + + async def consume(): + return [event async for event in provider.astream_events(CompletionRequest(prompt="hi"))] + + events = asyncio.run(consume()) + assert [event.type for event in events].count("finish") == 1 + + +def test_google_async_stream_deduplicates_completed_tool_calls(): + """Google 异步流同样只交付一次工具调用完成事件。""" + import types + + function_call = types.SimpleNamespace( + id="call-1", name="lookup", args='{"id":1}', will_continue=False + ) + chunk = types.SimpleNamespace( + response_id="response-1", + candidates=[ + types.SimpleNamespace( + content=types.SimpleNamespace( + parts=[types.SimpleNamespace(function_call=function_call)] + ), + finish_reason=None, + ) + ], + ) + terminal = types.SimpleNamespace( + response_id="response-1", + candidates=[ + types.SimpleNamespace(content=types.SimpleNamespace(parts=[]), finish_reason="STOP") + ], + ) + + class AsyncStream: + def __init__(self): + self._events = iter([chunk, chunk, terminal]) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._events) + except StopIteration as exc: + raise StopAsyncIteration from exc + + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini" + provider._provider = "google" + provider._options = {} + aio = types.SimpleNamespace( + models=types.SimpleNamespace(generate_content_stream=lambda **_kwargs: AsyncStream()) + ) + provider._client = types.SimpleNamespace(aio=aio) + provider._get_aclient = lambda: provider._client + + async def consume(): + return [event async for event in provider.astream_events(CompletionRequest(prompt="hi"))] + + events = asyncio.run(consume()) + completed = [event for event in events if event.type == "tool_call_completed"] + assert len(completed) == 1 + + +# --- except 元组收窄:声称捕获的第二类型必须有测试触发 ---------------------- +# +# 来源:把 ``except (A, B, ...)`` 收窄成 ``except A``(只留第一个类型)后 +# 全量测试仍绿的存活点。逐条用探针确认「命中被丢弃类型时行为确实改变」, +# 再按守卫存在时的行为写成断言。等价变异(被丢弃类型与第一个类型收场逐字 +# 相同)与不可达类型不在此列。 + + +def test_sanitize_resource_call_skips_valueerror_from_signature_property(): + """``__signature__`` 抛 ``ValueError`` 时同样跳过校验,而不是让异常冒泡。 + + 测试替身此前没有 ``__call__``,``inspect.signature`` 对它抛的是 + ``TypeError``——docstring 声称覆盖的 ``ValueError`` 分支从未被走到。 + 收窄成 ``except TypeError`` 后本用例变红。 + """ + + from src.providers.__base__.model_provider import _sanitize_resource_call + + class _NoSignature: + @property + def __signature__(self): + raise ValueError("无签名") + + def __call__(self): # 有 __call__ 才会走到 ValueError 分支 + pass + + args, kwargs = _sanitize_resource_call(_NoSignature(), object(), (1,), {"model": "m"}) + assert args == (1,) + assert kwargs == {"model": "m"} + + +def test_openai_validate_sdk_resource_call_skips_valueerror_from_signature_property(): + """``__signature__`` 抛 ``ValueError`` 时跳过校验而不是抛错。""" + + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + + class _NoSignature: + @property + def __signature__(self): + raise ValueError("无签名") + + def __call__(self): + pass + + provider._validate_sdk_resource_call(_NoSignature(), (), {}, "op") + + +def test_responses_json_text_reports_circular_reference_as_json_error(): + """循环引用的取值收敛成「必须可序列化为 JSON」,而不是裸 ``ValueError``。 + + 删除 ``except`` 里的 ``ValueError``(``json.dumps`` 对环抛 + ``ValueError: Circular reference detected``)后,消息变成 SDK 内部文本。 + """ + cyclic: list = [] + cyclic.append(cyclic) + with pytest.raises(ValueError, match="必须可序列化为 JSON"): + _responses_json_text(cyclic, "参数") + + +@pytest.mark.parametrize("payload", ["中文", "é"]) +def test_normalize_embedding_vector_reports_non_ascii_base64_readably(payload): + """非 ASCII 字符串让 ``b64decode`` 抛 ``ValueError``,收敛成本模块的消息。 + + 收窄成 ``except binascii.Error`` 后,消息变成 + ``string argument should contain only ASCII characters``。 + """ + from src.providers.__base__.model_provider import normalize_embedding_vector + + with pytest.raises(ValueError, match="Embedding base64 内容无效"): + normalize_embedding_vector(payload) + + +def test_anthropic_convert_content_reports_non_ascii_base64_readably(): + """Anthropic 图片 data URI 的非 ASCII base64 收敛成本模块的消息。""" + with pytest.raises(ValueError, match="Anthropic 图片 data URI 不是有效 Base64"): + AnthropicProvider._convert_content( + [{"type": "image_url", "image_url": {"url": "data:image/png;base64,中文"}}] + ) + + +def test_google_decode_data_uri_reports_non_ascii_base64_readably(): + """Google data URI 的非 ASCII base64 收敛成本模块的消息。""" + with pytest.raises(ValueError, match="data URI 不是有效 Base64"): + GoogleProvider._decode_data_uri("data:image/png;base64,中文", "图片") + + +def test_anthropic_snapshot_refusal_probe_tolerates_notimplementederror(): + """``current_message_snapshot`` 抛 ``NotImplementedError`` 时静默返回。 + + 收窄成 ``except AttributeError`` 后,未实现该属性的 SDK 响应对象会让流 + 消费在第一次探测时崩溃。 + """ + + class _Resp: + @property + def current_message_snapshot(self): + raise NotImplementedError("SDK 未实现") + + AnthropicProvider._raise_for_stream_snapshot_refusal(_Resp()) + + +def test_google_require_resource_converts_notimplementederror_property(): + """资源属性抛 ``NotImplementedError`` 时转成统一的能力错误。""" + + class _Client: + @property + def models(self): + raise NotImplementedError("SDK 未实现") + + with pytest.raises(NotImplementedError, match="当前 google-genai SDK 未提供"): + GoogleProvider._require_resource(_Client(), "models", "模型") + + +def test_delegated_resource_converts_notimplementederror_to_capability_error(): + """动态资源属性抛 ``NotImplementedError`` 时转成带 provider 名的能力错误。 + + 收窄成 ``except AttributeError`` 后,SDK 版本未实现该资源时 + ``NotImplementedError`` 冒泡,消息从「openai SDK 未提供 beta 资源」 + 退化成 SDK 内部文本。 + """ + import src.providers.resources as resources_module + + facade_cls = next( + cls + for name in dir(resources_module) + if isinstance(cls := getattr(resources_module, name), type) + and getattr(cls, "_delegate_native", False) + and "OpenAI" in name + ) + + class _BadClient: + @property + def beta(self): + raise NotImplementedError("SDK 未实现") + + class _Provider: + sdk_client = _BadClient() + async_sdk_client = _BadClient() + + def __init__(self): + self._provider = "openai" + + facade = object.__new__(facade_cls) + object.__setattr__(facade, "provider", _Provider()) + with pytest.raises(NotImplementedError, match="SDK 未提供 beta 资源"): + facade.beta # noqa: B018 - 触发属性访问即目的 + + +def test_facade_getattr_converts_notimplementederror_to_attributeerror(): + """动态节点属性抛 ``NotImplementedError`` 时收敛成 ``AttributeError``。 + + 收窄成 ``except AttributeError`` 后,``NotImplementedError`` 冒泡—— + 动态资源探测(``getattr`` 探测新资源)把它当成「资源存在但失败」。 + """ + import src.providers.resources as resources_module + + facade_cls = next( + cls + for name in dir(resources_module) + if isinstance(cls := getattr(resources_module, name), type) + and getattr(cls, "_delegate_native", False) + and "OpenAI" in name + ) + + class _BadClient: + @property + def brand_new_resource(self): + raise NotImplementedError("SDK 未实现") + + class _Provider: + sdk_client = _BadClient() + async_sdk_client = _BadClient() + + def __init__(self): + self._provider = "openai" + + facade = object.__new__(facade_cls) + object.__setattr__(facade, "provider", _Provider()) + with pytest.raises(AttributeError, match="brand_new_resource"): + facade.brand_new_resource # noqa: B018 - 触发属性访问即目的 + + +def test_google_require_provider_resource_converts_async_notimplementederror(): + """``client.aio`` 抛 ``NotImplementedError`` 时转成带资源名的能力错误。 + + 收窄成 ``except AttributeError`` 后,SDK 未提供异步通道时 + ``NotImplementedError`` 冒泡,消息退化成 SDK 内部文本。 + """ + provider = object.__new__(GoogleProvider) + + class _Client: + @property + def aio(self): + raise NotImplementedError("SDK 未实现") + + provider._client = _Client() + with pytest.raises(NotImplementedError, match="当前 google-genai SDK 未提供异步 Webhooks"): + provider._require_provider_resource("async_webhooks") + + +def test_google_connect_live_converts_async_notimplementederror(): + """``connect_live`` 对 ``client.aio`` 抛 ``NotImplementedError`` 时转成能力错误。""" + provider = object.__new__(GoogleProvider) + + class _Client: + @property + def aio(self): + raise NotImplementedError("SDK 未实现") + + provider._client = _Client() + with pytest.raises(NotImplementedError, match="当前 google-genai SDK 未提供异步 Live"): + provider.connect_live(model="m") + + +def test_background_poll_times_out_when_remaining_is_exactly_zero(monkeypatch): + """剩余时间恰好为 0 即超时(``<= 0`` 不是 ``< 0``),不得再发起 retrieve。 + + 时钟注入只替换**本模块**引用的 ``time``:``asyncio.run`` 自身也要读真实 + 时钟,直接打补丁到全局 ``time.monotonic`` 会让它的调度序列错位。 + 变异成 ``< 0`` 后 ``remaining == 0`` 这一轮不超时,会多调用一次 + ``retrieve``(断言 ``calls == []`` 即红)。 + """ + import time as _time + from types import SimpleNamespace + + import src.providers.openai_compatible as oc + + sync_calls: list[str] = [] + async_calls: list[str] = [] + + class _SyncResponses: + def retrieve(self, response_id, **kwargs): + sync_calls.append(response_id) + return SimpleNamespace(status="queued", id=response_id) + + class _AsyncResponses: + async def retrieve(self, response_id, **kwargs): + async_calls.append(response_id) + return SimpleNamespace(status="queued", id=response_id) + + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + provider._options = {"background_poll_interval": 0.5, "background_timeout": 10.0} + provider._get_client = lambda: SimpleNamespace(responses=_SyncResponses()) + provider._get_aclient = lambda: SimpleNamespace(responses=_AsyncResponses()) + pending = {"status": "in_progress", "id": "resp-1"} + + # deadline 读表 100.0(deadline=110);remaining 读表恰好 110.0 → == 0。 + clock = iter([100.0, 110.0, 121.0, 121.0]) + monkeypatch.setattr( + oc, "time", SimpleNamespace(monotonic=lambda: next(clock), sleep=_time.sleep) + ) + with pytest.raises(TimeoutError): + provider._poll_background_response(pending, {}) + assert sync_calls == [], "remaining == 0 即超时,不得再发起 retrieve" + + clock = iter([100.0, 110.0, 121.0, 121.0]) + monkeypatch.setattr( + oc, "time", SimpleNamespace(monotonic=lambda: next(clock), sleep=_time.sleep) + ) + + async def consume(): + await provider._poll_background_response_async(pending, {}) + + with pytest.raises(TimeoutError): + asyncio.run(consume()) + assert async_calls == [], "remaining == 0 即超时,不得再发起 retrieve" + + +# --- 布尔运算符互换:and/or 互换后仍绿的存活点 ----------------------------- +# +# 来源:把守卫里的 and↔or 互换后全量仍绿的存活点。存活即说明「只满足第一个 +# 条件」或「只满足第二个条件」的组合从未被测试覆盖。逐条用探针确认行为改变 +# 后按守卫存在时的行为写断言。 + + +def test_anthropic_output_format_requires_beta_for_tool_runner_only(): + """只开 ``for_tool_runner``(beta=False)时 output_format 仍被拒绝。 + + ``beta and for_tool_runner`` 改 ``or`` 后,只开 tool_runner 的组合会被 + 误放行,output_format 静默进请求。 + """ + provider = object.__new__(AnthropicProvider) + provider._options = {} + provider._model_name = "claude-sonnet-4-5" + with pytest.raises(ValueError, match="请使用 parse 或 stream"): + provider._build_message_params( + prompt="hi", output_format=str, stream=False, beta=False, for_tool_runner=True + ) + # 两个都开时照常放行。 + params = provider._build_message_params( + prompt="hi", output_format=str, stream=False, beta=True, for_tool_runner=True + ) + assert "output_format" in params + + +def test_anthropic_stream_ignores_empty_refusal_delta(): + """空 refusal 的 ``refusal_delta`` 事件不得报成拒答(``and`` 不是 ``or``)。 + + ``content_block_start``(type=refusal、无文本)转换出 ``refusal=''`` 的 + 事件;收窄成 ``or`` 后把它误报为拒答,用户看到空拒答消息。 + """ + + def gen(): + yield {"type": "content_block_start", "index": 0, "content_block": {"type": "refusal"}} + yield {"type": "message_stop"} + + class _Resp: + def __iter__(self): + return gen() + + assert list(AnthropicProvider._iter_invoke_stream_text(_Resp())) == [] + + +def test_anthropic_async_stream_ignores_empty_refusal_delta(): + """异步分支同判据。""" + + async def agen(): + yield {"type": "content_block_start", "index": 0, "content_block": {"type": "refusal"}} + yield {"type": "message_stop"} + + class _Resp: + def __aiter__(self): + return agen() + + async def collect(): + return [t async for t in AnthropicProvider._aiter_invoke_stream_text(_Resp())] + + assert asyncio.run(collect()) == [] + + +def test_google_generation_config_accepts_only_max_output_tokens(): + """只设 ``max_output_tokens``(不设 ``max_tokens``)时不得报重复。 + + ``and`` 改 ``or`` 后只设一个别名也会触发「重复」误报。 + """ + provider = object.__new__(GoogleProvider) + provider._options = {"max_output_tokens": 100} + provider._model_name = "gemini-2.0-flash" + config = provider._build_generation_config(None, None, None) + assert getattr(config, "max_output_tokens", None) == 100 + + +def test_google_count_tokens_devapi_without_system_prompt_proceeds(): + """Developer API 且**未**提供 system prompt 时不得报「不支持 system_prompt」。 + + ``vertexai is False and effective_system`` 改 ``or`` 后,只要走 Developer + API 就误报——普通 token 计数直接失败。 + """ + from types import SimpleNamespace + + from src.providers.__base__.model_provider import CompletionRequest + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._model_name = "gemini-2.0-flash" + provider._effective_vertex_mode = lambda _client: False + provider._token_count_system_prompt = lambda _request: None + provider._validate_token_count_fields = lambda _request: None + models = SimpleNamespace(count_tokens=lambda **_kw: SimpleNamespace(total_tokens=5)) + provider._get_client = lambda: SimpleNamespace(models=models) + assert provider.count_tokens(CompletionRequest(prompt="hi")) == 5 + + +def test_google_count_tokens_devapi_without_tools_proceeds(): + """Developer API 且未提供 tools 时不得报「不支持 tools」。""" + from types import SimpleNamespace + + from src.providers.__base__.model_provider import CompletionRequest + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._model_name = "gemini-2.0-flash" + provider._effective_vertex_mode = lambda _client: False + provider._token_count_system_prompt = lambda _request: None + provider._validate_token_count_fields = lambda _request: None + models = SimpleNamespace(count_tokens=lambda **_kw: SimpleNamespace(total_tokens=7)) + provider._get_client = lambda: SimpleNamespace(models=models) + assert provider.count_tokens(CompletionRequest(prompt="hi")) == 7 + + +def test_google_compute_tokens_vertex_without_system_prompt_proceeds(): + """Vertex 模式且未提供 system prompt 时不得报「不支持 system_prompt」。""" + from types import SimpleNamespace + + from src.providers.__base__.model_provider import CompletionRequest + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._model_name = "gemini-2.0-flash" + provider._effective_vertex_mode = lambda _client: True + provider._token_count_system_prompt = lambda _request: None + provider._validate_compute_token_fields = lambda _request: None + sentinel = SimpleNamespace(total_tokens=9) + models = SimpleNamespace(compute_tokens=lambda **_kw: sentinel) + provider._get_client = lambda: SimpleNamespace(models=models) + assert provider.compute_tokens(CompletionRequest(prompt="hi")) is sentinel + + +def test_google_async_count_tokens_devapi_without_system_prompt_proceeds(): + """异步 count_tokens 同判据。""" + from types import SimpleNamespace + + from src.providers.__base__.model_provider import CompletionRequest + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._model_name = "gemini-2.0-flash" + provider._effective_vertex_mode = lambda _client: False + provider._token_count_system_prompt = lambda _request: None + provider._validate_token_count_fields = lambda _request: None + + async def count_tokens(**_kw): + return SimpleNamespace(total_tokens=11) + + models = SimpleNamespace(count_tokens=count_tokens) + provider._get_client = lambda: SimpleNamespace( + aio=SimpleNamespace(models=models), models=models + ) + + async def run(): + return await provider.async_count_tokens(CompletionRequest(prompt="hi")) + + assert asyncio.run(run()) == 11 + + +def test_google_async_compute_tokens_vertex_without_system_prompt_proceeds(): + """异步 compute_tokens 同判据。""" + from types import SimpleNamespace + + from src.providers.__base__.model_provider import CompletionRequest + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._model_name = "gemini-2.0-flash" + provider._effective_vertex_mode = lambda _client: True + provider._token_count_system_prompt = lambda _request: None + provider._validate_compute_token_fields = lambda _request: None + + sentinel = SimpleNamespace(total_tokens=13) + + async def compute_tokens(**_kw): + return sentinel + + models = SimpleNamespace(compute_tokens=compute_tokens) + provider._get_client = lambda: SimpleNamespace( + aio=SimpleNamespace(models=models), models=models + ) + + async def run(): + return await provider.async_compute_tokens(CompletionRequest(prompt="hi")) + + assert asyncio.run(run()) is sentinel + + +def test_google_validate_tuning_reward_accepts_only_composite_config(): + """只传 ``composite_reward_config`` 时不得报「不能同时传入」。""" + from types import SimpleNamespace + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._get_client = lambda: object() + tunings = SimpleNamespace(validate_reward=lambda **_kw: "ok") + provider._require_resource = lambda *_a, **_kw: tunings + provider._resource_config_kwargs = lambda _cls, _config, _kwargs: {} + assert ( + provider.validate_tuning_reward( + "t1", sample_response={}, example={}, composite_reward_config={"b": 2} + ) + == "ok" + ) + + +def test_google_async_validate_tuning_reward_accepts_only_composite_config(): + """异步分支同判据。""" + from types import SimpleNamespace + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._get_client = lambda: SimpleNamespace(aio=object()) + + async def validate_reward(**_kw): + return "ok-async" + + tunings = SimpleNamespace(validate_reward=validate_reward) + provider._require_resource = lambda *_a, **_kw: tunings + provider._resource_config_kwargs = lambda _cls, _config, _kwargs: {} + + async def run(): + return await provider.async_validate_tuning_reward( + "t1", sample_response={}, example={}, composite_reward_config={"b": 2} + ) + + assert asyncio.run(run()) == "ok-async" + + +def test_openai_responses_format_rejects_empty_schema(): + """``json_schema.schema`` 为空对象时必须拒绝(``not json_schema`` 不可省)。 + + ``or`` 改 ``and`` 后空 schema 被静默接受——客户端以为拿到了结构化输出 + 约束,实际发的是空约束(与 Ark 副本修复前同一缺陷形态)。 + """ + with pytest.raises(ValueError, match="缺少 schema 字段"): + OpenAICompatibleProvider._convert_responses_format( + {"type": "json_schema", "json_schema": {"name": "x", "schema": {}}} + ) + + +def test_facade_getattr_allows_dunder_suffix_without_prefix(): + """只以 ``__`` 结尾(不以 ``__`` 开头)的属性名照常转发。 + + ``startswith('__') and endswith('__')`` 改 ``or`` 后,``beta__`` 这类 + 正常资源名被误判成 dunder 探测而拒绝。 + """ + import src.providers.resources as resources_module + + facade_cls = next( + cls + for name in dir(resources_module) + if isinstance(cls := getattr(resources_module, name), type) + and getattr(cls, "_delegate_native", False) + and "OpenAI" in name + ) + + class _Client: + beta__ = "ok" + + class _Provider: + sdk_client = _Client() + async_sdk_client = _Client() + + def __init__(self): + self._provider = "openai" + + facade = object.__new__(facade_cls) + object.__setattr__(facade, "provider", _Provider()) + assert facade.beta__ == "ok" + + +def test_volcengine_chat_request_accepts_only_max_completion_tokens(): + """只设 ``max_completion_tokens`` 时不得报「不能同时设置」。""" + provider = object.__new__(VolcengineProvider) + provider._options = {} + provider._model_name = "doubao" + provider._protocol = "chat_completions" + provider._request_options = lambda: {"max_completion_tokens": 20} + request = provider._build_chat_request(prompt="hi", stream=False) + assert request["max_completion_tokens"] == 20 + + +def test_volcengine_chat_request_top_k_param_without_extra_body(): + """请求级 ``top_k``(模型 options 未设)时不得报重复。""" + provider = object.__new__(VolcengineProvider) + provider._options = {} + provider._model_name = "doubao" + provider._protocol = "chat_completions" + provider._request_options = dict + request = provider._build_chat_request(prompt="hi", stream=False, top_k=5) + assert request["extra_body"]["top_k"] == 5 + + +def test_volcengine_chat_request_seed_param_without_extra_body(): + """请求级 ``seed``(模型 options 未设)时不得报重复。""" + provider = object.__new__(VolcengineProvider) + provider._options = {} + provider._model_name = "doubao" + provider._protocol = "chat_completions" + provider._request_options = dict + request = provider._build_chat_request(prompt="hi", stream=False, seed=7) + assert request["extra_body"]["seed"] == 7 + + +def test_volcengine_responses_request_seed_param_without_extra_body(): + """Responses 请求级 ``seed`` 同判据。""" + provider = object.__new__(VolcengineProvider) + provider._options = {} + provider._model_name = "doubao" + provider._protocol = "responses" + provider._request_options = dict + from src.providers.__base__.model_provider import CompletionRequest + + request = provider._build_responses_request(CompletionRequest(prompt="hi", seed=7)) + assert request["extra_body"]["seed"] == 7 + + +def test_native_item_keeps_id_and_status_metadata(): + """原生内容块 item 的 ``id``/``status`` 元数据必须保留(``not in`` 不是 ``in``)。 + + 内容块转换只产出 SDK 输入联合里的字段,item 级元数据不在其中; + ``key not in converted`` 换成 ``key in converted`` 后两个字段都被丢掉。 + """ + from src.providers.__base__.model_provider import _normalize_responses_native_item + + result = _normalize_responses_native_item( + {"type": "input_text", "text": "hi", "id": "i1", "status": "s1"}, "loc", "openai" + ) + + assert result["id"] == "i1" + assert result["status"] == "s1" + assert result["text"] == "hi" + + +def test_responses_content_part_extracts_nested_image_file_id(): + """嵌套 ``image_url`` 对象里的 ``file_id`` 要提取出来(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 file_id 被丢掉:同时带 url 时静默少发一个字段, + 只带 file_id 时报「缺少 image_url 或 file_id」——用户按文档写嵌套形态 + 却被拒。 + """ + from src.providers.__base__.model_provider import _responses_content_part + + both = _responses_content_part( + {"type": "input_image", "image_url": {"url": "https://x/y.png", "file_id": "f1"}}, "loc" + ) + assert both["image_url"] == "https://x/y.png" + assert both["file_id"] == "f1" + + only_id = _responses_content_part( + {"type": "input_image", "image_url": {"file_id": "f2"}}, "loc" + ) + assert only_id["file_id"] == "f2" + + +# --- 否定变异(not / not in / is not)扫描的覆盖缺口 ------------------------- +# +# 把 ``is not`` 换成 ``is``、``not in`` 换成 ``in``、去掉 ``not`` 后全量仍绿的 +# 存活点。这批集中在 Responses 输入转换与 Anthropic 参数装配——两处都是 +# 「可选字段有就给、没有就不给」的形态,判据反向时字段被静默丢掉。 + + +def test_function_call_output_keeps_status_for_list_output(): + """列表 ``output`` 的 ``function_call_output`` 保留 ``status``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 ``status`` 被丢掉——工具结果的状态元数据静默消失。 + """ + from src.providers.__base__.model_provider import _responses_function_output_item + + item = _responses_function_output_item( + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": "hi"}], + "status": "completed", + }, + "loc", + ) + + assert item["status"] == "completed" + + +def test_function_call_output_keeps_status_for_text_output(): + """纯文本 ``output`` 的分支同样保留 ``status``(两处判据逐字相同)。 + + 1062 行与 1051 行是两份独立拷贝,只测其中一份时另一份反向仍绿。 + """ + from src.providers.__base__.model_provider import _responses_function_output_item + + item = _responses_function_output_item( + { + "type": "function_call_output", + "call_id": "c1", + "output": "hi", + "status": "completed", + }, + "loc", + ) + + assert item["status"] == "completed" + + +def test_responses_content_part_extracts_nested_image_url(): + """嵌套 ``image_url`` 对象里的 ``url`` 要提取出来(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后走 ``source.pop("image_url")`` 分支,url 被丢掉。 + """ + from src.providers.__base__.model_provider import _responses_content_part + + converted = _responses_content_part( + {"type": "input_image", "image_url": {"url": "https://x/y.png"}}, "loc" + ) + + assert converted["image_url"] == "https://x/y.png" + + +def test_responses_content_part_extracts_nested_file_url(): + """``{"type": "file", "file": {"url": ...}}`` 的 ``url`` 映射成 ``file_url``(``not in`` 不是 ``in``)。 + + ``not in`` 换成 ``in`` 后嵌套 url 不再提升到顶层,整条输入被 + 「缺少 file_id、file_data 或 file_url」拒绝——用户按文档写嵌套形态却被拒。 + """ + from src.providers.__base__.model_provider import _responses_content_part + + converted = _responses_content_part({"type": "file", "file": {"url": "https://x/y.pdf"}}, "loc") + + assert converted == {"type": "input_file", "file_url": "https://x/y.pdf"} + + +def test_input_file_detail_allowlist_accepts_only_known_values(): + """``input_file`` 的 ``detail`` 白名单只放行 auto/low/high(``not in`` 不是 ``in``)。 + + ``not in`` 换成 ``in`` 后判据反转:合法的 ``high`` 被拒、任意的 ``weird`` + 被放行发给 SDK。 + """ + from src.providers.__base__.model_provider import _responses_content_part + + accepted = _responses_content_part( + {"type": "input_file", "file_id": "f1", "detail": "high"}, "loc", provider="openai" + ) + assert accepted["detail"] == "high" + + with pytest.raises(ValueError, match="detail 不受 Openai Responses SDK 支持"): + _responses_content_part( + {"type": "input_file", "file_id": "f1", "detail": "weird"}, "loc", provider="openai" + ) + + +def test_retry_async_stream_closes_iterator_on_early_exit(): + """提前退出时关闭被包装的异步迭代器(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 ``aclose()`` 永不调用——消费方 ``break`` 之后底层 + 连接不会被释放。 + """ + from src.providers.__base__.model_provider import retry_async_stream + + closed: list[bool] = [] + + def _make(): + async def _gen(): + try: + yield 1 + yield 2 + yield 3 + finally: + closed.append(True) + + return _gen() + + async def _consume_one(): + stream = retry_async_stream(_make) + first = await stream.__anext__() + await stream.aclose() + # 快照必须在协程**内**取:``asyncio.run`` 的收尾会关闭仍在引用的 + # 异步生成器,把「未显式关闭」的差异掩盖成相同结果。 + return first, list(closed) + + first, closed_after_close = asyncio.run(_consume_one()) + assert first == 1 + assert closed_after_close == [True] + + +def test_merge_tool_call_fragment_keeps_existing_text_arguments(): + """已有文本 ``arguments`` 时不被子片段的非字符串取值覆盖(去掉 ``not`` 后反转)。 + + ``not merged.get("arguments")`` 的 ``not`` 去掉后,非字符串分片会覆盖 + 已累积的文本参数——流式拼接出的参数从中间被替换成对象。 + """ + from src.providers.__base__.model_provider import merge_tool_call_fragment + + accumulator: dict = {} + merge_tool_call_fragment(accumulator, {"index": 0, "arguments": "abc"}) + _, merged = merge_tool_call_fragment(accumulator, {"index": 0, "arguments": {"obj": 1}}) + + assert merged["arguments"] == "abc" + + +# --- Anthropic 可选参数:``is not None`` 判据 --------------------------------- + + +def _anthropic_provider(): + provider = object.__new__(AnthropicProvider) + provider._model_name = "m" + provider._options = {} + provider._default_max_tokens = lambda: 1024 + return provider + + +def test_anthropic_message_params_keep_stop_sequences(): + """``stop`` 给出时装配 ``stop_sequences``(``is not None`` 不是 ``is None``)。""" + params = _anthropic_provider()._build_message_params(prompt="hi", stop="END") + + assert params["stop_sequences"] == ["END"] + + +def test_anthropic_message_params_keep_tool_choice(): + """``tool_choice`` 给出时装配(``is not None`` 不是 ``is None``)。""" + params = _anthropic_provider()._build_message_params(prompt="hi", tool_choice={"type": "auto"}) + + assert params["tool_choice"] == {"type": "auto"} + + +def test_anthropic_message_params_keep_container_and_inference_geo(): + """``container``/``inference_geo`` 给出时装配(两个 ``is not None`` 各自要判)。""" + params = _anthropic_provider()._build_message_params( + prompt="hi", container="c1", inference_geo="us" + ) + + assert params["container"] == "c1" + assert params["inference_geo"] == "us" + + +def test_anthropic_count_tokens_forwards_extra_query(): + """``count_tokens`` 转发 ``extra_query``(``is not None`` 不是 ``is None``)。""" + captured: dict = {} + + def _client(): + def count_tokens(**kwargs): + captured.update(kwargs) + return SimpleNamespace(input_tokens=3) + + return SimpleNamespace(messages=SimpleNamespace(count_tokens=count_tokens)) + + provider = _anthropic_provider() + provider._get_client = _client + + provider.count_tokens(CompletionRequest(prompt="hi", extra_query={"k": "v"})) + + assert captured["extra_query"] == {"k": "v"} + + +# --- Anthropic 四个 count_tokens 兄弟方法的可选字段透传 ----------------------- +# +# 四个方法(``count_tokens``/``beta_count_tokens``/``async_count_tokens``/ +# ``async_beta_count_tokens``)逐字重复同一组 ``if request.X is not None: +# params[...] = X``。否定变异扫描里这 40 余处**全部**存活:没有任何测试断言 +# 这些可选字段被转发到 SDK 调用。表驱动覆盖,避免逐条重复。 + +_ANTHROPIC_TOKEN_COUNT_COMMON_CASES = [ + ({"tool_choice": {"type": "auto"}}, "tool_choice", {"type": "auto"}), + ({"cache_control": {"type": "ephemeral"}}, "cache_control", {"type": "ephemeral"}), + ({"user": "u1"}, "user_profile_id", "u1"), + ( + {"output_config": {"format": {"type": "json_schema", "name": "x", "schema": {}}}}, + "output_config", + {"format": {"type": "json_schema", "name": "x", "schema": {}}}, + ), + ( + {"thinking": {"type": "enabled", "budget_tokens": 1024}}, + "thinking", + {"type": "enabled", "budget_tokens": 1024}, + ), + ({"extra_body": {"custom": "v"}}, "extra_body", {"custom": "v"}), + ({"extra_query": {"k": "v"}}, "extra_query", {"k": "v"}), + ({"timeout": 5.0}, "timeout", 5.0), + ({"extra_headers": {"X-Trace": "1"}}, "extra_headers", {"X-Trace": "1"}), +] + +_ANTHROPIC_TOKEN_COUNT_BETA_CASES = [ + ({"context_management": {"edits": []}}, "context_management", {"edits": []}), + ({"mcp_servers": [{"url": "https://x"}]}, "mcp_servers", [{"url": "https://x"}]), + ({"speed": "fast"}, "speed", "fast"), +] + + +def _capturing_anthropic_provider(captured: dict, *, beta: bool): + """构造一个把 ``count_tokens`` 参数记进 ``captured`` 的 Anthropic Provider。""" + provider = _anthropic_provider() + + def _count_tokens(**kwargs): + captured.update(kwargs) + return SimpleNamespace(input_tokens=3) + + messages = SimpleNamespace(count_tokens=_count_tokens) + client = ( + SimpleNamespace(beta=SimpleNamespace(messages=messages)) + if beta + else SimpleNamespace(messages=messages) + ) + provider._get_client = lambda: client + provider._get_aclient = lambda: client + return provider + + +@pytest.mark.parametrize( + "method_name,beta", + [ + ("count_tokens", False), + ("beta_count_tokens", True), + ("async_count_tokens", False), + ("async_beta_count_tokens", True), + ], +) +@pytest.mark.parametrize("request_kwargs,param_key,expected", _ANTHROPIC_TOKEN_COUNT_COMMON_CASES) +def test_anthropic_token_count_forwards_common_optional_field( + method_name, beta, request_kwargs, param_key, expected +): + """四个 count_tokens 方法都转发通用可选字段(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后字段被静默丢掉——调用方设置了 ``timeout``/``user`` + 却不会生效。 + """ + captured: dict = {} + provider = _capturing_anthropic_provider(captured, beta=beta) + + result = getattr(provider, method_name)(CompletionRequest(prompt="hi", **request_kwargs)) + if method_name.startswith("async_"): + asyncio.run(result) + + assert captured[param_key] == expected + + +@pytest.mark.parametrize("method_name", ["beta_count_tokens", "async_beta_count_tokens"]) +@pytest.mark.parametrize("request_kwargs,param_key,expected", _ANTHROPIC_TOKEN_COUNT_BETA_CASES) +def test_anthropic_beta_token_count_forwards_beta_only_field( + method_name, request_kwargs, param_key, expected +): + """Beta 专属字段只在 beta 方法上转发(``is not None`` 不是 ``is None``)。""" + captured: dict = {} + provider = _capturing_anthropic_provider(captured, beta=True) + + result = getattr(provider, method_name)(CompletionRequest(prompt="hi", **request_kwargs)) + if method_name.startswith("async_"): + asyncio.run(result) + + assert captured[param_key] == expected + + +@pytest.mark.parametrize("method_name", ["beta_count_tokens", "async_beta_count_tokens"]) +def test_anthropic_beta_token_count_merges_profile_beta(method_name): + """``betas`` 与 user profile 头合并后转发(``is not None`` 不是 ``is None``)。""" + captured: dict = {} + provider = _capturing_anthropic_provider(captured, beta=True) + + result = getattr(provider, method_name)( + CompletionRequest(prompt="hi", user="u1", betas=["custom-beta"]) + ) + if method_name.startswith("async_"): + asyncio.run(result) + + assert captured["betas"] == ["custom-beta", "user-profiles-2026-08-18"] + + +# --- Google ``_build_generation_config`` 的可选字段透传 ---------------------- +# +# 与 Anthropic 的 count_tokens 同一形态:一串 ``if X is not None: values[...] = X`` +# 在否定变异扫描里全部存活——没有任何测试断言这些生成参数真的进了 +# ``GenerateContentConfig``。 + +_GOOGLE_GENERATION_CONFIG_CASES = [ + ({"top_p": 0.9}, "top_p", 0.9), + ({"top_k": 7}, "top_k", 7), + ({"frequency_penalty": 0.5}, "frequency_penalty", 0.5), + ({"presence_penalty": 0.4}, "presence_penalty", 0.4), + ({"seed": 11}, "seed", 11), + ({"modalities": ["TEXT"]}, "response_modalities", ["TEXT"]), + ({"stop": "END"}, "stop_sequences", ["END"]), +] + + +def _google_provider(options=None): + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-2.0-flash" + provider._options = options or {} + return provider + + +@pytest.mark.parametrize("kwargs,attribute,expected", _GOOGLE_GENERATION_CONFIG_CASES) +def test_google_generation_config_forwards_optional_field(kwargs, attribute, expected): + """生成参数逐个进 ``GenerateContentConfig``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后参数被静默丢掉——调用方设置的 ``seed``/``stop`` + 不会到达服务端。 + """ + config = _google_provider()._build_generation_config(None, None, None, **kwargs) + + assert getattr(config, attribute) == expected + + +def test_google_generation_config_forwards_tool_choice(): + """``tool_choice`` 转成 ``tool_config``(``is not None`` 不是 ``is None``)。""" + config = _google_provider()._build_generation_config( + None, None, None, tool_choice={"type": "auto"} + ) + + assert config.tool_config is not None + assert config.tool_config.function_calling_config.mode.name == "AUTO" + + +def test_google_generation_config_forwards_options_thinking(): + """模型 options 里的 ``thinking`` 转成 ``thinking_config``(``is not None`` 不是 ``is None``)。""" + provider = _google_provider({"thinking": {"thinking_budget": 1024}}) + + config = provider._build_generation_config(None, None, None) + + assert config.thinking_config is not None + + +# --- Google / OpenAI 兼容:去掉 ``not`` 或反转 ``is not`` 的存活点 ------------ + + +def test_google_tool_choice_accepts_non_empty_function_name(): + """``function.name`` 合法时照常转换(第二个 ``not`` 去掉后判据反转)。 + + ``not name.strip()`` 的 ``not`` 去掉后,非空名字反而被拒——合法的 + function tool_choice 一律抛「缺少 function.name」。 + """ + from src.providers.google import GoogleProvider + + config = GoogleProvider._convert_tool_choice( + {"type": "function", "function": {"name": "get_weather"}} + ) + + assert config.function_calling_config.mode.name == "ANY" + assert config.function_calling_config.allowed_function_names == ["get_weather"] + + +def test_google_tool_result_resolves_name_from_call_id(): + """工具结果缺 ``name`` 时按 ``tool_call_id`` 反查(``not part_name`` 的 ``not`` 去掉后不再反查)。 + + 反查被跳过时报「缺少可关联的 tool_call_id/name」——而 id 恰好是调用方 + 唯一给出的关联信息。 + """ + from src.providers.google import GoogleProvider + + contents = GoogleProvider._contents( + [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ], + None, + None, + ) + + assert "lookup" in str(contents) + + +def test_google_candidate_refusal_falls_back_to_part_text(): + """``type=refusal`` 且 ``refusal`` 为空时退回 ``text``(``not value`` 的 ``not`` 去掉后不再退回)。 + + 退回被跳过时拒答被读成 ``None``,安全拦截表现为一次正常回复。 + """ + from src.providers.google import GoogleProvider + + candidate = { + "content": {"parts": [{"type": "refusal", "text": "无法回答该问题"}]}, + } + + assert GoogleProvider._candidate_refusal(candidate) == "无法回答该问题" + + +def test_google_merge_tool_arguments_merges_into_existing_element(): + """列表参数在**已有**元素上继续递归合并(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后已有元素被新值整体替换——分片追加变成覆盖,前一 + 分片里的字段丢失。 + """ + from src.providers.google import GoogleProvider + + merged = GoogleProvider._merge_tool_arguments([{"a": 1}], [{"b": 2}]) + + assert merged == [{"a": 1, "b": 2}] + + +def test_chat_request_forwards_stop_parameter(): + """Chat Completions 转发 ``stop``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后停止序列被静默丢掉——调用方设置的停止词不生效。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "gpt-4o" + provider._provider = "openai" + provider._options = {} + + request = provider._build_chat_request(prompt="hi", stop=["END"]) + + assert request["stop"] == ["END"] + + +def test_anthropic_beta_create_rejects_output_format(): + """``beta_create`` 拒绝 ``output_format``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后守卫不触发,落到 ``_build_message_params`` 的同名拒绝—— + 两者消息前缀相同、**指引不同**(这里指向 ``beta_parse``,那里指向 ``parse``), + 断言必须带上指引部分。 + """ + provider = _anthropic_provider() + + with pytest.raises(ValueError, match="请使用 beta_parse 或 beta_stream"): + provider.beta_create(CompletionRequest(prompt="hi", output_format={"type": "json_schema"})) + + +def test_anthropic_async_beta_create_rejects_output_format(): + """异步 ``async_beta_create`` 与同步版同判据(``is not None`` 不是 ``is None``)。""" + provider = _anthropic_provider() + + async def _call(): + await provider.async_beta_create( + CompletionRequest(prompt="hi", output_format={"type": "json_schema"}) + ) + + with pytest.raises(ValueError, match="请使用 beta_parse 或 beta_stream"): + asyncio.run(_call()) + + +def test_anthropic_async_count_tokens_prefers_explicit_output_config(): + """显式 ``output_config`` 不被 ``response_format`` 覆盖(``not in`` 不是 ``in``)。 + + ``not in`` 换成 ``in`` 后 fallback 无条件覆盖,用户显式给的 output_config + 被 ``response_format`` 派生的值替换。 + """ + captured: dict = {} + provider = _capturing_anthropic_provider(captured, beta=False) + + asyncio.run( + provider.async_count_tokens( + CompletionRequest( + prompt="hi", + output_config={"explicit": True}, + response_format={"type": "json_object"}, + ) + ) + ) + + assert captured["output_config"] == {"explicit": True} + + +def test_chat_request_forwards_remaining_optional_fields(): + """Chat Completions 转发 ``top_p``/``user``/``extra_query``(三个 ``is not None`` 各自要判)。 + + 与 ``stop`` 同形态:``is None`` 变异后字段被静默丢掉。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "gpt-4o" + provider._provider = "openai" + provider._options = {} + + request = provider._build_chat_request( + prompt="hi", top_p=0.9, user="u1", extra_query={"k": "v"} + ) + + assert request["top_p"] == 0.9 + assert request["user"] == "u1" + assert request["extra_query"] == {"k": "v"} + + +def test_jina_rerank_accepts_valid_results_list(monkeypatch): + """合法响应(``results`` 是列表)照常解析(去掉 ``not`` 后反转成「是列表就报错」)。 + + 判据反转后每一次**成功**的 rerank 都被当成响应非法,检索直接失败。 + """ + import httpx + + from src.providers.jina import JinaProvider + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"results": [{"index": 0, "relevance_score": 0.9}]} + + class _Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, *_args, **_kwargs): + return _Response() + + monkeypatch.setattr(httpx, "Client", _Client) + provider = object.__new__(JinaProvider) + provider._model_name = "m" + provider._options = {} + provider._base_url = "https://api.jina.ai/v1/rerank" + provider._get_headers = dict + + indices, scores = provider.rerank("q", ["a"], 1) + + assert indices == [0] + assert scores == [0.9] + + +def test_jina_arerank_accepts_valid_results_list(monkeypatch): + """异步 ``arerank`` 与同步版同判据(去掉 ``not`` 后同样反转)。""" + import httpx + + from src.providers.jina import JinaProvider + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"results": [{"index": 0, "relevance_score": 0.9}]} + + class _AsyncClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, *_args, **_kwargs): + return _Response() + + monkeypatch.setattr(httpx, "AsyncClient", _AsyncClient) + provider = object.__new__(JinaProvider) + provider._model_name = "m" + provider._options = {} + provider._base_url = "https://api.jina.ai/v1/rerank" + provider._get_headers = dict + + indices, scores = asyncio.run(provider.arerank("q", ["a"], 1)) + + assert indices == [0] + assert scores == [0.9] + + +def test_google_safety_block_chunk_emits_finish_event(): + """安全拦截块要产出 ``finish`` 事件(``not any`` 的 ``not`` 去掉后不再产出)。 + + 去掉 ``not`` 后判据反转成「已有 finish 才追加」,而此处恰恰是**没有** + finish 的路径——被拦截的流丢失终止事件,消费方看不到结束标记。 + """ + from src.providers.google import GoogleProvider + + chunk = SimpleNamespace( + candidates=[], + prompt_feedback=SimpleNamespace(block_reason="SAFETY", block_reason_message="m"), + ) + + events = GoogleProvider._stream_events_from_chunk(chunk) + + assert [event.type for event in events] == ["refusal_delta", "finish"] + assert events[-1].finish_reason == "SAFETY" + + +def test_google_vertex_count_tokens_keeps_system_instruction(): + """Vertex 模式下 ``count_tokens`` 带上 ``system_instruction``(``is not False`` 不是 ``is False``)。 + + ``is False`` 变异后该分支只在 Developer API 下成立——而那条路径已在上面 + 被拒,于是 system_instruction 永远进不了 config。 + """ + captured: dict = {} + + def _client(): + def count_tokens(**kwargs): + captured.update(kwargs) + return SimpleNamespace(total_tokens=3) + + return SimpleNamespace(models=SimpleNamespace(count_tokens=count_tokens)) + + provider = _google_provider() + provider._get_client = _client + provider._effective_vertex_mode = lambda _client: True + provider._token_count_system_prompt = lambda _request: "sys" + + provider.count_tokens(CompletionRequest(prompt="hi")) + + assert captured["config"].system_instruction == "sys" + + +def test_google_async_vertex_count_tokens_keeps_system_instruction(): + """异步 ``async_count_tokens`` 与同步版同判据(``is not False`` 不是 ``is False``)。""" + captured: dict = {} + + def _client(): + def count_tokens(**kwargs): + captured.update(kwargs) + return SimpleNamespace(total_tokens=3) + + aio = SimpleNamespace(models=SimpleNamespace(count_tokens=count_tokens)) + return SimpleNamespace(models=SimpleNamespace(count_tokens=count_tokens), aio=aio) + + provider = _google_provider() + provider._get_client = _client + provider._effective_vertex_mode = lambda _client: True + provider._token_count_system_prompt = lambda _request: "sys" + + asyncio.run(provider.async_count_tokens(CompletionRequest(prompt="hi"))) + + assert captured["config"].system_instruction == "sys" + + +def test_chat_request_forwards_response_format_tool_choice_and_stream_options(): + """Chat Completions 转发 ``response_format``/``tool_choice``/``stream_options``。 + + 三者与 ``stop`` 同形态,``is None`` 变异后字段被静默丢掉。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "gpt-4o" + provider._provider = "openai" + provider._options = {} + + request = provider._build_chat_request( + prompt="hi", + response_format={"type": "json_object"}, + tool_choice="auto", + stream_options={"include_usage": True}, + ) + + assert request["response_format"] == {"type": "json_object"} + assert request["tool_choice"] == "auto" + assert request["stream_options"] == {"include_usage": True} + + +def test_chat_request_forwards_extra_headers(): + """Chat Completions 转发 ``extra_headers``(``is not None`` 不是 ``is None``)。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "gpt-4o" + provider._provider = "openai" + provider._options = {} + + request = provider._build_chat_request(prompt="hi", extra_headers={"X-Trace": "1"}) + + assert request["extra_headers"] == {"X-Trace": "1"} + + +def test_chat_request_uses_configured_max_completion_tokens_when_request_omits_it(): + """请求未给长度时回落到配置的 ``max_completion_tokens``。 + + ``is not None`` 变异后回落不生效,配置的输出长度被忽略。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "gpt-4o" + provider._provider = "openai" + provider._options = {"max_completion_tokens": 256} + + request = provider._build_chat_request(prompt="hi") + + assert request.get("max_completion_tokens") == 256 + + +def test_verified_protocols_reject_blank_string_entry(): + """``server_verified_protocols`` 拒绝空白字符串条目(``not value.strip()`` 的 ``not`` 去掉后反转)。 + + 反转后合法协议反而被拒,而空白串被放行并归一化成无效协议。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "openai" + + with pytest.raises(ValueError, match="必须是非空字符串"): + provider._normalize_verified_protocols(["chat_completions", " "]) + + assert provider._normalize_verified_protocols(["chat_completions"]) == {"chat_completions"} + + +def test_response_tool_keys_defaults_to_event_when_item_missing(): + """``_response_tool_keys`` 未给 ``item`` 时退回 ``event``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后一律用 ``event``,显式传入的 ``item`` 被忽略——关联键 + 取自错误的对象。 + """ + event = {"output_index": None} + item = {"id": "item-1"} + + keys = OpenAICompatibleProvider._response_tool_keys(event, item) + + assert ("item_id", "item-1") in keys + + +def test_remember_response_tool_metadata_prefers_arguments_field(): + """``_remember_response_tool_metadata`` 从 ``arguments`` 取参数(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 ``arguments`` 与 ``input`` 都不再看,工具调用元数据 + 丢失参数。 + """ + metadata: dict = {} + event = { + "item_id": "k1", + "item": {"id": "k1", "type": "function_call", "name": "lookup", "arguments": '{"a": 1}'}, + } + + OpenAICompatibleProvider._remember_response_tool_metadata(event, metadata) + + assert metadata[("item_id", "k1")]["arguments"] == '{"a": 1}' + + +def test_vector_store_file_update_keeps_none_attributes(): + """``attributes=None`` 按 SDK 契约原样传 ``None``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 ``attributes`` 变成 ``None`` 而不是传入的对象, + SDK 侧「清空属性」与「不传」的语义被混淆。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._aclient = SimpleNamespace( + vector_stores=SimpleNamespace(files=SimpleNamespace(update=object())) + ) + captured: dict = {} + + async def _call(_method, *, kwargs=None, **_rest): + captured.update(kwargs or {}) + return "ok" + + provider._call_async_sdk_resource = _call + + asyncio.run(provider.async_update_vector_store_file("vs1", "f1", attributes={"k": "v"})) + + assert captured["attributes"] == {"k": "v"} + + +def test_google_generation_config_forwards_thinking_and_service_tier(): + """``thinking``/``service_tier`` 参数进 ``GenerateContentConfig``。 + + 与其它生成参数同形态,``is None`` 变异后字段被静默丢掉。 + """ + config = _google_provider()._build_generation_config( + None, None, None, thinking={"thinking_budget": 512}, service_tier="flex" + ) + + assert config.thinking_config is not None + assert config.service_tier == "flex" + + +def test_google_vertex_count_tokens_keeps_tools(): + """Vertex 模式下 ``count_tokens`` 带上 ``tools``(``is not False`` 不是 ``is False``)。""" + captured: dict = {} + + def _client(): + def count_tokens(**kwargs): + captured.update(kwargs) + return SimpleNamespace(total_tokens=3) + + return SimpleNamespace(models=SimpleNamespace(count_tokens=count_tokens)) + + provider = _google_provider() + provider._get_client = _client + provider._effective_vertex_mode = lambda _client: True + provider._token_count_system_prompt = lambda _request: None + + provider.count_tokens( + CompletionRequest(prompt="hi", tools=[{"type": "function", "function": {"name": "f"}}]) + ) + + assert captured["config"].tools + + +def test_google_async_vertex_count_tokens_keeps_tools(): + """异步 ``async_count_tokens`` 与同步版同判据(``is not False`` 不是 ``is False``)。""" + captured: dict = {} + + def _client(): + def count_tokens(**kwargs): + captured.update(kwargs) + return SimpleNamespace(total_tokens=3) + + aio = SimpleNamespace(models=SimpleNamespace(count_tokens=count_tokens)) + return SimpleNamespace(models=SimpleNamespace(count_tokens=count_tokens), aio=aio) + + provider = _google_provider() + provider._get_client = _client + provider._effective_vertex_mode = lambda _client: True + provider._token_count_system_prompt = lambda _request: None + + asyncio.run( + provider.async_count_tokens( + CompletionRequest(prompt="hi", tools=[{"type": "function", "function": {"name": "f"}}]) + ) + ) + + assert captured["config"].tools + + +def test_google_options_generation_count_rejects_negative_at_construction(): + """构造期校验 ``options`` 里的 ``max_output_tokens``/``candidate_count``。 + + ``options[key] is not None`` 换成 ``is None`` 后构造期校验被跳过, + 非法取值要等首次提问才暴露。 + """ + from src.providers.google import GoogleProvider + + with pytest.raises(ValueError, match="max_output_tokens"): + GoogleProvider("gemini-2.0-flash", {"max_output_tokens": -1}) + + with pytest.raises(ValueError, match="candidate_count"): + GoogleProvider("gemini-2.0-flash", {"candidate_count": 0}) + + +def test_google_extract_refusal_prefers_block_reason_message(): + """``_extract_refusal`` 优先用 ``block_reason_message``(``not message.strip()`` 的 ``not`` 去掉后反转)。 + + 反转后合法消息被跳过,退回 ``message`` 字段——取到的是另一条文案。 + """ + from src.providers.google import GoogleProvider + + feedback = SimpleNamespace( + block_reason="SAFETY", block_reason_message="被安全策略拦截", message="其它消息" + ) + + assert ( + GoogleProvider._extract_refusal(SimpleNamespace(candidates=[], prompt_feedback=feedback)) + == "被安全策略拦截" + ) + + +def test_google_merge_tool_call_fragment_keeps_non_string_arguments(): + """非字符串参数直接写入(``arguments is not None`` 不是 ``is None``)。 + + ``is None`` 变异后非字符串参数(对象形态的 JSON 参数)被丢弃。 + """ + from src.providers.google import GoogleProvider + + accumulator: dict = {} + _, merged = GoogleProvider._merge_tool_call(accumulator, {"index": 0, "arguments": {"a": 1}}) + + assert merged["arguments"] == {"a": 1} + + +def test_google_stream_chunk_keeps_refusal_from_part_text(): + """流块里 ``type=refusal`` 且无 ``refusal`` 字段时退回 ``text``(去掉 ``not`` 后不再退回)。""" + from src.providers.google import GoogleProvider + + chunk = SimpleNamespace( + candidates=[ + SimpleNamespace( + content=SimpleNamespace(parts=[SimpleNamespace(type="refusal", text="拒答内容")]) + ) + ] + ) + + events = GoogleProvider._stream_events_from_chunk(chunk) + + assert any(event.type == "refusal_delta" for event in events) + + +def test_google_stream_chunk_emits_candidate_refusal_once(): + """candidate 拒答只发一次(``not refusal_emitted`` 的 ``not`` 去掉后判据反转)。 + + 去掉 ``not`` 后 ``refusal_emitted`` 为假时才发——首个候选之后不再发出, + 多候选响应丢掉后续拒答。 + """ + from src.providers.google import GoogleProvider + + chunk = SimpleNamespace( + candidates=[ + SimpleNamespace(content=SimpleNamespace(parts=[SimpleNamespace(refusal="第一条")])), + SimpleNamespace(content=SimpleNamespace(parts=[SimpleNamespace(refusal="第二条")])), + ] + ) + + events = GoogleProvider._stream_events_from_chunk(chunk) + refusals = [event.refusal for event in events if event.type == "refusal_delta"] + + assert refusals[0] == "第一条" + + +def test_siliconflow_rerank_accepts_valid_results_list(monkeypatch): + """SiliconFlow 合法响应照常解析(去掉 ``not`` 后反转成「是列表就报错」)。 + + 与 Jina 同一份近似副本、同一处判据,两处都要各自覆盖。 + """ + import httpx + + from src.providers.siliconflow_rerank import SiliconflowRerankProvider + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"results": [{"index": 0, "relevance_score": 0.9}]} + + class _Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, *_args, **_kwargs): + return _Response() + + monkeypatch.setattr(httpx, "Client", _Client) + provider = object.__new__(SiliconflowRerankProvider) + provider._model_name = "m" + provider._options = {} + provider._base_url = "https://api.siliconflow.cn/v1/rerank" + provider._get_headers = dict + + indices, scores = provider.rerank("q", ["a"], 1) + + assert indices == [0] + assert scores == [0.9] + + +def test_siliconflow_arerank_accepts_valid_results_list(monkeypatch): + """异步 ``arerank`` 与同步版同判据。""" + import httpx + + from src.providers.siliconflow_rerank import SiliconflowRerankProvider + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"results": [{"index": 0, "relevance_score": 0.9}]} + + class _AsyncClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, *_args, **_kwargs): + return _Response() + + monkeypatch.setattr(httpx, "AsyncClient", _AsyncClient) + provider = object.__new__(SiliconflowRerankProvider) + provider._model_name = "m" + provider._options = {} + provider._base_url = "https://api.siliconflow.cn/v1/rerank" + provider._get_headers = dict + + indices, scores = asyncio.run(provider.arerank("q", ["a"], 1)) + + assert indices == [0] + assert scores == [0.9] + + +def test_retry_async_stream_invokes_first_event_validator(): + """首个事件交给 ``first_event_validator``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后校验器永不调用——流建立阶段的错误事件不再被拦。 + """ + from src.providers.__base__.model_provider import retry_async_stream + + seen: list = [] + + def _make(): + async def _gen(): + yield {"type": "error", "message": "boom"} + + return _gen() + + async def _consume(): + stream = retry_async_stream(_make, first_event_validator=seen.append) + return await stream.__anext__() + + asyncio.run(_consume()) + + assert seen == [{"type": "error", "message": "boom"}] + + +def test_retry_async_stream_closes_iterator_when_first_event_raises(): + """首事件校验抛错时关闭迭代器(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后建立阶段的失败不释放底层流。 + """ + from src.providers.__base__.model_provider import retry_async_stream + + closed: list[bool] = [] + + def _make(): + async def _gen(): + try: + yield {"type": "error"} + finally: + closed.append(True) + + return _gen() + + def _boom(_event): + raise ValueError("bad first event") + + async def _consume(): + stream = retry_async_stream(_make, first_event_validator=_boom) + try: + await stream.__anext__() + except ValueError: + pass + return list(closed) + + assert asyncio.run(_consume()) == [True] + + +def test_merge_tool_call_fragment_keeps_non_string_arguments_without_existing(): + """已有参数为空时非字符串分片直接写入(``not merged.get("arguments")`` 的 ``not`` 去掉后反转)。""" + from src.providers.__base__.model_provider import merge_tool_call_fragment + + accumulator: dict = {} + _, merged = merge_tool_call_fragment(accumulator, {"index": 0, "arguments": {"a": 1}}) + + assert merged["arguments"] == {"a": 1} + + +# --- 其余否定变异存活点 ------------------------------------------------------- + + +def test_resource_proxy_dir_lists_cached_children(): + """``_NativeResourceProxy.__dir__`` 列出已缓存的子节点(``not name.startswith("_")`` 的 ``not`` 去掉后反转)。 + + 反转后只列出下划线开头的名字——资源名一个都不出现,``dir(proxy)`` + 与 ``__getattr__`` 的可达名字不一致。 + """ + from src.providers.resources import _NativeResourceProxy + + proxy = _NativeResourceProxy(SimpleNamespace(), None, "root") + proxy._children["messages"] = object() + + names = dir(proxy) + + assert "messages" in names + + +def test_anthropic_message_params_keep_cache_control_thinking_and_service_tier(): + """``cache_control``/``thinking``/``service_tier`` 给出时装配(三个 ``is not None`` 各自要判)。 + + 与 ``stop``/``tool_choice`` 同形态:``is None`` 变异后字段被静默丢掉。 + """ + params = _anthropic_provider()._build_message_params( + prompt="hi", + cache_control={"type": "ephemeral"}, + thinking={"type": "enabled", "budget_tokens": 1024}, + service_tier="standard_only", + ) + + assert params["cache_control"] == {"type": "ephemeral"} + assert params["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert params["service_tier"] == "standard_only" + + +def _ark_responses_provider(): + provider = object.__new__(VolcengineProvider) + provider._options = {} + provider._model_name = "doubao" + provider._protocol = "responses" + provider._request_options = dict + return provider + + +def test_ark_responses_rejects_unsupported_field(): + """Ark Responses 拒绝不支持的请求字段(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后字典推导只收集**值为 None** 的字段,非空的不支持字段 + 不再进入拒绝列表——用户设置了 ``container`` 却被静默忽略。 + """ + provider = _ark_responses_provider() + + with pytest.raises(ValueError, match="container"): + provider._build_responses_request(CompletionRequest(prompt="hi", container="c1")) + + +def test_ark_responses_forwards_explicit_temperature(): + """显式 ``temperature`` 进请求(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后显式温度被丢掉,模型回落到服务端默认值。 + """ + provider = _ark_responses_provider() + + request = provider._build_responses_request(CompletionRequest(prompt="hi", temperature=0.25)) + + assert request["temperature"] == 0.25 + + +def test_ark_responses_rejects_credentials_in_extra_body(): + """Ark Responses 校验 ``extra_body`` 不含凭证(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后校验被跳过,凭证原样进请求体。 + """ + provider = _ark_responses_provider() + + with pytest.raises(ValueError, match="extra_body"): + provider._build_responses_request( + CompletionRequest(prompt="hi", extra_body={"api_key": "sk-live-SECRET"}) + ) + + +def test_anthropic_options_reject_unknown_key(): + """模型 ``options`` 里的未知键被拒(``key not in allowed_options`` 不是 ``key in``)。 + + ``not in`` 换成 ``in`` 后判据反转:**未知键被放行**、已知键反被拒—— + 用户写错一个字段名会被静默忽略。 + """ + provider = _anthropic_provider() + provider._options = {"unknown_option": 1} + + with pytest.raises(ValueError, match="unknown_option"): + provider._build_message_params(prompt="hi") + + +def test_google_async_validate_tuning_reward_forwards_single_config(): + """只传 ``single_reward_config`` 时转发它(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 kwargs 里什么都不放,SDK 收到空配置。 + """ + from types import SimpleNamespace + + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._get_client = lambda: SimpleNamespace(aio=object()) + captured: dict = {} + + async def validate_reward(**kwargs): + captured.update(kwargs) + return "ok" + + provider._require_resource = lambda *_a, **_kw: SimpleNamespace(validate_reward=validate_reward) + provider._resource_config_kwargs = lambda _cls, _config, _kwargs: {} + + asyncio.run( + provider.async_validate_tuning_reward( + "t1", sample_response={}, example={}, single_reward_config={"a": 1} + ) + ) + + assert captured["single_reward_config"] == {"a": 1} + + +def test_anthropic_stream_error_message_reads_nested_non_string_values(): + """嵌套错误对象里**非字符串**取值也要继续下钻(``value is not None`` 不是 ``is None``)。 + + ``is None`` 变异后非字符串取值被丢弃、不再入队,嵌套消息读不出来, + 用户只看到兜底文案「Anthropic Messages 流返回错误」。 + """ + provider = AnthropicProvider + + nested = SimpleNamespace(message=SimpleNamespace(message="深层消息")) + + assert provider._stream_error_message(nested) == "深层消息" + + +def test_google_merge_tool_call_keeps_non_string_arguments(): + """``_merge_tool_call`` 保留非字符串参数(``arguments is not None`` 不是 ``is None``)。 + + ``is None`` 变异后对象/列表形态的参数被丢弃,``arguments`` 变成空字典。 + """ + from src.providers.google import GoogleProvider + + _, merged = GoogleProvider._merge_tool_call({}, {"index": 0, "arguments": [1, 2]}) + assert merged["arguments"] == [1, 2] + + _, merged_int = GoogleProvider._merge_tool_call({}, {"index": 0, "arguments": 42}) + assert merged_int["arguments"] == 42 + + +# ── 否定变异扫描(face 11)补的覆盖缺口 ── +# +# 把守卫里的 ``not``/``not in``/``is not`` 变异后全量仍绿的 39 个存活点, +# 逐条用真实入口探针确认行为改变后补测试;27 处确认为真缺口,其余 12 处为 +# 等价变异(如 ``reject_unsupported_kwargs`` 自身已按 ``is not None`` 过滤, +# 上游再过滤一层不改变收场)。 + + +def test_anthropic_duplicate_sampling_field_with_extra_body(): + """``top_p`` 同时在 options 与 extra_body 时必须报错(两个 ``is not None`` 都要判)。 + + ``sampling_options[key] is not None`` 变异成 ``is None`` 后,只有 options + 一侧非空时不再报重复,请求字段被 ``extra_body`` 静默顶掉。 + """ + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-3-5-sonnet" + provider._options = {"top_p": 0.9} + provider._default_max_tokens = lambda: 1024 + + with pytest.raises(ValueError, match="top_p 与 extra_body 重复"): + provider._build_message_params(prompt="hi", extra_body={"top_p": 0.5}) + + +def test_anthropic_beta_only_fields_rejected_without_beta(): + """非 beta 调用传 beta 专属字段必须拒绝(``value is not None`` 不是 ``is None``)。 + + ``is None`` 变异后字典推导只收集**值为 None** 的 beta 字段,``mcp_servers`` + 这类非空字段不再进入 ``ignored``——参数被静默丢弃。 + """ + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-3-5-sonnet" + provider._options = {} + provider._default_max_tokens = lambda: 1024 + + with pytest.raises(ValueError, match="mcp_servers"): + provider._build_message_params(prompt="hi", mcp_servers=[{"url": "https://x"}]) + + +def test_anthropic_tool_content_list_is_preserved(): + """``role=tool`` 的列表内容原样透传(``not isinstance`` 不是 ``isinstance``)。 + + ``not`` 删除后列表被 ``content_to_text`` 压平成字符串,多块内容(文本+ + 图片)退化成一段拼接文本,结构化信息丢失。 + """ + converted = AnthropicProvider._convert_messages( + [ + { + "role": "assistant", + "tool_calls": [{"id": "call-1", "function": {"name": "lookup", "arguments": "{}"}}], + }, + { + "role": "tool", + "tool_call_id": "call-1", + "content": [ + {"type": "text", "text": "part-a"}, + {"type": "text", "text": "part-b"}, + ], + }, + ] + ) + + assert converted[1]["content"][0]["content"] == [ + {"type": "text", "text": "part-a"}, + {"type": "text", "text": "part-b"}, + ] + + +def test_anthropic_supports_sampling_option_tracks_model_generation(): + """旧模型支持采样、新模型不支持(``not deprecated or allow`` 的 ``not`` 不可删)。 + + ``not`` 删除后判据反转:旧模型被判成不支持采样(调用方丢掉 temperature), + 新模型被判成支持(把已弃用字段发给服务端换回 400)。 + """ + legacy = object.__new__(AnthropicProvider) + legacy._model_name = "claude-3-5-sonnet" + legacy._options = {} + modern = object.__new__(AnthropicProvider) + modern._model_name = "claude-sonnet-4-6" + modern._options = {} + + assert legacy.supports_sampling_option("temperature") is True + assert modern.supports_sampling_option("temperature") is False + + +def test_anthropic_async_stream_emits_completed_tool_call(): + """``astream_events`` 在 finish 前交付 ``tool_call_completed``。 + + ``key not in completed_tool_calls`` 变异成 ``in`` 后判据反转,工具调用 + 永远不满足条件——调用方只看到 delta,拿不到完整调用。 + """ + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-3-5-sonnet" + provider._options = {} + provider._api_key = "k" + provider._normalized_client_options = {} + provider._default_max_tokens = lambda: 1024 + + class _Stream: + def __init__(self, agen): + self._agen = agen.__aiter__() + + def __aiter__(self): + return self + + async def __anext__(self): + return await self._agen.__anext__() + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + class _Messages: + def stream(self, **_kwargs): + async def gen(): + yield { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "toolu_1", "name": "lookup"}, + } + yield { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": "{}"}, + } + yield {"type": "message_delta", "delta": {"stop_reason": "tool_use"}} + yield {"type": "message_stop"} + + return _Stream(gen()) + + provider._aclient = type("A", (), {"messages": _Messages()})() + + async def run(): + return [ + event.type + async for event in provider.astream_events(CompletionRequest(prompt="hi", stream=True)) + ] + + assert asyncio.run(run()) == [ + "tool_call_delta", + "tool_call_delta", + "tool_call_completed", + "finish", + ] + + +def test_google_tool_result_part_resolves_name_from_call_id(): + """``tool_result`` 只有 ``tool_call_id`` 时从已登记的调用名解析(``not part_name`` 不可删)。 + + ``not`` 删除后回退分支不再执行,``part_name`` 保持 ``None``,随后被判成 + 「缺少可关联的 tool_call_id/name」——标准形态的并行工具结果全部报错。 + """ + contents = GoogleProvider._contents( + [ + { + "role": "assistant", + "tool_calls": [ + {"id": "call-1", "function": {"name": "lookup_order", "arguments": "{}"}} + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_call_id": "call-1", "response": {"ok": True}} + ], + }, + ], + None, + None, + ) + + response = contents[1].parts[0].function_response + assert response.name == "lookup_order" + assert response.id == "call-1" + + +def test_google_tool_result_accepts_valid_name(): + """显式给出合法 ``name`` 的 ``tool_result`` 必须通过(``not part_name.strip()`` 的 ``not`` 不可删)。 + + ``not`` 删除后判据反转:非空名字反而触发「缺少可关联的 tool_call_id/name」, + 只有空名字才被接受——恰好与契约相反。 + """ + contents = GoogleProvider._contents( + [ + { + "role": "assistant", + "tool_calls": [{"id": "call-1", "function": {"name": "lookup", "arguments": "{}"}}], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_call_id": "call-1", + "name": "lookup", + "response": {"ok": True}, + } + ], + }, + ], + None, + None, + ) + + assert contents[1].parts[0].function_response.name == "lookup" + + +def test_google_stream_refusal_part_uses_text_fallback(): + """``type=refusal`` 的块缺 ``refusal`` 字段时回退到 ``text``(``not part_refusal`` 不可删)。 + + ``not`` 删除后回退分支不再执行,``part_refusal`` 保持 ``None``;同一块的 + ``text`` 又被当成普通文本交付——调用方同时收到 ``text_delta`` 与 + ``refusal_delta``,把拒答内容当正文显示。 + """ + part = SimpleNamespace(type="refusal", text="I cannot help with that.", refusal=None) + chunk = SimpleNamespace(candidates=[SimpleNamespace(content=SimpleNamespace(parts=[part]))]) + + events = GoogleProvider._stream_events_from_chunk(chunk) + + assert [(event.type, event.refusal) for event in events] == [ + ("refusal_delta", "I cannot help with that.") + ] + + +def test_google_stream_candidate_refusal_emitted_once(): + """candidate 级拒答原因在未发过 ``refusal_delta`` 时交付(``not refusal_emitted`` 不可删)。 + + ``not`` 删除后判据反转:只有**已经发过**拒答才补发,candidate 级的 + ``finish_message`` 永远不会被交付——拒答原因静默丢失。 + """ + candidate = SimpleNamespace( + content=SimpleNamespace(parts=[SimpleNamespace(type="text", text="hi", refusal=None)]), + finish_message="blocked by safety", + ) + chunk = SimpleNamespace(candidates=[candidate]) + + events = GoogleProvider._stream_events_from_chunk(chunk) + + assert [event.type for event in events] == ["text_delta", "refusal_delta"] + assert events[1].refusal == "blocked by safety" + + +def test_google_async_validate_tuning_reward_forwards_composite_config(): + """只传 ``composite_reward_config`` 时转发它(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 kwargs 里什么都不放,SDK 收到空配置。 + """ + provider = object.__new__(GoogleProvider) + provider._options = {} + provider._get_client = lambda: SimpleNamespace(aio=object()) + captured: dict = {} + + async def validate_reward(**kwargs): + captured.update(kwargs) + return "ok" + + provider._require_resource = lambda *_a, **_kw: SimpleNamespace(validate_reward=validate_reward) + provider._resource_config_kwargs = lambda _cls, _config, _kwargs: {} + + asyncio.run( + provider.async_validate_tuning_reward( + "t1", sample_response={}, example={}, composite_reward_config={"b": 2} + ) + ) + + assert captured["composite_reward_config"] == {"b": 2} + + +def test_openai_chat_request_forwards_metadata(): + """``metadata`` 给出时进请求(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后 metadata 被静默丢弃。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "chat_completions" + provider._options = {} + + request = provider._build_chat_request(prompt="hi", metadata={"trace": "t1"}) + + assert request["metadata"] == {"trace": "t1"} + + +def test_openai_responses_configured_top_logprobs_used_as_fallback(): + """请求未给 ``top_logprobs`` 时回退到 options(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后回退取值恒为 ``None``,配置里的 top_logprobs 永远不生效。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"top_logprobs": 3} + + request = provider._build_responses_request(prompt="hi") + + assert request["top_logprobs"] == 3 + + +def test_openai_responses_official_endpoint_rejects_configured_extra_body_stop(): + """官方端点下 options.extra_body 的 ``stop`` 必须拒绝(``value is not None`` 不是 ``is None``)。 + + ``is None`` 变异后字典推导只收集**值为 None** 的项,非空的 ``stop`` + 不再进入拒绝列表——官方端点会把它当未知字段或静默忽略。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"extra_body": {"stop": "END"}} + + with pytest.raises(ValueError, match="stop"): + provider._build_responses_request(prompt="hi") + + +def test_openai_response_retrieve_kwargs_drops_none_values(): + """``_response_retrieve_kwargs`` 过滤 ``None`` 值(``params[key] is not None`` 不是 ``is None``)。 + + ``is None`` 变异后只保留**值为 None** 的项,真正的取值被丢掉。 + """ + out = OpenAICompatibleProvider._response_retrieve_kwargs({"timeout": None, "include": ["a"]}) + + assert out == {"include": ["a"]} + + +def test_openai_responses_sync_emits_completed_before_later_events(): + """``output_item.done`` 的完整调用**立即**交付,不推迟到 finish。 + + ``key not in completed_tool_calls`` 变异成 ``in`` 后判据反转,完成事件被 + 推迟到 ``finish`` 才补发——调用方在中间收到文本增量,顺序错乱。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", call_id="call-1", name="lookup", arguments='{"id":1}' + ) + return iter( + [ + SimpleNamespace(type="response.output_item.done", item=item, output_index=0), + SimpleNamespace(type="response.output_text.delta", delta="后续文本"), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed"), + ), + ] + ) + + provider._get_client = lambda: SimpleNamespace(responses=Responses()) + + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + + assert [event.type for event in events] == ["tool_call_completed", "text_delta", "finish"] + + +def test_openai_responses_async_emits_completed_before_later_events(): + """异步分支同判据。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", call_id="call-1", name="lookup", arguments='{"id":1}' + ) + + async def gen(): + yield SimpleNamespace(type="response.output_item.done", item=item, output_index=0) + yield SimpleNamespace(type="response.output_text.delta", delta="后续文本") + yield SimpleNamespace( + type="response.completed", response=SimpleNamespace(status="completed") + ) + + return gen() + + provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) + + async def run(): + return [ + event.type async for event in provider.astream_events(CompletionRequest(prompt="hi")) + ] + + assert asyncio.run(run()) == ["tool_call_completed", "text_delta", "finish"] + + +def test_openai_async_compact_responses_forwards_only_given_fields(): + """``async_compact_responses`` 只转发显式给出的字段(``value is not None`` 不是 ``is None``)。 + + ``is None`` 变异后判据反转,只转发**值为 None** 的字段——SDK 收到一堆 + 空参数,真正的 ``input`` 与 ``timeout`` 全被丢掉。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + captured: dict = {} + + class Responses: + async def compact(self, **kwargs): + captured.update(kwargs) + return "ok" + + provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) + provider._call_async_sdk_resource = lambda fn, kwargs=None, operation="": fn(**(kwargs or {})) + + asyncio.run(provider.async_compact_responses(input="hi", timeout=9)) + + assert set(captured) == {"model", "input", "timeout"} + + +def test_openai_async_update_vector_store_file_drops_none_extras(): + """``async_update_vector_store_file`` 过滤未给出的扩展参数(``value is not None`` 不是 ``is None``)。 + + ``is None`` 变异后只转发**值为 None** 的扩展,SDK 收到一堆 ``extra_*`` + 空参数,而显式给出的 ``timeout`` 被丢掉。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + captured: dict = {} + + class _Files: + async def update(self, *args, **kwargs): + captured.update(kwargs) + return "ok" + + provider._get_aclient = lambda: SimpleNamespace(vector_stores=SimpleNamespace(files=_Files())) + provider._call_async_sdk_resource = lambda fn, args=(), kwargs=None, operation="": fn( + *args, **(kwargs or {}) + ) + + asyncio.run( + provider.async_update_vector_store_file( + "vs1", "f1", attributes={"a": 1}, extra_headers=None, timeout=9 + ) + ) + + assert set(captured) == {"vector_store_id", "attributes", "timeout"} + + +def test_ark_responses_rejects_unsupported_request_field(): + """拒绝不支持的 Ark Responses 请求字段(``getattr(...) is not None`` 不是 ``is None``)。 + + ``is None`` 变异后字典推导只收集**取值恰为 None** 的字段(即未设置的), + 非空的 ``fallbacks`` 不再进入拒绝列表——用户设置了却被静默忽略。 + """ + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + provider._options = {} + + with pytest.raises(ValueError, match="fallbacks"): + provider._build_responses_request(CompletionRequest(prompt="hi", fallbacks=[{"a": 1}])) + + +def test_ark_responses_native_kwargs_validate_extra_body_secrets(): + """原生入口的 ``extra_body`` 必须过凭证校验(``params["extra_body"] is not None`` 不是 ``is None``)。 + + ``is None`` 变异后校验分支不再执行,携带 ``api_key`` 的 extra_body + 被原样放行——凭证会随请求发往远端。 + """ + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + provider._options = {} + + with pytest.raises(ValueError, match="api_key"): + provider._validate_native_response_kwargs( + {"model": "m", "input": "hi", "extra_body": {"api_key": "sk-abcdef1234567890"}} + ) + + +def test_ark_responses_sync_emits_completed_before_later_events(): + """Ark 同步流同判据:``output_item.done`` 立即交付。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", call_id="call-1", name="lookup", arguments='{"id":1}' + ) + return iter( + [ + SimpleNamespace(type="response.output_item.done", item=item, output_index=0), + SimpleNamespace(type="response.output_text.delta", delta="后续文本"), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed"), + ), + ] + ) + + provider._get_client = lambda: SimpleNamespace(responses=Responses()) + + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + + assert [event.type for event in events] == ["tool_call_completed", "text_delta", "finish"] + + +def test_ark_responses_async_emits_completed_before_later_events(): + """Ark 异步流同判据。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", call_id="call-1", name="lookup", arguments='{"id":1}' + ) + + async def gen(): + yield SimpleNamespace(type="response.output_item.done", item=item, output_index=0) + yield SimpleNamespace(type="response.output_text.delta", delta="后续文本") + yield SimpleNamespace( + type="response.completed", response=SimpleNamespace(status="completed") + ) + + return gen() + + provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) + + async def run(): + return [ + event.type async for event in provider.astream_events(CompletionRequest(prompt="hi")) + ] + + assert asyncio.run(run()) == ["tool_call_completed", "text_delta", "finish"] + + +def test_ark_responses_async_deduplicates_tool_call_delivered_by_finish(): + """finish 分支补发 pending 时不得重复已交付的调用(``key not in completed`` 不是 ``in``)。 + + ``in`` 变异后 finish 分支对**已交付**的调用再发一次,调用方收到两个 + 相同 id 的 ``tool_call_completed``。 + """ + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", call_id="call-1", name="lookup", arguments='{"id":1}' + ) + + async def gen(): + yield SimpleNamespace(type="response.output_item.done", item=item, output_index=0) + yield SimpleNamespace( + type="response.function_call_arguments.done", item=item, output_index=0 + ) + yield SimpleNamespace( + type="response.completed", response=SimpleNamespace(status="completed") + ) + + return gen() + + provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) + + async def run(): + return [ + event.type async for event in provider.astream_events(CompletionRequest(prompt="hi")) + ] + + assert asyncio.run(run()).count("tool_call_completed") == 1 + + +def test_ark_responses_sync_deduplicates_tool_call_delivered_by_finish(): + """Ark 同步流同判据:finish 分支不得重复交付已发过的调用。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", call_id="call-1", name="lookup", arguments='{"id":1}' + ) + return iter( + [ + SimpleNamespace(type="response.output_item.done", item=item, output_index=0), + SimpleNamespace( + type="response.function_call_arguments.done", item=item, output_index=0 + ), + SimpleNamespace( + type="response.completed", response=SimpleNamespace(status="completed") + ), + ] + ) + + provider._get_client = lambda: SimpleNamespace(responses=Responses()) + + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + + assert [event.type for event in events].count("tool_call_completed") == 1 + + +def test_openai_compatible_merge_resource_kwargs_keeps_none_by_default(): + """默认(``omit_none=False``)必须原样保留 ``None`` 取值。 + + ``not omit_none`` 的 ``not`` 被删后默认路径也进入过滤:调用方显式给出的 + ``None`` 字段被静默丢弃,SDK 收到的是「未提供」而不是「显式置空」。 + """ + out = OpenAICompatibleProvider._merge_resource_kwargs({"a": 1, "b": None}, None, "T") + + assert out == {"a": 1, "b": None} + + +def test_openai_compatible_merge_resource_kwargs_omits_none_when_requested(): + """``omit_none=True`` 时过滤 ``None``(``value is not None`` 不是 ``is None``)。 + + ``is None`` 变异后判据反转:只保留值为 ``None`` 的字段,真正的取值全部 + 被丢掉。 + """ + out = OpenAICompatibleProvider._merge_resource_kwargs( + {"a": 1, "b": None}, None, "T", omit_none=True + ) + + assert out == {"a": 1} + + +def test_openai_responses_none_max_output_tokens_not_forwarded(): + """值为 ``None`` 的 ``max_output_tokens`` 不得进入请求体。 + + 原先只在取值非 ``None`` 时才把它移出 ``configured_options``;显式配置成 + ``None`` 时键留在 options 里、经 ``_merge_options`` 原样并入请求,SDK 收到 + ``max_output_tokens: null``。改为无条件移出:真实取值经 ``configured_limits`` + 重新写回,``None`` 不落进请求。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"max_output_tokens": None} + + request = provider._build_responses_request(prompt="hi") + + assert "max_output_tokens" not in request + + +# --- face 12(== ↔ != 互换扫描)存活位点 ------------------------------------- +# +# 358 个 eqneq 候选全量扫描后 27 个存活;下列每条经真实入口探针确认 base 与 +# 变异体收场逐字不同,按守卫存在时的行为写断言。 + + +def test_normalize_messages_inserts_system_only_when_history_lacks_it(): + """消息没有 system 角色时补插 system_prompt(``not any(...)`` 不是 ``any(...)``)。 + + ``==``→``!=`` 变异后判据反转:有 system 时重复插入、没有时反而丢弃 + system_prompt——实测变异体输出只剩 ``[{'role': 'user', ...}]``。 + """ + from src.providers.__base__.model_provider import normalize_messages + + out = normalize_messages(None, "你在做客服。", [{"role": "user", "content": "hi"}]) + + assert [m["role"] for m in out] == ["system", "user"] + assert out[0]["content"] == "你在做客服。" + + +def test_responses_content_part_original_detail_only_for_openai_variant(): + """``detail="original`` 只对 openai 变体合法(``variant == "openai"``)。 + + ``==``→``!=`` 变异后判据反转:openai 被拒、ark 反而接受 ``original``—— + 两侧的可选值集合互换,与 Responses 契约(原图只在官方端点存在)相反。 + """ + from src.providers.__base__.model_provider import _responses_content_part + + part = {"type": "input_image", "image_url": "https://x/y.png", "detail": "original"} + + openai_out = _responses_content_part(dict(part), "L", provider="openai") + assert openai_out.get("detail") == "original" + with pytest.raises(ValueError, match="L.detail 必须"): + _responses_content_part(dict(part), "L", provider="ark") + + +def test_capability_report_verified_protocols_cleared_for_non_llm_role(): + """非 llm 角色不得继承注册表里的 ``server_verified_protocols``(``== "llm"``)。 + + ``==``→``!=`` 变异后判据反转:注册的验收证据从 llm 报告里消失、却出现在 + embedding 报告里——与 `protocol_status` 对齐的注释约定(协议只对 llm 适用) + 相反。注册表项默认是空集,测试里临时写入非空值再还原。 + """ + from src.providers.factory import ModelProviderFactory + + info = ModelProviderFactory._provider_map["openai"] + original = info.get("server_verified_protocols") + info["server_verified_protocols"] = {"responses"} + try: + llm = ModelProviderFactory.capability_report("openai", role="llm") + embedding = ModelProviderFactory.capability_report("openai", role="embedding") + finally: + info["server_verified_protocols"] = original + + assert llm["server_verified_protocols"] == ["responses"] + assert llm["protocol_status"]["responses"]["server_verified"] is True + assert embedding["server_verified_protocols"] == [] + assert embedding["protocol_status"] == {} + + +def test_google_as_bool_coerces_numeric_zero_and_nonzero(): + """数值强制转换 ``value != 0``:0→False,非 0→True。 + + ``!=``→``==`` 变异后判据反转:``_as_bool(0)`` 为 True、``_as_bool(1)`` 为 + False——配置里的数值布尔被静默取反。 + """ + from src.providers.google import GoogleProvider + + assert GoogleProvider._as_bool(0) is False + assert GoogleProvider._as_bool(1) is True + assert GoogleProvider._as_bool(1.5) is True + assert GoogleProvider._as_bool(-2.0) is True + assert GoogleProvider._as_bool(True) is True + assert GoogleProvider._as_bool(False) is False + + +def test_google_stream_prompt_feedback_emits_refusal_alongside_content(): + """同一 chunk 既有内容又带安全拦截时,refusal_delta 仍要发出一次。 + + ``==``→``!=`` 变异后去重判据反转:已有 refusal 才追加——常见形态(内容与 + prompt_feedback 同 chunk)下安全拦截被静默丢弃,上层看不到拒答原因。 + """ + part = SimpleNamespace(type="text", text="部分内容", refusal=None) + cand = SimpleNamespace(content=SimpleNamespace(parts=[part]), finish_reason=None) + feedback = SimpleNamespace(block_reason="SAFETY", block_reason_message=None, message=None) + chunk = SimpleNamespace(candidates=[cand], prompt_feedback=feedback, usage_metadata=None) + + events = GoogleProvider._stream_events_from_chunk(chunk) + + assert [(e.type, e.refusal) for e in events] == [ + ("text_delta", None), + ("refusal_delta", "Google 提示被安全策略拦截: SAFETY"), + ("finish", None), + ] + + +def test_google_stream_top_level_finish_kept_when_chunk_has_content(): + """内容与顶层 finish_reason 同 chunk 时 finish 仍要发出(``==`` 去重判据)。 + + ``==``→``!=`` 变异后反转:已有 finish 才追加——实测变异体只剩 + ``[('text_delta', None)]``,finish_reason 丢失,上层永远等不到终止事件。 + """ + part = SimpleNamespace(type="text", text="回答", refusal=None) + cand = SimpleNamespace(content=SimpleNamespace(parts=[part]), finish_reason=None) + chunk = SimpleNamespace( + candidates=[cand], prompt_feedback=None, usage_metadata=None, finish_reason="STOP" + ) + + events = GoogleProvider._stream_events_from_chunk(chunk) + + assert [(e.type, e.finish_reason) for e in events] == [("text_delta", None), ("finish", "STOP")] + + +def test_local_hash_embedding_matches_reference_sign_pattern(): + """哈希符号位 ``digest[0] % 2 == 0`` / ``digest[15] % 2 == 0`` 决定落点符号。 + + 任一 ``==``→``!=`` 变异都会改变既有文本的嵌入值——快照指纹随之漂移, + 与「离线可复现」的契约矛盾。期望值由 base 源码实算得出。 + """ + provider = LocalHashEmbeddingProvider("local-hash-8") + + vec = provider._embed_text("hello world") + + assert [round(v, 4) for v in vec] == [0.0, 0.0, 0.7071, -0.7071, 0.0, 0.0, 0.0, 0.0] + + +def test_openai_chat_invoke_stream_yields_text_delta(): + """同步 invoke 流式路径按 ``event.type == "text_delta"`` 交付文本。 + + ``==``→``!=`` 变异后文本全部被丢弃——实测变异体 ``list(...)`` 为 ``[]``, + 聊天界面收到空回复;finish 守卫(独立判据)仍绿,所以只有显式断言文本 + 才能抓住它。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "chat_completions" + provider._options = {} + + class Completions: + def create(self, **_request): + return iter( + [ + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="答", tool_calls=None), + finish_reason=None, + ) + ] + ), + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=None), + finish_reason="stop", + ) + ] + ), + ] + ) + + provider._get_client = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + assert list(provider.invoke("hi", stream=True)) == ["答"] + + +def test_openai_chat_ainvoke_stream_yields_text_delta(): + """异步 invoke 流式路径同判据(``==``→``!=`` 变异后返回空列表)。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "chat_completions" + provider._options = {} + + class Completions: + async def create(self, **_request): + return [ + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="答", tool_calls=None), + finish_reason=None, + ) + ] + ), + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=None), + finish_reason="stop", + ) + ] + ), + ] + + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + async def consume(): + return [chunk async for chunk in provider.ainvoke("hi", stream=True)] + + assert asyncio.run(consume()) == ["答"] + + +def test_openai_responses_batch_guard_covers_explicit_endpoint(): + """显式 ``endpoint="/v1/responses"`` 同样要过未验证服务端的门禁。 + + 判据是 ``endpoint.rstrip("/") == "/v1/responses"``;``==``→``!=`` 变异后 + 网关(未验证 Responses)带着显式端点直接调到 SDK ``batches.create``—— + 实测变异体返回 ``batch``,远端 404/405 的保护被绕开。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + provider._base_url = "https://gateway.example/v1" + provider._server_verified_protocols = frozenset() + + with pytest.raises(ValueError, match="未验证 Responses"): + provider.create_batch("file-1", endpoint="/v1/responses") + + +def test_openai_responses_batch_default_endpoint_is_responses(): + """responses 协议下缺省 endpoint 是 ``/v1/responses``,不是 chat 端点。 + + ``==``→``!=`` 变异后判据反转:默认请求打到 ``/v1/chat/completions``—— + batch 与聊天端点混用,服务端按错误路由处理。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + provider._base_url = "https://api.openai.com/v1/" + provider._server_verified_protocols = frozenset() + captured = {} + + class Batches: + def create(self, **kwargs): + captured.update(kwargs) + return "batch" + + provider._get_client = lambda: SimpleNamespace(batches=Batches()) + + assert provider.create_batch("file-1") == "batch" + assert captured["endpoint"] == "/v1/responses" + + +def test_openai_async_responses_batch_guard_covers_explicit_endpoint(): + """异步 create_batch 同判据(``==``→``!=`` 变异后门禁被绕开)。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + provider._base_url = "https://gateway.example/v1" + provider._server_verified_protocols = frozenset() + + class Batches: + async def create(self, **_kwargs): + return "batch" + + provider._get_aclient = lambda: SimpleNamespace(batches=Batches()) + + with pytest.raises(ValueError, match="未验证 Responses"): + asyncio.run(provider.async_create_batch("file-1", endpoint="/v1/responses")) + + +def test_openai_async_responses_batch_default_endpoint_is_responses(): + """异步缺省 endpoint 同判据(``==``→``!=`` 变异后打到 chat 端点)。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {} + provider._base_url = "https://api.openai.com/v1/" + provider._server_verified_protocols = frozenset() + captured = {} + + class Batches: + async def create(self, **kwargs): + captured.update(kwargs) + return "batch" + + provider._get_aclient = lambda: SimpleNamespace(batches=Batches()) + + assert asyncio.run(provider.async_create_batch("file-1")) == "batch" + assert captured["endpoint"] == "/v1/responses" + + +def _ark_chat_chunks(): + return [ + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "t1", + "function": {"name": "f", "arguments": '{"a":1}'}, + } + ] + }, + "finish_reason": None, + } + ] + }, + {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}, + ] + + +def _ark_chat_provider(): + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._provider = "volcengine" + provider._protocol = "chat_completions" + provider._options = {} + return provider + + +def test_ark_chat_stream_events_flush_completed_after_delta(): + """finish 前先冲刷:``tool_call_delta`` 先于 ``tool_call_completed`` 发出。 + + 冲刷判据 ``converted.type == "finish"`` 若变异成 ``!=``,冲刷提前到首个 + delta 上——实测事件序列变成 ``[tool_call_completed, tool_call_delta, + finish]``,消费方在看到片段之前先收到完成事件。 + """ + provider = _ark_chat_provider() + + class Completions: + def create(self, **_request): + return iter(_ark_chat_chunks()) + + provider._get_client = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + events = list(provider.stream_events(CompletionRequest(prompt="hi"))) + + assert [e.type for e in events] == ["tool_call_delta", "tool_call_completed", "finish"] + + +def test_ark_async_chat_stream_events_flush_completed_after_delta(): + """异步 stream_events 同判据(``==``→``!=`` 变异后完成事件抢在片段前)。""" + provider = _ark_chat_provider() + + class Completions: + async def create(self, **_request): + return _ark_chat_chunks() + + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + async def collect(): + return [e.type async for e in provider.astream_events(CompletionRequest(prompt="hi"))] + + assert asyncio.run(collect()) == ["tool_call_delta", "tool_call_completed", "finish"] + + +def test_ark_invoke_stream_yields_text_delta(): + """Ark 同步 invoke 按 ``event.type == "text_delta"`` 交付文本。 + + ``==``→``!=`` 变异后文本全部丢弃(实测 ``[]``),聊天界面收到空回复。 + """ + provider = _ark_chat_provider() + + class Completions: + def create(self, **_request): + return iter( + [ + {"choices": [{"delta": {"content": "答"}, "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + ] + ) + + provider._get_client = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + assert list(provider.invoke("hi", stream=True, temperature=0.5)) == ["答"] + + +def test_ark_ainvoke_stream_yields_text_delta(): + """Ark 异步 invoke 同判据(``==``→``!=`` 变异后返回空列表)。""" + provider = _ark_chat_provider() + + class Completions: + async def create(self, **_request): + return [ + {"choices": [{"delta": {"content": "答"}, "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + ] + + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + async def consume(): + return [chunk async for chunk in provider.ainvoke("hi", stream=True, temperature=0.5)] + + assert asyncio.run(consume()) == ["答"] + + +# --- face 13(True ↔ False 互换扫描)存活位点 --------------------------------- + + +class _RecordingLLM(LargeLanguageModel): + """记录 ``invoke``/``ainvoke`` 收到的 ``stream`` 实参。""" + + def __init__(self): + self.seen: list[tuple[str, bool]] = [] + + def invoke(self, prompt=None, system_prompt=None, tools=None, stream=True, temperature=0.7): + self.seen.append(("invoke", stream)) + yield "x" + + async def ainvoke( + self, prompt=None, system_prompt=None, tools=None, stream=True, temperature=0.7 + ): + self.seen.append(("ainvoke", stream)) + yield "x" + + +def test_chat_tool_call_item_defaults_to_no_call_id_requirement(): + """``require_call_id`` 默认 ``False``:无 id 的历史工具调用必须被原样接受。 + + 缺 id 的 Gemini 历史经 ``_chat_tool_call_item`` 默认路径必须通过; + ``require_call_id=False`` 反向成 ``True`` 后该路径开始拒绝本可发送的请求。 + """ + item = model_provider_module._chat_tool_call_item( + {"type": "function", "function": {"name": "f", "arguments": "{}"}}, "loc" + ) + + assert item == {"type": "function", "function": {"name": "f", "arguments": "{}"}} + + +def test_chat_tool_call_item_does_not_leak_responses_marker_by_default(): + """``keep_responses_type`` 默认 ``False``:内部标记不得进入 Chat 请求体。 + + 该默认值反向成 ``True`` 后,``_chat_tool_call_item`` 会给 + ``type != function`` 的工具调用挂上 ``_responses_tool_call_type``——一个 + 只在 Responses 内部判定用的字段随之进入 Chat Completions 请求体。 + """ + item = model_provider_module._chat_tool_call_item( + {"type": "custom_tool_call", "name": "f", "input": "raw"}, "loc" + ) + + assert "_responses_tool_call_type" not in item + assert item == {"type": "function", "function": {"name": "f", "arguments": "raw"}} + + +def test_responses_input_rejects_tool_call_id_without_call_id_for_output(): + """只有 Chat 的 ``tool_call_id`` 不能满足 Responses 的 ``call_id`` 要求。 + + 缺 ``call_id`` 的 ``function_call_output`` 必须被拒; + ``require_call_id=True`` 反向成 ``False`` 后,``tool_call_id`` 被当成 + ``call_id`` 的别名回填,而 Responses 的关联键是 ``call_id``,回填值与 + 调用方的 ``function_call`` 对不上,工具输出静默失联。 + """ + messages = [{"type": "function_call_output", "tool_call_id": "tc1", "output": "{}"}] + + with pytest.raises(ValueError, match="tool_call_id/call_id"): + normalize_responses_input(None, None, messages) + + +@pytest.mark.parametrize("method", ["complete", "acomplete", "stream_events", "astream_events"]) +def test_compat_layer_forces_stream_flag_for_its_call_shape(method): + """兼容层必须按自己的调用形态固定 ``stream``,不能沿用请求里的取值。 + + ``complete``/``acomplete`` 要**非流式**(一次性聚合),``stream_events``/ + ``astream_events`` 要**流式**(逐块产出)。``CompletionRequest.stream`` 的 + 默认值 ``True`` 被反向成 ``False`` 后,``complete``/``acomplete`` 的 + ``copy_with(stream=False)`` 仍显式固定,但 ``stream_events``/ + ``astream_events`` 的 ``copy_with(stream=True)`` 变成 ``False``——两条事件 + 流路径静默退化成非流式,逐块产出变成一次性返回。既有用例只断言了 + ``complete`` 的文本结果,事件流两条路径的 ``stream`` 实参从未被观察。 + """ + provider = _RecordingLLM() + + if method == "complete": + provider.complete(prompt="hi") + elif method == "acomplete": + asyncio.run(provider.acomplete(prompt="hi")) + elif method == "stream_events": + list(provider.stream_events(prompt="hi")) + else: + + async def _drain(): + return [event async for event in provider.astream_events(prompt="hi")] + + asyncio.run(_drain()) + + expected_method = "ainvoke" if method in ("acomplete", "astream_events") else "invoke" + expected_stream = method in ("stream_events", "astream_events") + assert provider.seen == [(expected_method, expected_stream)] + + +def test_completion_request_stream_defaults_to_true(): + """``CompletionRequest.stream`` 的默认值是 ``True``(``bool`` 不是 ``int``)。 + + 默认值反向成 ``False`` 后,未显式指定 ``stream`` 的请求会静默走非流式; + ``to_invoke_kwargs`` 原样转发该字段,调用方拿到的 ``stream`` 与契约相反。 + """ + assert CompletionRequest(prompt="hi").stream is True + assert CompletionRequest(prompt="hi").to_invoke_kwargs()["stream"] is True + + +def test_completion_request_temperature_flag_defaults_off_and_stays_private(): + """``_temperature_explicit`` 的 dataclass 默认值是 ``False``,且字段保持私有。 + + 默认值反向成 ``True`` 后:``repr`` 里出现该字段(``repr=False`` 失效)、 + 两个 ``CompletionRequest`` 不再相等(``compare=False`` 失效)、而 + ``__post_init__`` 仍按真实显式性赋值——``temperature_is_explicit`` 与 + ``repr``/``==`` 的语义互相矛盾。``init=False`` 使该字段不能从构造器传入, + 因此 ``repr``/``==`` 是观察默认值的唯一入口。 + """ + request = CompletionRequest(prompt="hi") + assert "_temperature_explicit" not in repr(request) + assert request == CompletionRequest(prompt="hi", temperature=0.7) + + +def test_completion_request_temperature_field_default_is_false_in_the_dataclass(): + """``_temperature_explicit`` 的**字段声明默认值**必须是 ``False``。 + + 上面那条用例断言的是 ``repr``/``==`` 的行为,而这两个面由 + ``repr=False``/``compare=False`` 决定——把 ``default=False`` 反向成 + ``True`` 后它们逐字不变,实测全量套件仍绿。声明默认值本身是 + ``dataclasses.fields()`` 可直接读到的契约,也是「字段默认关」这条 + docstring 承诺的落点,必须单独钉住。 + """ + field_info = {item.name: item for item in dataclasses.fields(CompletionRequest)}[ + "_temperature_explicit" + ] + assert field_info.default is False + assert field_info.init is False + + +def test_completion_request_temperature_field_cannot_be_passed_to_the_constructor(): + """``init=False`` 必须让私有字段无法从构造器注入。 + + ``init=False`` 反向成 ``True`` 后,``CompletionRequest(prompt="hi", + _temperature_explicit=True)`` 被接受——调用方可以伪造「温度是显式传入的」 + 这一标记,而 ``__post_init__`` 随后会按真实取值覆盖它,标记与温度自相矛盾。 + """ + with pytest.raises(TypeError): + CompletionRequest(prompt="hi", _temperature_explicit=True) + + +def test_normalize_chat_messages_requires_tool_call_ids_only_when_asked(): + """缺 ``id`` 的历史工具调用:默认放行,``require_tool_call_ids=True`` 才拒绝。 + + ``require_tool_call_ids`` 默认 ``False`` 被反向成 ``True`` 后,**默认路径** + 开始拒绝无 id 的 assistant 历史——Gemini 这类不带 id 的历史在 Chat 路径上 + 被静默挡下;而显式要求 id 的路径(Responses)行为不变。既有用例只覆盖了 + ``require_tool_call_ids=True`` 与带 id 的输入,默认路径这一半从未被触达。 + """ + messages = [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"type": "function", "function": {"name": "f", "arguments": "{}"}}], + } + ] + + normalized = normalize_chat_messages(messages) + assert normalized[0]["tool_calls"][0]["type"] == "function" + + with pytest.raises(ValueError, match="缺少 id/call_id"): + normalize_chat_messages(messages, require_tool_call_ids=True) + + +def test_normalize_messages_keeps_custom_tool_call_type_only_when_asked(): + """``custom_tool_call`` 的原始类型只在 ``keep_responses_type=True`` 时保留。 + + 默认 ``False`` 被反向成 ``True`` 后,Chat 路径会把 Responses 专属的 + ``custom_tool_call`` 原样带进请求体——Chat Completions 的 + ``tool_calls[].type`` 只接受 ``function``。既有用例只覆盖了 + ``keep_responses_type=True`` 与 ``function`` 类型,默认路径这一半从未被触达。 + """ + messages = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "c1", "type": "custom_tool_call", "name": "f", "input": "raw text"} + ], + } + ] + + normalized = normalize_messages(None, None, messages) + assert normalized[0]["tool_calls"][0]["type"] == "function" + assert "_responses_tool_call_type" not in normalized[0]["tool_calls"][0] + + kept = normalize_messages(None, None, messages, keep_responses_type=True) + assert kept[0]["tool_calls"][0]["type"] == "function" + assert kept[0]["tool_calls"][0]["_responses_tool_call_type"] == "custom_tool_call" + + +def test_responses_input_requires_call_id_for_function_call_output(): + """Responses 的 ``function_call_output`` 必须带 ``call_id``(``require_call_id=True``)。 + + 该默认值反向成 ``False`` 后,缺 ``call_id`` 的 ``function_call_output`` 不再 + 被拒——下游按 ``call_id`` 关联工具调用时拿到 ``None``,关联静默失效。 + """ + messages = [{"type": "function_call_output", "output": "{}"}] + + with pytest.raises(ValueError, match="call_id"): + normalize_responses_input(None, None, messages) + + +def test_responses_json_text_keeps_non_ascii_verbatim(): + """``json.dumps`` 的 ``ensure_ascii=False`` 必须保留非 ASCII 字符。 + + 反向成 ``True`` 后中文被转义成 ``\\uXXXX`` 形态——字节数翻倍、下游按原文匹配 + 的调用方拿不到原值。既有用例只断言了「是 JSON 字符串」,没有断言转义形态。 + """ + assert model_provider_module._responses_json_text({"a": "中文"}, "loc") == '{"a":"中文"}' + + +def test_normalize_tool_arguments_keeps_non_ascii_verbatim(): + """工具参数序列化同样必须 ``ensure_ascii=False``。""" + assert normalize_tool_arguments({"a": "中文"}) == '{"a":"中文"}' + + +def test_normalize_embedding_vector_rejects_invalid_base64_with_value_error(): + """非法 base64 必须报 ``ValueError``,不能漏出 ``binascii.Error``。 + + ``validate=True`` 反向成 ``False`` 后,``base64.b64decode`` 忽略非法字符, + ``"YWJj!!!"`` 被解成 ``b"abc"`` 并继续走 float32 解析——错误从「内容无效」 + 变成「不是有效的 float32 字节序列」,调用方按前者写的错误分类接不住。 + """ + with pytest.raises(ValueError, match="base64 内容无效"): + normalize_embedding_vector("YWJj!!!") + + +@pytest.mark.parametrize("retry", [retry_sync_call, retry_async_call]) +def test_retry_helpers_reraise_original_error(retry): + """重试耗尽后必须抛出**原始异常**,不是 ``RetryError``。 + + ``reraise=True`` 反向成 ``False`` 后,tenacity 把最后一次异常包成 + ``RetryError`` 抛出——它既不是 ``ValueError`` 也不是 ``RuntimeError``, + 调用方按后者写的错误分类全部接不住。既有用例把 ``retry_*`` 整体 monkeypatch + 掉了,真实重试路径的收场从未被观察。 + """ + + class _TimeoutLike(Exception): + pass + + def _boom(): + raise _TimeoutLike("boom") + + async def _aboom(): + raise _TimeoutLike("aboom") + + target = _boom if retry is retry_sync_call else _aboom + with pytest.raises(_TimeoutLike): + if retry is retry_sync_call: + retry(target) + else: + asyncio.run(retry(target)) + + +def test_responses_has_content_treats_empty_containers_as_absent(): + """空串/空列表/``0`` 都算「没有内容」(``return True`` 兜底)。 + + 兜底反向成 ``False`` 后,``0`` 与空容器被判为无内容——``_responses_content_part`` + 随后走空内容分支,``0`` 这样的合法取值被静默丢弃。既有用例只覆盖了 + ``None` 与空串,``0` 与空容器这一半从未被触达。 + """ + assert _responses_has_content(None) is False + assert _responses_has_content("") is False + assert _responses_has_content("x") is True + assert _responses_has_content([]) is False + assert _responses_has_content([1]) is True + assert _responses_has_content(0) is True + assert _responses_has_content(1) is True + assert _responses_has_content({"a": 1}) is True + + +# --- face 13(True ↔ False 互换扫描):Anthropic 入口与数据形态 --------------- + + +class _AnyAttr: + """任意属性访问都返回可调用对象,调用结果仍是 ``_AnyAttr``。""" + + def __getattr__(self, _name): + return lambda *args, **kwargs: _AnyAttr() + + def __iter__(self): + return iter(()) + + def __aiter__(self): + async def _gen(): + for _item in (): + yield _item + + return _gen() + + +def _anthropic_flag_provider(recorder: dict): + """记录各入口传给 ``_build_message_params``/``_parse_params`` 的开关。""" + provider = _anthropic_provider() + + def _build(**kwargs): + recorder.setdefault("build", []).append( + {key: kwargs.get(key) for key in ("stream", "beta", "for_parse", "for_tool_runner")} + ) + return {} + + def _parse(params, *, beta): + recorder.setdefault("parse", []).append(beta) + return {} + + provider._build_message_params = _build + provider._parse_params = _parse + provider._validate_token_count_request = lambda request, *, beta: recorder.setdefault( + "token_count", [] + ).append(beta) + provider._get_client = lambda: _AnyAttr() + provider._get_aclient = lambda: _AnyAttr() + return provider + + +def _consume(call): + """调用入口并消费可能的生成器;返回是否抛异常。""" + try: + result = call() + if inspect.isawaitable(result): + asyncio.run(result) + elif inspect.isgenerator(result): + list(result) + elif inspect.isasyncgen(result): + + async def _drain(): + return [item async for item in result] + + asyncio.run(_drain()) + except Exception: # noqa: BLE001 - 入口内部实现与本用例无关 + return True + return False + + +@pytest.mark.parametrize( + "method_name,expected", + [ + ("complete", {"stream": False, "beta": None, "for_parse": None, "for_tool_runner": None}), + ("acomplete", {"stream": False, "beta": None, "for_parse": None, "for_tool_runner": None}), + ("parse", {"stream": False, "beta": None, "for_parse": True, "for_tool_runner": None}), + ( + "async_parse", + {"stream": False, "beta": None, "for_parse": True, "for_tool_runner": None}, + ), + ( + "beta_create", + {"stream": False, "beta": True, "for_parse": None, "for_tool_runner": None}, + ), + ( + "async_beta_create", + {"stream": False, "beta": True, "for_parse": None, "for_tool_runner": None}, + ), + ("beta_parse", {"stream": False, "beta": True, "for_parse": True, "for_tool_runner": None}), + ( + "async_beta_parse", + {"stream": False, "beta": True, "for_parse": True, "for_tool_runner": None}, + ), + ("beta_stream", {"stream": True, "beta": True, "for_parse": None, "for_tool_runner": None}), + ( + "async_beta_stream", + {"stream": True, "beta": True, "for_parse": None, "for_tool_runner": None}, + ), + ( + "beta_tool_runner", + {"stream": True, "beta": True, "for_parse": None, "for_tool_runner": True}, + ), + ( + "async_beta_tool_runner", + {"stream": True, "beta": True, "for_parse": None, "for_tool_runner": True}, + ), + ], +) +def test_anthropic_entries_pass_expected_stream_and_beta_flags(method_name, expected): + """每个公共入口传给 ``_build_message_params`` 的开关必须与它的形态一致。 + + 这些开关都是布尔字面量,反向成另一个取值后:``complete``/``acomplete``/ + ``parse`` 三条**非流式**入口变成流式装配、``beta_*`` 不再启用 Beta 资源、 + ``parse`` 不再走 ``for_parse`` 分支(``output_format`` 校验与配置覆盖的 + 行为随之改变)。既有用例只断言了各入口的最终结果或错误消息,装配开关 + 本身从未被观察。 + """ + recorder: dict = {} + provider = _anthropic_flag_provider(recorder) + request = CompletionRequest(prompt="hi") + calls = { + "complete": lambda: provider.complete(request), + "acomplete": lambda: provider.acomplete(request), + "parse": lambda: provider.parse({}, request), + "async_parse": lambda: provider.async_parse({}, request), + "beta_create": lambda: provider.beta_create(request), + "async_beta_create": lambda: provider.async_beta_create(request), + "beta_parse": lambda: provider.beta_parse({}, request), + "async_beta_parse": lambda: provider.async_beta_parse({}, request), + "beta_stream": lambda: provider.beta_stream(request), + "async_beta_stream": lambda: provider.async_beta_stream(request), + "beta_tool_runner": lambda: provider.beta_tool_runner([], request), + "async_beta_tool_runner": lambda: provider.async_beta_tool_runner([], request), + } + + _consume(calls[method_name]) + + assert recorder["build"] == [expected] + + +@pytest.mark.parametrize( + "method_name,expected_beta", + [ + ("count_tokens", False), + ("beta_count_tokens", True), + ("async_count_tokens", False), + ("async_beta_count_tokens", True), + ], +) +def test_anthropic_token_count_entries_declare_beta_consistently(method_name, expected_beta): + """``count_tokens`` 家族必须按入口声明是否走 Beta 资源(``beta=False`` 不是 ``True``)。 + + 反向成另一个取值后,普通入口被当成 Beta 调用(``beta=False`` 的 token + 计数会去申请 Beta 专属资源),或反过来让 Beta 入口走非 Beta 校验—— + 两边的参数约束与资源挂载不一致。既有用例只断言了返回的 token 数。 + """ + recorder: dict = {} + provider = _anthropic_flag_provider(recorder) + request = CompletionRequest(prompt="hi") + + _consume(lambda: getattr(provider, method_name)(request)) + + assert recorder["token_count"] == [expected_beta] + + +def test_anthropic_convert_content_rejects_illegal_base64_characters(): + """data URI 的 base64 必须 ``validate=True``,非法字符不能被静默忽略。 + + ``validate=False`` 时 ``base64.b64decode`` 丢弃非法字符,``"YWJj!!!"`` 被解成 + ``b"abc"`` 并作为图片内容发给上游——损坏的图片被当成合法数据发送,而 + ``"中文"`` 这类非 ASCII 输入则抛裸 ``ValueError: string argument should + contain only ASCII characters``(不是本模块的指引消息)。 + """ + with pytest.raises(ValueError, match="不是有效 Base64"): + AnthropicProvider._convert_content( + [{"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj!!!"}}] + ) + + +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +def test_anthropic_invoke_stream_returns_quietly_for_empty_stream(is_async): + """空事件流必须静默返回,不能报「message_stop 之前结束」。 + + ``saw_event`` 初值反向成 ``True`` 后,空响应被当成「收到过事件」并要求 + ``message_stop``——实测抛 RuntimeError,而「响应对象没有可迭代事件」是 + 被支持的既有形态(下面的 ``current_message_snapshot``/``text_stream`` + 两条兜底路径正是为它准备的)。 + """ + + class _EmptyStream: + def __iter__(self): + return iter(()) + + def __aiter__(self): + async def _gen(): + for _item in (): + yield _item + + return _gen() + + @property + def text_stream(self): + return () + + response = _EmptyStream() + if is_async: + + async def run(): + return [item async for item in AnthropicProvider._aiter_invoke_stream_text(response)] + + assert asyncio.run(run()) == [] + else: + assert list(AnthropicProvider._iter_invoke_stream_text(response)) == [] + + +def test_anthropic_stream_event_keeps_non_ascii_tool_arguments_verbatim(): + """``tool_use`` 的初始参数序列化必须 ``ensure_ascii=False``。 + + 反向成 ``True`` 后中文参数被转义成 ``\\uXXXX`` 形态——回填给模型的参数 + 与调用方给出的原值不同,按原文匹配的下游拿不到原值。 + """ + event = { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "t1", "name": "f", "input": {"a": "中文"}}, + } + + converted = AnthropicProvider._stream_event(event) + + assert converted is not None + assert converted.tool_call["arguments"] == '{"a":"中文"}' + + +# ── face 13(True ↔ False 互换)扫描在 Google 入口的存活位点 ── +# 实测:574/1483/1900 反向后全量 4269 条仍全绿,用能触达守卫的判别输入 +# 确认行为确实改变(EQUIV 桶逐条反证,见 1922/1287 等处的甄别记录)。 + + +def test_google_validate_http_options_keeps_blank_query_values(): + """``keep_blank_values=True``:query 里值为空的敏感参数同样要被报出来。 + + 反向成 ``False`` 后 ``parse_qsl`` 丢弃 ``token=``(值为空)这一项,整个 query + 归一成空对——「配置里放了一个空 token」被静默接受,与 ``redact_sensitive_text`` + 对非空值的拦截(同一行的前半判据)只判了一半。实测 base 报 + ``headers.X?token``,变异体接受。 + """ + with pytest.raises(ValueError, match=r"headers\.X\?token\b"): + GoogleProvider._validate_http_options({"headers": {"X": "http://h/p?token="}}) + + +def test_google_decode_data_uri_rejects_illegal_base64_characters(): + """Google data URI 的 base64 必须 ``validate=True``,与 Anthropic 侧同判据。 + + 反向成 ``False`` 后 ``base64.b64decode`` 丢弃非法字符,``"YWJj!!!"`` 被解成 + ``b"abc"`` 照常返回——损坏的图片被当成合法数据继续走。既有用例断言的是 + 非 ASCII 输入(走 ``ValueError``),ASCII 非法字符这一半此前无人看守。 + """ + with pytest.raises(ValueError, match="data URI 不是有效 Base64"): + GoogleProvider._decode_data_uri("data:image/png;base64,YWJj!!!", "图片") + + +def test_google_response_text_reaches_the_accessor_fallback_when_parts_are_empty(): + """``saw_parts`` 初值必须是 ``False``:parts 为空时才够得着 ``response.text`` 兜底。 + + 反向成 ``True`` 后空 parts 也被当成「看过部件」,直接返回空串,随后 + ``response.text`` 里的正文被跳过——SDK 构造的**无 parts** 响应 + (只有 ``text`` 属性)此前拿得到正文,变异后拿到空字符串。 + """ + from types import SimpleNamespace as NS + + response = NS(candidates=[NS(content=NS(parts=[]))], text="兜底正文") + + assert GoogleProvider._response_text(response) == "兜底正文" + + +class _AnthropicRecorder: + """路径感知的假 Anthropic SDK 客户端:属性链成路径,末端调用被记录。""" + + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + self._path = "" + + def __getattr__(self, name): + if name.startswith("__"): + raise AttributeError(name) + child = _AnthropicRecorder() + child.calls = self.calls + child._path = f"{self._path}.{name}" + return child + + def __call__(self, *args, **kwargs): + self.calls.append((self._path.lstrip("."), kwargs)) + return SimpleNamespace( + input_tokens=3, + total_tokens=3, + content=[SimpleNamespace(type="text", text="ok")], + stop_reason="end_turn", + usage=SimpleNamespace(input_tokens=3, output_tokens=1), + ) + + +@pytest.mark.parametrize( + "method_name,is_async", + [("parse", False), ("async_parse", True)], +) +def test_anthropic_parse_rejects_beta_only_params(method_name, is_async): + """非 Beta 的 ``parse`` 入口必须拒绝 ``cache_control`` 这类 Beta 专属参数。 + + ``_parse_params(params, beta=False)`` 的 ``beta`` 反向成 ``True`` 后,允许 + 集合被扩成 Beta 全集,``cache_control``/``speed`` 等字段被原样交给 + 非 Beta 的 ``messages.parse``——SDK 侧要么报未知参数、要么把 Beta 专属语义 + 发给普通端点。实测 base 抛「不支持请求参数: cache_control」,变异体照常调用 + ``messages.parse``。 + """ + recorder = _AnthropicRecorder() + provider = _anthropic_provider() + provider._get_client = lambda: recorder + provider._get_aclient = lambda: recorder + request = CompletionRequest(prompt="hi", cache_control={"type": "ephemeral"}) + + with pytest.raises(ValueError, match=r"Anthropic Messages parse 不支持请求参数: cache_control"): + result = getattr(provider, method_name)({}, request) + if is_async: + asyncio.run(result) + + +@pytest.mark.parametrize( + "method_name,is_async", + [("beta_parse", False), ("async_beta_parse", True)], +) +def test_anthropic_beta_parse_forwards_beta_only_params(method_name, is_async): + """Beta 的 ``beta_parse`` 入口必须放行 ``speed`` 这类 Beta 专属参数。 + + ``_parse_params(params, beta=True)`` 的 ``beta`` 反向成 ``False`` 后,允许 + 集合退化成普通集合,``speed``(经 ``beta_values`` 进入 params)被判为 + 「不支持请求参数」——合法的 Beta 调用直接失败。实测 base 调用 + ``beta.messages.parse``,变异体抛 ``ValueError``。 + """ + recorder = _AnthropicRecorder() + provider = _anthropic_provider() + provider._get_client = lambda: recorder + provider._get_aclient = lambda: recorder + request = CompletionRequest(prompt="hi", speed="fast") + + result = getattr(provider, method_name)({}, request) + if is_async: + asyncio.run(result) + + assert [name for name, _ in recorder.calls] == ["beta.messages.parse"] + assert recorder.calls[0][1]["speed"] == "fast" + + +def test_anthropic_async_count_tokens_declares_user_profile_beta_header(): + """``async_count_tokens`` 的 ``_user_profile_fields(beta=False)`` 必须走头合并分支。 + + 该调用的 ``beta=False`` 反向成 ``True`` 后,``_user_profile_fields`` 改走 + Beta 资源分支:``user-profiles-2026-08-18`` 被塞进**第二个**返回值 + (``betas``),而本处只取第一个返回值(``profile_headers``)——Beta 声明被 + 丢弃,``extra_headers`` 里不再有 ``anthropic-beta``。实测 base 的 + ``messages.count_tokens`` 收到该头,变异体收到 ``None``。同步入口 + (``count_tokens``)已被既有用例覆盖,异步这一支此前无人看守。 + """ + recorder = _AnthropicRecorder() + provider = _anthropic_provider() + provider._get_aclient = lambda: recorder + + asyncio.run(provider.async_count_tokens(CompletionRequest(prompt="hi", user="u1"))) + + assert recorder.calls == [ + ( + "messages.count_tokens", + { + "model": "m", + "messages": [{"role": "user", "content": "hi"}], + "system": "You are a helpful assistant.", + "user_profile_id": "u1", + "extra_headers": {"anthropic-beta": "user-profiles-2026-08-18"}, + }, + ) + ] + + +def test_anthropic_build_message_params_rejects_output_format_without_stream(): + """非流式装配拒绝 ``output_format``,除非调用方显式声明是 tool runner。 + + ``for_tool_runner`` 的默认值 ``False`` 反向成 ``True`` 后,任何**没有**声明 + tool runner 的调用都满足 ``not (beta and for_tool_runner)``,于是 + ``beta=True`` + ``stream=False`` + ``output_format`` 的组合不再报 + 「Messages.create 不支持 output_format」,``output_format`` 被原样塞进 + ``messages.create`` 的参数。实测 base 抛 ``ValueError``,变异体返回含 + ``output_format`` 的 params。 + """ + provider = _anthropic_provider() + + with pytest.raises(ValueError, match="不支持 output_format"): + provider._build_message_params(prompt="hi", beta=True, stream=False, output_format=int) + + +@pytest.mark.parametrize( + "provider_cls", + [AnthropicProvider, GoogleProvider, OpenAICompatibleProvider, VolcengineProvider], +) +def test_provider_invoke_and_ainvoke_default_to_streaming(provider_cls): + """所有 Provider 的 ``invoke``/``ainvoke`` 都必须默认 ``stream=True``。 + + ``stream`` 的默认值反向成 ``False`` 后,不传该参数的调用静默退化成非流式 + ——``chat/core.py`` 与 ``ChatService`` 都靠显式传参自保,但公共签名承诺的 + 默认形态被改变。抽象基类 ``LargeLanguageModel`` 上的两处默认值是各 Provider + 共同契约的落点,同样必须钉住(实测全量套件对这两处无感)。 + """ + for method_name in ("invoke", "ainvoke"): + parameters = inspect.signature(getattr(provider_cls, method_name)).parameters + assert parameters["stream"].default is True, f"{provider_cls.__name__}.{method_name}" + + +def test_abstract_base_invoke_signatures_default_to_streaming(): + """抽象基类的 ``invoke``/``ainvoke`` 签名默认值同样是 ``True``。 + + 这两处(``model_provider.py`` 2167/2178 行)是各 Provider 覆盖时的契约来源, + 反向成 ``False`` 后仅靠 ``inspect.signature`` 可观察;没有这条断言时全量 + 套件对它无感。 + """ + for method_name in ("invoke", "ainvoke"): + parameters = inspect.signature(getattr(LargeLanguageModel, method_name)).parameters + assert parameters["stream"].default is True, f"LargeLanguageModel.{method_name}" + + +# ========================================================================= +# face 13:boolc(True↔False 字面量互换)扫描确认的真缺口 +# ------------------------------------------------------------------------- +# 以下用例对应 ``face13_auth68.log`` 中 68 个存活位点里经真实入口探针 +# 判定为**真缺口**的部分(另 18 处等价变异见 CHANGELOG 条目)。 +# ========================================================================= + + +# --- Google:原生工具 ``is True`` 与分片 ``will_continue`` ----------------- + + +def test_google_native_tool_accepts_boolean_true_marker(): + """``{"type": "google_search", "google_search": True}`` 必须转成合法 ``Tool``。 + + ``native_value is True or native_value is None`` 反向成 ``is False`` 后, + 布尔 ``True`` 既不是 ``False`` 也不是 ``None``,被原样交给 + ``types.Tool(google_search=True)``——pydantic 校验失败,一条合法的 + 「打开内置搜索」声明直接抛 ``ValidationError``。 + """ + from google.genai import types + + tools = GoogleProvider._convert_tools([{"type": "google_search", "google_search": True}]) + + assert len(tools) == 1 + assert isinstance(tools[0], types.Tool) + assert tools[0].google_search is not None + + +def test_google_native_tool_rejects_boolean_false_marker(): + """``google_search=False`` 必须被拒(``is True or is None`` 不是「任意布尔」)。 + + ``is True`` 反向成 ``is False`` 后,``False`` 落进「当作空配置」分支, + 本应报错的显式关闭标记被静默接受成 ``Tool(google_search=GoogleSearch())``。 + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError): + GoogleProvider._convert_tools([{"type": "google_search", "google_search": False}]) + + +def test_google_tool_call_fragment_uses_will_continue_as_identity(): + """无 id/index 的分片靠 ``will_continue is True`` 关联到已有候选。 + + ``tool_call.get("will_continue") is True`` 反向成 ``is False`` 后,一个 + 显式声明 ``will_continue=True`` 的分片不再被认作可关联——本可合并的流式 + 分片抛 ``RuntimeError: ... 无法确认是否为同一调用``。 + """ + accumulator = {("position", 0): {"type": "function", "name": "f"}} + + key, merged = GoogleProvider._merge_tool_call( + accumulator, {"name": "f", "will_continue": True, "arguments": "{}"} + ) + + assert key == ("position", 0) + assert merged["name"] == "f" + + +def test_google_tool_call_fragment_rejects_explicit_false_continuation(): + """显式 ``will_continue=False`` 的分片不得被当成可关联。 + + ``is True`` 反向成 ``is False`` 后,``False is False`` 成立——一个显式声明 + 「本次调用到此为止」的分片被误认作可关联,两个同名并行调用被静默合并。 + """ + accumulator = {("position", 0): {"type": "function", "name": "f"}} + + with pytest.raises(RuntimeError, match="无法确认是否为同一调用"): + GoogleProvider._merge_tool_call( + accumulator, {"name": "f", "will_continue": False, "arguments": "{}"} + ) + + +# --- OpenAI 兼容:``strict`` 默认值、空状态判定、流终止、background -------- + + +def test_responses_tools_default_strict_to_false(): + """原生 function 工具未写 ``strict`` 时补 ``False``(严格模式默认关)。 + + ``setdefault("strict", False)`` 反向成 ``True`` 后,未显式要求严格模式的 + 工具被强制开启 ``strict``——服务端按严格 schema 校验,宽松的工具定义 + 开始报错。 + """ + converted = OpenAICompatibleProvider._convert_responses_tools( + [{"type": "function", "name": "f", "parameters": {"type": "object"}}] + ) + + assert converted[0]["strict"] is False + + +def test_responses_tools_preserve_explicit_strict_true(): + """显式 ``strict=True`` 必须保留(默认只补缺省,不覆盖调用方)。""" + converted = OpenAICompatibleProvider._convert_responses_tools( + [{"type": "function", "name": "f", "parameters": {"type": "object"}, "strict": True}] + ) + + assert converted[0]["strict"] is True + + +@pytest.mark.parametrize("status", ["", " ", " \t\n"]) +def test_chat_status_is_error_treats_blank_status_as_not_error(status): + """空白字符串状态不算错误(``if not normalized: return False``)。 + + ``return False`` 反向成 ``return True`` 后,网关在 HTTP 200 里放一个 + 空白 ``status`` 就会被判成业务失败——一次正常响应被报成 ``RuntimeError``。 + """ + assert OpenAICompatibleProvider._chat_status_is_error(status) is False + + +def test_openai_async_chat_stream_requires_terminal_finish(): + """异步 Chat 流缺少 finish 块时必须报错(``terminal_seen = True`` 初值)。 + + 初值反向成 ``True`` 后,被截断的流不再报「finish 之前结束」——调用方 + 拿到一段不完整文本却以为流正常收尾。 + """ + + def _rows(with_finish): + out = [ + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="hi", tool_calls=None), finish_reason=None + ) + ], + usage=None, + id="r", + ) + ] + if with_finish: + out.append( + SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=None), + finish_reason="stop", + ) + ], + usage=None, + id="r", + ) + ) + return out + + def _provider(with_finish): + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "chat_completions" + provider._options = {} + + class Completions: + async def create(self, **_kw): + return _rows(with_finish) + + provider._get_aclient = lambda: SimpleNamespace( + chat=SimpleNamespace(completions=Completions()) + ) + return provider + + async def _collect(provider): + return [event async for event in provider.astream_events(CompletionRequest(prompt="hi"))] + + complete = asyncio.run(_collect(_provider(True))) + assert [event.type for event in complete] == ["text_delta", "finish"] + + with pytest.raises(RuntimeError, match="finish 事件之前结束"): + asyncio.run(_collect(_provider(False))) + + +def test_openai_responses_stream_records_terminal_completed(): + """收到 ``response.completed`` 的完整流必须正常收尾(不误报截断)。 + + ``terminal_seen = True`` 反向成 ``False`` 后,**已经**收到终止事件的完整流 + 仍被判为「response.completed 之前结束」——一次正常响应被报成 ``RuntimeError``。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class Responses: + def create(self, **_request): + return iter( + [ + SimpleNamespace(type="response.output_text.delta", delta="hi"), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed", error=None), + ), + ] + ) + + provider._get_client = lambda: SimpleNamespace(responses=Responses()) + + assert list(provider._invoke_responses({"stream": True})) == ["hi"] + + +@pytest.mark.parametrize( + "method_name,is_async", + [("parse", False), ("async_parse", True)], +) +def test_openai_parse_rejects_background_requests(method_name, is_async): + """``parse`` 必须显式拒绝 ``background=True``(先于流式开关判定)。 + + ``request.copy_with(stream=False)`` 反向成 ``stream=True`` 后, + ``_build_responses_request`` 因 ``background and stream`` 抛出**另一条** + 「background 请求不支持流式模式」——指引与 ``parse`` 语义不符,调用方按 + 「parse 不支持 background」写的分类接不住。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class _Never: + @staticmethod + def parse(**_kw): + return "unused" + + provider._get_client = lambda: SimpleNamespace( + responses=_Never(), chat=SimpleNamespace(completions=_Never()) + ) + provider._get_aclient = lambda: SimpleNamespace( + responses=_Never(), chat=SimpleNamespace(completions=_Never()) + ) + request = CompletionRequest(prompt="hi", background=True) + + with pytest.raises(ValueError, match="不支持 background=true"): + result = getattr(provider, method_name)(text_format=int, request=request) + if is_async: + asyncio.run(result) + + +@pytest.mark.parametrize( + "method_name,is_async", + [("stream_responses", False), ("async_stream_responses", True)], +) +def test_openai_stream_responses_rejects_background_requests(method_name, is_async): + """``stream_responses`` 必须拒绝 ``background=True``。 + + ``request.copy_with(stream=True)`` 反向成 ``stream=False`` 后, + ``background`` 与 ``stream`` 不再冲突,后台请求被放行交给 + ``responses.stream``——一个本应「非流式轮询」的请求被当成流式发出。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + class _Never: + @staticmethod + async def stream(**_kw): + return "unused" + + provider._get_client = lambda: SimpleNamespace(responses=_Never()) + provider._get_aclient = lambda: SimpleNamespace(responses=_Never()) + request = CompletionRequest(prompt="hi", background=True) + + with pytest.raises(ValueError, match="background 请求不支持流式模式"): + result = getattr(provider, method_name)(request=request) + if is_async: + asyncio.run(result) + + +# --- Ark(Volcengine):异步 Responses 流终止 ------------------------------- + + +def test_ark_async_responses_stream_records_terminal_completed(): + """Ark 异步 Responses 收到 ``response.completed`` 的完整流必须正常收尾。 + + ``terminal_seen = True`` 反向成 ``False`` 后,**已经**收到终止事件的完整流 + 仍被判为「response.completed 之前结束」——一次正常响应被报成 ``RuntimeError``。 + """ + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + provider._provider = "volcengine" + provider._protocol = "responses" + provider._options = {} + provider._server_verified_protocols = frozenset({"responses"}) + + class Responses: + @staticmethod + def create(**_kw): + return iter( + [ + SimpleNamespace(type="response.output_text.delta", delta="hi"), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed", error=None), + ), + ] + ) + + provider._aclient = SimpleNamespace(responses=Responses()) + + async def _collect(): + return [chunk async for chunk in provider.ainvoke("hi")] + + assert asyncio.run(_collect()) == ["hi"] + + +# --- 私有请求装配器的 stream 默认值(与 ``_chat_tool_call_item`` 同类) ---- + + +def test_openai_chat_builder_defaults_to_streaming(): + """``_build_chat_request`` 的 ``stream`` 默认值必须是 ``True``。""" + parameters = inspect.signature(OpenAICompatibleProvider._build_chat_request).parameters + assert parameters["stream"].default is True + + +def test_openai_responses_builder_defaults_to_streaming(): + """``_build_responses_request`` 的 ``stream`` 默认值必须是 ``True``。""" + parameters = inspect.signature(OpenAICompatibleProvider._build_responses_request).parameters + assert parameters["stream"].default is True + + +def test_ark_chat_builder_defaults_to_streaming(): + """Ark ``_build_chat_request`` 的 ``stream`` 默认值必须是 ``True``。""" + parameters = inspect.signature(VolcengineProvider._build_chat_request).parameters + assert parameters["stream"].default is True + + +# --- reraise=True:重试耗尽后抛原始异常,不是 tenacity 的 RetryError -------- +# +# 同 ``test_retry_helpers_reraise_original_error``:``reraise=True`` 反向成 +# ``False`` 后,最后一次异常被包成 ``RetryError``——调用方按 +# ``TimeoutError``/``ConnectionError`` 写的错误分类接不住。这里覆盖各 +# Provider 的 ``embed_documents``/``aembed_documents``/``complete`` 装饰器 +# (每一处都是**独立**的 ``@retry(...)``,必须逐个看守)。 + + +def _patch_retry_sleep(monkeypatch, decorated): + """掏掉退避等待(tenacity 的 ``sleep`` 是 retry 对象的实例属性)。""" + monkeypatch.setattr(decorated.retry, "sleep", lambda _seconds: None) + + +@pytest.mark.parametrize( + "decorated", + [ + OpenAICompatibleProvider.embed_documents, + OpenAICompatibleProvider.aembed_documents, + GoogleProvider.embed_documents, + GoogleProvider.aembed_documents, + VolcengineProvider.complete, + VolcengineProvider.embed_documents, + VolcengineProvider.aembed_documents, + ], + ids=["oc.embed", "oc.aembed", "g.embed", "g.aembed", "v.complete", "v.embed", "v.aembed"], +) +def test_provider_retry_decorators_reraise_original_error(decorated): + """每一处 ``@retry(reraise=True)`` 耗尽后都必须抛出原始异常。""" + assert decorated.retry.reraise is True + + +def test_openai_embed_reraise_surfaces_timeout_error(monkeypatch): + """行为面确认:``embed_documents`` 耗尽后抛 ``TimeoutError`` 而非 ``RetryError``。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "m" + provider._provider = "openai" + provider._options = {} + _patch_retry_sleep(monkeypatch, OpenAICompatibleProvider.embed_documents) + + class Embeddings: + @staticmethod + def create(**_kw): + raise TimeoutError("boom") + + provider._get_client = lambda: SimpleNamespace(embeddings=Embeddings()) + + with pytest.raises(TimeoutError): + provider.embed_documents(["a"]) + + +def test_google_embed_reraise_surfaces_timeout_error(monkeypatch): + """行为面确认:Google ``embed_documents`` 耗尽后抛 ``TimeoutError``。""" + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-2.0-flash" + provider._options = {} + _patch_retry_sleep(monkeypatch, GoogleProvider.embed_documents) + + class Models: + @staticmethod + def embed_content(**_kw): + raise TimeoutError("boom") + + class Client: + vertexai = False + models = Models() + + provider._get_client = lambda: Client() + + with pytest.raises(TimeoutError): + provider.embed_documents(["a"]) diff --git a/tests/providers/test_local_hash.py b/tests/providers/test_local_hash.py index 898b2bd..bc60cc5 100644 --- a/tests/providers/test_local_hash.py +++ b/tests/providers/test_local_hash.py @@ -1,5 +1,7 @@ import asyncio +import pytest + from src.providers.local_hash import LocalHashEmbeddingProvider @@ -22,3 +24,127 @@ def test_local_hash_embedding_async_matches_sync(): async_vectors = asyncio.run(provider.aembed_documents(["异步一致性"])) assert async_vectors == sync_vectors + + +# ── 回归:维度必须在分配向量之前受上界约束 ── +# +# ``_parse_dimension`` 从 ``model_name`` 的数字后缀取维度,而 +# ``embedding_configurations..model_name`` 是 ``config.toml`` 里用户可写的 +# 字段。``_embed_text`` 会**先按该维度分配出完整向量**再返回:实测 +# ``local-hash-100000000`` 单次调用耗时 136 秒、峰值内存 3.9 GB,按真实知识库 +# 115 个分块算 ``np.vstack`` 需要约 4.3 GB。等调用方拿到结果再判已经晚了—— +# 代价已经付出,或者进程已经被 OOM 杀掉。 + + +@pytest.mark.parametrize("model_name", ["local-hash-1", "local-hash-256", "local-hash-65536"]) +def test_local_hash_accepts_dimensions_at_or_below_the_bound(model_name): + """收紧不能误伤:上界以内(含边界)的 model_name 必须照常可用。""" + provider = LocalHashEmbeddingProvider(model_name) + + assert len(provider.embed_documents(["文本"])[0]) == int(model_name.rsplit("-", 1)[-1]) + + +@pytest.mark.parametrize( + "model_name", ["local-hash-65537", "local-hash-1000000", "local-hash-100000000"] +) +def test_local_hash_rejects_oversized_dimensions_at_construction(model_name): + """超过上界的维度必须在**构造期**拒绝,不能等到分配向量。 + + 构造期就拒绝意味着没有任何分配发生过——这正是修复的要点,也是断言 + 「异常发生在构造而不是 embed」的原因:若只在 embed 里判,向量已经建好了。 + """ + with pytest.raises(ValueError, match="上界"): + LocalHashEmbeddingProvider(model_name) + + +def test_local_hash_keeps_default_dimension_for_unnamed_suffixes(): + """没有数字后缀时仍回落到默认维度,不受上界影响。""" + provider = LocalHashEmbeddingProvider("local-hash") + + assert len(provider.embed_documents(["文本"])[0]) == 256 + + +# ── 回归:``isdigit()`` 为真不代表 ``int()`` 能解析 ── +# +# ``_parse_dimension`` 原先用 ``suffix.isdigit()`` 做判据,但 ``isdigit()`` 对 +# 128 个 Unicode 码位为真(``²`` ``³`` ``①`` ``፩`` ``₅`` …)而 ``int()`` 对它 +# 们一律抛 ``ValueError``。``model_name`` 是 ``config.toml`` 里用户可写的字段, +# 于是 ``model_name = "local-hash-²"`` 让构造期抛出一条裸解析错误——消息只有 +# ``invalid literal for int() with base 10: '²'``,既不提 ``model_name`` 也不提 +# 配置,用户无从知道该改哪里。 +# +# 全码位穷举:``isdigit()`` 为真的 788 个码位里 128 个 ``int()`` 抛错; +# ``isdecimal()`` 为真的 660 个码位 ``int()`` **全部**可解析。因此 ``isdecimal()`` +# 才是「这串字符是十进制整数」的准确判据。 + + +@pytest.mark.parametrize( + "suffix", + ["²", "①", "፹", "₅", "¹", "⑴"], +) +def test_local_hash_falls_back_to_default_for_unicode_digits_int_cannot_parse(suffix): + """``int()`` 解析不了的「数字」必须走默认维度,而不是抛裸解析错误。""" + provider = LocalHashEmbeddingProvider(f"local-hash-{suffix}") + + assert len(provider.embed_documents(["文本"])[0]) == 256 + + +@pytest.mark.parametrize("suffix", ["٣", "12", "۷"]) +def test_local_hash_still_parses_unicode_digits_int_can_parse(suffix): + """收紧不能误伤:``int()`` 真能解析的 Unicode 十进制数字照常生效。 + + ``٣``(阿拉伯-印度数字 3)、``12``(全角 12)、``۷``(波斯数字 7)都 + 是 ``isdecimal()`` 为真且 ``int()`` 可解析的合法十进制数字。 + """ + provider = LocalHashEmbeddingProvider(f"local-hash-{suffix}") + + assert len(provider.embed_documents(["文本"])[0]) == int(suffix) + + +def test_local_hash_falls_back_to_default_for_digit_strings_beyond_the_limit(): + """超长数字后缀必须回落默认维度,而不是抛 ``ValueError``。 + + ``"9" * 4301`` 是合法十进制整数、``isdecimal()`` 也为真,但位数超过 + ``sys.get_int_max_str_digits()``(默认 4300),``int()`` 照样抛错。 + """ + import sys + + suffix = "9" * (sys.get_int_max_str_digits() + 1) + provider = LocalHashEmbeddingProvider(f"local-hash-{suffix}") + + assert len(provider.embed_documents(["文本"])[0]) == 256 + + +# ── 回归:非字符串 model_name 在构造期抛裸 AttributeError ── +# +# ``_parse_dimension`` 第一行就是 ``model_name.rsplit("-", maxsplit=1)``,而 +# ``LocalHashEmbeddingProvider`` 是**公开类**,嵌入方可以直接构造(本仓测试 +# 就是这么用 ``LocalHashEmbeddingProvider("local-hash-32")`` 的)。实测 +# ``10**4301``/``["m"]``/``None``/``5`` 全部抛裸 +# ``AttributeError: 'int' object has no attribute 'rsplit'``——不在本模块其余 +# 拒绝路径承诺的 ``ValueError`` 契约内(维度超上界给的是带指引的 ``ValueError``)。 +# ``model_name`` 的来源与 ``config.toml`` 的 ``model_name`` 同源;工厂路径已用 +# ``require_model_name`` 收敛,但直接构造这条路径漏了。 + + +@pytest.mark.parametrize( + "model_name", + [ + pytest.param(10**4301, id="huge-int"), + pytest.param(["m"], id="list"), + pytest.param(None, id="none"), + pytest.param(5, id="int"), + ], +) +def test_local_hash_rejects_non_string_model_name_readably(model_name): + """非字符串 ``model_name`` 必须给出带字段名的 ``ValueError``。""" + with pytest.raises(ValueError) as excinfo: + LocalHashEmbeddingProvider(model_name) + + assert "model_name" in str(excinfo.value) + + +def test_local_hash_still_accepts_string_model_names(): + """收紧不能误伤:字符串形态照常解析。""" + assert len(LocalHashEmbeddingProvider("local-hash-32").embed_documents(["文本"])[0]) == 32 + assert len(LocalHashEmbeddingProvider("local-hash").embed_documents(["文本"])[0]) == 256 diff --git a/tests/providers/test_protocol_adapters.py b/tests/providers/test_protocol_adapters.py index 7765992..03bbf01 100644 --- a/tests/providers/test_protocol_adapters.py +++ b/tests/providers/test_protocol_adapters.py @@ -13,6 +13,7 @@ from src.providers.__base__.model_provider import ( CompletionRequest, normalize_responses_input, + validate_complete_tool_call, ) from src.providers.anthropic import AnthropicProvider from src.providers.deepseek import DeepSeekProvider @@ -85,6 +86,63 @@ def test_anthropic_rejects_malformed_tool_call_arguments(): ) +# `_convert_messages` 的三条 assistant `tool_calls[]` 形态判据在整套测试里从未执行 +# (`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3260 条仍全绿)。 +# 既有用例 `test_anthropic_rejects_malformed_tool_call_arguments`/ +# `test_anthropic_rejects_tool_call_without_id_or_name` 命中的都是**元素内部**字段 +# (`arguments` 非法 JSON、缺 `id`、缺 `function.name`),三条形态判据都绕开了。 +# 删除后行为确实改变(实测):707 元素非对象 → 裸 +# `AttributeError: 'int' object has no attribute 'get'`;710 `function` 非对象 → +# 裸 `AttributeError: 'str' object has no attribute 'get'`;730 `arguments` 非对象 → +# 裸 `TypeError: 'int' object is not iterable`(`dict(arguments)`)。 +@pytest.mark.parametrize("call", [5, "bad", ["x"]]) +def test_anthropic_rejects_non_object_tool_call_entry(call): + """`tool_calls[]` 元素非对象时必须给带序号的 ``ValueError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match=r"tool_calls\[0\] 必须是对象"): + AnthropicProvider._convert_messages([{"role": "assistant", "tool_calls": [call]}]) + + +@pytest.mark.parametrize("function", ["bad", 5, ["x"]]) +def test_anthropic_rejects_non_object_tool_call_function(function): + """`tool_calls[].function` 非对象时给带字段名的 ``ValueError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match=r"tool_calls\[0\].function 必须是对象"): + AnthropicProvider._convert_messages( + [{"role": "assistant", "tool_calls": [{"function": function}]}] + ) + + +@pytest.mark.parametrize("arguments", [5, ["a"], 5.5]) +def test_anthropic_rejects_non_object_tool_call_arguments(arguments): + """`tool_calls[].function.arguments` 非 JSON 对象时给带字段名的 ``ValueError``。""" + with pytest.raises(ValueError, match=r"tool_calls\[0\] 的 arguments 必须是 JSON 对象"): + AnthropicProvider._convert_messages( + [ + { + "role": "assistant", + "tool_calls": [ + {"id": "call-1", "function": {"name": "lookup", "arguments": arguments}} + ], + } + ] + ) + + +def test_anthropic_accepts_well_formed_tool_call_arguments(): + """收紧不能误伤:JSON 字符串与对象两种合法形态的 ``arguments`` 照常转换。""" + for arguments in ('{"q": "x"}', {"q": "x"}): + converted = AnthropicProvider._convert_messages( + [ + { + "role": "assistant", + "tool_calls": [ + {"id": "call-1", "function": {"name": "lookup", "arguments": arguments}} + ], + } + ] + ) + assert converted[0]["content"][0]["input"] == {"q": "x"} + + def test_anthropic_rejects_image_content_without_url(): with pytest.raises(ValueError, match="图片内容缺少有效 image_url"): AnthropicProvider._convert_content([{"type": "image_url", "image_url": {}}]) @@ -140,6 +198,84 @@ def test_anthropic_rejects_malformed_tool_choices(tool_choice): AnthropicProvider._convert_tool_choice(tool_choice) +def test_anthropic_unknown_tool_choice_string_names_the_value(): + """未知字符串必须点名取值,不能退化成通用兜底消息。 + + 删除守卫后 ``"bogus"`` 落到最后一条通用 ``ValueError``——它不再包含出错的 + 取值,排查时无法分辨是「值不认识」还是「形态写错了」。变异扫描证实该守卫 + 在整套测试里没有反向覆盖(删掉后全量仍绿)。 + """ + with pytest.raises(ValueError, match=r"不支持 tool_choice: bogus"): + AnthropicProvider._convert_tool_choice("bogus") + + # 合法取值不受影响 + assert AnthropicProvider._convert_tool_choice("auto") == {"type": "auto"} + + +# `_convert_tools` 与 `_convert_tool_choice` 各有四条以上判据,上面两条用例只 +# 覆盖了其中一部分:`test_anthropic_rejects_malformed_tool_definitions` 覆盖 +# 「元素非对象」「function 非对象」「parameters 非对象」,但工具**缺 name** 与 +# **description 非字符串**两条从未执行;`test_anthropic_rejects_malformed_tool_choices` +# 覆盖「未知字符串」「function 缺 name」「tool 缺 name」,但 **function 取值非对象** +# 那条从未执行。`sys.settrace` 行覆盖证实这三条在整套测试里从未执行;把它们的 +# `raise` 换成 `pass` 后全量 3188 条仍然全绿(同一批里的「tools 非序列」「tool +# 缺 name」「tool_choice 兜底」三条已由既有用例钉住,变异后确实变红)。 +# +# 删除后行为确实改变(实测): +# * 缺 name → **静默接受**,`{"function": {}}` 产出 `{'name': None, ...}`,把 +# `name: None` 的工具定义发给上游; +# * description 非字符串 → **静默接受**,整数 `description` 原样进请求体; +# * function 取值非对象 → 带指引的 `ValueError` 变成裸 +# `AttributeError: 'str' object has no attribute 'get'`。 +@pytest.mark.parametrize( + "tool", + [ + {"type": "function", "function": {}}, + {"parameters": {}}, + {"description": "只有描述没有名字"}, + {"function": {"description": "也没有名字"}}, + ], +) +def test_anthropic_rejects_tool_definitions_without_a_name(tool): + """工具定义缺 name 必须 ``ValueError``,不能静默产出 ``name=None``。""" + with pytest.raises(ValueError, match="缺少 function.name"): + AnthropicProvider._convert_tools([tool]) + + +@pytest.mark.parametrize("description", [5, True, ["d"], {"d": 1}]) +def test_anthropic_rejects_non_string_tool_description(description): + """``description`` 必须是非字符串,不能原样进请求体。""" + with pytest.raises(ValueError, match="description 必须是字符串"): + AnthropicProvider._convert_tools([{"name": "lookup", "description": description}]) + + +@pytest.mark.parametrize("function", ["bad", 5, ["x"]]) +def test_anthropic_rejects_tool_choice_with_non_object_function(function): + """``tool_choice.function`` 非对象时给带指引的 ``ValueError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="tool_choice.function 必须是对象"): + AnthropicProvider._convert_tool_choice({"type": "function", "function": function}) + + +def test_anthropic_accepts_well_formed_tools_and_tool_choice(): + """收紧不能误伤:合法工具定义与 tool_choice 照常转换。""" + converted = AnthropicProvider._convert_tools( + [ + { + "type": "function", + "function": {"name": "lookup", "description": "查表", "parameters": {}}, + } + ] + ) + + assert converted == [{"name": "lookup", "description": "查表", "input_schema": {}}] + assert AnthropicProvider._convert_tool_choice( + {"type": "function", "function": {"name": "lookup"}} + ) == { + "type": "tool", + "name": "lookup", + } + + def test_anthropic_stream_errors_are_explicit(): with pytest.raises(RuntimeError, match="上游失败"): AnthropicProvider._stream_event( @@ -235,6 +371,74 @@ def test_google_merges_consecutive_same_role_messages(): assert [part.text for part in contents[0].parts] == ["第一条", "第二条"] +# `GoogleProvider._contents` 的两条判据在整套测试里从未执行(`sys.settrace` 行覆盖 +# 证实;把它们的 `raise` 换成 `pass` 后全量 3245 条仍然全绿)。删除后行为确实改变 +# (实测): +# * 1641 工具结果既无 `tool_call_id` 也无 `name` → **静默接受**,产出一个没有 +# `name` 的 `FunctionResponse`,Google 侧无法把它关联回任何一次函数调用; +# * 1881 assistant `tool_calls[]` 的 `arguments` 非对象 → 带字段名的 +# `ValueError` 变成裸 `TypeError: 'int' object is not iterable`(`dict(...)`)。 +# 同函数的 1862/1865/1869 经甄别是**等价变异**,不补测试:上游 +# `normalize_messages` 已用同措辞判据拦下(消息变成 +# `messages[0].tool_calls[0] ...`),删除后收场不变。 +@pytest.mark.parametrize( + "message", + [ + {"role": "tool", "content": '{"status":"paid"}'}, + {"role": "tool", "tool_call_id": 5, "content": "x"}, + ], +) +def test_google_rejects_tool_result_without_correlatable_call_id(message): + """工具结果既无 `tool_call_id` 也无 `name` 时必须报错,不能产出无名的响应块。""" + with pytest.raises(ValueError, match="缺少可关联的 tool_call_id/name"): + GoogleProvider._contents([message], None, None) + + +@pytest.mark.parametrize("arguments", [5, ["a"], 5.5]) +def test_google_rejects_non_object_function_call_arguments(arguments): + """assistant `tool_calls[].function.arguments` 非 JSON 对象时给带指引的 ``ValueError``。""" + with pytest.raises(ValueError, match="arguments 必须是 JSON 对象"): + GoogleProvider._contents( + [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call-1", + "function": {"name": "lookup_order", "arguments": arguments}, + } + ], + } + ], + None, + None, + ) + + +def test_google_accepts_json_string_and_object_function_call_arguments(): + """收紧不能误伤:JSON 字符串与对象两种合法形态的 ``arguments`` 照常转换。""" + for arguments in ("{}", {"outer": 1}): + contents = GoogleProvider._contents( + [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call-1", + "function": {"name": "lookup_order", "arguments": arguments}, + } + ], + } + ], + None, + None, + ) + calls = [part.function_call for part in contents[0].parts if part.function_call is not None] + assert len(calls) == 1 + assert calls[0].name == "lookup_order" + assert isinstance(calls[0].args, dict) + + def test_google_rejects_external_media_urls_and_unknown_part_types(): with pytest.raises(ValueError, match="仅支持 data: URI、gs:// URI 或 Google Files URI"): GoogleProvider._contents( @@ -451,10 +655,41 @@ def test_google_tool_call_message_with_null_content_does_not_emit_text(): def test_google_rejects_malformed_function_tool_choice(): - with pytest.raises(ValueError, match="function.name"): + """缺 name 必须是带指引的 ``ValueError``,不能退化成 pydantic ``ValidationError``。 + + ``match`` 必须钉住「缺少」前缀:删除守卫后落进 pydantic 校验,其消息里的 + ``allowed_function_names`` 恰好满足宽松正则 ``function.name``(``.`` 匹配 + ``_``),旧断言在变异后仍然变绿——变异扫描证实过这一盲区。 + """ + with pytest.raises(ValueError, match="缺少 function.name"): GoogleProvider._convert_tool_choice({"type": "function", "function": {}}) +# `GoogleProvider._convert_tools` 的容器判据与 `_convert_tool_choice` 的 +# 「function 非对象」判据在整套测试里从未执行(`sys.settrace` 行覆盖证实; +# 把它们的 `raise` 换成 `pass` 后全量 3238 条仍然全绿)。既有用例看似覆盖了同一 +# 入口,其实都绕过了这两条:`test_google_convert_tools_rejects_malformed_definitions` +# 传的是「列表**内含**畸形元素」(`[{"type": ["google_search"]}]`),命中的是 +# 元素级判据;`test_google_rejects_malformed_function_tool_choice` 传的 +# `function={}` 是合法 Mapping,命中的是缺 name 那条。删除后行为确实改变(实测): +# * 1245 `tools=5` → 带序号的 `ValueError` 变成裸 +# `TypeError: 'int' object is not iterable`(`enumerate(tools)`); +# * 1333 `function` 非对象 → 带字段名的 `ValueError` 变成裸 +# `AttributeError: 'str' object has no attribute 'get'`。 +@pytest.mark.parametrize("tools", [5, {"a": 1}, "oops", object()]) +def test_google_convert_tools_rejects_non_sequence_container(tools): + """``tools`` 非序列时必须给带指引的 ``ValueError``,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="Google 工具定义必须是列表"): + GoogleProvider._convert_tools(tools) + + +@pytest.mark.parametrize("function", ["lookup", 5, ["x"]]) +def test_google_rejects_tool_choice_with_non_object_function(function): + """``tool_choice.function`` 非对象时给带字段名的 ``ValueError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="function tool_choice 的 function 必须是对象"): + GoogleProvider._convert_tool_choice({"type": "function", "function": function}) + + def test_anthropic_merges_parallel_tool_results_into_one_user_turn(): converted = AnthropicProvider._convert_messages( [ @@ -902,6 +1137,99 @@ def test_ark_responses_request_omits_implicit_temperature(): assert "temperature" not in request +# `VolcengineProvider._build_responses_request` 有一组「同一字段两处配置」与 +# 「取值形态」判据,九条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们 +# 的 `raise` 换成 `pass` 后全量 3226 条仍然全绿)。删除后行为确实改变(实测): +# * 1218 模型 options 同时给 `conversation` 与 `session` → **静默接受**,只有 +# `session` 生效,`conversation` 被丢弃; +# * 1222/1227/1256/1261 同一标量字段在模型 options 与 `extra_body`(或请求级 +# 参数)两处出现 → **静默覆盖**,实测 `{"top_k": 1}` 被 `{"top_k": 2}` 顶掉; +# * 1303/1311/1315 `session`/`conversation` 非对象 → 带字段名的 `ValueError` +# 变成裸 `TypeError: 'int' object is not iterable`(`dict(...)`); +# * 1336 请求级 `extra_body` 与模型 options 的同名键重复 → **静默覆盖**。 +def _ark_responses_provider(**options): + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._options = {"server_verified_protocols": ["responses"], **options} + return provider + + +def test_ark_responses_rejects_conversation_and_session_together(): + """模型 options 同时给 `conversation` 与 `session` 时必须报错,不能静默丢弃其一。""" + provider = _ark_responses_provider(conversation={"a": 1}, session={"b": 2}) + + with pytest.raises(ValueError, match="不能同时设置 conversation 和 session"): + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) + + +@pytest.mark.parametrize("field", ["top_k", "seed"]) +def test_ark_responses_rejects_scalar_option_duplicating_extra_body(field): + """模型 options 的标量字段与其 `extra_body` 同名键重复时必须报错。""" + provider = _ark_responses_provider(**{field: 1, "extra_body": {field: 2}}) + + with pytest.raises(ValueError, match=rf"模型 options 的 {field} 与 extra_body 重复"): + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) + + +@pytest.mark.parametrize("field", ["frequency_penalty", "presence_penalty"]) +def test_ark_responses_rejects_sampling_field_duplicating_extra_body(field): + """采样字段与模型 options 的 `extra_body` 重复时必须报错。""" + provider = _ark_responses_provider(**{field: 0.5, "extra_body": {field: 0.5}}) + + with pytest.raises(ValueError, match=rf"{field} 与模型 options.extra_body 重复"): + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) + + +def test_ark_responses_rejects_request_top_k_duplicating_extra_body(): + """请求级 `top_k` 与模型 options 的 `extra_body` 重复时必须报错。""" + provider = _ark_responses_provider(extra_body={"top_k": 1}) + + with pytest.raises(ValueError, match="top_k 与模型 options.extra_body 重复"): + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False, top_k=2)) + + +@pytest.mark.parametrize("field", ["session", "conversation"]) +def test_ark_responses_rejects_non_object_request_state(field): + """请求级 `session`/`conversation` 非对象时给带字段名的 ``ValueError``,不是裸 ``TypeError``。""" + provider = _ark_responses_provider() + + with pytest.raises(ValueError, match=rf"Ark Responses {field} 必须是对象"): + provider._build_responses_request( + CompletionRequest(prompt="hello", stream=False, **{field: 5}) + ) + + +@pytest.mark.parametrize("field", ["session", "conversation"]) +def test_ark_responses_rejects_non_object_configured_state(field): + """模型 options 的 `session`/`conversation` 非对象时同样给带字段名的 ``ValueError``。""" + provider = _ark_responses_provider(**{field: 5}) + + with pytest.raises(ValueError, match=rf"模型 options.{field} 必须是对象"): + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) + + +def test_ark_responses_request_extra_body_cannot_override_model_options(): + """请求级 `extra_body` 与模型 options 的同名键重复时必须报错,不能静默覆盖。""" + provider = _ark_responses_provider(extra_body={"foo": 1}) + + with pytest.raises(ValueError, match="extra_body 与模型 options 重复: foo"): + provider._build_responses_request( + CompletionRequest(prompt="hello", stream=False, extra_body={"foo": 2}) + ) + + +def test_ark_responses_accepts_distinct_state_and_extra_body(): + """收紧不能误伤:合法的 `session` 与不重复的 `extra_body` 照常进请求。""" + provider = _ark_responses_provider(extra_body={"foo": 1}) + + request = provider._build_responses_request( + CompletionRequest(prompt="hello", stream=False, session={"id": "s1"}, extra_body={"bar": 2}) + ) + + assert request["session"] == {"id": "s1"} + assert request["extra_body"] == {"foo": 1, "bar": 2} + + def test_ark_responses_request_preserves_configured_temperature(): provider = object.__new__(VolcengineProvider) provider._model_name = "ark-model" @@ -1035,9 +1363,68 @@ def test_openai_responses_rejects_ark_only_multimodal_content(): ) +# `_convert_responses_tools` 在 OpenAI 兼容与 Ark 两份实现里逐字同构,各有五条 +# 元素级判据。既有用例只覆盖了「容器非序列」(`test_responses_converters_reject_non_sequence_tools`) +# 与「元素非对象」,另外五条从未执行:`sys.settrace` 行覆盖证实,把它们的 `raise` +# 换成 `pass` 后全量 3200 条仍然全绿(只有容器那条变异后变红)。删除后行为确实 +# 改变(实测,两个 provider 同形): +# * 原生工具缺有效 `type` → **静默接受**,`{"native": 1}` 原样透传; +# * 原生 `type=function` 缺 `name` → **静默接受**,产出没有 `name` 的工具; +# * 原生 function 的 `parameters` 非对象 → 带序号的 `ValueError` 变成裸 +# `TypeError: 'int' object is not iterable`(`dict(parameters)`); +# * 统一 schema 缺 `function.name` → **静默接受**,产出 `name: None`; +# * 统一 schema 的 `function.parameters` 非对象 → 同样变成裸 `TypeError`。 +# 两个 provider 用同一组形态参数化,避免只钉住其中一份。 +@pytest.mark.parametrize( + "converter", + [ + OpenAICompatibleProvider._convert_responses_tools, + VolcengineProvider._convert_responses_tools, + ], + ids=["openai", "ark"], +) +@pytest.mark.parametrize( + ("tools", "expected"), + [ + ([{"native": 1}], "缺少有效 type"), + ([{"type": "function"}], "缺少 name"), + ([{"type": "function", "name": "n", "parameters": 5}], "parameters 必须是对象"), + ([{"function": {}}], "缺少 function.name"), + ([{"function": {"name": "n", "parameters": 5}}], "function.parameters 必须是对象"), + ], + ids=[ + "missing-type", + "native-missing-name", + "native-bad-parameters", + "missing-function-name", + "bad-function-parameters", + ], +) +def test_responses_tool_converters_reject_malformed_entries(converter, tools, expected): + """元素级形态非法时必须给带指引的 ``ValueError``,不能静默接受或抛裸 ``TypeError``。""" + with pytest.raises(ValueError, match=expected): + converter(tools) + + +@pytest.mark.parametrize( + "converter", + [ + OpenAICompatibleProvider._convert_responses_tools, + VolcengineProvider._convert_responses_tools, + ], + ids=["openai", "ark"], +) +def test_responses_tool_converters_accept_well_formed_entries(converter): + """收紧不能误伤:原生工具与统一 schema 两种合法形态照常转换。""" + native = converter([{"type": "function", "name": "lookup", "parameters": {"type": "object"}}]) + assert native[0]["name"] == "lookup" + + unified = converter([{"function": {"name": "lookup", "parameters": {"type": "object"}}}]) + assert unified[0]["name"] == "lookup" + + def test_responses_request_converts_chat_tool_choice_to_flat_function_shape(): provider = _responses_provider() - request = provider._build_responses_request( "hello", tools=TOOLS, @@ -1640,111 +2027,494 @@ def test_openai_chat_stream_events_propagate_refusal_delta(): assert events[0].refusal == "不能处理" -def test_openai_chat_stream_events_preserve_parallel_tool_calls(): - chunk = SimpleNamespace( - id="chat-1", - choices=[ - SimpleNamespace( - delta=SimpleNamespace( - content=None, - tool_calls=[ - SimpleNamespace( - id="call-1", - index=0, - function=SimpleNamespace(name="lookup", arguments='{"id":'), - ), - SimpleNamespace( - id="call-2", - index=1, - function=SimpleNamespace(name="refund", arguments='{"order":'), - ), - ], - ), - finish_reason=None, - ) - ], - ) - - events = OpenAICompatibleProvider._chat_stream_events(chunk) - - assert [event.type for event in events] == ["tool_call_delta", "tool_call_delta"] - assert [event.tool_call["id"] for event in events] == ["call-1", "call-2"] - assert [event.tool_call["index"] for event in events] == [0, 1] +def test_openai_chat_stream_survives_non_string_tool_call_type(): + """网关把片段里的 ``type`` 写成数组时,chat 路径不能抛裸 ``TypeError``。 + ``openai`` SDK 对 SSE chunk 的未知顶层字段用 ``extra="allow"`` 原样保留, + ``{"type": ["function"]}`` 因此能构造出来。上一轮把判据从 ``==`` 改成集合 + 成员测试后,这条路径从「静默通过」变成 ``TypeError: unhashable type``。 + """ -def test_openai_responses_stream_exposes_tool_delta_and_completed_events(): - assert ( - OpenAICompatibleProvider._responses_stream_event( - SimpleNamespace( - type="response.output_item.added", - item=SimpleNamespace(type="function_call", call_id="call-1", name="lookup"), - ) + def chunk(arguments, tool_calls=None, finish_reason=None): + if tool_calls is None: + tool_calls = [ + SimpleNamespace( + id="call-1", + index=0, + type=["function"], + function=SimpleNamespace(name="lookup", arguments=arguments), + ) + ] + return SimpleNamespace( + id="chat-1", + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=tool_calls), + finish_reason=finish_reason, + ) + ], + usage=None, + model="chat-model", ) - is None - ) - delta = OpenAICompatibleProvider._responses_stream_event( - SimpleNamespace( - type="response.function_call_arguments.delta", - item_id="item-1", - output_index=2, - delta='{"id":', - ) - ) - completed = OpenAICompatibleProvider._responses_stream_event( - SimpleNamespace( - type="response.function_call_arguments.done", - item_id="item-1", - output_index=2, - item=SimpleNamespace( - type="function_call", - call_id="call-1", - name="lookup", - arguments='{"id":1}', - ), + accumulator: dict[object, dict[str, object]] = {} + completed: set[object] = set() + delivered = [] + for event in [ + *OpenAICompatibleProvider._chat_stream_events(chunk('{"id":1}')), + *OpenAICompatibleProvider._chat_stream_events(chunk(None, [], "tool_calls")), + ]: + for emitted in OpenAICompatibleProvider._chat_completion_events( + event, accumulator, completed + ): + if emitted.type == "tool_call_completed": + delivered.append(emitted.tool_call) + + assert [call["arguments"] for call in delivered] == ['{"id":1}'] + + +def test_validate_complete_tool_call_accepts_non_string_type(): + """``type`` 不是字符串时不能抛裸 ``TypeError``。 + + ``type`` 来自网关 JSON,``openai`` SDK 对 SSE chunk 的未知字段用 + ``extra="allow"`` 原样保留,因此 ``{"type": ["function"]}`` 是能构造出来的。 + 判据若直接做集合成员测试,``list``/``dict`` 会抛 + ``TypeError: unhashable type``——既不是调用方按 ``RuntimeError`` 做的分类, + 消息里也没有工具调用名。``merge_tool_call_fragment`` 与 ``enum_field`` 对同一 + 形态都已有守卫,此处口径一致。 + """ + for tool_type in (["function"], {"name": "function"}, 1, None): + result = validate_complete_tool_call( + {"name": "lookup", "type": tool_type, "arguments": '{"id":1}'}, + "OpenAI Chat Completions", ) - ) - assert delta.type == "tool_call_delta" - assert delta.tool_call["index"] == 2 - assert completed.type == "tool_call_completed" - assert completed.tool_call["id"] == "call-1" - assert completed.tool_call["arguments"] == '{"id":1}' + assert result["name"] == "lookup" -def test_openai_responses_stream_deduplicates_tool_completion_events(): - provider = _responses_provider() +def test_validate_complete_tool_call_rejects_unverifiable_arguments(): + """``json.loads`` 的另两类失败也必须收敛成带指引的 ``RuntimeError``。 - class Responses: - def create(self, **_request): - item = SimpleNamespace( - type="function_call", - call_id="call-1", - name="lookup", - arguments='{"id":1}', - ) - return iter( - [ - SimpleNamespace(type="response.output_item.done", item=item, output_index=0), - SimpleNamespace( - type="response.function_call_arguments.done", - item=item, - output_index=0, - ), - SimpleNamespace( - type="response.completed", - response=SimpleNamespace(status="completed"), - ), - ] + 除 ``JSONDecodeError``(``ValueError`` 的子类)外,``json.loads`` 还会因整数 + 位数超过 ``sys.get_int_max_str_digits()`` 抛 ``ValueError``、因嵌套超过解释器 + 栈抛 ``RecursionError``。前者**不是** ``RuntimeError``,会穿透调用方按 + ``RuntimeError`` 做的错误分类,消息更是与「哪个工具调用」无关;两者都让一段 + 结构完整、只是无法校验的 arguments 变成一条与工具调用无关的解析异常。 + """ + for arguments in ("9" * 4301, "[" * 2000 + "]" * 2000): + with pytest.raises(RuntimeError, match="lookup"): + validate_complete_tool_call( + {"name": "lookup", "type": "function", "arguments": arguments}, + "OpenAI Chat Completions", ) - provider._get_client = lambda: SimpleNamespace(responses=Responses()) - events = list(provider.stream_events(CompletionRequest(prompt="hello"))) - assert [event.type for event in events] == ["tool_call_completed", "finish"] +def test_validate_complete_tool_call_requires_json_for_unknown_type(): + """除 ``custom_tool_call`` 外,任何 ``type`` 都必须带完整 JSON。 + 同一函数里两条判据方向相反:第一条是**黑名单**(``type != + "custom_tool_call"`` 就必须有非空 arguments),第二条却是**白名单** + (``type in {"function", "function_call"}`` 才校验 JSON)。于是任何不在白名单 + 里的取值——网关改写过的 ``type``、大写形式、空串——同时满足「必须有参数」与 + 「不必校验参数」,半截 JSON 被当作完整调用交付。 -def test_openai_responses_stream_flushes_tool_delta_before_finish(): + 白名单方向本身还带来第二个问题:集合成员测试对 ``list``/``dict`` 抛 + ``TypeError: unhashable type``(``tool_calls[].type`` 在 SDK 流式解析里走 + ``construct_type``,不做 ``Literal`` 校验,所以非字符串是可达的)。 + """ + for tool_type in ("weird", "FUNCTION", "", ["function"], {"name": "function"}, None): + with pytest.raises(RuntimeError, match="lookup"): + validate_complete_tool_call( + {"name": "lookup", "type": tool_type, "arguments": '{"id":'}, + "OpenAI Chat Completions", + ) + + +def test_openai_chat_stream_rejects_unknown_type_with_incomplete_arguments(): + """端到端:未知 ``type`` 的半截 JSON 不能交付给调用方。 + + 用真实 SDK 的 ``construct_type`` 构造 chunk——这正是 SDK 流式路径的解析 + 方式(``_process_response_data`` 在非严格校验下走 ``construct_type``,跳过 + 已声明字段的 ``Literal`` 校验),因此这里的 ``type`` 取值是可真实到达的。 + """ + from openai._models import construct_type + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + + # 真实流式形态:``finish_reason`` 与最后一个 delta 在同一个 chunk 上。 + delta = construct_type( + type_=ChatCompletionChunk, + value={ + "id": "chat-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "chat-model", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call-1", + "type": "tool_call", + "function": {"name": "lookup", "arguments": '{"id":'}, + } + ] + }, + "finish_reason": "tool_calls", + } + ], + }, + ) + + accumulator: dict[object, dict[str, object]] = {} + completed: set[object] = set() + with pytest.raises(RuntimeError, match="不是完整 JSON"): + for event in OpenAICompatibleProvider._chat_stream_events(delta): + OpenAICompatibleProvider._chat_completion_events(event, accumulator, completed) + + +def test_validate_complete_tool_call_keeps_custom_tool_arbitrary_text(): + """``custom_tool_call`` 的任意文本语义不受上述收敛影响。""" + result = validate_complete_tool_call( + {"name": "run_command", "type": "custom_tool_call", "arguments": "echo hello"}, + "OpenAI Responses", + ) + + assert result["arguments"] == "echo hello" + + +def test_openai_chat_stream_rejects_incomplete_tool_call_arguments(): + """Chat Completions 的工具调用 type 是 ``function``,半截 JSON 必须被拦下。 + + Chat Completions 的流式片段只带 ``type: "function"``(Responses 用的是 + ``function_call``),因此 ``validate_complete_tool_call`` 的 JSON 判据若不 + 把 ``function`` 算作函数调用,半截参数会被当作完整调用交付给调用方。 + """ + + def chunk(arguments, finish_reason=None, tool_calls=None): + if tool_calls is None: + tool_calls = [ + SimpleNamespace( + id="call-1", + index=0, + function=SimpleNamespace(name="lookup", arguments=arguments), + ) + ] + return SimpleNamespace( + id="chat-1", + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=tool_calls), + finish_reason=finish_reason, + ) + ], + usage=None, + model="chat-model", + ) + + accumulator: dict[object, dict[str, object]] = {} + completed: set[object] = set() + with pytest.raises(RuntimeError, match="不是完整 JSON"): + for event in [ + *OpenAICompatibleProvider._chat_stream_events(chunk('{"id":')), + *OpenAICompatibleProvider._chat_stream_events(chunk(None, "tool_calls", [])), + ]: + OpenAICompatibleProvider._chat_completion_events(event, accumulator, completed) + + +def test_openai_chat_stream_accepts_complete_tool_call_arguments(): + """对照组:完整 JSON 必须仍然照常交付。""" + + def chunk(arguments, finish_reason=None, tool_calls=None): + if tool_calls is None: + tool_calls = [ + SimpleNamespace( + id="call-1", + index=0, + function=SimpleNamespace(name="lookup", arguments=arguments), + ) + ] + return SimpleNamespace( + id="chat-1", + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=tool_calls), + finish_reason=finish_reason, + ) + ], + usage=None, + model="chat-model", + ) + + accumulator: dict[object, dict[str, object]] = {} + completed: set[object] = set() + delivered = [] + for event in [ + *OpenAICompatibleProvider._chat_stream_events(chunk('{"id":1}')), + *OpenAICompatibleProvider._chat_stream_events(chunk(None, "tool_calls", [])), + ]: + for emitted in OpenAICompatibleProvider._chat_completion_events( + event, accumulator, completed + ): + if emitted.type == "tool_call_completed": + delivered.append(emitted.tool_call) + + assert [call["arguments"] for call in delivered] == ['{"id":1}'] + + +def test_google_stream_rejects_incomplete_tool_call_arguments(): + """Google 的流式片段同样是 ``type: "function"``。""" + accumulator: dict[object, dict[str, object]] = { + ("index", 0): { + "id": "call-1", + "index": 0, + "type": "function", + "name": "lookup", + "arguments": '{"order_id":', + } + } + + with pytest.raises(RuntimeError, match="不是完整 JSON"): + GoogleProvider._complete_stream_tool_calls(accumulator, set(), "resp-1", None) + + +def test_anthropic_stream_rejects_incomplete_tool_call_arguments(): + """Anthropic 的流式片段同样是 ``type: "function"``。""" + + raw_events = [ + SimpleNamespace( + type="content_block_start", + index=0, + content_block=SimpleNamespace(type="tool_use", id="toolu-1", name="lookup", input={}), + ), + SimpleNamespace( + type="content_block_delta", + index=0, + delta=SimpleNamespace(type="input_json_delta", partial_json='{"id":'), + ), + SimpleNamespace(type="message_delta", delta=SimpleNamespace(stop_reason="tool_use")), + SimpleNamespace(type="message_stop"), + ] + + class ClientManager: + def __enter__(self): + return iter(raw_events) + + def __exit__(self, *_exc): + return False + + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-model" + provider._options = {} + provider._get_client = lambda: SimpleNamespace( + messages=SimpleNamespace(stream=lambda **_params: ClientManager()) + ) + + with pytest.raises(RuntimeError, match="不是完整 JSON"): + list(provider.stream_events(CompletionRequest(prompt="hello"))) + + +def test_ark_chat_stream_rejects_incomplete_tool_call_arguments(): + """Ark Chat Completions 的流式片段同样是 ``type: "function"``。""" + + class Completions: + def create(self, **_request): + return iter( + [ + SimpleNamespace( + id="chat-1", + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content=None, + tool_calls=[ + SimpleNamespace( + id="call-1", + index=0, + function=SimpleNamespace( + name="lookup", arguments='{"id":' + ), + ) + ], + ), + finish_reason=None, + ) + ], + usage=None, + model="ark-model", + ), + SimpleNamespace( + id="chat-1", + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=None), + finish_reason="tool_calls", + ) + ], + usage=None, + model="ark-model", + ), + ] + ) + + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "chat" + provider._options = {"server_verified_protocols": ["chat"]} + provider._get_client = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) + + with pytest.raises(RuntimeError, match="不是完整 JSON"): + list(provider.stream_events(CompletionRequest(prompt="hello"))) + + +def test_openai_chat_stream_events_preserve_parallel_tool_calls(): + chunk = SimpleNamespace( + id="chat-1", + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content=None, + tool_calls=[ + SimpleNamespace( + id="call-1", + index=0, + function=SimpleNamespace(name="lookup", arguments='{"id":'), + ), + SimpleNamespace( + id="call-2", + index=1, + function=SimpleNamespace(name="refund", arguments='{"order":'), + ), + ], + ), + finish_reason=None, + ) + ], + ) + + events = OpenAICompatibleProvider._chat_stream_events(chunk) + + assert [event.type for event in events] == ["tool_call_delta", "tool_call_delta"] + assert [event.tool_call["id"] for event in events] == ["call-1", "call-2"] + assert [event.tool_call["index"] for event in events] == [0, 1] + + +def test_openai_responses_stream_exposes_tool_delta_and_completed_events(): + assert ( + OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.output_item.added", + item=SimpleNamespace(type="function_call", call_id="call-1", name="lookup"), + ) + ) + is None + ) + + delta = OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.function_call_arguments.delta", + item_id="item-1", + output_index=2, + delta='{"id":', + ) + ) + completed = OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.function_call_arguments.done", + item_id="item-1", + output_index=2, + item=SimpleNamespace( + type="function_call", + call_id="call-1", + name="lookup", + arguments='{"id":1}', + ), + ) + ) + + assert delta.type == "tool_call_delta" + assert delta.tool_call["index"] == 2 + assert completed.type == "tool_call_completed" + assert completed.tool_call["id"] == "call-1" + assert completed.tool_call["arguments"] == '{"id":1}' + + +def test_openai_responses_stream_deduplicates_tool_completion_events(): + provider = _responses_provider() + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", + call_id="call-1", + name="lookup", + arguments='{"id":1}', + ) + return iter( + [ + SimpleNamespace(type="response.output_item.done", item=item, output_index=0), + SimpleNamespace( + type="response.function_call_arguments.done", + item=item, + output_index=0, + ), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed"), + ), + ] + ) + + provider._get_client = lambda: SimpleNamespace(responses=Responses()) + events = list(provider.stream_events(CompletionRequest(prompt="hello"))) + + assert [event.type for event in events] == ["tool_call_completed", "finish"] + + +def test_openai_responses_async_stream_deduplicates_tool_completion_events(): + """异步流的 finish 分支同判据:``key not in completed_tool_calls`` 不是 ``in``。 + + ``in`` 变异后,同一次调用既经 ``output_item.done`` 立即交付、又留在 + ``function_call_arguments.done`` 的 pending 里,``finish`` 时再补发一遍, + 调用方收到两个相同 id 的 ``tool_call_completed``。 + """ + provider = _responses_provider() + + class Responses: + def create(self, **_request): + item = SimpleNamespace( + type="function_call", + call_id="call-1", + name="lookup", + arguments='{"id":1}', + ) + + async def gen(): + yield SimpleNamespace(type="response.output_item.done", item=item, output_index=0) + yield SimpleNamespace( + type="response.function_call_arguments.done", + item=item, + output_index=0, + ) + yield SimpleNamespace( + type="response.completed", + response=SimpleNamespace(status="completed"), + ) + + return gen() + + provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) + + async def collect(): + return [ + event.type async for event in provider.astream_events(CompletionRequest(prompt="hello")) + ] + + assert asyncio.run(collect()) == ["tool_call_completed", "finish"] + + +def test_openai_responses_stream_flushes_tool_delta_before_finish(): provider = _responses_provider() class Responses: @@ -3236,3 +4006,1648 @@ def test_responses_json_schema_accepts_flat_shape(): "schema": {"type": "object"}, "strict": True, } + + +# ── 回归:``max_tokens`` 的有限性必须先于 ``int()`` 判 ── +# +# ``_default_max_tokens`` 原先是「判类型 -> ``int(configured)`` -> 判正数与整数性」。 +# ``int(float("inf"))`` 抛的是 ``OverflowError: cannot convert float infinity to +# integer``,``int(float("nan"))`` 抛 ``ValueError: cannot convert float NaN to +# integer``——两者都**不是**该方法承诺的「必须是正整数」消息,而 ``OverflowError`` +# 连 ``except ValueError`` 都接不住。实测 ``options.max_tokens = inf`` 正是这种 +# 收场,而同为非法取值的 ``1.5``/``0``/``-1``/``True`` 都能得到带指引的消息。 + + +@pytest.mark.parametrize( + "value", + [float("inf"), float("-inf"), float("nan"), 1e400], +) +def test_anthropic_max_tokens_rejects_non_finite_values(value): + """``inf``/``nan`` 必须报成「必须是正整数」,而不是 ``OverflowError``。""" + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-5" + provider._options = {"max_tokens": value} + + with pytest.raises(ValueError, match="max_tokens"): + provider._default_max_tokens() + + +@pytest.mark.parametrize("value", [1.5, 0, -1, True, "100"]) +def test_anthropic_max_tokens_rejects_other_invalid_values(value): + """收紧不能误伤既有判据:其余非法取值照旧拒绝且消息一致。""" + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-5" + provider._options = {"max_tokens": value} + + with pytest.raises(ValueError, match="max_tokens"): + provider._default_max_tokens() + + +def test_anthropic_max_tokens_accepts_valid_values(): + """收紧不能误伤:合法取值照常返回。""" + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-5" + + provider._options = {"max_tokens": 100} + assert provider._default_max_tokens() == 100 + + provider._options = {"max_tokens": 8192.0} + assert provider._default_max_tokens() == 8192 + + provider._options = {} + assert provider._default_max_tokens() > 0 + + +# ── 回归:流式拒答文本的取值来自服务端,不得让它抛裸的整数转换错误 ── +# +# ``_stream_event`` 的两处拒答分支写的是 +# ``refusal if isinstance(refusal, str) else str(refusal or "")``——非字符串走 +# 裸 ``str()``。而 ``refusal`` 来自服务端返回的 JSON(``content_block`` 或 +# ``delta`` 里的字段),形态不受本地控制:JSON 里一个超大整数字面量 +# (``10**4301``)会让 ``str()`` 撞上 CPython 的整数位数上限,抛 +# ``ValueError: Exceeds the limit``。 +# +# 这个异常发生在**流式解析**过程中,即 ``_iterate_stream_events`` 的生成器里: +# 用户已经看到部分输出,随后拿到一条与「服务端拒答」毫无关系的整数转换错误, +# 而同一函数对正常拒答文本给出的是 ``StreamEvent(type="refusal_delta")``。 +# 同一份输入里 ``42`` 被正常转成 ``"42"``、``None`` 变成 ``""``,只有位数超限 +# 的那一个炸——收场方式取决于取值的**大小**而不是形态。 +@pytest.mark.parametrize( + "hostile_refusal", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_anthropic_stream_refusal_delta_survives_huge_integer(hostile_refusal): + block = SimpleNamespace(type="refusal", refusal=hostile_refusal) + event = SimpleNamespace(type="content_block_start", content_block=block) + + converted = AnthropicProvider._stream_event(event, "response-id") + + assert converted is not None + assert converted.type == "refusal_delta" + # 必须仍然是字符串,且不能是空的——空的会让调用方以为「没有拒答」。 + assert isinstance(converted.refusal, str) + assert converted.refusal + + +def test_anthropic_stream_refusal_delta_survives_huge_integer_in_delta(): + """``content_block_delta`` 路径的同一判据(两处都要守住)。""" + delta = SimpleNamespace(type="refusal_delta", refusal=10**4301) + event = SimpleNamespace(type="content_block_delta", delta=delta) + + converted = AnthropicProvider._stream_event(event, "response-id") + + assert converted is not None + assert converted.type == "refusal_delta" + assert isinstance(converted.refusal, str) + assert converted.refusal + + +def test_anthropic_stream_refusal_delta_keeps_plain_text_intact(): + """反向守卫:正常拒答文本必须原样保留,不能被量级描述改写。""" + block = SimpleNamespace(type="refusal", refusal="我无法回答这个问题。") + event = SimpleNamespace(type="content_block_start", content_block=block) + + converted = AnthropicProvider._stream_event(event, "response-id") + + assert converted.refusal == "我无法回答这个问题。" + + +# ── 回归:拒答详情(stop_details)的取值同样来自服务端,两处都要守卫 ── +# +# 上一轮修的是 ``_stream_event`` 里 ``refusal``/``delta.refusal`` 两条分支; +# 同一文件还有**两处**用同一写法处理 ``stop_details``:``explanation`` 与 +# ``category`` 都来自服务端 JSON(``stop_details`` 是 Anthropic 的拒答详情对象), +# 而它们写的是 ``str(explanation or category or "<默认文案>")``——非字符串走裸 +# ``str()``,JSON 里一个超大整数字面量就抛 ``ValueError: Exceeds the limit``。 +# +# 这两处的后果与上一轮同源但方向相反:``stop_reason == "refusal"`` 是**安全拦截** +# 的信号,此处抛异常会让一次本该被显式上报的拒答变成一条与安全策略无关的整数 +# 转换错误——用户看到的是「程序出错了」,而不是「请求被安全策略拒绝」。 +# +# 非流式与流式(``message_delta``)两条路径各有一份,都要守住。 +def _anthropic_refusal_response(explanation, category=None): + stop_details = SimpleNamespace(type="refusal", explanation=explanation, category=category) + return SimpleNamespace( + stop_reason="refusal", stop_details=stop_details, id="response-id", content=[], usage=None + ) + + +@pytest.mark.parametrize( + "hostile_value", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_anthropic_extract_result_survives_huge_integer_refusal_detail(hostile_value): + result = AnthropicProvider._extract_result(_anthropic_refusal_response(hostile_value)) + + assert isinstance(result.refusal, str) + # 空串会让调用方以为「没有拒答」,静默吞掉一次安全拦截。 + assert result.refusal + + +@pytest.mark.parametrize( + "hostile_value", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_anthropic_stream_refusal_stop_details_survives_huge_integer(hostile_value): + stop_details = SimpleNamespace(type="refusal", explanation=hostile_value, category=None) + delta = SimpleNamespace(stop_reason="refusal", stop_details=stop_details) + event = SimpleNamespace(type="message_delta", delta=delta) + + converted = AnthropicProvider._stream_event(event, "response-id") + + assert converted is not None + assert converted.type == "refusal_delta" + assert isinstance(converted.refusal, str) + assert converted.refusal + + +def test_anthropic_refusal_detail_keeps_plain_text_and_default(): + """反向守卫:正常文本原样保留,两者皆缺时仍给默认文案。""" + assert ( + AnthropicProvider._extract_result(_anthropic_refusal_response("策略说明")).refusal + == "策略说明" + ) + # 只有 category 时用它 + assert ( + AnthropicProvider._extract_result(_anthropic_refusal_response(None, "cyber")).refusal + == "cyber" + ) + # 两者皆缺时给可读的默认文案,而不是空串 + assert ( + AnthropicProvider._extract_result(_anthropic_refusal_response(None, None)).refusal + == "Anthropic 请求被安全策略拒绝" + ) + + +# ── 回归:finish_reason 归一化不得抛裸的整数转换错误 ── +# +# ``finish_reason`` 来自服务端响应(``stop_reason``/``finishReason`` 字段), +# 形态不受本地控制。两处归一化都用裸 ``str()``: +# ``GoogleProvider._normalize_finish_reason`` 的 ``str(raw)`` 与 +# ``AnthropicProvider._stream_event`` 的 ``str(reason)``。JSON 里一个超大整数 +# 字面量(``10**4301``)就让它们撞上 CPython 的整数位数上限,抛 +# ``ValueError: Exceeds the limit``——而这两个位置都在**流式/非流式响应解析** +# 里,用户已经拿到部分输出后才看到一条与「停止原因」毫无关系的整数转换错误。 +# +# 归一化的职责是「把枚举或原始值统一成协议字符串」,不是「原样渲染任意取值」: +# 位数超限时给可读描述(``safe_repr``)即可,仍能区分不同取值。 +@pytest.mark.parametrize( + "hostile_reason", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_google_normalize_finish_reason_survives_huge_integer(hostile_reason): + from src.providers.google import GoogleProvider + + normalized = GoogleProvider._normalize_finish_reason(hostile_reason) + + assert isinstance(normalized, str) + assert normalized + + +@pytest.mark.parametrize( + "hostile_reason", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_google_extract_result_survives_huge_finish_reason(hostile_reason): + part = SimpleNamespace(text="正文", function_call=None, thought=None, refusal=None) + response = SimpleNamespace( + candidates=[ + SimpleNamespace( + content=SimpleNamespace(parts=[part]), + finish_reason=hostile_reason, + refusal=None, + ) + ], + usage_metadata=SimpleNamespace(prompt_token_count=1, candidates_token_count=1), + response_id="response-id", + ) + + result = GoogleProvider._extract_result(response) + + assert isinstance(result.finish_reason, str) + + +@pytest.mark.parametrize( + "hostile_reason", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_anthropic_stream_finish_reason_survives_huge_integer(hostile_reason): + delta = SimpleNamespace(stop_reason=hostile_reason, stop_details=None) + event = SimpleNamespace(type="message_delta", delta=delta, usage=None) + + converted = AnthropicProvider._stream_event(event, "response-id") + + assert converted is not None + assert converted.type == "finish" + assert isinstance(converted.finish_reason, str) + assert converted.finish_reason + + +def test_finish_reason_normalization_keeps_enum_and_plain_values_intact(): + """反向守卫:枚举对象与普通字符串仍按原语义归一化。""" + from src.providers.google import GoogleProvider + + # 普通字符串照旧大写化、去掉点号前缀 + assert GoogleProvider._normalize_finish_reason("stop") == "STOP" + assert GoogleProvider._normalize_finish_reason("FinishReason.MAX_TOKENS") == "MAX_TOKENS" + assert GoogleProvider._normalize_finish_reason(None) is None + assert GoogleProvider._normalize_finish_reason("") is None + + # 枚举形态(有 name 属性)优先取 name + enum_like = SimpleNamespace(name="stop", value=1) + assert GoogleProvider._normalize_finish_reason(enum_like) == "STOP" + + # 只有 value 时取 value + value_only = SimpleNamespace(value="max_tokens") + assert GoogleProvider._normalize_finish_reason(value_only) == "MAX_TOKENS" + + +# ── Ark Responses 的 format/tools/tool_choice 形态判据 ── +# +# ``VolcengineProvider._convert_responses_format``/``_convert_responses_tools``/ +# ``_convert_responses_tool_choice`` 与 OpenAI 兼容侧同构,但四条判据在整套测试里 +# 从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后全量 3434 条仍全绿)。 +# 既有用例只覆盖了容器层(``tools`` 非列表)与「``function`` 非对象」,下面这几条 +# 全绕开了。删除后行为确实改变(实测): +# * 863 ``json_schema`` 非 dict → 裸 ``AttributeError: 'int' object has no +# attribute 'get'``(``schema.get("name")``); +# * 878 ``type`` 既非 ``json_schema`` 也非 ``json_object``、且没有 ``format`` 键 +# → **静默返回空字典**,请求体里 ``format`` 直接消失,用户要的结构化输出约束 +# 被无声丢弃; +# * 893 工具元素非 Mapping → 整数抛裸 ``TypeError: argument of type 'int' is not +# iterable``、字符串抛 ``dictionary update sequence ...``; +# * 911 ``function`` 非对象 → 裸 ``AttributeError: 'str' object has no attribute +# 'get'``; +# * 942 未知字符串 → 消息从「不支持 tool_choice: x」退化成 959 那条通用消息; +# * 955 ``function`` tool_choice 缺 name → **静默接受**,产出 ``name: None``; +# * 959 非字符串非工具对象 → **静默返回 None**,请求不带 ``tool_choice``,等于 +# 「让模型自己决定用不用工具」。 + + +@pytest.mark.parametrize( + "response_format", + [{"type": "json_schema", "json_schema": 5}, {"type": "json_schema", "json_schema": "s"}], + ids=["int", "str"], +) +def test_ark_responses_rejects_non_object_json_schema(response_format): + with pytest.raises(ValueError, match="json_schema 必须是对象"): + VolcengineProvider._convert_responses_format(response_format) + + +@pytest.mark.parametrize( + "response_format", + [{"type": "xml"}, {}, {"type": ""}, {"type": 5}], + ids=["xml", "empty", "blank-type", "int-type"], +) +def test_ark_responses_rejects_unsupported_response_format(response_format): + """不支持的 ``type`` 必须报错。 + + 删除守卫后实测静默返回 ``{}``——请求体里的 ``format`` 消失,用户配置的 + 结构化输出约束被无声丢弃。 + """ + with pytest.raises(ValueError, match="仅支持 json_schema 或 json_object"): + VolcengineProvider._convert_responses_format(response_format) + + +@pytest.mark.parametrize("tool", [5, "x", ["y"]], ids=["int", "str", "list"]) +def test_ark_responses_tools_reject_non_mapping_entries(tool): + with pytest.raises(ValueError, match=r"工具定义\[0\] 必须是对象"): + VolcengineProvider._convert_responses_tools([tool]) + + +def test_ark_responses_tools_reject_non_mapping_function(): + with pytest.raises(ValueError, match=r"工具定义\[0\]\.function 必须是对象"): + VolcengineProvider._convert_responses_tools([{"function": "bad"}]) + + +def test_ark_responses_tool_choice_rejects_unknown_string(): + """未知字符串必须给出指明取值的消息,而不是落到通用兜底。""" + with pytest.raises(ValueError, match="不支持 tool_choice: x"): + VolcengineProvider._convert_responses_tool_choice("x") + + +@pytest.mark.parametrize( + "tool_choice", + [{"type": "function", "function": {"x": 1}}, {"type": "function", "function": {}}], + ids=["missing-name", "empty-function"], +) +def test_ark_responses_tool_choice_requires_function_name(tool_choice): + """``function`` tool_choice 必须带 name。 + + 删除守卫后实测静默接受,产出 ``{"type": "function", "name": None}``。 + """ + with pytest.raises(ValueError, match="缺少 name"): + VolcengineProvider._convert_responses_tool_choice(tool_choice) + + +@pytest.mark.parametrize( + "tool_choice", + [5, {}, {"type": "custom", "name": "n"}, []], + ids=["int", "empty-dict", "custom-type", "list"], +) +def test_ark_responses_tool_choice_rejects_unsupported_shapes(tool_choice): + """兜底判据必须真的兜住。 + + 删除后实测静默返回 ``None``——请求里干脆不带 ``tool_choice``,等于「让模型 + 自己决定用不用工具」。 + """ + with pytest.raises(ValueError, match="必须是 auto、none、required 或工具对象"): + VolcengineProvider._convert_responses_tool_choice(tool_choice) + + +def test_ark_responses_format_still_accepts_supported_shapes(): + """反向守卫:``json_schema``(完整形态)/``json_object``/已带 ``format`` 照常转换。""" + assert VolcengineProvider._convert_responses_format({"type": "json_object"}) == { + "format": {"type": "json_object"} + } + assert VolcengineProvider._convert_responses_format( + {"type": "json_schema", "json_schema": {"schema": {"type": "object"}}} + ) == { + "format": { + "type": "json_schema", + "json_schema": {"name": "response", "schema": {"type": "object"}, "strict": True}, + } + } + assert VolcengineProvider._convert_responses_format({"format": {"type": "text"}}) == { + "format": {"type": "text"} + } + + +@pytest.mark.parametrize( + "response_format", + [ + {"type": "json_schema"}, + {"type": "json_schema", "json_schema": {"name": "r"}}, + {"type": "json_schema", "json_schema": {"schema": {}}}, + {"type": "json_schema", "json_schema": {"schema": []}}, + ], + ids=["no-json_schema", "name-only", "empty-schema", "schema-not-mapping"], +) +def test_ark_responses_json_schema_requires_schema(response_format): + """缺 schema 不能回退成 ``{}``——与 ``openai_compatible`` 同判据。 + + ``openai_compatible`` 的同处缺陷已在 ``453d096`` 修复(伪造 schema:客户端 + 以为拿到了结构化输出约束,实际服务端收到 ``schema: {}``),而本函数是 + 同一转换逻辑的 Ark 副本,漏修了同一判据。google 的 + ``_response_format_values`` 对同一输入同样拒绝——三份实现里两份拒绝、 + 一份放行,且放行的那一份被反向守卫钉成了「照常转换」。 + """ + with pytest.raises(ValueError, match="缺少 schema"): + VolcengineProvider._convert_responses_format(response_format) + + +def test_ark_responses_tool_choice_still_accepts_supported_values(): + """反向守卫:别名与原生工具对象照常转换。""" + assert VolcengineProvider._convert_responses_tool_choice("REQUIRED") == "required" + assert VolcengineProvider._convert_responses_tool_choice( + {"type": "function", "function": {"name": "f"}} + ) == {"type": "function", "name": "f"} + assert VolcengineProvider._convert_responses_tool_choice({"type": "web_search"}) == { + "type": "web_search" + } + + +# ── Anthropic tool_choice/图片 data URI 的四条形态判据 ── +# +# 这四条在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后 +# 全量 3479 条仍全绿)。既有用例覆盖的是「未知字符串」「function 缺 name」与 +# 「data URI 不是有效 Base64」,形态判据全绕开了。删除后行为确实改变(实测): +# * 576 ``tool_choice`` 既非字符串也非 Mapping(整数)→ 裸 ``TypeError: 'int' +# object is not iterable``(``dict(tool_choice)``);列表则退化成兜底消息; +# * 600 非字符串非工具对象(空 dict、未知 ``type``)→ **静默返回 None**, +# 请求不带 ``tool_choice``,等于「让模型自己决定用不用工具」; +# * 627 data URI 无逗号 → 消息从「缺少逗号分隔符」退化成裸 +# ``ValueError: not enough values to unpack (expected 2, got 1)``; +# * 636 data URI 缺 MIME 或 base64 标记 → **静默接受**,产出一个 +# ``media_type`` 为空串(或 ``header`` 被当成 data)的图片块发给上游。 + + +@pytest.mark.parametrize("tool_choice", [5, 1.5, object()], ids=["int", "float", "object"]) +def test_anthropic_tool_choice_rejects_non_string_non_mapping(tool_choice): + with pytest.raises(ValueError, match="必须是字符串或对象"): + AnthropicProvider._convert_tool_choice(tool_choice) + + +@pytest.mark.parametrize( + "tool_choice", + [{}, {"type": "custom", "name": "n"}, {"type": ""}, {"type": 5}], + ids=["empty", "custom", "blank-type", "int-type"], +) +def test_anthropic_tool_choice_rejects_unsupported_shapes(tool_choice): + """兜底判据必须真的兜住,不能静默返回 None。""" + with pytest.raises(ValueError, match="必须是 auto、any、none、tool 或 function 对象"): + AnthropicProvider._convert_tool_choice(tool_choice) + + +def test_anthropic_image_data_uri_requires_comma(): + """``data:`` URI 缺逗号时必须给「缺少逗号分隔符」,而不是裸解包错误。""" + with pytest.raises(ValueError, match="缺少逗号分隔符"): + AnthropicProvider._convert_content( + [{"type": "image_url", "image_url": {"url": "data:image/png;base64"}}] + ) + + +@pytest.mark.parametrize( + "url", + ["data:image/png,AAAA", "data:;base64,AAAA", "data:,AAAA", "data:image/png;utf8,AAAA"], + ids=["no-base64-marker", "no-mime", "neither", "wrong-encoding"], +) +def test_anthropic_image_data_uri_requires_mime_and_base64(url): + """``data:`` URI 必须同时带 MIME 类型与 ``base64`` 标记。 + + 删除守卫后实测静默接受,产出 ``media_type`` 为空串的图片块发给上游。 + """ + with pytest.raises(ValueError, match="必须包含 MIME 类型和 base64 标记"): + AnthropicProvider._convert_content([{"type": "image_url", "image_url": {"url": url}}]) + + +def test_anthropic_image_data_uri_still_accepts_well_formed_value(): + """反向守卫:合法 data URI 照常转成 base64 图片块。""" + converted = AnthropicProvider._convert_content( + [{"type": "image_url", "image_url": {"url": "data:image/png;base64,QUFB"}}] + ) + + assert converted == [ + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "QUFB"}, + } + ] + + +def test_anthropic_tool_choice_still_accepts_supported_values(): + """反向守卫:别名、原生工具对象与带 name 的裸对象照常转换。""" + assert AnthropicProvider._convert_tool_choice("required") == {"type": "any"} + assert AnthropicProvider._convert_tool_choice({"type": "tool", "name": "t"}) == { + "type": "tool", + "name": "t", + } + assert AnthropicProvider._convert_tool_choice({"name": "n"}) == {"type": "tool", "name": "n"} + + +# ── Google 配置边界与响应格式的十八条判据 ── +# +# 这些判据在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后 +# 全量 3505 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 681 ``debug_config`` 含未知字段 → **静默接受**,``nope`` 被 pydantic 静默 +# 忽略、产出一个什么都没设的 ``DebugConfig``; +# * 685 当前 SDK 没有 ``DebugConfig`` 时 → 漏出裸 ``NameError``,而不是 +# ``RuntimeError``「当前 google-genai SDK 不支持 debug_config」; +# * 689 ``DebugConfig`` 构造失败(``replay_id=5`` 这类非法值)→ **静默返回 +# None**,配置被无声丢弃; +# * 947 ``http_options`` 与请求级 HTTP 扩展同时出现 → **静默接受**,请求级值 +# 覆盖 SDK 容器; +# * 951 ``config`` 与同级配置关键字同时出现 → **静默返回 config**,同级的 +# ``temperature`` 等被无声丢弃; +# * 979 Embedding 请求含不支持的参数 → **静默接受**,参数原样进 SDK; +# * 1127/1134/1137 ``response_format`` 及其 ``json_schema``/``schema`` +# 非对象 → 裸 ``AttributeError: 'int' object has no attribute 'get'`` 或 +# ``TypeError: 'int' object is not iterable``; +# * 1144 ``type`` 既非 ``json_object`` 也非 ``json_schema`` → **静默返回 +# None**,结构化输出约束消失; +# * 1180 ``extra_body`` 与模型 options 重复 → **静默覆盖**,模型级值被请求级顶掉; +# * 1191 ``http_options`` 与请求级扩展冲突 → **静默接受**,两者同时生效; +# * 1391/1406/1412 同一量在两处配置(``max_tokens`` 与 +# ``max_output_tokens``、``thinking`` 与 ``thinking_config``、``response_format`` +# 与请求参数)→ 前两者**静默丢弃**一个(1406 实测被 pydantic 拒绝成 +# ``ValidationError``),1412 **静默接受**请求级值; +# * 1477 data URI 无逗号 → 退化成裸 ``ValueError: not enough values to unpack``; +# * 1481 data URI 缺 MIME → **静默接受**,产出 ``mime_type=""`` 的块; +# * 1541 ``urlsplit`` 解析失败 → ``parsed`` 从未赋值,漏出裸 ``UnboundLocalError``。 +# +# 同批的 1164/1171 经甄别是**等价变异**,不补测试:两条与共享 helper +# ``validate_secret_free_payload`` 抛**同一条消息的同一个 ``ValueError````, +# 删除后结论不变。 + + +class _GoogleConfigStub: + """``GenerateContentConfig`` 的最小替身,能记录构造参数。""" + + def __init__(self, **kwargs): + self.kwargs = kwargs + + +def _google_config_provider(**options): + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-1.5-flash" + provider._provider = "google" + provider._options = dict(options) + return provider + + +def test_google_debug_config_rejects_unknown_field(): + """``debug_config`` 含未知字段必须报错。 + + 删除后实测静默产出「什么都没设」的 ``DebugConfig``——配置写错了却看不出来。 + """ + with pytest.raises(ValueError, match="包含不支持的字段: nope"): + GoogleProvider._coerce_debug_config({"nope": 1}) + + +def test_google_debug_config_reports_missing_sdk_support(monkeypatch): + """当前 SDK 没有 ``DebugConfig`` 时必须报「SDK 不支持」,不能漏出 ``NameError``。""" + import sys + + monkeypatch.setitem(sys.modules, "google.genai.client", None) + + with pytest.raises(RuntimeError, match="SDK 不支持 debug_config"): + GoogleProvider._coerce_debug_config({"client_mode": "x"}) + + +@pytest.mark.parametrize( + "debug_config", + [{"replay_id": 5}, {"replays_directory": 5}, {"client_mode": 5}], + ids=["replay-id", "replays-directory", "client-mode"], +) +def test_google_debug_config_reports_invalid_values(debug_config): + """``DebugConfig`` 构造失败必须收敛成「配置无效」,不能静默返回 ``None``。""" + with pytest.raises(ValueError, match="debug_config 配置无效"): + GoogleProvider._coerce_debug_config(debug_config) + + +def test_google_debug_config_still_accepts_valid_fields(): + """反向守卫:合法字段照常转换。""" + config = GoogleProvider._coerce_debug_config({"client_mode": "auto", "replay_id": "r1"}) + + assert config.client_mode == "auto" + assert config.replay_id == "r1" + + +def test_google_resource_config_rejects_http_options_with_request_level_fields(): + """``http_options`` 不能与请求级 HTTP 扩展同时传入。 + + 删除后实测静默接受,请求级值直接覆盖 SDK 容器——用户配置的两套 HTTP 设置 + 谁生效取决于调用顺序。 + """ + with pytest.raises(ValueError, match="http_options 不能与请求级 HTTP 配置同时传入"): + GoogleProvider._resource_config( + _GoogleConfigStub, + None, + {"http_options": {"a": 1}, "extra_body": {"b": 2}}, + ) + + +def test_google_resource_config_rejects_config_with_sibling_keywords(): + """``config`` 不能与同级配置关键字同时传入,否则同级值被无声丢弃。""" + with pytest.raises(ValueError, match="config 不能与同级配置关键字同时传入"): + GoogleProvider._resource_config(_GoogleConfigStub, {"x": 1}, {"temperature": 0.5}) + + +def test_google_embedding_rejects_unsupported_request_kwargs(): + """Embedding 请求含不支持参数必须报错,不能静默进 SDK。""" + with pytest.raises(ValueError, match="Embedding 不支持请求参数: bogus"): + GoogleProvider._validate_embedding_kwargs({"bogus": 1}) + + +def test_google_embedding_still_accepts_supported_request_kwargs(): + """反向守卫:受支持的参数照常通过(``None`` 值视为未设置)。""" + assert ( + GoogleProvider._validate_embedding_kwargs( + {"task_type": "RETRIEVAL_DOCUMENT", "auto_truncate": None} + ) + is None + ) + + +@pytest.mark.parametrize("response_format", [5, "json_object", ["a"]], ids=["int", "str", "list"]) +def test_google_response_format_rejects_non_mapping(response_format): + with pytest.raises(ValueError, match="response_format 必须是对象"): + GoogleProvider._response_format_values(response_format) + + +@pytest.mark.parametrize("json_schema", [5, "s", ["a"]], ids=["int", "str", "list"]) +def test_google_response_format_rejects_non_mapping_json_schema(json_schema): + with pytest.raises(ValueError, match=r"json_schema 必须是对象"): + GoogleProvider._response_format_values({"type": "json_schema", "json_schema": json_schema}) + + +@pytest.mark.parametrize("schema", [5, "s", ["a"]], ids=["int", "str", "list"]) +def test_google_response_format_rejects_non_mapping_schema(schema): + with pytest.raises(ValueError, match="必须是 JSON Schema 对象"): + GoogleProvider._response_format_values( + {"type": "json_schema", "json_schema": {"schema": schema}} + ) + + +@pytest.mark.parametrize( + "response_format", + [{"type": "xml"}, {}, {"type": ""}, {"type": 5}, {"schema": {}}], + ids=["xml", "empty", "blank-type", "int-type", "schema-only"], +) +def test_google_response_format_rejects_unsupported_types(response_format): + """不支持的 ``type`` 必须报错。 + + 删除后实测静默返回 ``None``——用户配置的结构化输出约束从请求里消失。 + """ + with pytest.raises(ValueError, match="仅支持 json_object 或 json_schema"): + GoogleProvider._response_format_values(response_format) + + +def test_google_response_format_still_accepts_supported_types(): + """反向守卫:``json_object`` 与 ``json_schema`` 照常转换。""" + assert GoogleProvider._response_format_values({"type": "json_object"}) == { + "response_mime_type": "application/json" + } + assert GoogleProvider._response_format_values( + {"type": "json_schema", "json_schema": {"schema": {"type": "object"}}} + ) == { + "response_mime_type": "application/json", + "response_json_schema": {"type": "object"}, + } + + +_GOOGLE_HTTP_BASE = { + "extra_body": None, + "extra_headers": None, + "extra_query": None, + "timeout": None, +} + + +def test_google_extra_body_cannot_be_silently_overridden(): + """请求级 ``extra_body`` 与模型 options 同名键必须报错,不能静默覆盖。 + + 删除后实测模型级 ``{"k": 1}`` 被请求级 ``{"k": 2}`` 顶掉,谁生效取决于 + 调用顺序且无任何提示。 + """ + provider = _google_config_provider(extra_body={"k": 1}) + + with pytest.raises(ValueError, match="extra_body 与模型 options 重复: k"): + provider._generation_http_options(**{**_GOOGLE_HTTP_BASE, "extra_body": {"k": 2}}) + + +def test_google_http_options_cannot_coexist_with_request_level_extensions(): + """``http_options`` 与请求级 HTTP 扩展不能同时生效。 + + 删除后实测两者同时返回、都被套用——用户配置的两套 HTTP 设置发生叠加。 + """ + provider = _google_config_provider(http_options={"h": 1}) + + with pytest.raises(ValueError, match="不能与请求级"): + provider._generation_http_options(**{**_GOOGLE_HTTP_BASE, "extra_body": {"b": 2}}) + + +def test_google_http_options_and_extra_body_still_merge_when_disjoint(): + """反向守卫:不冲突的 ``extra_body`` 与请求级扩展照常合并。""" + provider = _google_config_provider(extra_body={"k": 1}) + + merged, *_ = provider._generation_http_options(**{**_GOOGLE_HTTP_BASE, "extra_body": {"j": 2}}) + + assert merged == {"k": 1, "j": 2} + + +@pytest.mark.parametrize( + ("options", "message"), + [ + ( + {"max_tokens": 10, "max_output_tokens": 20}, + "max_tokens 与 options.max_output_tokens 重复", + ), + ( + {"thinking": {"a": 1}, "thinking_config": {"b": 2}}, + "thinking 与 options.thinking_config 重复", + ), + ], + ids=["max-tokens", "thinking"], +) +def test_google_generation_config_rejects_duplicate_scalar_options(options, message): + """同一量在两处配置必须报错,不能静默丢弃其一。""" + provider = _google_config_provider(**options) + + with pytest.raises(ValueError, match=message): + provider._build_generation_config(None, None, None) + + +def test_google_generation_config_rejects_response_format_duplicate(): + """``response_format`` 同时出现在 options 与请求参数必须报错。""" + provider = _google_config_provider(response_format={"type": "json_object"}) + + with pytest.raises(ValueError, match="response_format 与模型 options 重复"): + provider._build_generation_config(None, None, None, response_format={"type": "json_object"}) + + +def test_google_decode_data_uri_requires_comma(): + with pytest.raises(ValueError, match="缺少逗号分隔符"): + GoogleProvider._decode_data_uri("data:image/png;base64", "媒体") + + +def test_google_decode_data_uri_requires_mime_type(): + """``data:`` URI 缺 MIME 类型必须报错,不能产出 ``mime_type=""`` 的块。""" + with pytest.raises(ValueError, match="缺少 MIME 类型"): + GoogleProvider._decode_data_uri("data:;base64,QUFB", "媒体") + + +def test_google_decode_data_uri_still_accepts_well_formed_value(): + """反向守卫:合法 data URI 照常解码。""" + assert GoogleProvider._decode_data_uri("data:image/png;base64,QUFB", "媒体") == ( + "image/png", + b"AAA", + ) + + +def test_google_media_uri_reports_invalid_uri_readably(): + """``urlsplit`` 解析失败必须收敛成带上下文的 ``ValueError``。 + + 删除守卫后 ``parsed`` 从未赋值,漏出裸 ``UnboundLocalError``。 + """ + with pytest.raises(ValueError, match="URI 无效"): + GoogleProvider._contents( + [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": "http://[bad"}}], + } + ], + None, + None, + ) + + +# ── Google 工具调用/工具结果四条形态判据 ── +# +# 这四条在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后 +# 全量 3544 条仍全绿)。删除后行为确实改变(实测,均经 ``GoogleProvider._contents``): +# * 1811 ``function_call`` 缺 name(或 name 非字符串)→ 缺名时 **静默产出**一个 +# ``name=None`` 的 ``FunctionCall``,非字符串时漏出裸 +# ``ValidationError: 1 validation error for FunctionCall``; +# * 1819 ``arguments`` 是非法 JSON 字符串 → 消息从「不是有效 JSON」退化成 +# 「必须是 JSON 对象」——把「JSON 语法错了」说成「JSON 顶层不是对象」; +# * 1823 ``arguments`` 是非对象(数组)→ 裸 ``TypeError: cannot convert +# dictionary update sequence element #0 to a sequence``(``dict(arguments)``); +# * 1837 ``function_response`` 无可关联的 ``tool_call_id``/``name`` → 缺标识时 +# **静默产出**一个无名的 ``FunctionResponse``(Google 侧无法关联回任何调用), +# 非字符串标识时漏出裸 ``ValidationError``。 +# +# 同批的 1795 经甄别是**等价变异**,不补测试:它是 ``role="tool"`` 路径的同一条 +# 判据,删掉后落到 1837 那条 ``function_response`` 分支,抛**同一条消息**的同一个 +# ``ValueError``,结论不变。 + + +def _google_contents(parts): + return GoogleProvider._contents([{"role": "user", "content": parts}], None, None) + + +@pytest.mark.parametrize( + "part", + [ + {"type": "function_call", "arguments": {}}, + {"type": "function_call", "name": 5, "arguments": {}}, + ], + ids=["missing-name", "non-string-name"], +) +def test_google_function_call_requires_string_name(part): + """``function_call`` 必须带字符串 name。 + + 删除守卫后缺名静默产出 ``name=None`` 的 ``FunctionCall``,非字符串 name + 漏出裸 pydantic ``ValidationError``。 + """ + with pytest.raises(ValueError, match="function_call 缺少 name"): + _google_contents([part]) + + +def test_google_function_call_rejects_invalid_json_arguments(): + """``arguments`` 是非法 JSON 字符串时必须报「不是有效 JSON」。""" + with pytest.raises(ValueError, match="arguments 不是有效 JSON"): + _google_contents([{"type": "function_call", "name": "f", "arguments": "{bad"}]) + + +def test_google_function_call_rejects_non_object_arguments(): + """``arguments`` 必须是 JSON 对象,数组要报「必须是 JSON 对象」而不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="arguments 必须是 JSON 对象"): + _google_contents([{"type": "function_call", "name": "f", "arguments": [1, 2]}]) + + +@pytest.mark.parametrize( + "part", + [ + {"type": "function_response", "response": {}}, + {"type": "function_response", "response": {}, "tool_call_id": 5}, + {"type": "function_response", "response": {}, "name": 5}, + ], + ids=["no-identifier", "non-string-id", "non-string-name"], +) +def test_google_function_response_requires_linkable_identifier(part): + """``function_response`` 必须带可关联的 ``tool_call_id`` 或 ``name``。 + + 删除守卫后缺标识静默产出无名的 ``FunctionResponse``,Google 侧无法关联回 + 任何一次调用;非字符串标识漏出裸 pydantic ``ValidationError``。 + """ + with pytest.raises(ValueError, match="function_response 缺少可关联的 tool_call_id/name"): + _google_contents([part]) + + +def test_google_tool_parts_still_convert_when_well_formed(): + """反向守卫:合法的 ``function_call`` 与 ``function_response`` 照常转换。""" + contents = _google_contents( + [ + {"type": "function_call", "id": "c1", "name": "f", "arguments": {"a": 1}}, + {"type": "function_response", "tool_call_id": "c1", "response": {"ok": True}}, + ] + ) + + calls = [ + part.function_call for content in contents for part in content.parts if part.function_call + ] + responses = [ + part.function_response + for content in contents + for part in content.parts + if part.function_response + ] + assert [call.name for call in calls] == ["f"] + assert calls[0].args == {"a": 1} + assert [response.name for response in responses] == ["f"] + + +# ── Google 渠道 google.py 覆盖缺口守卫(选项/凭证/资源/媒体/partial_args/ +# 分片合并/token 统计/Embedding/流终止/close)── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3638 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 239/391 ``options`` 非 Mapping → 裸 ``TypeError: 'int' object is not iterable``; +# * 401 options 键非字符串 → 裸 ``TypeError: sequence item 0: expected str instance``; +# * 585 ``http_options`` 键非字符串 → **静默返回 None**、校验整段跳过; +# * 677 ``debug_config`` 键非字符串 → 裸 ``TypeError``; +# * 448 ``_require_resource`` 资源为 None → **静默返回 None**; +# * 490 异步 ``aio`` 节点缺失 → 消息退化成「未提供 Auth Token 资源」,丢掉「异步」; +# * 370 缺 google-auth → 消息退化成「凭证文件无法加载」; +# * 374 凭证文件无法加载 → 裸 ``UnboundLocalError``; +# * 925 ``output_dimensionality`` 非整数 → 裸 ``TypeError``,布尔 → **静默接受 True**; +# * 929 维度越界 → **静默接受 0/10**9; +# * 1571 Vertex 模式非 gs:// HTTPS → 消息退化,丢掉「Vertex 模式」; +# * 1590 ``mime_type`` 非字符串 → 裸 ``AttributeError``,空白 → 消息退化; +# * 1795 工具结果缺标识 → **静默产出**无名 ``FunctionResponse``; +# * 1958/1973/2000 ``parts``/``embeddings``/``candidates`` 非列表 → 裸 ``TypeError``; +# * 2100 ``partial_arg`` 缺值 → **静默返回 None**; +# * 2141 json_path 索引过大 → **静默接受**(会按该下标把列表填充到那么大); +# * 2153 json_path 不支持的写法 → **死循环**(``rest`` 不推进,实测挂起); +# * 2164 ``partial_args`` 非列表 → 裸 ``TypeError``; +# * 2172 根值非对象 → 裸 ``ValueError: dictionary update sequence element``; +# * 2180/2192 json_path 无法写入 → 裸 ``AttributeError``/``TypeError``; +# * 2286/2316/2354/2376/2403/2406/2409 工具调用分片合并的七条冲突判据 → +# **静默合并**两个不同调用(或换成另一条同族消息); +# * 2781/2786/2828/3016/3021/3059/3065 token 统计的字段支持判据 → +# 消息退化成裸 ``AttributeError``(``models``/``aio`` 不存在); +# * 2832/3069 compute_tokens 的 tools 判据 → 被 ``_validate_compute_token_fields`` +# 先拦(**死代码**); +# * 3184/3307/4382/4488 流在终止事件前结束 → **静默返回已收片段**; +# * 3556/3659 ``validate_reward`` 双配置 → 裸 ``AttributeError``; +# * 4012 Live 缺 ``aio`` → 裸 ``UnboundLocalError``;4016 connect 非可调用 → 裸 ``TypeError``; +# * 4530/4594 Embedding 覆盖 ``model`` → 消息退化;4554/4620 Vertex 单内容非唯一 → +# **静默接受多个 embedding**;4564/4632 数量不一致 → **静默返回不匹配的向量**; +# * 4692/4714 ``close``/``aclose`` 首个异常 → **静默吞掉**。 +# +# 同批的 1164/1171/1519/1862/1865/1869 经甄别是**等价变异**,不补测试: +# 1164/1171 删掉后落到下游 ``validate_secret_free_payload`` 的同一条消息; +# 1519 删掉后落到调用方 ``_contents`` 的同一条消息;1862/1865/1869 删掉后落到 +# 上游 ``normalize_chat_messages`` 的同族消息,结论都不变。 + + +def _google_options_provider(**options): + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-1.5-flash" + provider._provider = "google" + provider._options = dict(options) + provider._client = None + return provider + + +def _google_contents_vertex(parts): + """``_contents`` 的 Vertex 模式入口(既有 ``_google_contents`` 不带该开关)。""" + return GoogleProvider._contents( + [{"role": "user", "content": parts}], None, None, vertex_mode=True + ) + + +@pytest.mark.parametrize( + "options", + [pytest.param(5, id="int"), pytest.param([1, 2], id="list")], +) +def test_google_constructor_rejects_non_mapping_options(options): + """``options`` 非 Mapping 必须报 ``ValueError``,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="Google options 必须是对象"): + GoogleProvider("m", options=options) + + +def test_google_validate_options_rejects_non_mapping(): + """``_validate_options`` 自身也要收敛非 Mapping。""" + with pytest.raises(ValueError, match="Google options 必须是对象"): + GoogleProvider._validate_options(5) + + +def test_google_options_reject_non_string_key(): + """``options`` 键非字符串必须报错,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="Google options 的键必须是字符串"): + GoogleProvider("m", options={1: "x"}) + + +def test_google_http_options_reject_non_string_key(): + """``http_options`` 键非字符串必须报错,不能静默跳过校验。""" + with pytest.raises(ValueError, match=r"options\.http_options 的键必须是字符串"): + GoogleProvider._validate_options({"http_options": {1: "x"}}) + + +def test_google_debug_config_rejects_non_string_key(): + """``debug_config`` 键非字符串必须报错,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="Google debug_config 的键必须是字符串"): + GoogleProvider._coerce_debug_config({1: "x"}) + + +def test_google_require_resource_rejects_none_resource(): + """资源属性存在但为 ``None`` 时必须报错,不能静默返回 ``None``。""" + with pytest.raises(NotImplementedError, match="未提供 Tuned Models资源"): + GoogleProvider._require_resource( + SimpleNamespace(tuned_models=None), "tuned_models", "Tuned Models" + ) + + +def test_google_async_resource_reports_missing_aio_node(): + """异步 ``aio`` 节点缺失时消息必须保留「异步」。""" + provider = _google_options_provider() + provider._get_client = lambda: SimpleNamespace() + + with pytest.raises(NotImplementedError, match="异步 Auth Token资源"): + provider._require_provider_resource("async_create_auth_token") + + +def test_google_credentials_require_google_auth(monkeypatch): + """缺 google-auth 时必须报专属消息,不能退化成「文件无法加载」。""" + import builtins + + original = builtins.__import__ + + def _fake_import(name, *args, **kwargs): + if name == "google.auth" or name.startswith("google.auth."): + raise ImportError("no google-auth") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _fake_import) + + with pytest.raises(RuntimeError, match="缺少 google-auth"): + GoogleProvider._load_credentials_file("/x") + + +def test_google_credentials_file_failure_reports_readably(): + """凭证文件无法加载时必须报 ``ValueError``,不是裸 ``UnboundLocalError``。""" + with pytest.raises(ValueError, match="GOOGLE_APPLICATION_CREDENTIALS 文件无法加载"): + GoogleProvider._load_credentials_file("/nonexistent/definitely-not-here.json") + + +@pytest.mark.parametrize( + "value", + [pytest.param("x", id="str"), pytest.param(True, id="bool"), pytest.param(1.5, id="float")], +) +def test_google_embedding_dimensions_reject_non_integer(value): + """``output_dimensionality`` 非整数必须报错,布尔不能静默当 1。""" + with pytest.raises(ValueError, match="output_dimensionality 必须是整数"): + GoogleProvider._validate_embedding_dimensions(value) + + +@pytest.mark.parametrize( + "value", + [pytest.param(0, id="zero"), pytest.param(-1, id="negative"), pytest.param(10**9, id="huge")], +) +def test_google_embedding_dimensions_reject_out_of_range(value): + """``output_dimensionality`` 越界必须报错,不能静默接受。""" + with pytest.raises(ValueError, match="output_dimensionality 必须在 1 到"): + GoogleProvider._validate_embedding_dimensions(value) + + +def test_google_vertex_media_rejects_non_gs_https_uri(): + """Vertex 模式仅支持 gs:// 或可公开访问 HTTPS,消息必须点明 Vertex 模式。""" + with pytest.raises(ValueError, match="Vertex/Enterprise 模式仅支持 gs://"): + _google_contents_vertex([{"type": "file", "file": "https://user:pass@example.com/x"}]) + + +@pytest.mark.parametrize( + "mime", + [pytest.param(5, id="int"), pytest.param(" ", id="blank")], +) +def test_google_media_mime_type_must_be_non_empty_string(mime): + """媒体块的 ``mime_type`` 必须是非空字符串,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="mime_type 必须是非空字符串"): + _google_contents( + [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/x.png", "mime_type": mime}, + } + ] + ) + + +def test_google_tool_result_requires_linkable_identifier(): + """工具结果块缺可关联标识时必须报错,不能静默产出无名响应。""" + with pytest.raises(ValueError, match="工具结果缺少可关联的 tool_call_id/name"): + _google_contents([{"type": "tool_result", "response": {}}]) + + +@pytest.mark.parametrize( + "method,attr,source", + [ + pytest.param("_require_parts", "parts", "响应", id="parts"), + pytest.param("_require_embeddings", "embeddings", "响应", id="embeddings"), + pytest.param("_require_candidates", "candidates", "响应", id="candidates"), + ], +) +def test_google_response_containers_reject_non_list(method, attr, source): + """响应容器字段非列表必须报 ``RuntimeError``,不是裸 ``TypeError``。""" + owner = SimpleNamespace(**{attr: 5}) + + with pytest.raises(RuntimeError, match="必须是列表"): + getattr(GoogleProvider, method)(owner, source=source) + + +def test_google_partial_arg_value_requires_a_value(): + """``PartialArg`` 没有任何标量值时必须报错,不能静默返回 ``None``。""" + with pytest.raises(ValueError, match="partial_args 缺少有效值"): + GoogleProvider._partial_arg_value(SimpleNamespace()) + + +def test_google_partial_arg_tokens_reject_oversized_index(): + """json_path 索引过大必须报错,不能静默接受(会按该下标填充列表)。""" + with pytest.raises(ValueError, match="json_path 索引过大"): + GoogleProvider._partial_arg_tokens("$[999999]") + + +def test_google_partial_arg_tokens_reject_unsupported_path(): + """不支持的 json_path 必须报错——删掉守卫会**死循环**(``rest`` 不推进)。""" + with pytest.raises(ValueError, match="不支持的 json_path"): + GoogleProvider._partial_arg_tokens("$..x") + + +def test_google_partial_args_to_mapping_rejects_non_list(): + """``partial_args`` 非列表且非 Mapping 必须报错,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="partial_args 必须是列表"): + GoogleProvider._partial_args_to_mapping(5) + + +def test_google_partial_args_root_value_must_be_object(): + """根路径的 partial 值必须是 JSON 对象,不是裸 ``ValueError: dictionary update``。""" + with pytest.raises(ValueError, match="根值必须是 JSON 对象"): + GoogleProvider._partial_args_to_mapping([SimpleNamespace(json_path="$", string_value="x")]) + + +def test_google_partial_args_write_into_list_rejects_conflict(): + """向列表元素写入子字段时类型冲突必须报错,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="json_path 无法写入"): + GoogleProvider._partial_args_to_mapping( + [ + SimpleNamespace(json_path="$[0]", string_value="x"), + SimpleNamespace(json_path="$[0].a", string_value="y"), + ] + ) + + +def test_google_partial_args_write_into_scalar_rejects_conflict(): + """向标量写入子字段必须报错,不是裸 ``TypeError: 'str' object does not support``。""" + with pytest.raises(ValueError, match="json_path 无法写入"): + GoogleProvider._partial_args_to_mapping( + [ + SimpleNamespace(json_path="$.a", string_value="x"), + SimpleNamespace(json_path="$.a.b", string_value="y"), + ] + ) + + +def _merge_into(accumulator, tool_call): + GoogleProvider._merge_tool_call(accumulator, tool_call) + return accumulator + + +def _fc(**kwargs): + return {"type": "function", **kwargs} + + +def test_google_merge_tool_call_rejects_move_onto_existing_key(): + """把分片并入已存在的 key 时必须报错,不能静默覆盖。""" + with pytest.raises(RuntimeError, match="id/index 指向多个调用"): + _merge_into( + {("index", 5): _fc(index=5, name="other"), ("id", "a"): _fc(id="a", name="f")}, + _fc(index=5, id="a", name="f"), + ) + + +def test_google_merge_tool_call_index_branch_rejects_multiple_id_matches(): + """index 分支里同一 id 命中多个候选时必须报错。""" + with pytest.raises(RuntimeError, match="id 指向多个调用"): + _merge_into( + {("id", "a"): _fc(id="a", name="f"), ("position", 0): _fc(name="f", id="a")}, + _fc(index=1, id="a", name="f"), + ) + + +def test_google_merge_tool_call_id_branch_rejects_ambiguous_name_candidates(): + """call_id 分支下同名多候选时必须报错,不能静默挑一个。""" + with pytest.raises(RuntimeError, match="无法关联并行调用"): + _merge_into( + {("position", 0): _fc(name="f"), ("position", 1): _fc(name="f")}, + _fc(call_id="c1", name="f"), + ) + + +def test_google_merge_tool_call_without_identity_rejects_ambiguous_candidates(): + """既无 id 也无 index 且同名多候选时必须报错。""" + with pytest.raises(RuntimeError, match="无法关联并行调用"): + _merge_into( + {("position", 0): _fc(name="f"), ("position", 1): _fc(name="f")}, + _fc(name="f"), + ) + + +def test_google_merge_tool_call_rejects_conflicting_id(): + """同一分片键上的 id 冲突必须报错,不能静默保留旧值。""" + with pytest.raises(RuntimeError, match="id 指向多个调用"): + _merge_into( + {("index", 0): _fc(index=0, id="a", name="f")}, + _fc(index=0, call_id="b", name="f"), + ) + + +def test_google_merge_tool_call_rejects_conflicting_index(): + """同一分片键上的 index 冲突必须报错。""" + with pytest.raises(RuntimeError, match="index 指向多个调用"): + _merge_into( + {("id", "a"): _fc(id="a", index=0, name="f")}, + _fc(index=1, id="a", name="f"), + ) + + +def test_google_merge_tool_call_rejects_conflicting_name(): + """同一分片键上的 name 冲突必须报错。""" + with pytest.raises(RuntimeError, match="name 指向多个调用"): + _merge_into({("id", "a"): _fc(id="a", name="f")}, _fc(call_id="a", name="g")) + + +def _google_vertex_mode_provider(vertex, **options): + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-1.5-flash" + provider._provider = "google" + provider._options = {**options, "vertexai": vertex} + client = SimpleNamespace(vertexai=vertex) + provider._client = client + provider._get_client = lambda: client + provider._get_aclient = lambda: client + return provider + + +def _tool_defs(): + return [{"type": "function", "function": {"name": "f"}}] + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("field", ["system_prompt", "tools"]) +def test_google_developer_count_tokens_rejects_unsupported_fields(async_mode, field): + """Developer API 的 count_tokens 不支持 system_prompt/tools。""" + provider = _google_vertex_mode_provider(False) + kwargs = {field: "sys" if field == "system_prompt" else _tool_defs()} + request = CompletionRequest(prompt="hi", **kwargs) + + with pytest.raises(ValueError, match="Developer API 的 count_tokens 不支持"): + if async_mode: + asyncio.run(provider.async_count_tokens(request)) + else: + provider.count_tokens(request) + + +def test_google_compute_tokens_rejects_system_prompt(): + """Vertex compute_tokens 不支持 system_prompt。""" + provider = _google_vertex_mode_provider(True) + + with pytest.raises(ValueError, match="compute_tokens 不支持 system_prompt"): + provider.compute_tokens(CompletionRequest(prompt="hi", system_prompt="sys")) + + +def test_google_async_compute_tokens_requires_vertex_mode(): + """compute_tokens 仅支持 Vertex/Enterprise 模式。""" + provider = _google_vertex_mode_provider(False) + + with pytest.raises(ValueError, match="仅支持 Vertex/Enterprise 模式"): + asyncio.run(provider.async_compute_tokens(CompletionRequest(prompt="hi"))) + + +def test_google_async_compute_tokens_rejects_system_prompt(): + """异步 compute_tokens 不支持 system_prompt。""" + provider = _google_vertex_mode_provider(True) + + with pytest.raises(ValueError, match="compute_tokens 不支持 system_prompt"): + asyncio.run( + provider.async_compute_tokens(CompletionRequest(prompt="hi", system_prompt="sys")) + ) + + +def _google_stream_provider(): + """构造一个「无终止事件」的 Google 流替身。""" + chunk = SimpleNamespace( + response_id="r", + candidates=[ + SimpleNamespace( + content=SimpleNamespace(parts=[SimpleNamespace(text="hi")]), + finish_reason=None, + ) + ], + ) + + class _AsyncIter: + def __init__(self): + self._done = False + + def __aiter__(self): + return self + + async def __anext__(self): + if self._done: + raise StopAsyncIteration + self._done = True + return chunk + + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini" + provider._provider = "google" + provider._options = {} + aio = SimpleNamespace( + models=SimpleNamespace(generate_content_stream=lambda **kwargs: _AsyncIter()) + ) + provider._client = SimpleNamespace( + models=SimpleNamespace(generate_content_stream=lambda **kwargs: iter([chunk])), + aio=aio, + ) + provider._get_client = lambda: provider._client + provider._get_aclient = lambda: provider._client + return provider + + +def test_google_stream_events_rejects_missing_terminal_event(): + """流在终止事件之前结束必须报错,不能静默返回已收片段。""" + provider = _google_stream_provider() + + with pytest.raises(RuntimeError, match="流在终止事件之前结束"): + list(provider.stream_events(CompletionRequest(prompt="hi"))) + + +def test_google_async_stream_events_rejects_missing_terminal_event(): + """异步分支同判据。""" + provider = _google_stream_provider() + + async def _drain(): + return [event async for event in provider.astream_events(CompletionRequest(prompt="hi"))] + + with pytest.raises(RuntimeError, match="异步流在终止事件之前结束"): + asyncio.run(_drain()) + + +def test_google_invoke_stream_rejects_missing_terminal_event(): + """``invoke(stream=True)`` 同判据。""" + provider = _google_stream_provider() + + with pytest.raises(RuntimeError, match="流在终止事件之前结束"): + list(provider.invoke("hi", stream=True)) + + +def test_google_ainvoke_stream_rejects_missing_terminal_event(): + """``ainvoke(stream=True)`` 同判据。""" + provider = _google_stream_provider() + + async def _drain(): + return [chunk async for chunk in provider.ainvoke("hi", stream=True)] + + with pytest.raises(RuntimeError, match="异步流在终止事件之前结束"): + asyncio.run(_drain()) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_google_validate_tuning_reward_rejects_dual_configs(async_mode): + """``single_reward_config`` 与 ``composite_reward_config`` 不能同时传入。""" + provider = object.__new__(GoogleProvider) + provider._model_name = "m" + provider._options = {} + provider._client = SimpleNamespace(tunings=object(), aio=SimpleNamespace(tunings=object())) + provider._get_client = lambda: provider._client + + async def _run(): + return await provider.async_validate_tuning_reward( + "p", + sample_response=None, + example=None, + single_reward_config=1, + composite_reward_config=2, + ) + + with pytest.raises(ValueError, match="不能同时传入"): + if async_mode: + asyncio.run(_run()) + else: + provider.validate_tuning_reward( + "p", + sample_response=None, + example=None, + single_reward_config=1, + composite_reward_config=2, + ) + + +def test_google_connect_live_reports_missing_aio_node(): + """缺 ``aio`` 节点时必须报 ``NotImplementedError``,不是裸 ``UnboundLocalError``。""" + provider = object.__new__(GoogleProvider) + provider._model_name = "m" + provider._options = {} + provider._client = SimpleNamespace() + provider._get_client = lambda: provider._client + + with pytest.raises(NotImplementedError, match="未提供异步 Live 资源"): + provider.connect_live() + + +def test_google_connect_live_rejects_non_callable_connect(): + """``live.connect`` 不可调用时必须报错,不是裸 ``TypeError``。""" + provider = object.__new__(GoogleProvider) + provider._model_name = "m" + provider._options = {} + provider._client = SimpleNamespace(aio=SimpleNamespace(live=SimpleNamespace(connect=5))) + provider._get_client = lambda: provider._client + + with pytest.raises(NotImplementedError, match="未提供可调用的 Live 连接资源"): + provider.connect_live() + + +def _google_embedding_provider(vertex, count): + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-embedding-001" + provider._provider = "google" + provider._options = {"vertexai": vertex} + provider._client = None + + def _mk(**kwargs): + return SimpleNamespace(embeddings=[SimpleNamespace(values=[0.1]) for _ in range(count)]) + + async def _amk(**kwargs): + return _mk(**kwargs) + + models = SimpleNamespace(embed_content=_mk) + aio = SimpleNamespace(models=SimpleNamespace(embed_content=_amk)) + provider._client = SimpleNamespace(vertexai=vertex, models=models, aio=aio) + provider._get_client = lambda: provider._client + return provider + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_google_embedding_rejects_reserved_field_override(async_mode): + """Embedding 请求覆盖 ``model`` 必须报「不允许覆盖请求字段」。""" + provider = _google_embedding_provider(False, 1) + + async def _run(): + return await provider.aembed_documents(["a"], model="x") + + with pytest.raises(ValueError, match="Embedding 不允许覆盖请求字段"): + if async_mode: + asyncio.run(_run()) + else: + provider.embed_documents(["a"], model="x") + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_google_vertex_embedding_requires_single_embedding(async_mode): + """Vertex 单内容请求必须返回唯一 embedding,不能静默接受多个。""" + provider = _google_embedding_provider(True, 2) + + async def _run(): + return await provider.aembed_documents(["a"]) + + with pytest.raises(RuntimeError, match="单内容请求未返回唯一 embedding"): + if async_mode: + asyncio.run(_run()) + else: + provider.embed_documents(["a"]) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_google_embedding_count_must_match_input(async_mode): + """Embedding 返回数量与输入数量不一致时必须报错。""" + provider = _google_embedding_provider(False, 2) + + async def _run(): + return await provider.aembed_documents(["a"]) + + with pytest.raises(RuntimeError, match="返回数量与输入文本数量不一致"): + if async_mode: + asyncio.run(_run()) + else: + provider.embed_documents(["a"]) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_google_close_reports_first_error(async_mode): + """``close``/``aclose`` 首个关闭异常必须向上报告,不能静默吞掉。""" + + class _BadClose: + def close(self): + raise RuntimeError("close 失败") + + aio = SimpleNamespace(aclose=lambda: (_ for _ in ()).throw(RuntimeError("close 失败"))) + + provider = object.__new__(GoogleProvider) + provider._client = _BadClose() + provider._provider = "google" + + with pytest.raises(RuntimeError, match="close 失败"): + if async_mode: + asyncio.run(provider.aclose()) + else: + provider.close() + + +# ── anthropic / model_provider / resources / local_hash 的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3698 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * anthropic 192 构造期缺 ``ANTHROPIC_API_KEY`` → **静默构造**出无凭证的 +# provider(``logger.error`` 已打,但对象照样返回给调用方); +# * anthropic 279 ``supports_sampling_option`` 收到未知字段 → **静默返回 +# ``False``**,把「这个字段名本身是非法的」说成「这个模型不支持该字段」; +# 调用方(``chat_service._temperature_kwargs``/``chat.core``)只按真假值 +# 分支,于是非法字段名被静默吞掉; +# * anthropic 2204 ``aclose`` 首个关闭异常 → **静默吞掉**(同步 ``close`` +# 有覆盖,异步侧没有——sibling 缺口); +# * anthropic 1419 流错误事件(``type`` 以 ``.error`` 结尾但**无** ``error`` +# 子对象,如 ``overloaded.error``)→ ``raise_for_stream_error_event`` 不拦 +# 这一形态,本条是唯一的报错点;删除后该事件被**静默返回 None**,错误信号 +# 在流里消失; +# * model_provider 1126/1143 Responses 的 ``tool_calls`` 容器/元素形态 → +# 上游 ``normalize_messages``(466/470 行)先以同族消息拦住,两条不可达 +# (等价变异,见下); +# * resources 236 ``_delegated_resource`` SDK 抛 ``AttributeError`` → +# **裸 ``UnboundLocalError``**(``value`` 未绑定);240 资源值为 ``None`` → +# **静默返回 None**,调用方拿 None 当资源用、在更远处才崩; +# * resources 282 dunder 探针 → 经真实入口仍以 ``AttributeError`` 收场 +# (等价变异,见下); +# * local_hash 87 Embedding 请求参数 → **静默接受**并原样丢弃。 +# +# 同批经甄别不补测试的:model_provider 1771/1785(tenacity 的 ``reraise=True`` +# 保证重试耗尽时抛原异常,末尾两条 ``RuntimeError`` 不可达)、2155(``client_getter()`` +# 返回 None 时随即被下游以同一条消息拦住)、anthropic 335(三条同消息判据中的 +# 第一条:bool/非数值落到 337 的 ``is_double_convertible``,收场同一条消息)、 +# resources 282(``__getattr__`` 里的 dunder 探针:本函数的三条 ``AttributeError`` +# 消息逐字相同,删除任一条仍以同一条消息收场)。 + + +def _anthropic_provider_with_settings(monkeypatch, **settings): + """经真实 ``__init__`` 构造 AnthropicProvider。 + + 不能用字符串形式 ``monkeypatch.setattr("src.providers.anthropic.get_settings", ...)``: + ``tests/providers/test_factory.py`` 会把该模块从 ``sys.modules`` 删掉再重新导入, + 字符串形式解析到的是**新**模块,而 ``__init__`` 仍从**旧**模块字典取 + ``get_settings``——patch 静默落空。单跑本文件不触发,只有全量运行才暴露。 + """ + monkeypatch.setitem( + AnthropicProvider.__init__.__globals__, + "get_settings", + lambda: SimpleNamespace(**settings), + ) + return AnthropicProvider("claude-sonnet-4-6") + + +def test_anthropic_constructor_requires_api_key(monkeypatch): + """缺 API Key 必须在构造期拒绝,不能静默构造出无凭证的 provider。""" + with pytest.raises(ValueError, match="ANTHROPIC_API_KEY is required"): + _anthropic_provider_with_settings(monkeypatch, anthropic_api_key=None) + + +def test_anthropic_supports_sampling_option_rejects_unknown_field(monkeypatch): + """未知采样字段名必须报错,不能静默返回 ``False``。 + + ``False`` 与「模型不支持该字段」在调用方(``chat_service._temperature_kwargs``) + 那里无法区分——非法字段名会被静默吞掉。 + """ + provider = _anthropic_provider_with_settings(monkeypatch, anthropic_api_key="k") + + with pytest.raises(ValueError, match="不支持的采样字段: bogus"): + provider.supports_sampling_option("bogus") + + +def test_anthropic_aclose_reports_first_error(): + """``aclose`` 首个关闭异常必须向上报告,不能静默吞掉。 + + 与同步 ``close`` 对称:Google/Volcengine 的两个入口都有覆盖,Anthropic + 只有同步侧。 + """ + + class _BadClose: + def close(self): + raise RuntimeError("close 失败") + + provider = object.__new__(AnthropicProvider) + provider._client = _BadClose() + provider._aclient = None + + with pytest.raises(RuntimeError, match="close 失败"): + asyncio.run(provider.aclose()) + + +@pytest.mark.parametrize( + "event", + [ + pytest.param({"type": "overloaded.error"}, id="suffix-only"), + pytest.param({"type": "rate_limit_error.error"}, id="dotted"), + ], + ids=lambda e: e["type"], +) +def test_anthropic_stream_error_event_without_embedded_error_is_reported(event): + """``type`` 以 ``.error`` 结尾的流事件必须报错,不能静默返回 ``None``。 + + 这类事件**没有** ``error`` 子对象,``raise_for_stream_error_event`` 按 + ``embedded_error is None`` 放行——本条是该形态唯一的报错点。删除后错误信号 + 在流里消失:调用方继续消费后续事件,把半截响应当成完整答案。 + """ + with pytest.raises(RuntimeError, match="流返回错误"): + AnthropicProvider._stream_event(event, "rid") + + +def _resources_facade(**client_attrs): + from src.providers import resources as resources_module + + provider = SimpleNamespace( + _provider="openai", + sdk_client=SimpleNamespace(**client_attrs), + ) + return resources_module.OpenAIResources(provider) + + +def test_resources_delegated_resource_reports_missing_sdk_resource(): + """SDK 没有该资源节点时必须报 ``NotImplementedError``,不是裸 ``UnboundLocalError``。""" + facade = _resources_facade() + + with pytest.raises(NotImplementedError, match="SDK 未提供 beta 资源"): + facade._delegated_resource("beta") + + +def test_resources_delegated_resource_rejects_none_resource(): + """资源节点存在但为 ``None`` 时必须报错,不能静默返回 ``None``。""" + facade = _resources_facade(beta=None) + + with pytest.raises(NotImplementedError, match="SDK 未提供 beta 资源"): + facade._delegated_resource("beta") + + +def test_resources_native_facade_proxies_present_resource(): + """反向守卫:真实存在的资源照常代理。""" + facade = _resources_facade(beta=SimpleNamespace(create=lambda **kw: kw)) + + assert facade._delegated_resource("beta") is not None + + +def test_resources_native_getattr_rejects_none_resource(): + """``__getattr__`` 的动态资源为 ``None`` 时必须 ``AttributeError``,不能返回 ``None``。 + + 与 ``_delegated_resource`` 是两条独立路径:本函数在 dunder 探针、非原生渠道 + 之后还有一道「值为 ``None``」的判据,删除后 ``getattr(facade, name)`` 返回 + ``None``——调用方按属性访问的惯例会继续用,崩在更远处。 + """ + facade = _resources_facade(some_resource=None) + + with pytest.raises(AttributeError, match="some_resource"): + _ = facade.some_resource + + +def test_resources_native_facade_dunder_probe_is_rejected_before_delegation(): + """dunder 探针必须在触达 SDK 之前被拒,否则 ``deepcopy`` 会无限递归。 + + ``copy``/``pickle`` 的协议在重建对象时会先 ``hasattr(y, '__setstate__')`` + 之类地探测 dunder——此时新实例的属性还没填充,``__getattr__`` 里的 + ``self.provider`` 本身又走 ``__getattr__``,没有这道判据就无限递归 + (实测删掉守卫后 ``copy.deepcopy(facade)`` 抛 ``RecursionError``)。 + """ + import copy + + facade = _resources_facade(beta="REAL") + + copied = copy.deepcopy(facade) + + assert type(copied) is type(facade) + + +def test_local_hash_embedding_rejects_unsupported_kwargs(): + """LocalHash Embedding 的请求参数必须报错,不能静默接受并丢弃。""" + from src.providers.local_hash import LocalHashEmbeddingProvider + + provider = LocalHashEmbeddingProvider("local-hash-32") + + with pytest.raises(ValueError, match="不支持请求参数: bogus"): + provider.embed_documents(["文本"], bogus=1) diff --git a/tests/providers/test_rerank_contracts.py b/tests/providers/test_rerank_contracts.py index 500bb07..f25fe5a 100644 --- a/tests/providers/test_rerank_contracts.py +++ b/tests/providers/test_rerank_contracts.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + import pytest from src.providers.jina import JinaProvider @@ -48,6 +50,51 @@ def test_rerank_rejects_invalid_top_n(provider_type, top_n): provider._validate_inputs("query", ["document"], top_n) +# `_validate_inputs` 有四个判据:query 非空字符串、documents 非空列表、 +# documents 元素全为字符串、top_n 范围。上面两条只钉住了最后一条——前三条的 +# 用例要么喂合法输入(`"query"`/`["document"]`),要么根本不经过这里,于是 +# 把前三道 `raise` 换成 `pass` 后全量套件仍然全绿。删除后行为确实改变(实测): +# `query=5`/`" "`、`documents=[]`、`documents=["a", 5]` 全部**静默接受**并原样 +# 进入请求体发给上游;`documents=5` 从带指引的 `ValueError` 变成裸 +# `TypeError: 'int' object is not iterable`(`any(...)` 迭代失败)。 +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +@pytest.mark.parametrize("query", [5, None, " ", ""], ids=["int", "none", "blank", "empty"]) +def test_rerank_rejects_invalid_query(provider_type, query): + """query 必须是非空字符串,不能静默放行非字符串或空白串。""" + provider = object.__new__(provider_type) + + with pytest.raises(ValueError, match="query 必须是非空字符串"): + provider._validate_inputs(query, ["document"], 1) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +@pytest.mark.parametrize("documents", [5, None, [], "abc"], ids=["int", "none", "empty", "str"]) +def test_rerank_rejects_invalid_documents_container(provider_type, documents): + """documents 必须是非空列表;非列表取值要给 ``ValueError`` 而不是裸 ``TypeError``。""" + provider = object.__new__(provider_type) + + with pytest.raises(ValueError, match="documents 必须是非空列表"): + provider._validate_inputs("query", documents, 1) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +@pytest.mark.parametrize("documents", [["a", 5], [None], ["ok", {"t": 1}]]) +def test_rerank_rejects_non_string_document_entries(provider_type, documents): + """documents 的元素必须全是字符串,否则会在请求体里被静默序列化。""" + provider = object.__new__(provider_type) + + with pytest.raises(ValueError, match="documents 必须全部是字符串"): + provider._validate_inputs("query", documents, 1) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_validate_inputs_accepts_well_formed_arguments(provider_type): + """收紧不能误伤:合法的 query/documents/top_n 照常通过。""" + provider = object.__new__(provider_type) + + assert provider._validate_inputs("query", ["document"], 1) is None + + @pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) def test_rerank_invalid_index_is_explicit(provider_type): provider = object.__new__(provider_type) @@ -59,6 +106,61 @@ def test_rerank_invalid_index_is_explicit(provider_type): ) +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_bool_index_is_rejected(provider_type): + """``index`` 为 bool 时必须报「index 无效」,不得被当作整数下标。 + + ``isinstance(direct_index, bool)`` 那一段是**有意**排除 bool 的(bool 是 + int 子类,``True`` 会变成下标 ``1``)。把它前面的 ``or`` 互换为 ``and`` 后, + ``index=True`` 且 ``len(documents) >= 2`` 时整条判据为假 → bool 被当 index=1 + 接受。既有 ``test_rerank_invalid_index_is_explicit`` 只喂越界整数 4、 + ``test_rerank_accepts_positive_index_within_range`` 只喂合法整数 1,bool + 这条分支从未被覆盖(``docs`` 必须 ≥ 2 元素才分岔——单元素时范围判据恰好 + 仍拒绝,掩盖了缺陷)。 + """ + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="index 无效"): + provider._parse_response( + [{"index": True, "relevance_score": 0.9}], + ["one", "two"], + ) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_accepts_positive_index_within_range(provider_type): + """区间内的**正**下标必须被接受并映射到对应文档。 + + 范围判据 ``not 0 <= direct_index < len(documents)`` 是链式比较,``<=`` 反向 + 成 ``>=`` 后整条判据变成「index > 0 或 index >= len 即拒绝」——除 0 以外的 + 合法下标全被拒(实测 index=1 报「index 无效」),而**负**下标反而被放行, + 落到 ``positions[...].remove(-1)`` 抛裸 ``ValueError``,穿透本方法承诺的 + ``RuntimeError``。既有用例只喂过 index=0 与越界的 index=4,因此这一半从未 + 被触达。 + """ + provider = object.__new__(provider_type) + + indices, scores = provider._parse_response( + [{"index": 1, "relevance_score": 0.9}], + ["one", "two"], + ) + + assert indices == [1] + assert scores == [0.9] + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_rejects_negative_index_with_runtime_error(provider_type): + """负下标必须报 ``index 无效``,不得穿透成 ``list.remove`` 的裸 ``ValueError``。""" + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="index 无效"): + provider._parse_response( + [{"index": -1, "relevance_score": 0.9}], + ["one", "two"], + ) + + @pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) def test_rerank_unknown_document_is_explicit(provider_type): provider = object.__new__(provider_type) @@ -82,6 +184,33 @@ def test_rerank_invalid_score_is_explicit(provider_type, score): ) +@pytest.mark.parametrize("score", [True, False], ids=["true", "false"]) +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_bool_score_is_not_reported_as_out_of_range(provider_type, score): + """``relevance_score`` 为 bool 时不得报「超出双精度范围」。 + + 超范围判据是 ``isinstance(raw_score, int) and not isinstance(raw_score, bool) + and not is_double_convertible(raw_score)``:中间那个 ``not isinstance(..., + bool)`` 是**有意**排除 bool 的(bool 是 int 子类,``float(True) == 1.0`` 会 + 静默变 1.0,走的不是「超范围」这条)。把它前面的 ``and`` 互换为 ``or`` 后, + ``raw_score=True`` 会命中 ``(isinstance(int)=True or ...) and not + is_double_convertible(True)=True`` → 误报「超出双精度范围」。既有 + ``test_rerank_invalid_score_is_explicit`` 只喂字符串/nan/inf(都带 + ``relevance_score`` 键走 float() 路径),``test_rerank_response_score_beyond + _double_range_is_explicit`` 只喂超大整数(两种变体都 raise,区分不了), + 因此 bool 这条独立分支从未被覆盖。 + """ + provider = object.__new__(provider_type) + + indices, scores = provider._parse_response( + [{"index": 0, "relevance_score": score}], + ["one"], + ) + + assert indices == [0] + assert scores == [1.0 if score else 0.0] + + @pytest.mark.parametrize("timeout", [float("nan"), float("inf"), float("-inf"), 0, -1]) @pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) def test_rerank_non_finite_or_non_positive_timeout_is_explicit(provider_type, timeout): @@ -134,3 +263,458 @@ def test_jina_rerank_requests_documents_by_default(): payload = provider._prepare_payload("query", ["document"], 1) assert payload["return_documents"] is True + + +def test_siliconflow_rerank_requests_documents_by_default(): + """SiliconFlow 侧的 sibling:默认请求也必须带 ``return_documents=True``。 + + 反向成 ``False`` 后响应里没有文档文本,调用方按「返回文档」写的 + ``_parse_response`` 拿到空内容——而 Jina 侧早有这条默认值断言, + SiliconFlow 侧只断言了「不允许覆盖」,默认值本身无人看守。 + """ + provider = object.__new__(SiliconflowRerankProvider) + provider._model_name = "rerank-model" + provider._options = {} + + payload = provider._prepare_payload("query", ["document"], 1) + + assert payload["return_documents"] is True + + +# --------------------------------------------------------------------------- +# 超出双精度范围的整数 +# --------------------------------------------------------------------------- +# +# ``math.isfinite(x)`` 与 ``float(x)`` 对**任意大的 Python int** 都会先尝试 +# 转成 C double,超出 ``DBL_MAX``(约 1.8e308)时抛 +# ``OverflowError: int too large to convert to float``。 +# +# 这不是学术边界:``config.toml`` 的整数是**无上界**的 TOML 整数,``tomllib`` +# 原样解析成 Python int;``options`` 又是文档指定的扩展入口。实测 +# ``[rerank_configurations.x.options] timeout = <401 位十进制整数>`` 让 +# ``JinaProvider._request_timeout`` 抛裸 ``OverflowError``。 +# +# 后果比「报错难看」严重:这些方法承诺抛 ``ValueError``,而调用方 +# (``retrieval_service`` 的校验链、``get_settings`` 的 ``except ValueError`` +# 兜底、``questionary`` 的 validator 契约)都只接 ``ValueError``。 +# ``OverflowError`` 是 ``ArithmeticError`` 的子类,**不是** ``ValueError``, +# 于是它穿透所有既有守卫,冒到解释器默认 handler。 +# +# 参数 id 必须显式给出:``10**4301`` 超过 ``sys.get_int_max_str_digits()``, +# pytest 为它自动生成 id 时调 ``str(val)`` 会自己抛 ``ValueError``,测试在 +# **收集阶段**就崩掉。 +_BEYOND_DOUBLE_INTS = [ + pytest.param(10**400, id="400digits"), + pytest.param(-(10**400), id="negative-400digits"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), +] + + +@pytest.mark.parametrize("timeout", _BEYOND_DOUBLE_INTS) +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_timeout_beyond_double_range_is_explicit(provider_type, timeout): + """超出双精度范围的 ``timeout`` 必须报成「必须是正数」,而不是 ``OverflowError``。""" + provider = object.__new__(provider_type) + provider._options = {"timeout": timeout} + + with pytest.raises(ValueError, match="timeout.*正数"): + provider._request_timeout() + + +@pytest.mark.parametrize("score", _BEYOND_DOUBLE_INTS) +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_response_score_beyond_double_range_is_explicit(provider_type, score): + """响应里的超大整数得分必须报成 ``RuntimeError``,而不是 ``OverflowError``。 + + ``_parse_response`` 的契约是「形态异常一律 ``RuntimeError``」,原实现的 + ``except (KeyError, TypeError, ValueError)`` 接不住 ``OverflowError``。 + """ + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="relevance_score"): + provider._parse_response([{"index": 0, "relevance_score": score}], ["one"]) + + +# ``_parse_response`` 的契约是「形态异常一律 ``RuntimeError``」。原实现在下面 +# 这个位置上用了 ``res["relevance_score"]``——它**不在**那个 +# ``except (KeyError, TypeError, ValueError)`` 的覆盖范围内(那段只包 +# ``float(...)``),于是缺字段的响应项让契约漏成裸 +# ``KeyError: 'relevance_score'``。调用方的错误分类与日志脱敏都按 +# ``RuntimeError`` 写,接不住 ``KeyError``。 +# +# 上面的 ``test_rerank_invalid_score_is_explicit`` 覆盖不到这条:它的探针 +# (``"not-a-number"``/``nan``/``inf``)**都带** ``relevance_score`` 键, +# 走的正是 ``float()`` 那条被 ``try`` 包住的路径。缺字段是另一条分支。 +@pytest.mark.parametrize( + "item", + [ + {"index": 0}, + {"index": 0, "document": {"text": "one"}}, + {"document": {"text": "one"}}, + ], + ids=["index-only", "index-and-document", "document-only"], +) +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_missing_score_is_explicit(provider_type, item): + """缺少 ``relevance_score`` 的结果项必须报成 ``RuntimeError``。 + + 响应来自网络 JSON,任何服务器或网关都可能省略这个字段——实测修复前抛的是 + 裸 ``KeyError``,而本方法的文档承诺是 ``RuntimeError``。 + """ + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="relevance_score"): + provider._parse_response([item], ["one"]) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_parse_response_never_leaks_a_bare_key_error(provider_type): + """推广:任意畸形响应都不得漏出 ``KeyError``。 + + 上面那条钉住已知的那个字段;这条用一批形态各异的条目断言「绝不漏出 + ``KeyError``」——那类异常只可能来自本函数自己的字典下标,不可能来自契约。 + """ + provider = object.__new__(provider_type) + probes = [ + [{}], + [{"index": 0}], + [{"document": {"text": "one"}}], + [{"index": 0, "document": {"text": "one"}}], + [{"index": 1}], + [{"document": "one"}], + [{"relevance_score": 0.5}], + ] + + for probe in probes: + try: + provider._parse_response(probe, ["one"]) + except RuntimeError: + pass + except BaseException as exc: + raise AssertionError(f"{probe!r} 抛出了 {type(exc).__name__}: {exc}") from exc + + +# ── 响应解析的形态判据 ── +# +# ``_parse_response`` 的四条形态判据(results 非列表、结果项非 Mapping、 +# index/document 关联、document 不可映射)在整套测试里从未执行:既有用例 +# ``test_rerank_invalid_index_is_explicit``/``test_rerank_unknown_document_is_explicit`` +# 命中的都是「index 值越界」与「document 映射不到输入」两条**取值**判据。 +# 行覆盖证实四条从未执行;把它们的 ``raise`` 换成 ``pass`` 后全量 3341 条仍全绿。 +# +# ``rerank``/``arerank`` 里「响应必须是 JSON 对象」那两条同样是缺口: +# 它们在 ``payload.get("results")`` 之前,非 Mapping 取值会先撞裸 +# ``AttributeError: 'list' object has no attribute 'get'``。删掉后实测正是如此。 + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class _FakeClient: + payload: object = None + + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def post(self, *args, **kwargs): + return _FakeResponse(self.payload) + + +class _FakeAsyncClient(_FakeClient): + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, *args, **kwargs): + return _FakeResponse(self.payload) + + +class _FakeHttpx: + Client = _FakeClient + AsyncClient = _FakeAsyncClient + + +def _fake_rerank_providers(provider_type): + """构造绕过网络与配置读取的 provider 实例。""" + provider = object.__new__(provider_type) + provider._model_name = "m" + provider._options = {} + provider._base_url = "https://example.invalid/v1/rerank" + provider._api_key = "k" + return [provider] + + +def _fake_rerank_http(monkeypatch, payload): + """把两个 provider 内部 ``import httpx`` 拿到的客户端换成假实现。""" + import sys + + _FakeClient.payload = payload + monkeypatch.setitem(sys.modules, "httpx", _FakeHttpx()) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +@pytest.mark.parametrize("bad_item", [5, "text", ["x"]], ids=["int", "str", "list"]) +def test_rerank_rejects_non_mapping_result_item(provider_type, bad_item): + """结果项必须是 Mapping。 + + 删除守卫后 ``"index" in res`` 对整数取值抛裸 ``TypeError: argument of type + 'int' is not iterable``——而本模块承诺的解析契约是 ``RuntimeError``, + 调用方的错误分类与日志脱敏都按 ``RuntimeError`` 写,接不住 ``TypeError``。 + """ + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="包含无效结果项"): + provider._parse_response([bad_item], ["one"]) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_rejects_index_document_mismatch(provider_type): + """响应声明的 ``index`` 与随附的 ``document`` 文本必须指向同一个输入文档。 + + 上游返回的 index 与 document 不一致时,删除守卫会**静默接受**:实测返回 + ``([0], [1.0])``,把「命中的是另一篇文档」当成有效排序交给检索层。 + """ + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="index 与 document 不匹配"): + provider._parse_response( + [{"index": 0, "document": {"text": "other"}, "relevance_score": 0.9}], + ["one", "other"], + ) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_rejects_non_mapping_result_entries(provider_type): + """``_parse_response`` 声明 ``results`` 是列表,传入非列表必须显式报错。 + + 这条与 ``rerank``/``arerank`` 里那条同消息判据构成前后两道闸;删除后本 + 函数会在 ``for res in results`` 处以形态各异的异常崩掉。 + """ + provider = object.__new__(provider_type) + + with pytest.raises(RuntimeError, match="缺少有效的 results 列表"): + provider._parse_response("not-a-list", ["one"]) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_response_must_be_json_object(provider_type, monkeypatch): + """上游响应必须是 JSON 对象:同步与异步两条入口都要在 ``.get`` 之前报错。 + + 走公开 ``rerank``/``arerank`` 入口并用假 ``httpx`` 拦网,验证删除守卫后 + 收场从 ``RuntimeError`` 退化成裸 ``AttributeError``。 + """ + import asyncio + + monkeypatch.setattr( + "src.providers.jina.get_settings", + lambda: SimpleNamespace(jina_api_key="k"), + ) + monkeypatch.setattr( + "src.providers.siliconflow_rerank.get_settings", + lambda: SimpleNamespace(siliconflow_api_key="k", siliconflow_base_url="https://x/v1"), + ) + + for provider in _fake_rerank_providers(provider_type): + _fake_rerank_http(monkeypatch, ["not", "a", "map"]) + for run in ( + lambda p: p.rerank(query="q", documents=["a", "b"], top_n=2), + lambda p: asyncio.run(p.arerank(query="q", documents=["a", "b"], top_n=2)), + ): + with pytest.raises(RuntimeError, match="响应必须是 JSON 对象"): + run(provider) + + +# ── 构造期与 payload 构造的覆盖缺口(Jina / SiliconFlow 兄弟对) ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3698 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 61/51 ``options`` 含未知字段 → **静默构造**出带未知字段的 provider, +# 非法配置一路带进请求体; +# * 66/58 缺 ``JINA_API_KEY``/``SILICONFLOW_API_KEY`` → **静默构造**出无 +# 凭证的 provider,请求发出时才 401; +# * 61(SiliconFlow)缺 ``SILICONFLOW_BASE_URL`` → **静默构造**,``_base_url`` +# 落成 ``None``; +# * 96/90 ``options`` 与 ``options.extra_body`` 重复 → **静默产出** payload, +# 其中一个取值被无声丢弃(``truncate`` 被 extra_body 覆盖); +# * 101/96 ``options.extra_body`` 覆盖请求字段 → **静默产出** +# ``payload["model"]="hijack"``,把请求的模型整个换掉。 +# +# 构造期判据必须经真实 ``__init__`` 才能覆盖,而 ``tests/providers/test_factory.py`` +# 会从 ``sys.modules`` 删除这两个模块再重新导入——字符串形式的 ``monkeypatch`` +# 会打到**新**模块,本文件顶层绑定的仍是**旧**类。因此与 +# ``test_sdk_capabilities.py`` 的 anthropic 助手同法:把 ``get_settings`` 打到 +# 类自己的 ``__init__.__globals__`` 上。 +def _rerank_settings_patch(provider_type, monkeypatch, **settings): + monkeypatch.setitem( + provider_type.__init__.__globals__, "get_settings", lambda: SimpleNamespace(**settings) + ) + + +_RERANK_SETTINGS = { + JinaProvider: {"jina_api_key": "k"}, + SiliconflowRerankProvider: {"siliconflow_api_key": "k", "siliconflow_base_url": "https://x/v1"}, +} + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_constructor_rejects_unknown_option_field(provider_type, monkeypatch): + """``options`` 里的未知字段必须在构造期拒绝,不能静默带进请求体。""" + _rerank_settings_patch(provider_type, monkeypatch, **_RERANK_SETTINGS[provider_type]) + + with pytest.raises(ValueError, match="包含不支持的字段: bogus_field"): + provider_type("m", {"bogus_field": 1}) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_constructor_requires_api_key(provider_type, monkeypatch): + """缺 API Key 必须在构造期拒绝,不能静默构造出无凭证的 provider。""" + settings = dict(_RERANK_SETTINGS[provider_type]) + settings["jina_api_key" if provider_type is JinaProvider else "siliconflow_api_key"] = None + _rerank_settings_patch(provider_type, monkeypatch, **settings) + + with pytest.raises(ValueError, match="API_KEY is required"): + provider_type("m") + + +def test_siliconflow_constructor_requires_base_url(monkeypatch): + """缺 ``SILICONFLOW_BASE_URL`` 必须在构造期拒绝(Jina 的 base_url 硬编码,无此判据)。""" + _rerank_settings_patch( + SiliconflowRerankProvider, + monkeypatch, + siliconflow_api_key="k", + siliconflow_base_url=None, + ) + + with pytest.raises(ValueError, match="SILICONFLOW_BASE_URL is required"): + SiliconflowRerankProvider("m") + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_rejects_option_duplicated_in_extra_body(provider_type): + """``options`` 与 ``options.extra_body`` 重复时必须报错,不能静默丢弃其中一个。""" + provider = object.__new__(provider_type) + provider._model_name = "m" + provider._options = {"truncate": True, "extra_body": {"truncate": False}} + + with pytest.raises(ValueError, match="与 options.extra_body 重复: truncate"): + provider._prepare_payload("q", ["d"], 1) + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +def test_rerank_rejects_extra_body_override_of_request_field(provider_type): + """``options.extra_body`` 覆盖请求字段时必须报错,不能静默换掉 ``model``。""" + provider = object.__new__(provider_type) + provider._model_name = "m" + provider._options = {"extra_body": {"model": "hijack"}} + + with pytest.raises(ValueError, match="extra_body 不允许覆盖请求字段: model"): + provider._prepare_payload("q", ["d"], 1) + + +# ── 回归:Rerank 的重试耗尽必须抛**原始异常**(boolc 扫描存活点)── +# +# ``@retry(..., reraise=True)`` 是 jina/siliconflow 两条 rerank 路径共用的 +# 守卫(``True`` ↔ ``False`` 互换后全量测试仍全绿)。反向成 ``False`` 后 +# tenacity 把最后一次异常包成 ``RetryError`` 抛出——它既不是 ``ValueError`` +# 也不是 ``RuntimeError``/``ConnectionError``,调用方按后三者写的错误分类 +# 全部接不住:一次「上游不可达」被显示成与错误内容无关的 tenacity 内部类型。 +# +# 同文件 ``test_rerank_*`` 都靠 monkeypatch 掉 httpx,重试守卫本身从未执行。 + + +@pytest.mark.parametrize( + "provider_cls", + [JinaProvider, SiliconflowRerankProvider], + ids=["jina", "siliconflow"], +) +def test_rerank_reraise_original_error_after_retries_exhausted(monkeypatch, provider_cls): + """重试耗尽后必须抛出**原始** ``ConnectionError``,不是 ``RetryError``。""" + provider = object.__new__(provider_cls) + provider._model_name = "m" + provider._options = {} + provider._base_url = "http://example.invalid" + provider._api_key = "k" + provider._get_headers = dict + provider._request_timeout = lambda: 1.0 + + class _BoomClient: + def __init__(self, *_args, **_kwargs): + return None + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, *_args, **_kwargs): + raise ConnectionError("上游不可达") + + monkeypatch.setattr("httpx.Client", _BoomClient) + # 不真的等 2s + 4s 退避:直接掏掉 wait 调用(tenacity 的 sleep 是实例属性)。 + monkeypatch.setattr(provider_cls.rerank.retry, "sleep", lambda _seconds: None) + + with pytest.raises(ConnectionError): + provider.rerank("q", ["d1"], 1) + + +@pytest.mark.parametrize( + "provider_cls", + [JinaProvider, SiliconflowRerankProvider], + ids=["jina", "siliconflow"], +) +def test_arerank_reraise_original_error_after_retries_exhausted(monkeypatch, provider_cls): + """异步入口是**另一处装饰器**(同文件 256/248),守卫必须逐个看守。""" + import asyncio + + provider = object.__new__(provider_cls) + provider._model_name = "m" + provider._options = {} + provider._base_url = "http://example.invalid" + provider._api_key = "k" + provider._get_headers = dict + provider._request_timeout = lambda: 1.0 + + class _BoomAsyncClient: + def __init__(self, *_args, **_kwargs): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, *_args, **_kwargs): + raise ConnectionError("上游不可达") + + monkeypatch.setattr("httpx.AsyncClient", _BoomAsyncClient) + + # 异步重试的 ``sleep`` 是被 ``await`` 的,必须给协程函数——给同步 lambda + # 会抛 ``TypeError: object NoneType can't be used in 'await' expression``, + # 那样测试在**未变异**的代码上也会失败,红验证就成了假绿。 + async def _no_sleep(_seconds): + return None + + monkeypatch.setattr(provider_cls.arerank.retry, "sleep", _no_sleep) + + with pytest.raises(ConnectionError): + asyncio.run(provider.arerank("q", ["d1"], 1)) diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index b1adb88..4985245 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -1,6 +1,7 @@ import asyncio import inspect import json +import sys from types import SimpleNamespace import pytest @@ -10,11 +11,14 @@ from src.providers.__base__.model_provider import ( CompletionRequest, LargeLanguageModel, + StreamEvent, TextEmbeddingModel, _normalize_responses_native_item, + _responses_content_part, normalize_chat_messages, normalize_messages, normalize_responses_input, + raise_for_stream_error_event, ) from src.providers.anthropic import AnthropicProvider from src.providers.factory import ModelProviderFactory @@ -22,7 +26,12 @@ from src.providers.openai import OpenAIProvider from src.providers.openai_compatible import OpenAICompatibleProvider from src.providers.resources import AsyncGoogleResources, GoogleResources -from src.providers.volcengine import _ARK_BUILTIN_TOOL_ITEM_TYPES, VolcengineProvider +from src.providers.volcengine import ( + _ARK_BUILTIN_TOOL_ITEM_TYPES, + VolcengineProvider, + _require_ark_content, + _require_ark_list, +) from src.utils.config import ModelDetail from src.utils.security import ( find_sensitive_option_paths, @@ -270,6 +279,61 @@ def test_completion_request_omits_implicit_temperature_for_model_defaults(): assert explicit.to_invoke_kwargs()["temperature"] == 0.7 +# `_build_chat_request` 用三条「与模型 options.extra_body 重复」判据挡住同一字段 +# 被两处配置:显式参数(`reasoning` 的 effort 分支与无 effort 分支各一条)与 +# 请求级 `extra_body`。三条在整套测试里从未执行(`sys.settrace` 行覆盖证实; +# 把它们的 `raise` 换成 `pass` 后全量 3212 条仍然全绿)。删除后行为确实改变 +# (实测): +# * 1167(`reasoning` 带 effort)与 1171(`reasoning` 无 effort)→ **静默接受**, +# 显式 `reasoning` 覆盖模型 options 里的 `reasoning`,两处配置谁生效取决于 +# 调用顺序,没有任何提示; +# * 1185(请求级 `extra_body` 与模型 options 的键重复)→ **静默覆盖**, +# 实测 `{"foo": 1}` 被 `{"foo": 2}` 顶掉。 +# 同批的 1145(`seed` 重复)经甄别是**等价变异**:删除后仍抛 `ValueError`, +# 只是消息换成 `_validated_extra_body` 那条「不允许覆盖请求字段: seed」,结论不变, +# 故不补测试。 +def _chat_request_provider(**options): + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._protocol = "chat_completions" + provider._provider = "openai" + provider._options = options + return provider + + +@pytest.mark.parametrize( + "reasoning", + [{"effort": "high"}, {"summary": "auto"}], + ids=["with-effort", "without-effort"], +) +def test_openai_chat_request_rejects_reasoning_duplicating_model_options(reasoning): + """显式 ``reasoning`` 与模型 options 里的 ``reasoning`` 重复时必须报错,不能静默覆盖。""" + provider = _chat_request_provider(extra_body={"reasoning": {"configured": True}}) + + with pytest.raises(ValueError, match="reasoning 与模型 options.extra_body 重复"): + provider._build_chat_request(prompt="hi", stream=False, reasoning=reasoning) + + +def test_openai_chat_request_rejects_request_extra_body_overriding_model_options(): + """请求级 ``extra_body`` 与模型 options 的同名键重复时必须报错,不能静默覆盖。""" + provider = _chat_request_provider(extra_body={"foo": 1}) + + with pytest.raises(ValueError, match="extra_body 与模型 options 重复: foo"): + provider._build_chat_request(prompt="hi", stream=False, extra_body={"foo": 2}) + + +def test_openai_chat_request_keeps_distinct_reasoning_and_extra_body(): + """收紧不能误伤:不重复的 ``reasoning`` 与 ``extra_body`` 照常进请求。""" + provider = _chat_request_provider(extra_body={"foo": 1}) + + request = provider._build_chat_request( + prompt="hi", stream=False, reasoning={"effort": "high"}, extra_body={"bar": 2} + ) + + assert request["reasoning_effort"] == "high" + assert request["extra_body"] == {"foo": 1, "bar": 2} + + def test_completion_request_copy_preserves_implicit_temperature_marker(): request = CompletionRequest(prompt="hi") copied = request.copy_with(stream=False) @@ -764,6 +828,84 @@ def test_openai_chat_explicit_extra_body_extensions_cannot_be_silently_overridde provider._build_chat_request(prompt="hi", stream=False, **kwargs) +# `_build_responses_request` 与 `_build_chat_request` 同属一组「同一字段两处配置」 +# 判据,下面五条在整套测试里从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` +# 换成 `pass` 后全量 3216 条仍然全绿)。删除后行为确实改变(实测): +# * 1407 `options.text` 非对象 → 带字段名的 `ValueError` 变成裸 +# `TypeError: 'int' object is not iterable`(`dict(configured_text)`); +# * 1456 显式 `frequency_penalty`/`presence_penalty` 与模型 options 的 +# `extra_body` 重复 → **静默接受**,两处配置谁生效取决于调用顺序; +# * 1596 `background` 非布尔 → **静默接受**,`"yes"` 原样进请求体; +# * 1598 `background=true` 配流式 → **静默接受**,把官方不支持的组合发出去; +# * 1610 请求级 `extra_body` 与模型 options 的同名键重复 → **静默覆盖**。 +# 同函数的 1415(`options.stop`/`seed` 与 extra_body 重复)与 1553 +# (`reasoning_effort` 与 extra_body 重复)经甄别是**等价变异**,不补测试: +# 删除后仍抛 `ValueError`,只是消息换成 `_merge_extra_body`/`_validated_extra_body` +# 那条,结论不变。 +def _responses_request_provider(**options): + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "responses" + provider._base_url = "https://gateway.example.com/v1" + provider._options = options + return provider + + +def test_openai_responses_rejects_non_object_options_text(): + """`options.text` 非对象时必须给带字段名的 ``ValueError``,不是裸 ``TypeError``。""" + provider = _responses_request_provider(text=5) + + with pytest.raises(ValueError, match="options.text 必须是对象"): + provider._build_responses_request(prompt="hi", stream=False) + + +@pytest.mark.parametrize("field", ["frequency_penalty", "presence_penalty"]) +def test_openai_responses_rejects_sampling_field_duplicating_model_extra_body(field): + """显式采样字段与模型 options 的 ``extra_body`` 重复时必须报错,不能静默接受。""" + provider = _responses_request_provider(extra_body={field: 0.5}) + + with pytest.raises(ValueError, match=rf"{field} 与模型 options.extra_body 重复"): + provider._build_responses_request(prompt="hi", stream=False, **{field: 0.5}) + + +@pytest.mark.parametrize("background", ["yes", 1, [], {"a": 1}]) +def test_openai_responses_rejects_non_boolean_background(background): + """``background`` 必须是布尔值,不能把任意取值原样发给服务端。""" + provider = _responses_request_provider() + + with pytest.raises(ValueError, match="background 必须是布尔值"): + provider._build_responses_request(prompt="hi", stream=False, background=background) + + +def test_openai_responses_rejects_background_with_streaming(): + """``background=true`` 与流式互斥,必须在构造阶段拦住。""" + provider = _responses_request_provider() + + with pytest.raises(ValueError, match="background 请求不支持流式模式"): + provider._build_responses_request(prompt="hi", stream=True, background=True) + + +def test_openai_responses_request_extra_body_cannot_override_model_options(): + """请求级 ``extra_body`` 与模型 options 的同名键重复时必须报错,不能静默覆盖。""" + provider = _responses_request_provider(extra_body={"foo": 1}) + + with pytest.raises(ValueError, match="extra_body 与模型 options 重复: foo"): + provider._build_responses_request(prompt="hi", stream=False, extra_body={"foo": 2}) + + +def test_openai_responses_accepts_distinct_extra_body_and_background(): + """收紧不能误伤:不重复的 ``extra_body`` 与非流式 ``background`` 照常进请求。""" + provider = _responses_request_provider(extra_body={"foo": 1}) + + request = provider._build_responses_request( + prompt="hi", stream=False, extra_body={"bar": 2}, background=True + ) + + assert request["extra_body"] == {"foo": 1, "bar": 2} + assert request["background"] is True + + def test_ark_responses_rejects_instructions_with_enabled_caching(): """官方规定 ``instructions`` 与 ``caching={"type": "enabled"}`` 互斥。 @@ -1946,6 +2088,68 @@ def test_google_tool_call_merge_rejects_ambiguous_unidentified_parallel_calls(): ) +# `_merge_tool_call` 有五条「分片指向多个调用/缺少稳定标识」的守卫在整套测试里 +# 从未执行(`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3283 条 +# 仍然全绿)。既有用例 `test_google_tool_call_merge_rejects_ambiguous_unidentified_ +# parallel_calls` 命中的只是「先建一条、再补一条」这条路径,下面三条全绕开了。 +# 删除后行为确实改变(实测): +# * 2303 同 index 已有两条 name 不同的记录 → **静默接受**,两个并行调用的参数 +# 被并进同一条记录; +# * 2341 同 id 已挂在两条不同 key 的记录上 → **静默接受**,同样合并; +# * 2326 index 分片匹配到多条「无 index 且同名」的记录 → **静默接受**。 +# 这三处的后果与源码注释写明的意图正相反:注释明确「绝不能从分片位置合成 +# index——该位置每个 chunk 都会重置,会把并行的独立调用合并在一起」,而合并后 +# 两次工具调用的参数会互相污染。 +# 同批的 2316 与 2376 经甄别是**等价变异**,不补测试:删除后落到相邻守卫,仍抛 +# `RuntimeError` 且消息仍指向同一类问题(实测分别是「index 指向多个调用」与 +# 「无法确认是否为同一调用」),结论不变。 +@pytest.mark.parametrize( + ("existing", "fragment"), + [ + pytest.param( + [ + (("index", 0), {"type": "function", "index": 0, "name": "lookup"}), + (("index", 0, "b"), {"type": "function", "index": 0, "name": "search"}), + ], + {"index": 0, "arguments": {"third": 3}}, + id="same-index-different-names", + ), + pytest.param( + [ + (("id", "call-1"), {"type": "function", "id": "call-1", "name": "lookup"}), + (("index", 7), {"type": "function", "index": 7, "id": "call-1", "name": "search"}), + ], + {"id": "call-1", "arguments": {"third": 3}}, + id="same-id-two-keys", + ), + ], +) +def test_google_tool_call_merge_rejects_fragments_pointing_at_multiple_calls(existing, fragment): + """分片同时匹配多条已累积记录时必须 ``RuntimeError``,不能把两次调用合并成一条。""" + # 手工构造累积状态:走 ``_merge_tool_call`` 建这两条时它们会被同名规则合并, + # 构造不出「两条独立记录」这个前提。 + accumulator = {key: {**value, "arguments": {}} for key, value in existing} + + with pytest.raises(RuntimeError, match="指向多个调用"): + GoogleProvider._merge_tool_call(accumulator, fragment) + + assert len(accumulator) == len(existing) + + +def test_google_tool_call_merge_rejects_index_fragment_matching_multiple_unidentified_records(): + """index 分片匹配到多条「无 index 且同名」的记录时必须报错,不能静默合并。""" + # 同上,手工构造两条同名且都无 index 的记录,模拟上游把它们当作两次独立调用 + accumulator = { + ("position", 0): {"type": "function", "index": None, "name": "lookup", "arguments": {}}, + ("position", 1): {"type": "function", "index": None, "name": "lookup", "arguments": {}}, + } + + with pytest.raises(RuntimeError, match="缺少稳定 id/index"): + GoogleProvider._merge_tool_call(accumulator, {"index": 3, "arguments": {"third": 3}}) + + assert len(accumulator) == 2 + + def test_google_sync_stream_merges_tool_call_when_id_arrives_after_first_chunk(): provider = object.__new__(GoogleProvider) provider._model_name = "gemini" @@ -3658,11 +3862,19 @@ def test_anthropic_extra_body_sampling_conflicts_are_explicit(): def test_anthropic_beta_tool_runner_rejects_unsupported_compaction_control(): + """不支持的 ``compaction_control`` 必须报「SDK 不支持」,不能误报成参数混用。 + + 删除守卫后 ``compaction_control`` 被透传给 ``coerce_completion_request``, + 消息变成「不能同时传 request 和关键字参数」(或「SDK 参数不匹配」)——把 + 「Beta tool_runner 不支持这个能力」说成「你调用的方式不对」,用户改调用形态 + 也解决不了。旧断言只匹配 ``compaction_control`` 子串,两条消息都含它,变异 + 后仍然变绿(变异扫描证实),必须钉住「当前 SDK 不支持」前缀。 + """ provider = object.__new__(AnthropicProvider) provider._model_name = "claude" provider._options = {} - with pytest.raises(ValueError, match="compaction_control"): + with pytest.raises(ValueError, match="当前 SDK 不支持 compaction_control"): provider.beta_tool_runner([], prompt="hi", compaction_control={"enabled": True}) @@ -3679,11 +3891,19 @@ def test_anthropic_output_format_follows_create_and_stream_sdk_shapes(): def test_anthropic_beta_create_rejects_output_format(): + """Beta 入口必须报 Beta 的消息,不能退化成下游普通 ``create`` 的指引。 + + 删除 ``beta_create`` 的前置守卫后落到 ``_build_message_params`` 里的判据, + 消息变成「Anthropic Messages.create 不支持 output_format;请使用 parse 或 + stream。」——指引指向的 ``beta_parse``/``beta_stream`` 被换成普通入口,用户 + 按提示改会再次撞墙。变异扫描证实宽松的 ``match="create 不支持"`` 在变异后 + 仍然变绿(两条消息都含该子串)。 + """ provider = object.__new__(AnthropicProvider) provider._model_name = "claude" provider._options = {} - with pytest.raises(ValueError, match="create 不支持 output_format"): + with pytest.raises(ValueError, match=r"Beta Messages\.create 不支持 output_format"): provider.beta_create(CompletionRequest(prompt="hi", output_format={"type": "json_schema"})) @@ -3700,7 +3920,9 @@ async def create(self, **kwargs): provider._get_aclient = lambda: SimpleNamespace(beta=SimpleNamespace(messages=Messages())) - with pytest.raises(ValueError, match="create 不支持 output_format"): + # 异步侧是**另一处**守卫(async_beta_create 各自判一次),必须单独钉住 Beta + # 消息:同步用例打不到这里,删除后同样退化成下游普通 ``create`` 的指引。 + with pytest.raises(ValueError, match=r"Beta Messages\.create 不支持 output_format"): asyncio.run( provider.async_beta_create( CompletionRequest(prompt="hi", output_format={"type": "json_schema"}) @@ -3793,6 +4015,129 @@ def test_anthropic_modern_models_allow_explicit_legacy_sampling_for_proxies(): assert provider.supports_sampling_option("top_k") is True +# `_normalize_sampling_value` 的三条「必须是数字」判据在整套测试里从未执行 +# (`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3251 条仍全绿)。 +# 既有用例 `test_anthropic_modern_models_omit_deprecated_sampling_defaults` 只喂 +# **合法数字**(`temperature=0.2`/`top_k=5`),所以三条都绕开了。删除后行为确实 +# 改变(实测): +# * 299 `temperature="abc"` → 消息从「Anthropic temperature 必须是数字。」换成 +# 「当前 Claude 模型不支持该 temperature;请省略该字段或使用 1.0。」——把 +# **类型错误**说成**值域错误**,照着提示改的人会把 `abc` 换成 `1.0`,而那与 +# 真正的问题无关; +# * 309 `top_p="abc"` → `to_finite_float` 返回 `None`,`None >= 0.99` 抛裸 +# `TypeError: '>=' not supported between instances of 'NoneType' and 'float'`; +# * 312 `top_p=0.5`(合法数字但低于阈值)→ 落到函数末尾的 top_k 分支,消息变成 +# 「当前 Claude 模型不支持 top_k;请移除该字段。」——用户传的是 `top_p`, +# 报错却说 `top_k`,改错字段。 +@pytest.mark.parametrize("value", ["abc", [1], {"a": 1}, object()]) +def test_anthropic_modern_models_reject_non_numeric_temperature(value): + """`temperature` 非数字时消息要直指「必须是数字」,不能说成「值不受支持」。""" + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-6" + provider._options = {} + + with pytest.raises(ValueError, match="temperature 必须是数字"): + provider._normalize_sampling_value("temperature", value) + + +@pytest.mark.parametrize("value", ["abc", [1], {"a": 1}, object()]) +def test_anthropic_modern_models_reject_non_numeric_top_p(value): + """`top_p` 非数字时给带字段名的 ``ValueError``,不是裸 ``TypeError``。""" + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-6" + provider._options = {} + + with pytest.raises(ValueError, match="top_p 必须是数字"): + provider._normalize_sampling_value("top_p", value) + + +def test_anthropic_modern_models_reject_low_top_p_with_the_right_field_name(): + """`top_p` 低于阈值时消息要指向 `top_p`,不能误报成 `top_k`。""" + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-6" + provider._options = {} + + with pytest.raises(ValueError, match="该 top_p"): + provider._normalize_sampling_value("top_p", 0.5) + + +# `_build_message_params` 的六条「配置与请求参数不冲突」判据在整套测试里从未执行 +# (`sys.settrace` 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3260 条仍全绿)。 +# 删除后行为确实改变(实测,全在 `src/providers/anthropic.py`): +# * 916 `allow_deprecated_sampling="yes"` → **静默接受**,非空字符串为真,等同于 +# 显式打开「转发已废弃采样字段」,与调用方写 `False` 的意图正好相反; +# * 920 `options.response_format` 非对象 → 带字段名的 `ValueError` 变成裸 +# `TypeError: 'int' object is not iterable`; +# * 922/930 `response_format`/`output_format` 同时出现在 options 与请求参数 → +# **静默接受**,请求侧取值被丢弃,两处配置冲突无从察觉; +# * 962 请求级 `extra_body` 与模型 options 的 `extra_body` 有同名键 → **静默覆盖**; +# * 995 `extra_body` 的键与已建好的请求字段(如 `model`)重名 → **静默接受**, +# 该字段会被 `extra_body` 覆盖掉再发给服务端。 +def _anthropic_options_provider(**options): + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-sonnet-4-6" + provider._options = options + return provider + + +@pytest.mark.parametrize("value", ["yes", 1, 0, [], {}]) +def test_anthropic_rejects_non_boolean_allow_deprecated_sampling(value): + """`allow_deprecated_sampling` 必须是布尔值,不能靠真值语义静默生效。""" + provider = _anthropic_options_provider(allow_deprecated_sampling=value) + + with pytest.raises(ValueError, match="allow_deprecated_sampling 必须是布尔值"): + provider._build_message_params(prompt="hi") + + +@pytest.mark.parametrize("value", [5, "json_object", ["a"]]) +def test_anthropic_rejects_non_object_options_response_format(value): + """`options.response_format` 非对象时给带字段名的 ``ValueError``,不是裸 ``TypeError``。""" + provider = _anthropic_options_provider(response_format=value) + + with pytest.raises(ValueError, match="response_format 必须是对象"): + provider._build_message_params(prompt="hi") + + +@pytest.mark.parametrize("field", ["response_format", "output_format"]) +def test_anthropic_rejects_field_configured_in_both_options_and_request(field): + """同一字段同时配在 options 与请求参数时必须报错,不能静默丢掉一侧。""" + request_value = {"type": "json_object"} if field == "response_format" else str + provider = _anthropic_options_provider(**{field: request_value}) + + with pytest.raises(ValueError, match=rf"{field} 与配置项重复"): + provider._build_message_params(prompt="hi", stream=True, **{field: request_value}) + + +def test_anthropic_rejects_request_extra_body_overriding_configured_extra_body(): + """请求级 `extra_body` 与模型 options 的 `extra_body` 同名键必须报错,不能静默覆盖。""" + provider = _anthropic_options_provider(extra_body={"temperature": 0.5}) + + with pytest.raises(ValueError, match="extra_body 不允许覆盖配置字段: temperature"): + provider._build_message_params(prompt="hi", extra_body={"temperature": 0.9}) + + +def test_anthropic_rejects_extra_body_overriding_a_request_field(): + """`extra_body` 的键与已建好的请求字段重名时必须报错,不能静默顶掉该字段。""" + provider = _anthropic_options_provider(extra_body={"model": "other-model"}) + + with pytest.raises(ValueError, match="extra_body 不允许覆盖请求字段: model"): + provider._build_message_params(prompt="hi") + + +def test_anthropic_accepts_distinct_configured_and_request_values(): + """收紧不能误伤:不冲突的 options/请求参数组合照常构造请求。""" + provider = _anthropic_options_provider(extra_body={"metadata": {"team": "search"}}) + + params = provider._build_message_params( + prompt="hi", response_format={"type": "json_object"}, extra_body={"seed": 7} + ) + + assert params["output_config"] == { + "format": {"type": "json_schema", "schema": {"type": "object"}} + } + assert params["extra_body"] == {"metadata": {"team": "search"}, "seed": 7} + + def test_anthropic_default_max_tokens_scales_with_model_family(): provider = object.__new__(AnthropicProvider) provider._options = {} @@ -4049,6 +4394,28 @@ def connect(self, *args, **kwargs): provider.resources.responses.connect(*args) +def test_call_sdk_resource_contracts_signature_mismatch_to_valueerror(): + """签名 ``bind`` 失败必须收敛成带渠道名的 ``ValueError``,不能透出裸 ``TypeError``。 + + 删除守卫后未知关键字参数直接打到 ``method(...)``,抛的是裸 + ``TypeError: ... unexpected keyword argument``——与本模块承诺的 + 「SDK 参数不匹配」错误分类不一致。变异扫描证实该守卫经 + ``_call_sdk_resource`` 真实转发路径没有反向覆盖。 + """ + + def method(a, b): + return a, b + + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "openai" + + with pytest.raises(ValueError, match=r"chat\.create SDK 参数不匹配: "): + provider._call_sdk_resource(method, (), {"zzz": 1}, "chat.create") + + # 收紧不能误伤:签名匹配的调用照常转发 + assert provider._call_sdk_resource(method, (), {"a": 1, "b": 2}, "chat.create") == (1, 2) + + def test_direct_openai_resource_call_rejects_sensitive_positional_arguments(): provider = object.__new__(OpenAIProvider) provider._provider = "openai" @@ -4094,6 +4461,109 @@ def get(self, *, id): provider.get_interaction("interaction-1", id="interaction-2") +# ``_guard_provider_resource_method`` 把 SDK 抛出的 ``TypeError`` 收敛成带渠道名 +# 与原始消息的 ``ValueError``,分三个子类:unexpected keyword / multiple values / +# missing required;同步与异步各有一份(2016/2020/2024 与 2046/2048/2050)。 +# 既有用例只钉住**同步**的「multiple values」那一条,其余五条在整套测试里从未 +# 执行(行覆盖证实;把它们换成 ``pass`` 后全量 3397 条仍全绿)。 +# +# 删除后收场从 ``ValueError`` 退化成裸 ``TypeError``:调用方的错误分类按 +# ``ValueError`` 写,而 SDK 的 ``TypeError`` 文本只说「哪个参数不对」,没有 +# 渠道名——排查时无法定位到是哪个 provider 的资源方法。 + + +def _resource_guard_sync_provider(sdk_call): + class Interactions: + def get(self, *, id, **kwargs): + return sdk_call(id=id, **kwargs) + + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini" + provider._provider = "test" + provider._get_client = lambda: SimpleNamespace(interactions=Interactions()) + return provider + + +def _resource_guard_async_provider(sdk_call): + class Caches: + async def get(self, *, name, **kwargs): + return sdk_call(name=name, **kwargs) + + class Aio: + caches = Caches() + + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini" + provider._provider = "test" + provider._get_client = lambda: SimpleNamespace(aio=Aio()) + return provider + + +def _unexpected_keyword(**_kwargs): + raise TypeError("get() got an unexpected keyword argument 'tenant'") + + +def _duplicate_argument(id=None, **_kwargs): + raise TypeError("get() got multiple values for argument 'id'") + + +def _missing_required_argument(**_kwargs): + raise TypeError("get() missing 1 required positional argument: 'name'") + + +_RESOURCE_GUARD_CASES = [ + pytest.param(_unexpected_keyword, "不匹配", id="unexpected-keyword"), + pytest.param(_duplicate_argument, "重复", id="duplicate-argument"), + pytest.param(_missing_required_argument, "不匹配", id="missing-required"), +] + + +@pytest.mark.parametrize(("sdk_call", "expected"), _RESOURCE_GUARD_CASES) +def test_resource_guard_reports_sync_type_errors_readably(sdk_call, expected): + """同步资源入口的 ``TypeError`` 必须收敛成带渠道名的 ``ValueError``。""" + provider = _resource_guard_sync_provider(sdk_call) + + with pytest.raises(ValueError, match=f"test 资源 SDK 参数{expected}"): + provider.get_interaction("interaction-1", extra_body={"tenant": "t"}) + + +@pytest.mark.parametrize(("sdk_call", "expected"), _RESOURCE_GUARD_CASES) +def test_resource_guard_reports_async_type_errors_readably(sdk_call, expected): + """异步资源入口是**另一份包装器**,三条判据同样必须生效。 + + 同步侧的用例打不到这里——``async_wrapper`` 与 ``wrapper`` 是两个独立的闭包。 + """ + provider = _resource_guard_async_provider(sdk_call) + + async def run(): + return await provider.async_get_cache("caches/1", extra_body={"tenant": "t"}) + + with pytest.raises(ValueError, match=f"test 资源 SDK 参数{expected}"): + asyncio.run(run()) + + +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +def test_resource_guard_keeps_unrelated_type_errors_intact(async_mode): + """反向守卫:与参数无关的 ``TypeError`` 必须原样透出,不能被误分类。 + + 守卫按消息内容分类;一条与 SDK 参数无关的 ``TypeError`` 若被收敛成 + 「资源 SDK 参数不匹配」,排查方向会被带偏。 + """ + + def unrelated(**_kwargs): + raise TypeError("unsupported operand type(s) for +: 'int' and 'str'") + + if async_mode: + provider = _resource_guard_async_provider(unrelated) + coroutine = provider.async_get_cache("caches/1", extra_body={"tenant": "t"}) + with pytest.raises(TypeError, match="unsupported operand"): + asyncio.run(coroutine) + else: + provider = _resource_guard_sync_provider(unrelated) + with pytest.raises(TypeError, match="unsupported operand"): + provider.get_interaction("interaction-1", extra_body={"tenant": "t"}) + + def test_resource_argument_validator_keeps_nested_business_values_isolated(): original = {"query": {"text": "hello"}} validated = validate_secret_free_resource_args((original,), "Provider") @@ -6177,6 +6647,139 @@ def test_responses_input_still_requires_tool_call_id(): ) +@pytest.mark.parametrize("bad_tool_call", [5, "oops", ["x"]]) +def test_chat_tool_call_normalization_rejects_non_mapping_entries(bad_tool_call): + """``tool_calls`` 的元素必须是对象,非 Mapping 不能走到 ``dict()`` 或迭代。 + + 删除该守卫后,整数元素抛裸 ``TypeError: 'int' object is not iterable``, + 字符串元素抛 ``dictionary update sequence element #0 has length 1``—— + 两者都与「工具调用写错了」无关,且 ``TypeError`` 会穿透只捕获 ``ValueError`` + 的调用方。 + """ + with pytest.raises(ValueError, match=r"必须是对象"): + normalize_chat_messages( + [{"role": "assistant", "content": "", "tool_calls": [bad_tool_call]}] + ) + + +def test_chat_tool_call_normalization_rejects_non_mapping_function_field(): + """嵌套 ``function`` 非对象时必须报错,不能静默改用顶层同名字段。 + + 输入里顶层 ``name``/``arguments`` 都合法,只有 ``function`` 是字符串。 + 删除守卫后本函数会退回 ``normalized`` 取值并正常返回,把一个明显畸形的 + 工具调用当作有效请求发出。 + """ + with pytest.raises(ValueError, match=r"\.function 必须是对象"): + normalize_chat_messages( + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "type": "function", + "name": "n", + "arguments": "{}", + "function": "bad", + } + ], + } + ] + ) + + +def test_responses_function_call_item_rejects_non_mapping_function_field(): + """Responses 原生 ``function_call`` 的嵌套 ``function`` 同样要校验。 + + 这条守卫与 ``_chat_tool_call_item`` 里的是**两份独立代码**(969 与 384), + Chat 路径的测试杀不掉它。删除后原生 item 分支静默接受,把字符串 ``function`` + 丢弃、改用顶层字段拼出请求。 + """ + with pytest.raises(ValueError, match=r"\.function 必须是对象"): + normalize_responses_input( + None, + None, + [ + { + "role": "assistant", + "type": "function_call", + "call_id": "c1", + "name": "n", + "arguments": "{}", + "function": "bad", + } + ], + ) + + +def test_responses_function_call_item_requires_function_name(): + """原生 ``function_call`` 缺函数名必须在请求前报错。 + + 删除守卫后返回 ``name=None`` 的 item,把无名调用发给 Responses。 + """ + with pytest.raises(ValueError, match="缺少函数名"): + normalize_responses_input( + None, + None, + [ + { + "role": "assistant", + "type": "function_call", + "call_id": "c1", + "arguments": "{}", + } + ], + ) + + +@pytest.mark.parametrize( + "message", + [ + { + "role": "assistant", + "type": "function_call", + "call_id": "c1", + "name": "n", + "arguments": {"k": object()}, + }, + {"type": "function_call_output", "call_id": "c1", "output": {"k": object()}}, + ], +) +def test_responses_json_text_rejects_unserializable_payload(message): + """工具参数与输出必须可 JSON 序列化,否则 ``json.dumps`` 会抛裸 ``TypeError``。 + + 删除守卫后异常不再收敛为带 ``location`` 的 ``ValueError``;实测在 + ``_responses_json_text`` 内部先撞 ``TypeError``,调用方只捕获 ``ValueError`` + 时无法给出「哪个字段写错了」的消息。 + """ + with pytest.raises(ValueError, match="必须可序列化为 JSON"): + normalize_responses_input(None, None, [message]) + + +def test_validate_complete_tool_call_requires_name_in_stream_events(): + """流式工具调用在发出 completed 事件前必须带函数名。 + + ``validate_complete_tool_call`` 的 name 判据是流式两条路径(Chat Completions + 与 Responses)共用的第一道闸。删除后无名调用被当作完整事件交付。 + """ + finish = StreamEvent(type="finish", finish_reason="tool_calls", usage={}) + with pytest.raises(RuntimeError, match="缺少 name"): + OpenAICompatibleProvider._chat_completion_events( + finish, + {0: {"id": "t1", "type": "function", "arguments": "{}"}}, + set(), + ) + with pytest.raises(RuntimeError, match="缺少 name"): + OpenAICompatibleProvider._responses_completion_event( + StreamEvent( + type="tool_call_completed", + tool_call={"id": "t1", "type": "function", "arguments": "{}"}, + ), + {}, + ) + + @pytest.mark.parametrize( "model_name", [ @@ -7534,3 +8137,2906 @@ def test_ark_responses_accepts_ark_only_fields(): ) assert converted[0]["image_pixel_limit"] == {"m": 1} + + +# --------------------------------------------------------------------------- +# 客户端级 max_retries +# --------------------------------------------------------------------------- +# +# ``timeout`` 与 ``max_retries`` 是 openai SDK 的**客户端构造参数**,本项目把它们 +# 从 ``options`` 直接透传给 ``openai.OpenAI(...)``(见 ``_get_client``)。SDK 自己 +# 不做任何校验,于是 ``options`` 里的取值可以造成两类失败: +# +# 1. ``max_retries`` 为**负数**:SDK 的重试循环是 ``range(max_retries + 1)``, +# 负数让循环体一次都不执行,请求根本没发出去,最后抛的是 SDK 内部的 +# ``AssertionError: could not resolve response (should never happen)``—— +# 实测 ``max_retries=-1`` 时请求数为 **0**。 +# 2. ``max_retries`` 为**超大值**(``10**400`` 这类,``config.toml`` 的 TOML +# 整数无上界):等价于无上限重试,网络故障下会一直重试下去。 +# +# 非整数(``"abc"``/``1.5``/``True``)则是第三种:SDK 把它们存进 +# ``self.max_retries``,直到真正重试时才在 ``max_retries - retries_taken`` 上 +# 抛 ``TypeError``,报错点离配置处很远。 +# +# Ark 渠道的 ``_normalize_client_options`` 早就校验了 ``max_retries``(要求 +# ``>= 0`` 的整数),OpenAI 兼容渠道漏了同一道。这里补齐,并给上界——重试次数 +# 是配置项,不是可以取到天文数字的量。 +_CLIENT_OPTION_PROVIDER = "qwen" +_CLIENT_OPTION_SETTINGS = SimpleNamespace( + qwen_api_key="key", + qwen_base_url="https://qwen.invalid/v1", +) + + +def _build_client_option_provider(**options): + return OpenAICompatibleProvider( + "demo", _CLIENT_OPTION_PROVIDER, settings=_CLIENT_OPTION_SETTINGS, options=options + ) + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(-1, id="negative"), + pytest.param(-(10**400), id="negative-beyond-double-range"), + pytest.param(10**400, id="400digits-over-upper-bound"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param(True, id="bool"), + pytest.param(1.5, id="float"), + pytest.param("abc", id="non-numeric-string"), + pytest.param("", id="empty-string"), + ], +) +def test_openai_compatible_client_max_retries_must_be_bounded_non_negative_int(bad_value): + """``options.max_retries`` 必须在构造期收敛成有上界的非负整数。""" + with pytest.raises(ValueError, match="max_retries"): + _build_client_option_provider(max_retries=bad_value) + + +@pytest.mark.parametrize( + ("good_value", "expected"), + [ + (0, 0), + (1, 1), + (10, 10), + (100, 100), + # 数字字符串必须继续接受:``.env`` 与 TOML 来源本来就以字符串形态到达, + # 且 Ark 渠道的同一套判据也接受 ``"5"``。 + ("5", 5), + (5.0, 5), + ], +) +def test_openai_compatible_client_max_retries_accepts_valid_values(good_value, expected): + provider = _build_client_option_provider(max_retries=good_value) + assert provider._client_options()["max_retries"] == expected + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(-1, id="negative"), + pytest.param(0, id="zero"), + pytest.param(10**400, id="400digits-beyond-double-range"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param(True, id="bool"), + pytest.param("abc", id="non-numeric-string"), + ], +) +def test_openai_compatible_client_timeout_must_be_finite_positive(bad_value): + """``options.timeout`` 是客户端构造参数,同样要在构造期拒绝非法取值。 + + 原实现把 ``timeout`` 原样透传给 ``openai.OpenAI``,而 SDK 不校验:实测 + ``timeout="abc"``、``timeout=-1``、``timeout=0`` 全部被接受并存进 + ``client.timeout``,直到 httpx 层才以形态各异的错误暴露。 + """ + with pytest.raises(ValueError, match="timeout"): + _build_client_option_provider(timeout=bad_value) + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(101, id="just-over-upper-bound"), + pytest.param(10**400, id="400digits-over-upper-bound"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param(-1, id="negative"), + pytest.param(True, id="bool"), + pytest.param(1.5, id="float"), + pytest.param("abc", id="non-numeric-string"), + ], +) +def test_ark_client_max_retries_must_be_bounded_non_negative_int(bad_value): + """Ark 的 ``max_retries`` 与 OpenAI 兼容渠道走同一套判据。 + + 此前的内联实现只要求「``>= 0`` 的整数」,**没有上界**:实测 + ``max_retries = 10**400`` 被原样接受并传给 Ark SDK,而 SDK 的重试循环按该值 + 决定次数,等价于无限重试。 + """ + with pytest.raises(ValueError, match="max_retries"): + VolcengineProvider._normalize_client_options({"max_retries": bad_value}) + + +@pytest.mark.parametrize( + ("good_value", "expected"), + [(0, 0), (1, 1), (100, 100), ("5", 5), (5.0, 5)], +) +def test_ark_client_max_retries_accepts_valid_values(good_value, expected): + normalized = VolcengineProvider._normalize_client_options({"max_retries": good_value}) + assert normalized["max_retries"] == expected + + +# --------------------------------------------------------------------------- +# Anthropic 的客户端级 timeout / max_retries +# --------------------------------------------------------------------------- +# +# 与 OpenAI 兼容渠道同一类问题:``timeout``/``max_retries`` 被原样透传给 +# ``anthropic.Anthropic(...)``,SDK 不做任何校验。Anthropic SDK 的重试循环同样是 +# ``for retries_taken in range(max_retries + 1)``,因此 ``max_retries=-1`` 时循环体 +# 一次都不执行——实测 HTTP 请求数为 **0**,最终抛 +# ``RuntimeError: could not resolve response (should never happen)``。 +_ANTHROPIC_CLIENT_OPTION_SETTINGS = SimpleNamespace(anthropic_api_key="sk-ant-test") + + +def _patch_anthropic_settings(monkeypatch) -> None: + """把 ``get_settings`` 打到 ``AnthropicProvider`` 真正读取的那个模块字典上。 + + 不能用字符串形式 ``monkeypatch.setattr("src.providers.anthropic.get_settings", ...)``: + ``tests/providers/test_factory.py`` 会从 ``sys.modules`` 里删除 + ``src.providers.anthropic`` 再重新导入,于是字符串形式解析到的是**新**模块对象, + 而 ``AnthropicProvider.__init__`` 仍从**旧**模块字典取 ``get_settings``——patch + 落空,构造器读到真实配置(无 key)后抛 ``ANTHROPIC_API_KEY is required``。 + 单独跑本文件时不会触发,只有全量运行才暴露。 + """ + module_globals = AnthropicProvider.__init__.__globals__ + monkeypatch.setitem(module_globals, "get_settings", lambda: _ANTHROPIC_CLIENT_OPTION_SETTINGS) + + +def _build_anthropic_provider(**options): + return AnthropicProvider("claude-sonnet-4-6", options=options or None) + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(101, id="just-over-upper-bound"), + pytest.param(10**400, id="400digits-over-upper-bound"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param(-1, id="negative"), + pytest.param(True, id="bool"), + pytest.param(1.5, id="float"), + pytest.param("abc", id="non-numeric-string"), + ], +) +def test_anthropic_client_max_retries_must_be_bounded_non_negative_int(monkeypatch, bad_value): + _patch_anthropic_settings(monkeypatch) + with pytest.raises(ValueError, match="max_retries"): + _build_anthropic_provider(max_retries=bad_value) + + +@pytest.mark.parametrize( + ("good_value", "expected"), + [(0, 0), (1, 1), (100, 100), ("5", 5)], +) +def test_anthropic_client_max_retries_accepts_valid_values(monkeypatch, good_value, expected): + _patch_anthropic_settings(monkeypatch) + provider = _build_anthropic_provider(max_retries=good_value) + assert provider._normalized_client_options["max_retries"] == expected + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(-1, id="negative"), + pytest.param(0, id="zero"), + pytest.param(10**400, id="400digits-beyond-double-range"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param(True, id="bool"), + pytest.param("abc", id="non-numeric-string"), + ], +) +def test_anthropic_client_timeout_must_be_finite_positive(monkeypatch, bad_value): + _patch_anthropic_settings(monkeypatch) + with pytest.raises(ValueError, match="timeout"): + _build_anthropic_provider(timeout=bad_value) + + +def test_anthropic_client_accepts_httpx_timeout_object(monkeypatch): + """``httpx.Timeout`` 形态必须保留——SDK 接受它,且无法用 TOML 表达。""" + import httpx2 + + _patch_anthropic_settings(monkeypatch) + provider = _build_anthropic_provider(timeout=httpx2.Timeout(5.0)) + assert hasattr(provider._normalized_client_options["timeout"], "connect") + + +# ── 回归:客户端 timeout 的**上界**与派生值 ── +# +# 四个渠道原本只判「有限正数」,没有上界。失效点不在超时值本身,而在派生值: +# Google 是唯一把秒换算成毫秒再交给 SDK 的渠道(``_build_http_options`` 的 +# ``int(timeout * 1000)``),超过 ``DBL_MAX/1000`` 的秒数让该派生抛 +# ``OverflowError``——它是 ``ArithmeticError`` 的子类,**不是** ``ValueError``, +# 穿透项目全部以 ``ValueError`` 为契约的 handler,以裸 traceback 终止会话。 +# 实测 ``config.toml`` 写 ``timeout = 1e306`` 即可复现:配置加载成功、provider +# 构造成功,首次提问才炸。 + +_BEYOND_MILLISECOND_RANGE = [ + pytest.param(1e306, id="306exponent"), + pytest.param(1e308, id="308exponent"), + pytest.param(86_401.0, id="just-over-one-day"), +] + + +@pytest.mark.parametrize("bad_value", _BEYOND_MILLISECOND_RANGE) +def test_google_request_timeout_has_upper_bound(bad_value): + """``options.timeout`` 必须在上界内,否则毫秒派生值会抛 OverflowError。""" + provider = object.__new__(GoogleProvider) + provider._model_name = "gemini-test" + provider._options = {} + with pytest.raises(ValueError, match="不能超过"): + provider._build_generation_config("system", None, 0.2, timeout=bad_value) + + +@pytest.mark.parametrize("bad_value", _BEYOND_MILLISECOND_RANGE) +def test_openai_compatible_client_timeout_has_upper_bound(bad_value): + with pytest.raises(ValueError, match="不能超过"): + _build_client_option_provider(timeout=bad_value) + + +@pytest.mark.parametrize("bad_value", _BEYOND_MILLISECOND_RANGE) +def test_anthropic_client_timeout_has_upper_bound(monkeypatch, bad_value): + _patch_anthropic_settings(monkeypatch) + with pytest.raises(ValueError, match="不能超过"): + _build_anthropic_provider(timeout=bad_value) + + +def test_ark_client_timeout_has_upper_bound(): + with pytest.raises(ValueError, match="不能超过"): + VolcengineProvider._normalize_client_options({"timeout": 1e306}) + + +def test_google_http_options_timeout_rejects_beyond_millisecond_range(): + """``options.http_options.timeout`` 是毫秒,同样需要上界。 + + 此前判据只要求「正整数毫秒」,实测 ``1e308`` 能通过,到真实 + ``genai.Client(...)`` 构造时才抛 pydantic 的 + ``Unable to parse input string as an integer``——错误消息与 Google 配置 + 毫无关联。 + """ + with pytest.raises(ValueError, match="正整数毫秒"): + GoogleProvider._validate_http_options({"timeout": 1e308}) + + +@pytest.mark.parametrize("good_value", [1, 30.0, "60"]) +def test_client_timeout_accepts_values_within_bound(good_value): + """上界之内的正常取值必须继续接受(Ark 渠道的字符串形态也在其中)。""" + assert VolcengineProvider._normalize_client_options({"timeout": good_value})["timeout"] > 0 + + +# ── 回归:Ark ``count_tokens`` 与其余七个 token 计数入口同契约 ── +# +# 八个 provider 的 token 计数入口都已改用 ``require_token_count``,只有 Ark 的 +# 同步/异步 ``count_tokens`` 漏了,写的是 ``int(field(item, "total_tokens", 0))``: +# ``"abc"`` 抛裸 ``ValueError``、``None`` 抛裸 ``TypeError``、位数超限的数字串抛 +# 与 token 计数无关的位数错误,且消息里连字段名都没有;``0`` 默认值还把 +# 「响应不完整」静默算成「0 个 token」。 + + +def _ark_provider_with_tokenization_response(items): + """构造只带假 tokenization 资源的 Ark provider。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._options = {} + provider._get_client = lambda: SimpleNamespace( + tokenization=SimpleNamespace( + create=lambda **kwargs: SimpleNamespace(data=items), + ) + ) + return provider + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param("abc", id="non-numeric-string"), + pytest.param(None, id="missing-field"), + pytest.param(1.5, id="fractional"), + pytest.param(-5, id="negative"), + pytest.param("9" * 4301, id="4301digits-over-int-max-str-digits"), + ], +) +def test_ark_count_tokens_reports_field_name_for_bad_values(bad_value): + provider = _ark_provider_with_tokenization_response([SimpleNamespace(total_tokens=bad_value)]) + with pytest.raises(RuntimeError, match="total_tokens"): + provider.count_tokens("hello") + + +def test_ark_count_tokens_sums_valid_values(): + provider = _ark_provider_with_tokenization_response( + [SimpleNamespace(total_tokens=3), SimpleNamespace(total_tokens=4)] + ) + assert provider.count_tokens("hello") == 7 + + +# ── 回归:不可哈希取值不得让集合成员守卫抛 TypeError ── +# +# ``field(x, "type") in {"a", "b"}`` 对 ``list``/``dict`` 抛 +# ``TypeError: unhashable type``,而调用方按 ``ValueError``/``RuntimeError`` +# 分类错误。这些值来自网关 JSON:openai SDK 对 SSE chunk 的未知顶层字段用 +# ``extra="allow"`` 原样保留,实测一个 ``{"type": ["error"]}`` 就能构造出来, +# 于是「网关返回了形态异常的事件」变成裸 ``TypeError``。 + + +@pytest.mark.parametrize( + "bad_value", + [pytest.param(["error"], id="list"), pytest.param({"a": 1}, id="dict")], +) +def test_responses_stream_event_type_must_not_raise_type_error(bad_value): + """非字符串 ``type`` 按「未知事件」处理,不得抛 TypeError。""" + assert raise_for_stream_error_event({"type": bad_value}, "openai") is None + + +@pytest.mark.parametrize( + "bad_value", + [pytest.param(["auto"], id="list"), pytest.param({"a": 1}, id="dict")], +) +@pytest.mark.parametrize("part_type", ["input_image", "input_file"]) +def test_responses_content_part_detail_must_not_raise_type_error(part_type, bad_value): + """``detail`` 非字符串时必须报 ``ValueError``,而不是 ``TypeError``。""" + part = {"type": part_type, "detail": bad_value} + if part_type == "input_image": + part["image_url"] = "https://x/y.png" + else: + part["file_data"] = "data:x" + with pytest.raises(ValueError, match="detail"): + _responses_content_part(part, "input[0]", provider="openai") + + +# `_responses_content_part` 的二十六条形态判据在整套测试里从未执行(`sys.settrace` +# 行覆盖证实;把它们的 `raise` 换成 `pass` 后全量 3286 条仍然全绿)。既有用例 +# `test_responses_content_part_detail_must_not_raise_type_error` 只钉住两个分支里 +# 的 `detail` 成员判据,其余形态判据全绕开了。删除后行为确实改变(实测,全在 +# `src/providers/__base__/model_provider.py`):**二十五条静默接受**——畸形取值 +# 原样进 `converted` 字典再发给上游(如 `text: 5`、`file_id: 5`、`detail: "ultra"`、 +# 整块缺 URI 却产出 `{"type": "input_video"}`);**两条变成裸 `TypeError`**—— +# `dict(...)` 对非映射取值抛 `TypeError: 'int' object is not iterable`。 +# 最值得记的是四条 `prompt_cache_breakpoint` 守卫(700/810/858/901):它们 +# 拦住的是「把 OpenAI 专属字段发给 Ark SDK」——删掉后 Ark 侧静默丢弃该字段, +# 而调用方以为缓存已生效。 +_DATA_PNG = "data:image/png;base64,AAA" + + +@pytest.mark.parametrize( + ("part", "provider", "expected"), + [ + pytest.param(5, "openai", r"必须是字符串或对象", id="part-not-str-or-object"), + pytest.param( + {"type": "text", "text": 5}, "openai", r"\.text 必须是字符串", id="text-not-string" + ), + pytest.param( + {"type": "text", "text": "a", "prompt_cache_breakpoint": 1}, + "ark", + "prompt_cache_breakpoint 不受 Ark", + id="text-pcb-on-ark", + ), + pytest.param( + {"type": "input_audio", "audio_url": "https://x/a.wav", "file_id": 5}, + "ark", + r"\.file_id 必须是字符串", + id="audio-file-id-not-string", + ), + pytest.param( + {"type": "input_audio", "audio_url": "https://x/a.wav", "chunking_strategy": 5}, + "ark", + r"chunking_strategy 必须是对象", + id="audio-chunking-not-mapping", + ), + pytest.param( + {"type": "input_video", "video_url": {"url": 5}}, + "ark", + r"video_url\.url 必须是字符串", + id="video-url-not-string", + ), + pytest.param( + {"type": "input_video", "video_url": {"url": "https://x/v.mp4"}, "file_id": 5}, + "ark", + r"\.file_id 必须是字符串", + id="video-file-id-not-string", + ), + pytest.param( + {"type": "input_video", "video_url": {}}, + "ark", + "缺少 video_url 或 file_id", + id="video-missing-url-and-file-id", + ), + pytest.param( + {"type": "image_url", "image_url": 5}, + "openai", + r"\.image_url 必须是字符串或对象", + id="image-url-not-str-or-object", + ), + pytest.param( + {"type": "image_url", "image_url": {"url": 5}}, + "openai", + r"image_url\.url 必须是字符串", + id="image-url-value-not-string", + ), + pytest.param( + {"type": "image_url", "file_id": 5}, + "openai", + r"\.file_id 必须是字符串", + id="image-file-id-not-string", + ), + pytest.param( + {"type": "image_url", "image_url": _DATA_PNG, "detail": "ultra"}, + "openai", + r"\.detail 必须是", + id="image-detail-not-allowed", + ), + pytest.param( + {"type": "image_url", "image_url": _DATA_PNG, "image_pixel_limit": {"min_pixels": 1}}, + "openai", + "image_pixel_limit 不受 OpenAI", + id="image-pixel-limit-on-openai", + ), + pytest.param( + {"type": "image_url", "image_url": _DATA_PNG, "image_pixel_limit": 5}, + "ark", + r"image_pixel_limit 必须是对象", + id="image-pixel-limit-not-mapping", + ), + pytest.param( + {"type": "image_url", "image_url": _DATA_PNG, "prompt_cache_breakpoint": 1}, + "ark", + "prompt_cache_breakpoint 不受 Ark", + id="image-pcb-on-ark", + ), + pytest.param( + {"type": "input_image", "image_url": 5}, + "openai", + r"\.image_url 必须是字符串", + id="input-image-url-not-string", + ), + pytest.param( + {"type": "input_image", "image_url": _DATA_PNG, "file_id": 5}, + "openai", + r"\.file_id 必须是字符串", + id="input-image-file-id-not-string", + ), + pytest.param( + {"type": "input_image"}, + "openai", + "缺少 image_url 或 file_id", + id="input-image-missing-url-and-file-id", + ), + pytest.param( + {"type": "input_image", "image_url": _DATA_PNG, "image_pixel_limit": 5}, + "ark", + r"image_pixel_limit 必须是对象", + id="input-image-pixel-limit-not-mapping", + ), + pytest.param( + {"type": "input_image", "image_url": _DATA_PNG, "prompt_cache_breakpoint": 1}, + "ark", + "prompt_cache_breakpoint 不受 Ark", + id="input-image-pcb-on-ark", + ), + pytest.param( + {"type": "file", "file": 5}, + "openai", + r"\.file 必须是字符串或对象", + id="file-not-str-or-object", + ), + pytest.param( + {"type": "file"}, + "openai", + "缺少 file_id、file_data 或 file_url", + id="file-missing-all-three", + ), + pytest.param( + {"type": "file", "file_id": 5}, + "openai", + r"\.file_id 必须是字符串", + id="file-id-not-string", + ), + pytest.param( + {"type": "file", "file_id": "f", "detail": "auto"}, + "ark", + r"\.detail 不受 Ark", + id="file-detail-on-ark", + ), + pytest.param( + {"type": "file", "file_id": "f", "prompt_cache_breakpoint": 1}, + "ark", + "prompt_cache_breakpoint 不受 Ark", + id="file-pcb-on-ark", + ), + pytest.param( + {"type": "input_file", "file_id": 5}, + "openai", + r"\.file_id 必须是字符串", + id="input-file-id-not-string", + ), + pytest.param( + {"type": "input_file", "file_id": "f", "prompt_cache_breakpoint": 1}, + "ark", + "prompt_cache_breakpoint 不受 Ark", + id="input-file-pcb-on-ark", + ), + ], +) +def test_responses_content_part_rejects_malformed_blocks(part, provider, expected): + """内容块形态非法时必须给带字段名的 ``ValueError``,不能静默接受或抛裸 ``TypeError``。""" + with pytest.raises(ValueError, match=expected): + _responses_content_part(part, "input[0]", provider=provider) + + +@pytest.mark.parametrize( + ("part", "provider"), + [ + pytest.param({"type": "text", "text": "hi"}, "openai", id="text"), + pytest.param( + {"type": "text", "text": "hi", "prompt_cache_breakpoint": True}, + "openai", + id="text-pcb-openai", + ), + pytest.param( + { + "type": "input_audio", + "audio_url": "https://x/a.wav", + "chunking_strategy": {"size": 1}, + }, + "ark", + id="audio-chunking", + ), + pytest.param({"type": "input_video", "video_url": "https://x/v.mp4"}, "ark", id="video"), + pytest.param( + {"type": "image_url", "image_url": _DATA_PNG, "detail": "original"}, + "openai", + id="image", + ), + pytest.param( + {"type": "image_url", "image_url": _DATA_PNG, "image_pixel_limit": {"min_pixels": 1}}, + "ark", + id="image-pixel-limit-ark", + ), + pytest.param({"type": "input_image", "image_url": _DATA_PNG}, "openai", id="input-image"), + pytest.param({"type": "file", "file_id": "file-1"}, "openai", id="file"), + pytest.param( + {"type": "file", "file_url": "https://x/f.pdf", "detail": "high"}, + "openai", + id="file-detail", + ), + pytest.param({"type": "input_file", "file_id": "file-1"}, "openai", id="input-file"), + ], +) +def test_responses_content_part_accepts_well_formed_blocks(part, provider): + """收紧不能误伤:各分支的合法内容块照常转换。""" + converted = _responses_content_part(dict(part), "input[0]", provider=provider) + + assert converted["type"].startswith("input_") + + +def test_responses_stream_delta_survives_unhashable_event_type(): + """网关 SSE chunk 的 ``type`` 是 list 时不得抛 TypeError。 + + openai SDK 对未知顶层字段用 ``extra="allow"`` 原样保留,因此 + ``{"type": ["error"]}`` 会以 list 形态到达 ``_stream_delta``。 + """ + chunk = SimpleNamespace(type=["error"], delta="") + assert OpenAICompatibleProvider._stream_delta(chunk) == "" + + +# ── 回归:错误消息插值不得因超大整数改抛位数错误 ── +# +# ``f"{10**4300}"`` 抛 ``ValueError: Exceeds the limit (4300 digits)``。 +# 这些字段(``status``/``code``/``message``)来自网关 JSON,而 openai SDK 能 +# 解析出 4300 位的整数——于是「网关返回了业务错误」变成「整数转字符串超限」。 + + +@pytest.mark.parametrize( + "field_name", + [ + pytest.param("status", id="status"), + pytest.param("code", id="code"), + pytest.param("error_code", id="error_code"), + pytest.param("message", id="message"), + ], +) +def test_chat_response_error_message_survives_huge_integer_field(field_name): + response = SimpleNamespace(choices=None, **{field_name: 10**4300}) + with pytest.raises(RuntimeError, match="Chat Completions"): + OpenAICompatibleProvider._raise_for_chat_response_error(response) + + +def test_chat_response_error_status_still_compares_numerically(): + """``status`` 仍须参与 ``>= 400`` 的数值比较,不能被收敛成空串。""" + response = SimpleNamespace(choices=None, status=503) + with pytest.raises(RuntimeError, match="status=503"): + OpenAICompatibleProvider._raise_for_chat_response_error(response) + + +# ── 回归:Embedding ``options.dimensions`` 必须校验 ── +# +# 这个值从 ``config.toml`` 直达请求体,此前**完全没有校验**:``-1``/``0``/ +# ``"abc"``/``1.5``/``True`` 全部原样发给远端;``10**4301`` 在 ``json.dumps`` +# 编码请求体时才抛位数错误。项目里 ``_MAX_EMBEDDING_DIMENSION`` 早就是这个量 +# 的上界(``local_hash`` 与 ``embedding_service`` 都在用)。 + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(-1, id="negative"), + pytest.param(0, id="zero"), + pytest.param(10**400, id="400digits-over-upper-bound"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param("abc", id="non-numeric-string"), + pytest.param(1.5, id="float"), + pytest.param(True, id="bool"), + ], +) +def test_embedding_dimensions_option_is_validated(bad_value): + provider = object.__new__(OpenAICompatibleProvider) + provider._options = {"dimensions": bad_value} + provider._provider = "openai" + with pytest.raises(ValueError, match="dimensions"): + provider._embedding_options() + + +def test_embedding_dimensions_option_accepts_valid_value(): + provider = object.__new__(OpenAICompatibleProvider) + provider._options = {"dimensions": 1536} + provider._provider = "openai" + assert provider._embedding_options()["dimensions"] == 1536 + + +# ── 回归:Google 整数型生成参数必须校验 ── +# +# 此前只做键名白名单,取值原样透传给 ``types.GenerateContentConfig``:实测 +# ``options.max_output_tokens = 10**400`` 时构造与调用都成功,请求体里就带着 +# 那个 401 位整数发给服务端(MockTransport 抓包确认);``candidate_count`` +# 的 ``-1``/``0`` 同样静默通过。 + + +@pytest.mark.parametrize("field_name", ["max_output_tokens", "candidate_count"]) +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(-1, id="negative"), + pytest.param(0, id="zero"), + pytest.param(10**400, id="400digits-over-upper-bound"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), + pytest.param(1.5, id="float"), + pytest.param(True, id="bool"), + ], +) +def test_google_generation_options_are_validated(field_name, bad_value): + provider = object.__new__(GoogleProvider) + provider._options = {field_name: bad_value} + with pytest.raises(ValueError, match=field_name): + provider._generation_options() + + +def test_google_generation_options_accept_valid_values(): + provider = object.__new__(GoogleProvider) + provider._options = {"max_output_tokens": 4096, "candidate_count": 1} + values = provider._generation_options() + assert values == {"max_output_tokens": 4096, "candidate_count": 1} + + +# ── 回归:Ark Classification 的动态资源路径必须与 Facade 同约束 ── +# +# AGENTS.md 要求 Ark Classification 资源「需通过显式 Facade/入口调用,不能把 +# 缺少资源挂载静默当成成功」。Facade 侧做对了(``_get_classification_resource`` +# 在 SDK 未挂载时抛带指引的 ``NotImplementedError``),但动态路径 +# ``resources.classification.create(...)`` 既不过 Facade、也不做参数白名单, +# 实测 ``恶意字段=1`` 一路放行,最终拿到 SDK 内部的 +# ``AttributeError: 'Ark' object has no attribute 'classification'``。 + + +def _ark_provider_with_classification_resource(): + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._options = {} + provider._classification_resource = SimpleNamespace(create=lambda **kwargs: kwargs) + provider._get_client = lambda: SimpleNamespace() + return provider + + +def test_ark_classification_dynamic_path_shares_facade_kwargs_whitelist(): + provider = _ark_provider_with_classification_resource() + with pytest.raises(ValueError, match="Classification"): + provider._require_provider_resource( + "volcengine.classification.create", + {"unknown": 1}, + ) + + +def test_ark_classification_dynamic_path_accepts_facade_supported_kwargs(): + provider = _ark_provider_with_classification_resource() + # 不抛错即通过;具体资源可用性由 SDK 决定,本测试只固定「不受门禁误伤」。 + provider._require_provider_resource( + "volcengine.classification.create", + {"user": "u1"}, + ) + + +# ── 回归:诊断入口对形态异常的参数必须给可读消息,而不是裸的整数转换错误 ── +# +# ``protocol_status`` 与 ``capability_report`` 是公开的诊断 API(配置界面与 +# 排障工具会调),参数来自调用方而非 ``Settings``——``ModelDetail.protocol`` +# 有 validator,但这两个入口收的是**裸字符串**,没有任何校验。 +# +# 两处都把取值直接喂给 ``str(...).strip().lower()`` 做协议名归一化: +# ``10**4301`` 撞上 CPython 的整数位数上限,抛的是 +# ``ValueError: Exceeds the limit (4300 digits) for integer string conversion`` +# ——既没有「哪个参数错了」,也没有「可选值是什么」,用户拿到的是与协议诊断 +# 毫无关系的整数转换错误。同一文件的其它入口(``_normalize_verified_protocols`` +# 的非字符串分支)早已给出带指引的消息,这里补齐一致性。 +# +# ``server_verified_protocols`` 里的取值同理:它是 ``options`` 里的任意值, +# 而 ``validate_secret_free_options`` 只查敏感键名、不做类型校验。 +# 用显式 id:pytest 默认把参数值 ``str()`` 成用例 id,而 ``10**4301`` 在 +# 收集期就会抛 ``ValueError``,整个文件无法导入。 +@pytest.mark.parametrize( + "hostile_protocol", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + pytest.param(None, id="none"), + pytest.param(["responses"], id="list"), + ], +) +def test_protocol_status_rejects_malformed_protocol_with_a_readable_message(hostile_protocol): + with pytest.raises(ValueError) as excinfo: + ModelProviderFactory.protocol_status("openai", hostile_protocol) + + message = str(excinfo.value) + # 只断言「抛 ValueError」不够:未修复的实现抛的也是 ValueError(整数转换 + # 错误),必须断言消息指向**参数名**。用参数名而不是「协议」二字,是因为 + # 诊断入口的调用方要靠它定位自己传错了哪个实参。 + assert "protocol" in message + assert "必须是字符串" in message + + +@pytest.mark.parametrize( + "hostile_value", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(10**4000, id="huge-int-4000"), + ], +) +def test_protocol_status_rejects_malformed_verified_entry_with_a_readable_message(hostile_value): + with pytest.raises(ValueError) as excinfo: + ModelProviderFactory.protocol_status( + "openai", + "responses", + options={"server_verified_protocols": [hostile_value]}, + ) + + message = str(excinfo.value) + assert "server_verified_protocols" in message + assert "必须是字符串" in message + # 关键区分:未修复的实现抛的是 CPython 的整数转换错误,用户拿不到任何 + # 「哪个参数错了」的线索。断言消息里**不出现**那段原始文本。 + assert "Exceeds the limit" not in message + + +def test_protocol_status_still_accepts_valid_protocols_after_the_guard(): + """反向守卫:收紧不得误伤正常取值与别名。""" + assert ModelProviderFactory.protocol_status("openai", "responses") == { + "adapter_supported": True, + "server_verified": False, + } + assert ModelProviderFactory.protocol_status("openai", " RESPONSES ") == ( + ModelProviderFactory.protocol_status("openai", "responses") + ) + + +# ── 回归:协议名归一化的另外三处同样不能抛裸的整数转换错误 ── +# +# 与 ``factory`` 那两处同一根因:把取值直接喂给 ``str(...).strip().lower()``, +# ``10**4301`` 撞上 CPython 的整数位数上限,抛的是与「协议名不支持」毫无关系的 +# ``ValueError: Exceeds the limit``。三处都可达(前两处经公开构造器,第三处经 +# ``_normalize_responses_native_item`` 的入口分流)。 +@pytest.mark.parametrize( + "hostile_protocol", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(None, id="none"), + pytest.param(["responses"], id="list"), + ], +) +def test_openai_compatible_normalize_protocol_rejects_non_string(hostile_protocol): + from src.providers.openai_compatible import OpenAICompatibleProvider + + with pytest.raises(ValueError) as excinfo: + OpenAICompatibleProvider._normalize_protocol(hostile_protocol) + + message = str(excinfo.value) + assert "必须是字符串" in message + assert "Exceeds the limit" not in message + # 诊断入口要给出可选值,否则用户只知道「传错了」。 + assert "chat_completions" in message + + +@pytest.mark.parametrize( + "hostile_protocol", + [ + pytest.param(10**4301, id="huge-int-4301"), + pytest.param(None, id="none"), + pytest.param(["responses"], id="list"), + ], +) +def test_volcengine_rejects_non_string_protocol(hostile_protocol): + from src.providers.volcengine import VolcengineProvider + + with pytest.raises(ValueError) as excinfo: + VolcengineProvider(model_name="demo", protocol=hostile_protocol) + + message = str(excinfo.value) + assert "必须是字符串" in message + assert "Exceeds the limit" not in message + + +def test_responses_provider_variant_falls_back_instead_of_raising(): + """纯分流判断不该抛错:非字符串一律走 openai 变体。 + + 它抛在 ``_normalize_responses_native_item`` 的入口,会把本该给出的 + 「字段类型不对」消息换成一条与协议无关的整数转换错误。 + """ + from src.providers.__base__.model_provider import _responses_provider_variant + + for hostile in (10**4301, None, ["ark"], 42): + assert _responses_provider_variant(hostile) == "openai" + # 反向守卫:正常取值仍要正确分流。 + assert _responses_provider_variant("ark") == "ark" + assert _responses_provider_variant("volcengine") == "ark" + assert _responses_provider_variant(" ARK ") == "ark" + assert _responses_provider_variant("openai") == "openai" + + +# ── 回归:``normalize_messages`` 的 prompt / system_prompt 形态 ── +# +# 本函数是四个 provider 公共 ``invoke`` 的**统一请求边界**(``google.py:1497``、 +# ``anthropic.py:834/1746/…``、``openai_compatible.py:1024``、``volcengine.py:1001`` +# 都调它)。``messages`` 那条分支有完整判据(非序列、非 Mapping 条目都给 +# ``ValueError``),而 ``prompt``/``system_prompt`` 这一半**完全没有**: +# +# * ``prompt=5``/``["a"]`` 静默生成 ``{"role": "user", "content": 5}``,非字符串 +# 字面量被当成真实用户输入发给模型(与 ``ChatService._build_prompt`` 钉住的 +# ``用户问题: None`` 是同一形态); +# * ``prompt=10**4301`` 在 ``str(prompt).strip()`` 处抛 ``ValueError: Exceeds the +# limit``——消息与「请求内容写错了」无关,而 provider 的错误分类按 +# ``ValueError``/``RuntimeError`` 写; +# * ``system_prompt`` 同理:非字符串直接进 ``content``,位数超限的整数在 +# ``normalize_responses_input`` 的日志插值处炸。 +# +# 可达路径:``invoke``/``ainvoke`` 是公开方法,参数一路透传到 ``normalize_messages``; +# 本仓测试就有 ``ainvoke("hello", system_prompt=None, stream=False)`` 这类直调。 + + +@pytest.mark.parametrize( + "hostile_prompt", + [ + pytest.param(10**4301, id="huge-int"), + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + ], +) +def test_normalize_messages_rejects_non_string_prompt_readably(hostile_prompt): + """非字符串 ``prompt`` 必须给带指引的 ``ValueError``,不是静默进 content 或裸整数转换错。""" + with pytest.raises(ValueError) as excinfo: + normalize_messages(hostile_prompt, None, None) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "prompt" in message + + +@pytest.mark.parametrize( + "hostile_system", + [ + pytest.param(10**4301, id="huge-int"), + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + ], +) +def test_normalize_messages_rejects_non_string_system_prompt_readably(hostile_system): + """非字符串 ``system_prompt`` 同样必须被拒绝,而不是进 ``content``。""" + with pytest.raises(ValueError) as excinfo: + normalize_messages("hi", hostile_system, None) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message, message + assert "system_prompt" in message + + +def test_normalize_messages_still_accepts_string_prompt_and_system_prompt(): + """收紧不能误伤:字符串 prompt/system_prompt 照常构造消息。""" + normalized = normalize_messages("北京天气", "你是助手", None) + + assert normalized == [ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "北京天气"}, + ] + + +# ── 回归:Responses background 轮询超时消息的 id 形态 ── +# +# ``_poll_background_response``/``_poll_background_response_async`` 在超时分支 +# 里用裸 f-string 插值 ``response_id``(``f"...响应 {response_id} 在 {timeout:g}s +# 内未完成。"``)。``response_id`` 来自网关 JSON 的 ``id`` 字段,形态不受控: +# ``10**4301`` 让 f-string 抛 ``ValueError: Exceeds the limit``——**正要报告超时** +# 的那一步变成整数转换错误,而调用方按 ``TimeoutError``/``RuntimeError`` 分类。 +# 判据与同文件 ``_error_message``/``_extract_refusal`` 一致(字符串原样,其余走 +# ``safe_repr``)。 + + +def _background_poll_provider(): + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "demo" + provider._options = {} + provider._background_poll_config = lambda: (0.0, 0.0) + provider._response_retrieve_kwargs = lambda params: {} + return provider + + +@pytest.mark.parametrize( + "hostile_id", + [pytest.param(10**4301, id="huge-int"), pytest.param(["a"], id="list")], +) +def test_responses_background_timeout_reports_malformed_id_readably(hostile_id): + """超时消息不得因非字符串 ``id`` 抛裸异常。""" + provider = _background_poll_provider() + response = {"status": "queued", "id": hostile_id} + + with pytest.raises(TimeoutError) as excinfo: + provider._poll_background_response(response, {}) + + assert "Exceeds the limit" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "hostile_id", + [pytest.param(10**4301, id="huge-int"), pytest.param(["a"], id="list")], +) +def test_responses_background_timeout_reports_malformed_id_readably_async(hostile_id): + """异步分支同判据。""" + provider = _background_poll_provider() + response = {"status": "queued", "id": hostile_id} + + with pytest.raises(TimeoutError) as excinfo: + asyncio.run(provider._poll_background_response_async(response, {})) + + assert "Exceeds the limit" not in str(excinfo.value) + + +def test_responses_background_timeout_still_reports_string_id(): + """收紧不能误伤:字符串 id 仍原样出现在消息里。""" + provider = _background_poll_provider() + response = {"status": "queued", "id": "resp_abc"} + + with pytest.raises(TimeoutError) as excinfo: + provider._poll_background_response(response, {}) + + assert "resp_abc" in str(excinfo.value) + + +# ── 流终止与拒答守卫 ── +# +# ``OpenAICompatibleProvider`` 与 ``VolcengineProvider`` 各自有四条「流在终结事件 +# 之前结束」与四条「返回拒答」守卫,分布在 ``invoke``/``ainvoke``(纯文本生成) +# 与 ``stream_events``/``astream_events``(结构化事件)两套入口上,合计 30 条。 +# 这些判据在整套测试里从未执行(`sys.settrace` 行覆盖证实);把它们的 ``raise`` +# 换成 ``pass`` 后全量 3361 条仍然全绿——既有用例只钉住 Responses 异步流的一条 +# 拒 delta 收敛(``test_openai_responses_async_streaming_refusal_delta_is_coerced``)。 +# +# 删除后的后果分两类(实测,均经公开入口): +# * **流提前结束**:静默正常返回 ``["hi"]``(invoke 侧)或一串 ``StreamEvent`` +# (stream_events 侧)。调用方无法区分「模型答完了」与「连接断了」,半截答案 +# 当成完整答案进入对话历史; +# * **拒答**:非流式静默返回空列表,流式退化成相邻的「流提前结束」消息——把 +# 「模型明确拒绝回答」说成「流断了」,两者需要完全不同的处置。 +PROVIDER_STREAM_MATRIX = [ + (OpenAICompatibleProvider, "responses", "responses"), + (OpenAICompatibleProvider, "chat_completions", "chat"), + (VolcengineProvider, "responses", "responses"), + (VolcengineProvider, "chat_completions", "chat"), +] + +_RESPONSES_REFUSAL_BODY = { + "id": "r1", + "status": "completed", + "output": [{"type": "message", "role": "assistant", "refusal": "no", "content": []}], +} +# Ark 的 ``_extract_result`` 只认 ``output[].type == "refusal"``,与 OpenAI 侧的 +# ``output[].refusal`` 字段形态不同(见两处 ``_extract_result`` 的对比)。 +_ARK_REFUSAL_BODY = { + "id": "r1", + "status": "completed", + "output": [{"type": "refusal", "refusal": "no"}], +} +_RESPONSES_TEXT_BODY = { + "id": "r1", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}], + } + ], +} +_RESPONSES_REFUSAL_EVENT = { + "type": "response.refusal.delta", + "item_id": "i1", + "output_index": 0, + "delta": "no", +} +_RESPONSES_TEXT_EVENT = { + "type": "response.output_text.delta", + "item_id": "i1", + "output_index": 0, + "delta": "hi", +} +_RESPONSES_DONE_EVENT = {"type": "response.completed", "response": {"id": "r1"}} +_CHAT_REFUSAL_BODY = { + "id": "c1", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "content_filter", + "message": {"role": "assistant", "refusal": "no"}, + } + ], +} +_CHAT_REFUSAL_CHUNK = { + "id": "c1", + "object": "chat.completion.chunk", + "choices": [{"index": 0, "delta": {"refusal": "no"}}], +} +_CHAT_TEXT_CHUNK = { + "id": "c1", + "object": "chat.completion.chunk", + "choices": [{"index": 0, "delta": {"content": "hi"}}], +} +_CHAT_FINISH_CHUNK = { + "id": "c1", + "object": "chat.completion.chunk", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], +} + + +def _sync_iterator(items): + return iter(items) + + +def _async_iterator(items): + async def gen(): + for item in items: + yield item + + return gen() + + +def _stream_client(payload, kind, *, is_async): + """构造只区分 ``stream=`` 的假 SDK 客户端。 + + 非流式调用返回响应对象本身、流式调用返回迭代器——两者不能混,否则 + ``_raise_for_*`` 会拿到迭代器并把「响应缺字段」误报成非法响应。 + """ + + def create(**kwargs): + if kwargs.get("stream"): + return _async_iterator(payload) if is_async else _sync_iterator(payload) + return payload[0] + + if kind == "responses": + return SimpleNamespace(responses=SimpleNamespace(create=create)) + return SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + + +def _stream_provider(provider_type, protocol, kind, payload, *, is_async): + provider = object.__new__(provider_type) + provider._model_name = "demo" + provider._provider = "demo" + provider._protocol = protocol + provider._options = {"server_verified_protocols": ["responses", "chat_completions"]} + provider._server_verified_protocols = frozenset({"responses", "chat_completions"}) + client = _stream_client(payload, kind, is_async=is_async) + if is_async: + provider._get_aclient = lambda: client + else: + provider._get_client = lambda: client + return provider + + +def _refusal_body(provider_type, kind): + if kind != "responses": + return _CHAT_REFUSAL_BODY + if provider_type is VolcengineProvider: + return _ARK_REFUSAL_BODY + return _RESPONSES_REFUSAL_BODY + + +def _unterminated_payload(kind): + if kind == "responses": + return [_RESPONSES_TEXT_EVENT] + return [_CHAT_TEXT_CHUNK] + + +def _terminated_payload(kind): + if kind == "responses": + return [_RESPONSES_TEXT_EVENT, _RESPONSES_DONE_EVENT] + return [_CHAT_TEXT_CHUNK, _CHAT_FINISH_CHUNK] + + +@pytest.mark.parametrize( + ("provider_type", "protocol", "kind"), + PROVIDER_STREAM_MATRIX, + ids=lambda value: value if isinstance(value, str) else value.__name__, +) +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +def test_streaming_refusal_is_explicit(provider_type, protocol, kind, async_mode): + """拒答必须报成「返回拒答」,不能退化成「流提前结束」或静默返回空。 + + 删除流式拒答守卫后,收场从「返回拒答: no」变成「流在 … 之前结束」——把 + 模型明确拒绝回答说成连接中断,两者需要完全不同的处置;删除非流式守卫后 + 则**静默返回空列表**,调用方把「拒答」当成「模型没话说」。 + """ + if kind == "responses": + payload = [_RESPONSES_REFUSAL_EVENT] + else: + payload = [_CHAT_REFUSAL_CHUNK] + provider = _stream_provider(provider_type, protocol, kind, payload, is_async=async_mode) + + if async_mode: + + async def run(): + return [x async for x in provider.ainvoke(prompt="hi", stream=True)] + + with pytest.raises(RuntimeError, match="返回拒答"): + asyncio.run(run()) + else: + with pytest.raises(RuntimeError, match="返回拒答"): + list(provider.invoke(prompt="hi", stream=True)) + + +@pytest.mark.parametrize( + ("provider_type", "protocol", "kind"), + PROVIDER_STREAM_MATRIX, + ids=lambda value: value if isinstance(value, str) else value.__name__, +) +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +def test_non_streaming_refusal_is_explicit(provider_type, protocol, kind, async_mode): + """非流式拒答同样必须显式报错。 + + 删除后实测静默返回 ``[]``——一次明确的拒答变成「模型没有输出」。 + """ + payload = [_refusal_body(provider_type, kind)] + provider = _stream_provider(provider_type, protocol, kind, payload, is_async=async_mode) + + if async_mode: + + async def run(): + return [x async for x in provider.ainvoke(prompt="hi", stream=False)] + + with pytest.raises(RuntimeError, match="返回拒答"): + asyncio.run(run()) + else: + with pytest.raises(RuntimeError, match="返回拒答"): + list(provider.invoke(prompt="hi", stream=False)) + + +@pytest.mark.parametrize( + ("provider_type", "protocol", "kind"), + PROVIDER_STREAM_MATRIX, + ids=lambda value: value if isinstance(value, str) else value.__name__, +) +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +def test_streaming_without_terminal_event_is_explicit(provider_type, protocol, kind, async_mode): + """流在终结事件之前结束必须报错,不能静默交付半截答案。 + + 删除后实测静默返回 ``["hi"]``:调用方无法区分「模型答完了」与「连接断了」。 + """ + provider = _stream_provider( + provider_type, protocol, kind, _unterminated_payload(kind), is_async=async_mode + ) + + if async_mode: + + async def run(): + return [x async for x in provider.ainvoke(prompt="hi", stream=True)] + + with pytest.raises(RuntimeError, match="之前结束"): + asyncio.run(run()) + else: + with pytest.raises(RuntimeError, match="之前结束"): + list(provider.invoke(prompt="hi", stream=True)) + + +@pytest.mark.parametrize( + ("provider_type", "protocol", "kind"), + PROVIDER_STREAM_MATRIX, + ids=lambda value: value if isinstance(value, str) else value.__name__, +) +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +def test_stream_events_requires_terminal_event(provider_type, protocol, kind, async_mode): + """``stream_events``/``astream_events`` 是另一套实现,终止判据同样要生效。 + + 删除后实测静默产出一串 ``StreamEvent``——结构化事件流的调用方连「流是否 + 完整」都无从判断,而它们正是靠这条区分「完整事件」与「半截事件」。 + """ + provider = _stream_provider( + provider_type, protocol, kind, _unterminated_payload(kind), is_async=async_mode + ) + + if async_mode: + + async def run(): + return [x async for x in provider.astream_events(prompt="hi")] + + with pytest.raises(RuntimeError, match="之前结束"): + asyncio.run(run()) + else: + with pytest.raises(RuntimeError, match="之前结束"): + list(provider.stream_events(prompt="hi")) + + +@pytest.mark.parametrize( + ("provider_type", "protocol", "kind"), + PROVIDER_STREAM_MATRIX, + ids=lambda value: value if isinstance(value, str) else value.__name__, +) +def test_terminated_stream_still_yields_content(provider_type, protocol, kind): + """反向守卫:带终结事件的正常流照常产出内容与 finish 事件。""" + provider = _stream_provider( + provider_type, protocol, kind, _terminated_payload(kind), is_async=False + ) + + events = list(provider.stream_events(prompt="hi")) + + assert any(event.type == "finish" for event in events) + + +# ── 消息归一化的形态判据 ── +# +# ``normalize_chat_messages``/``normalize_messages``/``normalize_responses_input`` +# 是四个 provider 公共的**统一请求边界**,上面六条形态判据在整套测试里从未执行 +# (行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后全量 3405 条仍全绿)。既有用例 +# 覆盖的是元素**内部**字段(缺 id、缺 name、非法 arguments),这几条**形态**判据 +# 全绕开了。删除后行为确实改变(实测): +# * 消息元素非对象 → 整数抛裸 ``TypeError: 'int' object is not iterable``、字符串抛 +# ``dictionary update sequence element #0 has length 1``; +# * ``tool_calls`` 非序列 → 裸 ``TypeError``,或对字符串退化成元素级错误消息; +# * ``messages`` 非序列 → 裸 ``TypeError``(字符串则退化成元素级消息); +# * ``prompt`` 为空/全空白 → **静默接受**,产出 ``{"role": "user", "content": None}`` +# 或全空白内容发给模型; +# * Responses ``content`` 非序列 → 裸 ``TypeError``; +# * ``role="function"`` → **静默接受**,产出一个没有 ``tool_call_id`` 的 function +# 消息,Responses 无法把它关联回任何调用。 + + +@pytest.mark.parametrize("bad_message", [5, "oops", ["x"]], ids=["int", "str", "list"]) +def test_chat_messages_reject_non_mapping_entries(bad_message): + """``messages`` 的元素必须是对象。""" + with pytest.raises(ValueError, match=r"messages\[0\] 必须是对象"): + normalize_chat_messages([bad_message]) + + +@pytest.mark.parametrize("bad_tool_calls", [5, "abc", {"a": 1}], ids=["int", "str", "dict"]) +def test_chat_messages_reject_non_sequence_tool_calls(bad_tool_calls): + """``tool_calls`` 必须是序列;字符串虽可迭代但语义不同,须显式排除。""" + with pytest.raises(ValueError, match=r"tool_calls 必须是序列"): + normalize_chat_messages( + [{"role": "assistant", "content": "", "tool_calls": bad_tool_calls}] + ) + + +@pytest.mark.parametrize("bad_messages", [5, "abc", {"a": 1}], ids=["int", "str", "dict"]) +def test_normalize_messages_rejects_non_sequence_container(bad_messages): + """``messages`` 容器本身必须是序列,不能是字符串或映射。""" + with pytest.raises(ValueError, match="messages 必须是消息对象序列"): + normalize_messages(None, None, bad_messages) + + +@pytest.mark.parametrize("prompt", [None, "", " "], ids=["none", "empty", "blank"]) +def test_normalize_messages_requires_prompt_or_messages(prompt): + """既没有 ``messages`` 也没有非空 ``prompt`` 时必须报错。 + + 删除守卫后实测产出 ``{"role": "user", "content": None}``(或全空白内容)并 + 发给模型——一次「请求内容为空」的配置错误变成真实提问。 + """ + with pytest.raises(ValueError, match="必须提供 prompt 或 messages"): + normalize_messages(prompt, None, None) + + +@pytest.mark.parametrize("bad_content", [5, {"a": 1}, object()], ids=["int", "dict", "object"]) +def test_responses_input_rejects_non_sequence_content(bad_content): + """Responses 消息的 ``content`` 必须是字符串或内容块序列。""" + with pytest.raises(ValueError, match="content 必须是字符串或内容块序列"): + normalize_responses_input(None, None, [{"role": "user", "content": bad_content}]) + + +@pytest.mark.parametrize("bad_content", [5, {"a": 1}], ids=["int", "dict"]) +def test_responses_input_rejects_invalid_assistant_content_alongside_tool_calls(bad_content): + """带 ``tool_calls`` 的 assistant 消息,其「有值但非法」的 ``content`` 必须显式报错。 + + ``_responses_has_content`` 末行 ``return True`` 反向成 ``False`` 后实测: + int/dict 这类非 ``None``、非字符串、非序列的 content 被判为「无内容」而整段 + 丢弃,只剩 ``function_call`` 发给服务端——助手文本无声消失。既有 + ``test_responses_input_rejects_non_sequence_content`` 只走 ``role="user"`` + 路径(另一处守卫),从未触发这里。 + """ + with pytest.raises(ValueError, match="content 必须是字符串或内容块序列"): + normalize_responses_input( + None, + None, + [ + { + "role": "assistant", + "content": bad_content, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + } + ], + ) + + +def test_responses_input_rejects_function_role_without_tool_call_id(): + """``role="function"`` 的消息必须带 ``tool_call_id``。 + + 删除守卫后实测静默接受,产出没有 ``tool_call_id`` 的 function 消息—— + Responses 侧无法把它关联回任何一次函数调用。 + """ + with pytest.raises(ValueError, match="function 消息缺少 tool_call_id"): + normalize_responses_input(None, None, [{"role": "function", "name": "f"}]) + + +def test_normalization_still_accepts_well_formed_messages(): + """反向守卫:合法消息形态照常归一,不受上述收紧影响。""" + chat = normalize_chat_messages( + [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}] + ) + assert [message["role"] for message in chat] == ["user", "assistant"] + + responses = normalize_responses_input( + None, None, [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}] + ) + assert responses[0]["role"] == "user" + + assert normalize_messages("hi", None, None) == [{"role": "user", "content": "hi"}] + + +# ── Ark Chat/Embedding/Classification 的形态判据 ── +# +# 六条判据在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后 +# 全量 3434 条仍全绿)。删除后行为确实改变(实测,均经公开入口): +# * 1027 ``max_tokens`` 与 ``max_completion_tokens`` 同时出现在模型 options → +# **静默接受**,只有 ``max_tokens`` 进请求,另一处配置被无声丢弃; +# * 1404 原生 Responses 调用缺 ``input``/``input=None`` → **静默接受**, +# 请求不带 ``input`` 直接发给服务端; +# * 1420 ``session`` 非对象 → 带字段名的 ``ValueError`` 变成裸 ``TypeError: +# 'int' object is not iterable``(``dict(session)``); +# * 2249/2296 Embedding 的 ``embed_documents``/``aembed_documents`` 请求参数 +# 覆盖 ``input``/``model`` → 消息从「不允许覆盖请求字段」退化成 +# ``require_text_list`` 抛的「不支持请求参数」,把「参数冲突」说成「不支持的 +# 参数」; +# * 2978 ``async_classify`` 的 ``labels`` 形态判据 → 非列表抛裸 ``TypeError``、 +# 元素非字符串抛裸 ``AttributeError``。 + + +def test_ark_chat_rejects_conflicting_max_tokens_options(): + """两处输出长度配置同时出现必须报错,不能静默只取其一。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "doubao-model" + provider._protocol = "chat_completions" + provider._options = {"max_tokens": 10, "max_completion_tokens": 20} + + with pytest.raises(ValueError, match="不能同时设置 max_tokens 和 max_completion_tokens"): + provider._build_chat_request(prompt="hi", stream=False) + + +@pytest.mark.parametrize( + "params", + [{"model": "m"}, {"model": "m", "input": None}], + ids=["missing-input", "none-input"], +) +def test_ark_native_responses_requires_input(params): + """原生 Responses 调用缺 ``input`` 必须在请求前报错。""" + with pytest.raises(ValueError, match="创建需要 input"): + VolcengineProvider._validate_native_response_kwargs(params) + + +def test_ark_native_responses_rejects_non_object_session(): + """``session`` 非对象必须给出可读消息,不能漏出裸 ``TypeError``。""" + with pytest.raises(ValueError, match="session 必须是对象"): + VolcengineProvider._validate_native_response_kwargs( + {"model": "m", "input": "hi", "session": 5} + ) + + +def test_ark_native_responses_still_accepts_valid_session(): + """反向守卫:合法 ``session`` 照常归一为 dict。""" + params = VolcengineProvider._validate_native_response_kwargs( + {"model": "m", "input": "hi", "session": {"a": 1}} + ) + + assert params["session"] == {"a": 1} + + +def _ark_embedding_provider(): + provider = object.__new__(VolcengineProvider) + provider._model_name = "doubao-embedding" + provider._protocol = "chat_completions" + provider._options = {} + return provider + + +@pytest.mark.parametrize("field", ["input", "model"]) +def test_ark_embedding_rejects_request_field_overrides(field): + """请求参数覆盖 ``input``/``model`` 必须报「不允许覆盖请求字段」。 + + 删除守卫后消息退化成 ``require_text_list`` 的「不支持请求参数」——把参数 + 冲突说成参数不受支持,排查方向被带偏。 + """ + provider = _ark_embedding_provider() + + with pytest.raises(ValueError, match="不允许覆盖请求字段"): + provider.embed_documents(["a"], **{field: ["b"] if field == "input" else "other"}) + + +@pytest.mark.parametrize("field", ["input", "model"]) +def test_ark_async_embedding_rejects_request_field_overrides(field): + """异步入口同判据(另一份实现)。""" + provider = _ark_embedding_provider() + + async def run(): + return await provider.aembed_documents( + ["a"], **{field: ["b"] if field == "input" else "other"} + ) + + with pytest.raises(ValueError, match="不允许覆盖请求字段"): + asyncio.run(run()) + + +@pytest.mark.parametrize( + "labels", + [5, [], [5], [" "], ["ok", None]], + ids=["int", "empty", "non-string-entry", "blank-entry", "none-entry"], +) +def test_ark_async_classify_rejects_malformed_labels(labels): + """异步 Classification 的 ``labels`` 必须是非空字符串列表。 + + 删除守卫后实测非列表抛裸 ``TypeError: 'int' object is not iterable``、元素 + 非字符串抛裸 ``AttributeError: 'int' object has no attribute 'strip'``。 + """ + provider = _ark_embedding_provider() + + async def run(): + return await provider.async_classify(query="q", labels=labels) + + with pytest.raises(ValueError, match="labels 必须是非空字符串列表"): + asyncio.run(run()) + + +@pytest.mark.parametrize( + "labels", + [5, [], [5], [" "]], + ids=["int", "empty", "non-string-entry", "blank-entry"], +) +def test_ark_sync_classify_rejects_malformed_labels(labels): + """同步 Classification 是另一份实现,同样要有判据(既有用例已覆盖,这里作对照)。""" + provider = _ark_embedding_provider() + + with pytest.raises(ValueError, match="labels 必须是非空字符串列表"): + provider.classify(query="q", labels=labels) + + +# ── Anthropic 流终止与 parse 必填参数的八条守卫 ── +# +# 这八条在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` 后 +# 全量 3479 条仍全绿)。既有用例只钉住 ``beta_tool_runner`` **同步**侧的 +# ``compaction_control`` 与 ``output_format`` 在 create/stream 两条路径上的判据。 +# 删除后行为确实改变(实测): +# * 1175/1210 ``invoke``/``ainvoke`` 的流在 ``message_stop`` 之前结束 → **静默 +# 返回已收到的片段**(实测 ``["hi"]``),调用方无法区分「答完了」与「断了」; +# * 1541/1634 ``stream_events``/``astream_events`` 同判据 → 静默产出一串 +# ``StreamEvent``,结构化事件流的调用方连「流是否完整」都无从判断; +# * 1663/2146 ``parse``/``async_parse`` 与 1815/2032 ``beta_parse``/ +# ``async_beta_parse`` 缺 ``output_format`` → 消息从「必须提供 output_format」 +# 退化成 ``normalize_messages`` 的「聊天请求必须提供 prompt 或 messages」, +# 把「缺少结构化输出」说成「缺少提问」,排查方向被带偏; +# * 944 ``output_format`` 传字典 schema(流式路径)→ **静默接受**,把整个字典 +# 当作 Python 类型原样放进请求的 ``output_format``; +# * 2124 ``async_beta_tool_runner`` 的 ``compaction_control``(异步侧是**另一处**) +# → 消息退化成 ``CompletionRequest.__init__() got an unexpected keyword``, +# 既不提 Anthropic 也不提 compaction_control。 + +_ANTHROPIC_TEXT_EVENT = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hi"}, +} +_ANTHROPIC_STOP_EVENT = {"type": "message_stop"} + + +class _AnthropicSyncStream: + def __init__(self, events): + self._events = events + + def __iter__(self): + return iter(self._events) + + +class _AnthropicAsyncStream: + def __init__(self, events): + self._events = events + + def __aiter__(self): + async def gen(): + for event in self._events: + yield event + + return gen() + + +def _anthropic_stream_provider(events, *, is_async): + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-fable-5" + provider._options = {} + provider._provider = "anthropic" + + class SyncContext: + def __enter__(self): + return _AnthropicSyncStream(events) + + def __exit__(self, *exc): + return False + + class AsyncContext: + async def __aenter__(self): + return _AnthropicAsyncStream(events) + + async def __aexit__(self, *exc): + return False + + if is_async: + + class Messages: + @staticmethod + def stream(**_kwargs): + return AsyncContext() + + client = SimpleNamespace(aio=SimpleNamespace(messages=Messages()), messages=Messages()) + else: + + class Messages: + @staticmethod + def stream(**_kwargs): + return SyncContext() + + client = SimpleNamespace(messages=Messages()) + + provider._get_client = lambda: client + provider._get_aclient = lambda: client + return provider + + +def _anthropic_invoke_text(events, *, is_async): + # ``_iter_invoke_stream_text``/``_aiter_invoke_stream_text`` 是 classmethod, + # 直接消费响应流,不经过 provider 实例。 + response = _AnthropicAsyncStream(events) if is_async else _AnthropicSyncStream(events) + if is_async: + + async def run(): + return [item async for item in AnthropicProvider._aiter_invoke_stream_text(response)] + + return asyncio.run(run()) + return list(AnthropicProvider._iter_invoke_stream_text(response)) + + +def _anthropic_stream_events(events, *, is_async): + provider = _anthropic_stream_provider(events, is_async=is_async) + if is_async: + + async def run(): + return [item async for item in provider.astream_events(prompt="hi")] + + return asyncio.run(run()) + return list(provider.stream_events(prompt="hi")) + + +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +def test_anthropic_invoke_stream_requires_message_stop(is_async): + """``invoke`` 的文本流必须在 ``message_stop`` 之后才算完整。 + + 删除守卫后实测静默返回已收到的片段——半截答案当成完整答案交给调用方。 + """ + with pytest.raises(RuntimeError, match="message_stop 之前结束"): + _anthropic_invoke_text([_ANTHROPIC_TEXT_EVENT], is_async=is_async) + + +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +def test_anthropic_stream_events_requires_message_stop(is_async): + """``stream_events`` 是另一套实现,终止判据同样要生效。""" + with pytest.raises(RuntimeError, match="message_stop 之前结束"): + _anthropic_stream_events([_ANTHROPIC_TEXT_EVENT], is_async=is_async) + + +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +def test_anthropic_invoke_stream_still_yields_text_when_stopped(is_async): + """反向守卫:带 ``message_stop`` 的正常流照常产出全部文本。""" + assert _anthropic_invoke_text( + [_ANTHROPIC_TEXT_EVENT, _ANTHROPIC_STOP_EVENT], is_async=is_async + ) == ["hi"] + + +def _anthropic_parse_provider(): + provider = object.__new__(AnthropicProvider) + provider._model_name = "claude-fable-5" + provider._options = {} + provider._provider = "anthropic" + return provider + + +@pytest.mark.parametrize( + ("method", "message"), + [ + ("parse", "Anthropic parse 必须提供 output_format"), + ("beta_parse", "Anthropic Beta parse 必须提供 output_format"), + ], +) +def test_anthropic_parse_requires_output_format(method, message): + """同步两个 parse 入口缺 ``output_format`` 必须给专属消息。""" + provider = _anthropic_parse_provider() + + with pytest.raises(ValueError, match=message): + getattr(provider, method)(None) + + +@pytest.mark.parametrize( + ("method", "message"), + [ + ("async_parse", "Anthropic parse 必须提供 output_format"), + ("async_beta_parse", "Anthropic Beta parse 必须提供 output_format"), + ], +) +def test_anthropic_async_parse_requires_output_format(method, message): + """异步两个 parse 入口是**另一份**实现,同样要有专属消息。""" + provider = _anthropic_parse_provider() + + with pytest.raises(ValueError, match=message): + asyncio.run(getattr(provider, method)(None)) + + +def test_anthropic_stream_rejects_mapping_output_format(): + """流式路径的 ``output_format`` 必须是 Python 类型,不能是字典 schema。 + + 删除守卫后实测字典被原样放进请求的 ``output_format``——SDK 只接受类型对象, + 这个请求到服务端才失败。 + """ + provider = _anthropic_parse_provider() + + with pytest.raises(ValueError, match="必须是 Python 类型"): + provider._build_message_params(prompt="hi", output_format={"type": "object"}, stream=True) + + +def test_anthropic_async_beta_tool_runner_rejects_compaction_control(): + """异步 tool runner 的 ``compaction_control`` 判据是**另一处**守卫。 + + 同步侧已有 ``test_anthropic_beta_tool_runner_rejects_unsupported_compaction_control``, + 异步侧此前从未执行。 + """ + provider = _anthropic_parse_provider() + + with pytest.raises(ValueError, match="当前 SDK 不支持 compaction_control"): + asyncio.run( + provider.async_beta_tool_runner([], prompt="hi", compaction_control={"enabled": True}) + ) + + +# ── SDK 客户端属性的三条守卫 ── +# +# ``sdk_client``/``async_sdk_client`` 是 provider 基类上「供原生资源调用」的出口, +# 三条判据在整套测试里从未执行(行覆盖证实;把它们的 ``raise`` 换成 ``pass`` +# 后全量 3540 条仍全绿)。删除后行为确实改变(实测): +# * 2140 同步侧没有 ``_get_client`` → 带 provider 类名的 ``AttributeError`` +# 变成裸 ``TypeError: 'NoneType' object is not callable``; +# * 2150 ``_get_aclient()`` 返回 ``None`` → **静默返回 None**,调用方拿到 +# ``None`` 当客户端用,在更远处才崩; +# * 2158 客户端没有 ``aio`` 节点 → **静默返回 None**,同样把「没有异步客户端」 +# 说成「有,但它是 None」。 +# +# 同批的 2155(``client_getter()`` 返回 ``None``)经甄别是**等价变异**,不补 +# 测试:删掉后 ``getattr(None, "aio", None)`` 得到 ``None``,随即被 2158 以 +# **同一条消息**拦住,结论不变。 + + +class _ClientlessProvider(LargeLanguageModel): + """没有 ``_get_client``/``_get_aclient`` 的 provider——两者都是纯基类实现。""" + + def invoke(self, *_args, **_kwargs): + return iter(()) + + async def ainvoke(self, *_args, **_kwargs): + return + yield # pragma: no cover - 让函数成为异步生成器 + + def stream_events(self, *_args, **_kwargs): + return iter(()) + + async def astream_events(self, *_args, **_kwargs): + return + yield # pragma: no cover - 让函数成为异步生成器 + + +def test_sdk_client_reports_missing_getter_readably(): + """没有 ``_get_client`` 时必须报出 provider 类名,而不是裸 ``TypeError``。""" + with pytest.raises(AttributeError, match="_ClientlessProvider 没有同步 SDK 客户端"): + _ = _ClientlessProvider().sdk_client + + +def test_async_sdk_client_reports_none_client_readably(): + """``_get_aclient()`` 返回 ``None`` 时必须报错,不能静默交出 ``None``。""" + provider = _ClientlessProvider() + provider._get_aclient = lambda: None + + with pytest.raises(AttributeError, match="_ClientlessProvider 没有异步 SDK 客户端"): + _ = provider.async_sdk_client + + +def test_async_sdk_client_reports_missing_aio_node_readably(): + """客户端没有 ``aio`` 节点时必须报错,不能静默交出 ``None``。 + + 这是「没有异步客户端」与「有但它是 None」的分界——两条守卫消息相同但位置 + 不同,少一条就会把一种说成另一种。 + """ + provider = _ClientlessProvider() + provider._get_client = lambda: SimpleNamespace() + + with pytest.raises(AttributeError, match="_ClientlessProvider 没有异步 SDK 客户端"): + _ = provider.async_sdk_client + + +def test_sdk_client_still_resolves_through_aio_node(): + """反向守卫:同步客户端可用时,异步出口从 ``aio`` 节点解析。""" + provider = _ClientlessProvider() + aio = object() + provider._get_client = lambda: SimpleNamespace(aio=aio) + + assert provider.async_sdk_client is aio + + +# ── OpenAI 兼容渠道 openai_compatible.py 覆盖缺口守卫 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证:把它们的 ``raise`` 换成 +# ``pass`` 后全量 3552 条仍全绿,唯一被抓住的 1208/1706 是覆盖器的假阳性、 +# 既有用例已覆盖)。删除后行为确实改变(实测,均经真实入口): +# * 289 构造期缺 ``api_key`` → **静默接受**,无凭证的 provider 被构造出来; +# * 343 ``_normalize_protocol`` 未知协议 → **静默返回 None**,``_protocol`` 变 +# ``None``,后续 ``== "responses"`` 判断全部落空; +# * 478 ``_validate_sdk_resource_call`` 的 ``Signature.bind`` 抛 ``ValueError`` +# (位置专用参数)→ **静默跳过**参数校验,未知参数一路发到 SDK; +# * 568 ``_background_poll_config`` 的 ``timeout`` 非正/非数值 → 0 与负值被 +# **静默接受**,``None`` 抛裸 ``TypeError``,超大整数抛裸 ``OverflowError``; +# * 585/617 后台响应缺 ``id`` → 裸 ``AttributeError``(``_client``/``_aclient``); +# * 920 ``_require_responses_resource`` 非 responses 协议 → 不再拦,把本地 SDK +# 资源当远端 Responses 能力; +# * 936 ``_merge_options`` options 覆盖请求字段 → **静默覆盖** ``model``/``input``; +# * 987 ``_merge_resource_kwargs`` 调用方覆盖 Facade 已绑定字段 → **静默覆盖**; +# * 1145 Chat ``seed`` 与 options.extra_body 重复 → 消息退化成「extra_body 不允许 +# 覆盖请求字段」; +# * 1264 ``_convert_responses_tool_choice`` 既非字符串也非 Mapping → 裸 ``TypeError``; +# * 1273/1279 function tool_choice 缺 name → 1273 删掉后**静默产出** +# ``{"type": "function", "name": None}``,1279 删掉后**静默返回 None**; +# * 1627 ``_convert_responses_format`` 的 json_schema 非对象 → 裸 ``AttributeError``; +# * 1649 response_format 的 type 不支持且无 format → **静默返回 None**; +# * 1885 Chat 错误体含 ``error`` 且状态码正常 → 消息退化成「响应缺少有效 choices」; +# * 1906 ``allow_empty_choices`` 下 ``choices=None`` 且有 detail → **静默接受**; +# * 2290 Responses 终止事件非 ``response.completed`` → **静默产出一个 finish 事件**; +# * 2580/2617 ``parse``/``async_parse`` 缺 text_format → 消息退化成 +# ``normalize_messages`` 的「聊天请求必须提供 prompt 或 messages」; +# * 2598/2632 ``parse`` 同时用 request.response_format 与 text_format → +# **静默接受**,两者之一被无声丢弃; +# * 3078/3137/3117 Embedding 请求参数与模型 options 重复/覆盖 ``model`` → +# **静默接受**或消息退化; +# * 3195/4465 ``file_content`` 缺 ``files.content`` 资源 → 裸 +# ``TypeError: 'NoneType' object is not callable``,同构的 +# ``container_file_content`` 却有专属 ``NotImplementedError``(sibling 缺口); +# * 3355/4612 ``stream_responses``/``async_stream_responses`` 同时传 request 与 +# 原生关键字参数 → 裸 ``AttributeError``。 +# +# 同批的 951(``_validated_extra_body`` 非对象)经甄别是**等价变异**,不补测试: +# 删掉后落到下游 ``validate_secret_free_payload``,抛同一条消息的同一个 +# ``ValueError``,结论不变。 + + +def _openai_compatible_provider(**options): + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "responses" + provider._base_url = None + provider._options = options + return provider + + +def test_openai_compatible_constructor_requires_api_key(): + """构造期缺 ``api_key`` 必须报错,不能静默构造出无凭证的 provider。""" + settings = SimpleNamespace(demo_api_key=None, demo_base_url="https://demo.invalid/v1") + + with pytest.raises(ValueError, match="demo_api_key is required for demo"): + OpenAICompatibleProvider("m", "demo", settings=settings, options={}) + + +@pytest.mark.parametrize( + "protocol", + [ + pytest.param("grpc", id="unknown"), + pytest.param("", id="empty"), + pytest.param("chat-completions-x", id="near-miss"), + ], +) +def test_openai_compatible_protocol_rejects_unsupported_names(protocol): + """未知协议名必须报错,不能静默返回 ``None`` 让后续分支全部落空。""" + with pytest.raises(ValueError, match="不支持 .* 协议"): + OpenAICompatibleProvider._normalize_protocol(protocol) + + +def test_openai_compatible_sdk_parameter_validation_failure_is_not_skipped(): + """``Signature.bind`` 抛 ``ValueError`` 时不得静默跳过校验。""" + + class _ValueErrorSignature(inspect.Signature): + def bind(self, *args, **kwargs): + raise ValueError("positional-only argument passed as keyword") + + def bind_partial(self, *args, **kwargs): + raise ValueError("positional-only argument passed as keyword") + + def method(x): + return x + + method.__signature__ = _ValueErrorSignature() + + with pytest.raises(ValueError, match="SDK 参数无法验证"): + OpenAICompatibleProvider._validate_sdk_resource_call(method, (), {}, "OpenAI 资源") + + +@pytest.mark.parametrize( + "timeout", + [ + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + pytest.param("0", id="string"), + pytest.param(True, id="bool"), + pytest.param(None, id="none"), + pytest.param(10**4301, id="huge-int"), + pytest.param(float("nan"), id="nan"), + pytest.param(float("inf"), id="inf"), + ], +) +def test_openai_compatible_background_timeout_must_be_positive_number(timeout): + """``background_timeout`` 非正/非数值必须报错,不是静默接受或裸异常。 + + 消息含上界:``background_timeout`` 超过 ``_MAX_CLIENT_TIMEOUT_SECONDS`` 时 + 会在 ``time.sleep`` 上抛裸 ``OverflowError: timestamp out of range``(实测 + ``1e19`` 即触发),而不是本方法承诺的 ``ValueError``。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._options = {"background_timeout": timeout} + + with pytest.raises(ValueError, match="background_timeout 必须是大于 0 且不超过 86400 的正数"): + provider._background_poll_config() + + +def test_openai_compatible_background_timeout_upper_bound(): + """``background_timeout`` 的上界与客户端 timeout 同一个常量。 + + ``1e19`` 会让 ``time.sleep`` 抛裸 ``OverflowError``——不是 ``TimeoutError`` + 也不是 ``ValueError``,调用方按 ``TimeoutError`` 建立的分类接不住。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._options = {"background_timeout": 1e19} + + with pytest.raises(ValueError, match="background_timeout 必须是大于 0 且不超过 86400 的正数"): + provider._background_poll_config() + + +@pytest.mark.parametrize( + "status", + [pytest.param("queued", id="queued"), pytest.param("in_progress", id="in-progress")], +) +def test_openai_compatible_background_poll_requires_response_id(status): + """后台响应缺 ``id`` 必须报 ``RuntimeError``,不是裸 ``AttributeError``。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "demo" + + with pytest.raises(RuntimeError, match="background 响应缺少 id"): + provider._poll_background_response({"status": status, "id": ""}, {}) + + +def test_openai_compatible_background_poll_requires_response_id_async(): + """异步分支同判据。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "demo" + + with pytest.raises(RuntimeError, match="background 响应缺少 id"): + asyncio.run(provider._poll_background_response_async({"status": "queued", "id": ""}, {})) + + +def test_openai_compatible_responses_resource_rejects_chat_protocol(): + """非 responses 协议下不得把本地 SDK 资源当成远端 Responses 能力。""" + provider = _openai_compatible_provider() + provider._protocol = "chat_completions" + provider._get_client = lambda: pytest.fail("非 responses 协议不应初始化客户端") + + with pytest.raises(ValueError, match="仅适用于 responses 协议"): + provider.retrieve_response("resp-1") + + +@pytest.mark.parametrize("field", ["model", "input", "messages"]) +def test_openai_compatible_merge_options_rejects_reserved_field_override(field): + """``options`` 覆盖请求保留字段必须报错,不能静默覆盖。""" + with pytest.raises(ValueError, match="Provider options 不允许覆盖请求字段"): + OpenAICompatibleProvider._merge_options({"model": "a", "input": "b"}, {field: "x"}) + + +def test_openai_compatible_merge_resource_kwargs_rejects_caller_override(): + """调用方覆盖 Facade 已绑定的资源字段必须报错,不能静默覆盖。""" + with pytest.raises(ValueError, match="资源 SDK 参数重复"): + OpenAICompatibleProvider._merge_resource_kwargs( + {"response_id": "r1"}, {"response_id": "r2"}, "拉取响应" + ) + + +def test_openai_compatible_chat_seed_duplicating_extra_body_reports_seed(): + """Chat ``seed`` 与 options.extra_body 重复时消息必须点名 ``seed``。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._model_name = "demo" + provider._provider = "openai" + provider._protocol = "chat_completions" + provider._options = {"extra_body": {"seed": 1}} + provider._get_client = lambda: SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kw: SimpleNamespace())) + ) + + with pytest.raises(ValueError, match="Chat Completions seed 与模型 options.extra_body 重复"): + provider._build_chat_request(prompt="hi", seed=2) + + +@pytest.mark.parametrize( + "tool_choice", + [pytest.param(5, id="int"), pytest.param(1.5, id="float"), pytest.param([1], id="list")], +) +def test_openai_compatible_responses_tool_choice_rejects_non_string_non_mapping(tool_choice): + """``tool_choice`` 既非字符串也非 Mapping 必须给带字段名的 ``ValueError``。""" + with pytest.raises(ValueError, match="必须是字符串或对象"): + OpenAICompatibleProvider._convert_responses_tool_choice(tool_choice) + + +@pytest.mark.parametrize( + "tool_choice", + [ + pytest.param({"type": "function", "function": {}}, id="empty-function"), + pytest.param({"type": "function", "function": {"name": ""}}, id="blank-name"), + ], +) +def test_openai_compatible_responses_tool_choice_requires_function_name(tool_choice): + """``function`` 形态的 tool_choice 缺 name 必须报错,不能静默产出无名对象。""" + with pytest.raises(ValueError, match="function tool_choice 缺少 name"): + OpenAICompatibleProvider._convert_responses_tool_choice(tool_choice) + + +def test_openai_compatible_responses_tool_choice_function_not_mapping_without_name(): + """``function`` 非 Mapping 且顶层无 name 时,兜底判据必须拦住,不能静默返回 None。""" + with pytest.raises(ValueError, match="function tool_choice 缺少 name"): + OpenAICompatibleProvider._convert_responses_tool_choice({"type": "function", "function": 5}) + + +@pytest.mark.parametrize( + "response_format", + [ + pytest.param({"type": "json_schema", "json_schema": 5}, id="int"), + pytest.param({"type": "json_schema", "json_schema": "s"}, id="str"), + ], +) +def test_openai_compatible_responses_format_rejects_non_object_json_schema(response_format): + """``json_schema`` 非对象必须报错,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="json_schema 必须是对象"): + OpenAICompatibleProvider._convert_responses_format(response_format) + + +@pytest.mark.parametrize( + "response_format", + [pytest.param({"type": "xml"}, id="xml"), pytest.param({}, id="empty")], +) +def test_openai_compatible_responses_format_rejects_unsupported_type(response_format): + """不支持的 ``type`` 必须报错,不能静默返回 ``None`` 丢掉结构化输出约束。""" + with pytest.raises(ValueError, match="仅支持 json_schema 或 json_object"): + OpenAICompatibleProvider._convert_responses_format(response_format) + + +def test_openai_compatible_chat_error_body_reports_detail(): + """Chat 错误体含 ``error`` 且状态码正常时必须报 detail,不是「缺少 choices」。""" + with pytest.raises(RuntimeError, match="返回错误: boom"): + OpenAICompatibleProvider._raise_for_chat_response_error( + {"status": 200, "error": {"message": "boom"}}, provider="openai" + ) + + +def test_openai_compatible_chat_error_with_empty_choices_and_detail(): + """``allow_empty_choices`` 下 ``choices=None`` 且有 detail 时必须报错。""" + with pytest.raises(RuntimeError, match="返回错误"): + OpenAICompatibleProvider._raise_for_chat_response_error( + {"choices": None, "msg": "boom"}, allow_empty_choices=True, provider="openai" + ) + + +def test_openai_compatible_terminal_event_without_error_status_is_rejected(): + """终止事件非 ``response.completed`` 必须报错,不能静默产出 finish 事件。""" + with pytest.raises(RuntimeError, match="收到终止事件: response.incomplete"): + OpenAICompatibleProvider._responses_stream_event( + {"type": "response.incomplete", "response": {}}, error_provider="openai" + ) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_openai_compatible_parse_requires_text_format(async_mode): + """``parse``/``async_parse`` 缺 ``text_format`` 必须报专属消息。""" + provider = _openai_compatible_provider() + + with pytest.raises(ValueError, match="必须提供 text_format"): + if async_mode: + asyncio.run(provider.async_parse(None)) + else: + provider.parse(None) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_openai_compatible_parse_rejects_dual_response_format(async_mode): + """``request.response_format`` 与 ``text_format`` 同时使用必须报错,不能静默接受。""" + provider = _openai_compatible_provider() + provider._get_client = lambda: SimpleNamespace( + responses=SimpleNamespace(parse=lambda **kw: "ok") + ) + + async def async_parse(): + return await provider.async_parse( + dict, CompletionRequest(prompt="hi", response_format={"type": "json_object"}) + ) + + with pytest.raises(ValueError, match="不能同时使用 request.response_format 和 text_format"): + if async_mode: + asyncio.run(async_parse()) + else: + provider.parse( + dict, CompletionRequest(prompt="hi", response_format={"type": "json_object"}) + ) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_openai_compatible_embedding_rejects_request_option_overlap(async_mode): + """Embedding 请求参数与模型 options 重复时必须报错,不能静默接受。""" + provider = _openai_compatible_provider(dimensions=128) + provider._get_client = lambda: SimpleNamespace( + embeddings=SimpleNamespace(create=lambda **kw: SimpleNamespace(data=[])) + ) + provider._get_aclient = lambda: SimpleNamespace( + embeddings=SimpleNamespace(create=lambda **kw: SimpleNamespace(data=[])) + ) + + async def run(): + return await provider.aembed_documents(["x"], dimensions=64) + + with pytest.raises(ValueError, match="Embedding 请求参数与模型 options 重复"): + if async_mode: + asyncio.run(run()) + else: + provider.embed_documents(["x"], dimensions=64) + + +def test_openai_compatible_embedding_rejects_reserved_field_override(): + """Embedding 请求参数覆盖 ``model`` 必须报「不允许覆盖请求字段」。""" + provider = _openai_compatible_provider() + provider._get_aclient = lambda: SimpleNamespace( + embeddings=SimpleNamespace(create=lambda **kw: SimpleNamespace(data=[])) + ) + + async def run(): + return await provider.aembed_documents(["x"], model="other") + + with pytest.raises(ValueError, match="Embedding 不允许覆盖请求字段"): + asyncio.run(run()) + + +def test_openai_compatible_file_content_reports_missing_sdk_resource(): + """``file_content`` 缺 ``files.content`` 必须报 ``NotImplementedError``。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "openai" + provider._get_client = lambda: SimpleNamespace(files=SimpleNamespace()) + + with pytest.raises(NotImplementedError, match=r"files\.content"): + provider.file_content("file-1") + + +def test_openai_compatible_async_file_content_reports_missing_sdk_resource(): + """异步分支同判据。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "openai" + provider._get_aclient = lambda: SimpleNamespace(files=SimpleNamespace()) + + async def run(): + return await provider.async_file_content("file-1") + + with pytest.raises(NotImplementedError, match=r"files\.content"): + asyncio.run(run()) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_openai_compatible_stream_responses_rejects_request_and_kwargs(async_mode): + """``stream_responses`` 同时传 request 与原生关键字参数必须报错。""" + provider = _openai_compatible_provider() + provider._get_client = lambda: pytest.fail("双来源必须在构造请求前拦住") + + with pytest.raises(ValueError, match="不能同时传 request 和原生关键字参数"): + if async_mode: + provider.async_stream_responses(CompletionRequest(prompt="hi"), model="x") + else: + provider.stream_responses(CompletionRequest(prompt="hi"), model="x") + + +# ── Ark/Volcengine 渠道 volcengine.py 覆盖缺口守卫 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3598 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 106/125 ``_require_ark_list``/``_require_ark_content`` 非列表 → 裸 +# ``TypeError: 'int' object is not iterable``(str 还会被**逐字符**当成元素); +# * 558 ``__init__`` 未知协议 → 不再拦,``_protocol`` 落空; +# * 582 ``__init__`` 缺全部凭证 → **静默构造**出无凭证的 provider; +# * 605 ``_client_options`` 认证不完整 → **静默返回空字典**,SDK 拿到无认证客户端; +# * 648 ``_normalize_client_options`` region 非字符串 → 裸 ``AttributeError``, +# 空白 → **静默接受**、region 变成空串; +# * 660/671 ``_normalize_verified_protocols`` 非序列 → 裸 ``TypeError``,元素 +# 非法 → 裸 ``AttributeError``; +# * 675 登记未知协议 → **静默接受**,``frozenset({'grpc'})`` 通过; +# * 801 ``_require_classification_resource`` 资源未挂载 → **静默返回 None**; +# * 857/889 ``_convert_responses_format``/``_convert_responses_tools`` 形态 → +# 裸 ``AttributeError``/``TypeError``; +# * 1035/1040/1084/1088 Ark Chat 的 top_k/seed 在模型 options 与 extra_body +# 两处配置 → **静默接受**,其中一个被无声丢弃; +# * 1108 extra_body 与模型 options 重复 → **静默覆盖**; +# * 1507 ``_prepare_batch_kwargs`` 缺必填参数 → 消息退化成「不支持请求参数」, +# 把「缺字段」说成「字段不受支持」; +# * 1510 资源 model 非字符串/空白 → **静默接受**,非法 model 一路发给 SDK; +# * 1663 Responses 状态 ``incomplete`` → **静默返回 None**,不完整响应当成成功; +# * 2396/2718 ``create_response``/``async_create_response`` 同时传 request 与 +# 原生关键字参数 → 裸 ``AttributeError``; +# * 2525/2881 ``count_tokens``/``async_count_tokens`` 响应缺 ``data`` → +# **静默返回 0**,把「响应不完整」报成「没有 token」; +# * 2608/2957 Classification 资源导入失败 → 裸 ``UnboundLocalError``; +# * 3028/3051 ``close``/``aclose`` 首个关闭异常 → **静默吞掉**,不向上报告。 +# +# 同批的 578(部分 AK/SK)与 631(timeout 非数值)经甄别是**等价变异**,不补测试: +# 578 删掉后落到 582 的同一条消息,631 删掉后落到下游 ``normalize_client_timeout`` +# 的同一条消息,结论都不变。 + + +def _volcengine_provider(**options): + provider = object.__new__(VolcengineProvider) + provider._model_name = "demo" + provider._provider = "volcengine" + provider._protocol = "chat_completions" + provider._options = options + provider._base_url = None + provider._api_key = "ak" + provider._secret_key = "sk" + provider._ark_api_key = "ark-key" + return provider + + +def _volcengine_init(settings): + """用给定 settings 跑一遍真实构造路径(构造期凭证判据在 ``get_settings`` 之后)。 + + 补丁打在 ``VolcengineProvider`` 所属类的 ``__globals__`` 上,而**不是** + 重新 ``import src.providers.volcengine``:``tests/providers/test_factory.py`` + 的 autouse 夹具会把该模块从 ``sys.modules`` 删除并重新导入,此时 + ``import ... as m`` 拿到的是**新模块对象**,而类仍从**旧模块字典**取全局名 + ——补丁会静默落空,走真实 ``get_settings``(全量套件下才复现)。 + """ + module_globals = VolcengineProvider.__init__.__globals__ + + original = module_globals["get_settings"] + module_globals["get_settings"] = lambda: settings + try: + return VolcengineProvider("m", protocol="ark", options={}) + finally: + module_globals["get_settings"] = original + + +async def _async_value(value): + return value + + +class _ClassificationImportBlocker: + """拦截 ``volcenginesdkarkruntime.resources.classification`` 的导入。""" + + _TARGET = "volcenginesdkarkruntime.resources.classification" + + def find_spec(self, name, path=None, target=None): + if name == self._TARGET: + raise ImportError(f"No module named {name!r}") + + +def _with_blocked_classification_import(call): + # 必须先逐出 ``sys.modules`` 里的缓存:全套运行时更早的用例可能已经导入过该 + # 模块,此后 ``from ... import`` 直接命中缓存、根本不会走 ``find_spec``,拦截器 + # 形同虚设(单跑时缓存为空才通过)。逐出后再装拦截器,才能稳定复现「SDK 未 + # 提供该资源」这条分支。 + target = _ClassificationImportBlocker._TARGET + cached = sys.modules.pop(target, None) + blocker = _ClassificationImportBlocker() + sys.meta_path.insert(0, blocker) + try: + return call() + finally: + sys.meta_path.remove(blocker) + if cached is not None: + sys.modules[target] = cached + + +@pytest.mark.parametrize( + "value", + [pytest.param(5, id="int"), pytest.param(1.5, id="float"), pytest.param({"a": 1}, id="dict")], +) +def test_ark_require_list_rejects_non_sequence(value): + """Ark 容器字段非列表必须报 ``RuntimeError``,不是裸 ``TypeError``。""" + with pytest.raises(RuntimeError, match="必须是列表"): + _require_ark_list(value, label="output") + + +def test_ark_require_list_treats_string_as_sequence_of_characters(): + """字符串会被**逐字符**当成元素——必须显式拒绝,不能静默拆成字符列表。""" + with pytest.raises(RuntimeError, match="必须是列表"): + _require_ark_list("ab", label="output") + + +def test_ark_require_content_rejects_non_sequence(): + """嵌套 ``content`` 非列表必须报 ``RuntimeError``。""" + with pytest.raises(RuntimeError, match="必须是列表"): + _require_ark_content({"content": 5}, label="content") + + +@pytest.mark.parametrize( + "protocol", + [pytest.param("grpc", id="unknown"), pytest.param("", id="empty")], +) +def test_volcengine_init_rejects_unsupported_protocol(protocol): + """未知协议必须报错,不能静默留下落空的 ``_protocol``。""" + with pytest.raises(ValueError, match="仅支持 ark/chat_completions 或 responses"): + VolcengineProvider("m", protocol=protocol) + + +def test_volcengine_init_requires_credentials(): + """缺全部凭证必须报错,不能静默构造出无凭证的 provider。""" + settings = SimpleNamespace( + volc_access_key=None, volc_secret_key=None, ark_api_key=None, volc_base_url=None + ) + + with pytest.raises(ValueError, match="需要配置 ARK_API_KEY"): + _volcengine_init(settings) + + +def test_volcengine_client_options_require_complete_credentials(): + """``_client_options`` 认证不完整必须报错,不能静默返回空字典。""" + provider = object.__new__(VolcengineProvider) + provider._options = {} + provider._base_url = None + provider._api_key = None + provider._secret_key = None + provider._ark_api_key = None + + with pytest.raises(ValueError, match="认证配置不完整"): + provider._client_options() + + +def test_volcengine_client_options_reject_non_string_region(): + """region 非字符串必须报错,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="region 必须是非空字符串"): + VolcengineProvider._normalize_client_options({"region": 5}) + + +def test_volcengine_client_options_reject_blank_region(): + """region 纯空白必须报错,不能静默收敛成空串。""" + with pytest.raises(ValueError, match="region 必须是非空字符串"): + VolcengineProvider._normalize_client_options({"region": " "}) + + +@pytest.mark.parametrize( + "configured", + [pytest.param(5, id="int"), pytest.param(1.5, id="float")], +) +def test_volcengine_verified_protocols_reject_non_sequence(configured): + """``server_verified_protocols`` 非序列必须报错,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="必须是字符串或协议序列"): + VolcengineProvider._normalize_verified_protocols(configured) + + +def test_volcengine_verified_protocols_reject_non_string_element(): + """登记项非字符串必须报错,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="中的协议必须是非空字符串"): + VolcengineProvider._normalize_verified_protocols([5]) + + +def test_volcengine_verified_protocols_reject_unknown_name(): + """登记未知协议必须报错,不能静默接受 ``frozenset({'grpc'})``。""" + with pytest.raises(ValueError, match="不支持登记服务端协议: grpc"): + VolcengineProvider._normalize_verified_protocols(["grpc"]) + + +def test_ark_classification_guard_reports_missing_resource(): + """分类资源未挂载时必须报错,不能静默返回 ``None``。""" + provider = _volcengine_provider() + provider._protocol = "responses" + provider._client = object() + provider._get_classification_resource = lambda: (_ for _ in ()).throw( + NotImplementedError("no classification") + ) + + with pytest.raises(NotImplementedError, match="Ark classify"): + provider._require_classification_resource("classify") + + +@pytest.mark.parametrize( + "response_format", + [pytest.param(5, id="int"), pytest.param("s", id="str")], +) +def test_ark_responses_format_rejects_non_object(response_format): + """``response_format`` 非对象必须报错,不是裸 ``AttributeError``。""" + with pytest.raises(ValueError, match="response_format 必须是对象"): + VolcengineProvider._convert_responses_format(response_format) + + +@pytest.mark.parametrize("tools", [pytest.param(5, id="int"), pytest.param(1.5, id="float")]) +def test_ark_responses_tools_reject_non_sequence(tools): + """``tools`` 非列表必须报错,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="工具定义必须是列表"): + VolcengineProvider._convert_responses_tools(tools) + + +@pytest.mark.parametrize("field", ["top_k", "seed"]) +def test_ark_chat_model_options_reject_duplicate_with_extra_body(field): + """模型 options 的 top_k/seed 与 extra_body 重复时必须报错,不能静默丢弃。""" + provider = _volcengine_provider(extra_body={field: 1}, **{field: 2}) + + with pytest.raises(ValueError, match=rf"模型 options 的 {field} 与 extra_body 重复"): + provider._build_chat_request("hi") + + +@pytest.mark.parametrize("field", ["top_k", "seed"]) +def test_ark_chat_request_rejects_duplicate_with_model_extra_body(field): + """请求级 top_k/seed 与模型 options.extra_body 重复时必须报错。""" + provider = _volcengine_provider(extra_body={field: 1}) + + with pytest.raises(ValueError, match=rf"{field} 与模型 options.extra_body 重复"): + provider._build_chat_request("hi", **{field: 2}) + + +def test_ark_chat_extra_body_duplicate_with_model_options(): + """请求级 extra_body 与模型 options 重复时必须报错,不能静默覆盖。""" + provider = _volcengine_provider(extra_body={"a": 1}) + + with pytest.raises(ValueError, match="extra_body 与模型 options 重复"): + provider._build_chat_request("hi", extra_body={"a": 2}) + + +def test_ark_batch_kwargs_reports_missing_required_parameter(): + """缺必填参数必须点名,不能退化成「不支持请求参数」。""" + with pytest.raises(ValueError, match="缺少必填参数: name"): + VolcengineProvider._prepare_batch_kwargs( + {}, "demo", "知识库", frozenset({"name"}), ("name",) + ) + + +@pytest.mark.parametrize("model", [pytest.param(5, id="int"), pytest.param(" ", id="blank")]) +def test_ark_batch_kwargs_rejects_invalid_model(model): + """资源 model 非字符串/空白必须报错,不能静默发给 SDK。""" + with pytest.raises(ValueError, match="model 必须是非空字符串"): + VolcengineProvider._prepare_batch_kwargs( + {"model": model, "name": "k"}, + "demo", + "知识库", + frozenset({"model", "name"}), + ("name",), + ) + + +def test_ark_responses_incomplete_status_is_rejected(): + """状态 ``incomplete`` 必须报错,不能静默当成成功。""" + with pytest.raises(RuntimeError, match="响应不完整"): + VolcengineProvider._raise_for_response_error( + {"status": "incomplete", "incomplete_details": {"reason": "max_tokens"}} + ) + + +def test_ark_responses_cancelled_status_is_rejected(): + """状态 ``cancelled`` 必须报错,不能静默返回 None。 + + 删除守卫后 ``status == "cancelled"`` 的响应不再抛错,调用方把「请求已取消」 + 当成成功响应继续用(``_raise_for_response_error`` 静默返回 ``None``)。 + 变异扫描证实该守卫在整套测试里没有反向覆盖(既有 ``cancelled`` 用例打的是 + 流式事件路径 ``_responses_stream_event``,不是本函数)。 + """ + with pytest.raises(RuntimeError, match="已取消"): + VolcengineProvider._raise_for_response_error({"status": "cancelled"}) + + +def test_ark_responses_incomplete_reason_with_huge_int_reports_readably(): + """``incomplete`` 原因是大整数时消息不得抛位数超限的 ``ValueError``。""" + with pytest.raises(RuntimeError) as excinfo: + VolcengineProvider._raise_for_response_error( + {"status": "incomplete", "incomplete_details": {"reason": 10**4300}} + ) + + assert "Exceeds the limit" not in str(excinfo.value) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_ark_create_response_rejects_request_and_kwargs(async_mode): + """``create_response`` 同时传 request 与原生关键字参数必须报错。""" + provider = _volcengine_provider() + provider._protocol = "responses" + provider._server_verified_protocols = frozenset({"responses"}) + provider._get_client = lambda: pytest.fail("双来源必须在构造请求前拦住") + provider._get_aclient = lambda: pytest.fail("双来源必须在构造请求前拦住") + + with pytest.raises(ValueError, match="不能同时传 request 和原生关键字参数"): + if async_mode: + asyncio.run(provider.async_create_response(CompletionRequest(prompt="hi"), model="x")) + else: + provider.create_response(CompletionRequest(prompt="hi"), model="x") + + +@pytest.mark.parametrize( + "items", + [pytest.param([], id="empty"), pytest.param(None, id="missing-field")], +) +def test_ark_count_tokens_requires_data(items): + """tokenization 响应缺 ``data`` 必须报错,不能静默返回 0。""" + provider = _ark_provider_with_tokenization_response(items) + + with pytest.raises(RuntimeError, match="tokenization 响应缺少 data"): + provider.count_tokens("hi") + + +@pytest.mark.parametrize( + "items", + [pytest.param([], id="empty"), pytest.param(None, id="missing-field")], +) +def test_ark_async_count_tokens_requires_data(items): + """异步分支同判据。""" + provider = _ark_provider_with_tokenization_response(items) + provider._get_aclient = lambda: SimpleNamespace( + tokenization=SimpleNamespace( + create=lambda **kwargs: _async_value(SimpleNamespace(data=items)) + ) + ) + + async def run(): + return await provider.async_count_tokens("hi") + + with pytest.raises(RuntimeError, match="tokenization 响应缺少 data"): + asyncio.run(run()) + + +def test_ark_classification_import_failure_reports_readably(): + """Classification 模块导入失败时必须报 ``NotImplementedError``,不是裸 ``UnboundLocalError``。""" + provider = _volcengine_provider() + provider._protocol = "responses" + provider._classification_resource = None + provider._get_client = lambda: SimpleNamespace() + + with pytest.raises(NotImplementedError, match="Ark Classification 资源"): + _with_blocked_classification_import(provider._get_classification_resource) + + +def test_ark_async_classification_import_failure_reports_readably(): + """异步分支同判据。""" + provider = _volcengine_provider() + provider._protocol = "responses" + provider._async_classification_resource = None + provider._get_aclient = lambda: SimpleNamespace() + + async def run(): + return await provider._get_async_classification_resource() + + with pytest.raises(NotImplementedError, match="Ark Async Classification 资源"): + _with_blocked_classification_import(lambda: asyncio.run(run())) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_volcengine_close_reports_first_error(async_mode): + """``close``/``aclose`` 首个关闭异常必须向上报告,不能静默吞掉。""" + + class _BadClose: + def close(self): + raise RuntimeError("close 失败") + + provider = object.__new__(VolcengineProvider) + provider._client = _BadClose() + provider._aclient = None + provider._classification_resource = None + provider._async_classification_resource = None + + with pytest.raises(RuntimeError, match="close 失败"): + if async_mode: + asyncio.run(provider.aclose()) + else: + provider.close() + + +# ── OpenAICompatibleProvider Responses 扩展字段重复 / close 的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3698 条仍全绿)。删除后行为确实改变(实测,经 +# ``_build_responses_request`` 真实入口): +# * 1415 ``options.stop``/``options.seed`` 与 ``extra_body`` 重复 → 消息从 +# 「Responses options.X 与 extra_body 重复」退化成下游 +# ``_merge_extra_body`` 的「模型 options 的扩展字段重复」:同一个重复被报两次, +# 但**点名的是合并器而不是字段来源**,调用方按消息定位要改的地方会改错; +# * 1553 ``options.reasoning_effort`` 与 ``extra_body.reasoning`` 重复 → 消息 +# 退化成「extra_body 不允许覆盖请求字段: reasoning」,把「两个来源配了同一个 +# 意图」说成「extra_body 越权覆盖请求字段」; +# * 5678/5699 ``close``/``aclose`` 首个关闭异常 → **静默吞掉**(Google 与 +# Volcengine 的两个入口都有覆盖,OpenAI 兼容渠道没有——sibling 缺口)。 +def _responses_provider(**options): + provider = _openai_compatible_provider(**options) + provider._server_verified_protocols = frozenset({"responses"}) + return provider + + +@pytest.mark.parametrize("field,value", [("stop", ["x"]), ("seed", 7)]) +def test_openai_compatible_responses_rejects_option_duplicated_in_extra_body(field, value): + """``options`` 的 stop/seed 与 ``extra_body`` 重复时必须点名是哪个字段重复。 + + 只覆盖 ``configured_options`` 那一半:``_compat_extra_options()`` 里的 + ``top_k``/``repetition_penalty`` 走的是下游 ``_merge_extra_body``,报的是 + 「模型 options 的扩展字段重复」,与本条不是同一判据。 + """ + provider = _responses_provider(**{field: value, "extra_body": {field: "other"}}) + + with pytest.raises(ValueError, match=rf"Responses options\.{field} 与 extra_body 重复"): + provider._build_responses_request(prompt="hi") + + +def test_openai_compatible_responses_rejects_reasoning_effort_duplicated_in_extra_body(): + """``reasoning_effort`` 与 ``extra_body.reasoning`` 重复时必须点名来源。""" + provider = _responses_provider( + reasoning_effort="high", extra_body={"reasoning": {"effort": "low"}} + ) + + with pytest.raises( + ValueError, match="Responses reasoning_effort 与模型 options.extra_body 重复" + ): + provider._build_responses_request(prompt="hi") + + +def test_openai_responses_rejects_reasoning_duplicating_model_extra_body(): + """显式 ``reasoning`` 与 ``extra_body.reasoning`` 重复时必须点名来源。 + + ``_build_chat_request`` 的同名判据早有覆盖,但 Responses 路径(1549)是 + **另一处**独立判据,变异扫描证实它在整套测试里存活:删除后消息退化成 + ``_validated_extra_body`` 的「不允许覆盖请求字段: reasoning」——只说 + 「冲突」,不再指出冲突的是「模型 options 里的 extra_body」,用户不知道该 + 去改配置文件还是改调用参数。同函数 1553 的 ``reasoning_effort`` 分支已有 + 用例钉住(变异后变红),本条补对称的 ``reasoning`` 分支。 + """ + provider = _responses_provider(extra_body={"reasoning": {"effort": "low"}}) + + with pytest.raises(ValueError, match="Responses reasoning 与模型 options.extra_body 重复"): + provider._build_responses_request(prompt="hi", reasoning={"effort": "high"}) + + +class _BadOpenAIClose: + def close(self): + raise RuntimeError("close 失败") + + +@pytest.mark.parametrize("method_name", ["close", "aclose"]) +def test_openai_compatible_close_reports_first_error(method_name): + """``close``/``aclose`` 首个关闭异常必须向上报告,不能静默吞掉。""" + provider = _openai_compatible_provider() + provider._client = _BadOpenAIClose() if method_name == "close" else None + provider._aclient = _BadOpenAIClose() if method_name == "aclose" else None + + with pytest.raises(RuntimeError, match="close 失败"): + result = getattr(provider, method_name)() + if method_name == "aclose": + asyncio.run(result) + + +# ── VolcengineProvider Ark SDK 缺失时的报错形态 ── +# +# ``_load_ark_clients`` 在导入失败时抛带指引的 ``RuntimeError``。这条判据在整套 +# 测试里从未执行(行覆盖 + 变异扫描双证;把 ``raise`` 换成 ``pass`` 后全量 3698 +# 条仍全绿)——SDK 在开发机与 CI 上都已安装,这条分支只在缺依赖的部署里走到。 +# 删除后行为确实改变(实测):``Ark`` 未绑定,收场从 +# ``RuntimeError: 未安装 volcengine-python-sdk[ark]...`` 变成裸 +# ``UnboundLocalError: cannot access local variable 'Ark'``——调用方按 +# ``RuntimeError`` 分类,接不住。 +class _ArkSdkImportBlocker: + """拦截 ``volcenginesdkarkruntime`` 顶层包的导入。""" + + _TARGET = "volcenginesdkarkruntime" + + def find_spec(self, name, path=None, target=None): + if name == self._TARGET: + raise ImportError(f"No module named {name!r}") + + +def _with_blocked_ark_sdk_import(call): + # 同 ``_with_blocked_classification_import``:先逐出缓存再装拦截器,否则 + # ``from volcenginesdkarkruntime import ...`` 直接命中 ``sys.modules``、 + # 拦截器形同虚设。顶层包与全部子模块都要逐出——``from X import Y`` 命中 + # 缓存时走的是 ``X`` 本身。 + target = _ArkSdkImportBlocker._TARGET + cached = { + name: sys.modules.pop(name) + for name in list(sys.modules) + if name == target or name.startswith(target + ".") + } + blocker = _ArkSdkImportBlocker() + sys.meta_path.insert(0, blocker) + try: + return call() + finally: + sys.meta_path.remove(blocker) + sys.modules.update(cached) + + +def test_volcengine_ark_sdk_missing_reports_readably(): + """缺 Ark SDK 时必须报带安装指引的 ``RuntimeError``,不是裸 ``UnboundLocalError``。""" + import src.providers.volcengine as volcengine_module + + with pytest.raises(RuntimeError, match=r"未安装 volcengine-python-sdk\[ark\]"): + _with_blocked_ark_sdk_import(volcengine_module._load_ark_clients) + + +def _ark_provider_for_logging_tests(): + """构造一个最小可用的 Ark provider(不发真实请求)。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "doubao" + provider._options = {} + provider._protocol = "chat_completions" + return provider + + +@pytest.mark.parametrize( + "method_name, is_async", + [ + pytest.param("invoke", False, id="invoke"), + pytest.param("ainvoke", True, id="ainvoke"), + pytest.param("embed_documents", False, id="embed_documents"), + pytest.param("aembed_documents", True, id="aembed_documents"), + ], +) +def test_ark_call_paths_log_failures_before_reraising(caplog, method_name, is_async): + """Ark 的四条调用路径失败时必须留下日志。 + + 另外三家(anthropic/google/openai_compatible)的 ``invoke``/``ainvoke``/ + ``embed_documents``/``aembed_documents`` 都有同一层包装: + ``except Exception: logger.exception(...); raise``——记日志后**重新抛出**, + 不吞掉异常。Ark 是同一批适配器里的第四个渠道,四处全部缺失:失败时零日志, + 用户只看到异常,排查无从下手(``safe_exception_text`` 的脱敏与截断也一并失效)。 + """ + import logging + + provider = _ark_provider_for_logging_tests() + + class _Boom: + def __getattr__(self, name): + raise RuntimeError("Ark 调用失败") + + provider._get_client = lambda: _Boom() + provider._get_aclient = lambda: _Boom() + + with ( + caplog.at_level(logging.ERROR, logger="src.providers.volcengine"), + pytest.raises(RuntimeError, match="Ark 调用失败"), + ): + if is_async: + if method_name == "ainvoke": + asyncio.run(_consume_async(provider.ainvoke(prompt="hi"))) + else: + asyncio.run(provider.aembed_documents(["文本"])) + else: + if method_name == "invoke": + list(provider.invoke(prompt="hi")) + else: + provider.embed_documents(["文本"]) + assert caplog.records, f"{method_name} 失败时没有留下任何日志" + + +async def _consume_async(agen): + async for _ in agen: + pass + + +def test_volcengine_init_partial_credentials_names_both_keys(): + """只配一半 VOLC 凭证时必须点名缺的另一半,而不是泛化消息。 + + ``except`` 前的分支区分「完全没有凭证」与「只配了一半」:前者提示 + ``ARK_API_KEY``,后者提示「需要同时配置 VOLC_ACCESS_KEY 和 + VOLC_SECRET_KEY」。删除部分凭证分支后两者共用泛化消息,用户按提示 + 再配一个 ARK key 仍然报同样的错。 + """ + settings = SimpleNamespace( + volc_access_key="ak", volc_secret_key=None, ark_api_key=None, volc_base_url=None + ) + + with pytest.raises(ValueError, match="需要同时配置 VOLC_ACCESS_KEY 和 VOLC_SECRET_KEY"): + _volcengine_init(settings) + + +class _ClassificationAttributeErrorBlocker: + """让 ``classification`` 模块**导入成功但模块体抛 AttributeError**。 + + 与 ``_ClassificationImportBlocker`` 的区别:那个在 ``find_spec`` 抛 + ``ImportError``,覆盖的是「模块不存在」;这个在 ``exec_module`` 抛 + ``AttributeError``,覆盖的是「模块存在但初始化引用了缺失属性」—— + 真实的 SDK 版本错配常是后者。 + """ + + _TARGET = "volcenginesdkarkruntime.resources.classification" + + class _Loader: + def create_module(self, spec): + return None + + def exec_module(self, module): + raise AttributeError("模块体引用了缺失属性") + + def find_spec(self, name, path=None, target=None): + if name == self._TARGET: + import importlib.util + + return importlib.util.spec_from_loader(name, self._Loader()) + return None + + +def _with_classification_attribute_error(call): + import sys + + target = _ClassificationAttributeErrorBlocker._TARGET + cached = sys.modules.pop(target, None) + blocker = _ClassificationAttributeErrorBlocker() + sys.meta_path.insert(0, blocker) + try: + return call() + finally: + sys.meta_path.remove(blocker) + if cached is not None: + sys.modules[target] = cached + + +def test_ark_classification_attribute_error_reports_readably(): + """模块体抛 ``AttributeError`` 时同样收敛成 ``NotImplementedError``。 + + ``except (ImportError, AttributeError)`` 的 ``AttributeError`` 此前无测试 + 触发——收窄成 ``except ImportError`` 后,模块体 AttributeError 冒泡, + 用户拿到的是 SDK 内部错误而不是能力缺失提示。 + """ + provider = _volcengine_provider() + provider._protocol = "responses" + provider._classification_resource = None + provider._get_client = lambda: SimpleNamespace() + + with pytest.raises(NotImplementedError, match="Ark Classification 资源"): + _with_classification_attribute_error(provider._get_classification_resource) + + +def test_ark_async_classification_attribute_error_reports_readably(): + """异步分支同判据。""" + provider = _volcengine_provider() + provider._protocol = "responses" + provider._async_classification_resource = None + provider._get_aclient = lambda: SimpleNamespace() + + async def run(): + return await provider._get_async_classification_resource() + + with pytest.raises(NotImplementedError, match="Ark Async Classification 资源"): + _with_classification_attribute_error(lambda: asyncio.run(run())) + + +def test_google_http_options_timeout_rejects_zero_and_accepts_positive(): + """``timeout <= 0`` 拒绝:0 与负数都必须拦下(``<`` 不是 ``<=``)。""" + from src.providers.google import GoogleProvider + + for bad in (0, -1, 0.0): + with pytest.raises(ValueError, match="正整数毫秒"): + GoogleProvider._validate_http_options({"timeout": bad}) + GoogleProvider._validate_http_options({"timeout": 1}) + + +# ── 回归:chat 请求构造必须把 ``require_tool_call_ids=True`` 传给规范化层 ── +# +# openai 1048 与 volcengine 1020 的布尔字面量互换后全量测试仍全绿:两者调用 +# ``normalize_messages(..., require_tool_call_ids=True)`` 的**调用点标志**无人 +# 看守——既有的 ``test_chat_completions_still_requires_tool_call_id`` 直接调 +# ``normalize_messages`` 本身,不经过构造器。反向成 ``False`` 后缺 id 的 +# assistant 历史被静默放进请求,网关按自己那一套处理(或直接 400),而 Chat +# Completions 协议要求 assistant 历史的每个 ``tool_call`` 都带 id(构造器上方 +# 的注释写明了这一点)。 + + +@pytest.mark.parametrize( + "provider_cls,protocol", + [ + pytest.param(OpenAICompatibleProvider, "chat_completions", id="openai"), + pytest.param(VolcengineProvider, "chat_completions", id="volcengine"), + ], +) +def test_build_chat_request_rejects_tool_calls_without_id(provider_cls, protocol): + provider = object.__new__(provider_cls) + provider._model_name = "m" + provider._provider = "openai" + provider._protocol = protocol + provider._options = {} + messages = [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"type": "function", "function": {"name": "f", "arguments": "{}"}}], + } + ] + + with pytest.raises(ValueError, match="缺少 id/call_id"): + provider._build_chat_request(prompt="hi", messages=messages) + + +# ── 回归:动态资源 Facade 的 ``_delegate_native`` 必须逐类声明 ── +# +# 四个类的 ``_delegate_native = True`` 互换后全量测试仍全绿(boolc 扫描): +# 既有用例只通过 ``dir(resources_module)`` 取**第一个** OpenAI 命名的 Facade +# 来验证动态资源代理,其余类从不实例化。反向成 ``False`` 后 ``__getattr__`` +# 与 ``beta``/``async_beta`` 直接抛 ``AttributeError``——官方 SDK 在模块发布 +# 之后新增的资源(Realtime/Webhooks/Interactions 等)对这几个渠道整棵树消失, +# 而这是 Facade 存在的唯一理由。 + + +@pytest.mark.parametrize( + "facade_name,client_attr", + [ + pytest.param("GoogleResources", "sdk_client", id="google"), + pytest.param("AsyncGoogleResources", "async_sdk_client", id="async-google"), + pytest.param("AsyncArkResources", "async_sdk_client", id="async-ark"), + ], +) +def test_facade_delegates_native_resources_for_every_declaring_class(facade_name, client_attr): + """每个声明 ``_delegate_native = True`` 的 Facade 都要能转发动态资源。""" + import src.providers.resources as resources_module + + facade_cls = getattr(resources_module, facade_name) + assert facade_cls._delegate_native is True + + provider = SimpleNamespace( + _provider="demo", + sdk_client=SimpleNamespace(brand_new="SYNC", beta="SYNC-BETA"), + async_sdk_client=SimpleNamespace(brand_new="ASYNC", beta="ASYNC-BETA"), + ) + facade = facade_cls(provider) + + # ``__getattr__`` 走 ``_delegate_client_name``,而 ``beta``/``async_beta`` + # 两个属性方法固定读同步/异步客户端——三者的客户端来源不同,分开断言。 + expected = "ASYNC" if client_attr == "async_sdk_client" else "SYNC" + assert facade.brand_new == expected + assert facade.beta == "SYNC-BETA" + assert facade.async_beta == "ASYNC-BETA" diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 10f5a60..1a3ac05 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -1,11 +1,14 @@ """凭证边界与脱敏的回归测试。""" +import base64 + import pytest from src.utils.security import ( find_sensitive_option_paths, is_sensitive_option_key, redact_sensitive_text, + safe_exception_text, validate_secret_free_options, ) @@ -103,7 +106,7 @@ def test_security_redacts_every_sensitive_key_name_in_text(key): def test_security_redacts_sensitive_key_names_in_url_query(key): """URL query 形态同样要覆盖,不能只处理 key=value。 - 值必须**短到只有 ``_URL_QUERY_SECRET_RE`` 能覆盖**:``_KEY_VALUE_TEXT_RE`` + 值必须**短到只有 ``_URL_QUERY_SECRET_RE`` 能覆盖**:``_CREDENTIAL_KEY_VALUE_RE`` 的空白/标点形态要求值至少 12 字符且含数字或符号,用一个长值会让这条断言 由另一条规则满足——删掉 URL query 规则测试照样通过,即测错了对象。 """ @@ -143,6 +146,93 @@ def test_security_redacts_masked_credentials_after_bearer_prefix(value): assert "****" not in redacted +# ── 回归:无键名锚定的自证前缀裸凭证必须脱敏 ── +# +# ``sk-``/``AIza``/``AKLT`` 已有自证前缀规则,但 GitHub PAT/Slack token/ +# AWS Access Key/Jina(本项目 rerank 渠道实际在用)同样是无键名也能认出的 +# 固定前缀形态。触发面是**不可控文本**:SDK 异常回显、知识库正文、嵌入方回传 +# 的服务端消息——不能依赖 ``key=value`` 上下文。 + + +# 假凭证主体用 ``"".join`` 拼出而非字面量:GitHub Push Protection 会对 +# 字面量形态的 token 前缀 + 主体整串拦推送,而它们只是合成样本,不指向 +# 任何真实凭证。拼接后的运行时取值与原字面量逐字相同。体均**含数字**且 +# 长度在正则上界内(真实 token 是随机混合、必含数字;纯字母体是正文 +# 标识符,按 2026-10 第三轮审查决策显式不脱敏)。 +_GHP_BODY = "0123456789abcdefghijklmnopqrstuvwxyzAB9" +_PAT_BODY = "11AAA0123aaaaBBBBcccc5678dddd" +_XOX_BODY = "-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx" +_AKIA_BODY = "IOSFODNN7EXAMPLE" +_ASIA_BODY = "0123456789ABCDEF12" +_JINA_BODY = "0123456789abcdef0123456789abc123" + + +@pytest.mark.parametrize( + "credential", + [ + "ghp_" + _GHP_BODY, + "github_pat_" + _PAT_BODY, + "xoxb" + _XOX_BODY, + "xoxp" + _XOX_BODY, + "invalid key AKIA" + _AKIA_BODY + " here", + "invalid key ASIA" + _ASIA_BODY + " here", + "invalid key 'jina_" + _JINA_BODY + "'", + ], +) +def test_security_redacts_bare_self_attesting_prefix_credentials(credential): + redacted = redact_sensitive_text(credential) + assert credential not in redacted + assert "[REDACTED]" in redacted + + +@pytest.mark.parametrize( + "benign", + [ + # 前缀词后面没有凭证体:普通文本不能被脱敏(否则正文被破坏)。 + "the ghp variable is set", + "AKIAIOSFOD", + "xoxb is a prefix", + "jina_ is the key prefix", + "访问 ghp 文档", # 中文正文里的裸前缀词 + ], +) +def test_security_does_not_redact_bare_prefix_words_without_credential_body(benign): + assert redact_sensitive_text(benign) == benign + + +# ── 回归:Basic 认证的 base64 必须脱敏 ── + + +# ``Authorization: Basic `` 是 SDK 异常回显的常见形态。``Basic`` 是纯 +# 字母状态词,会被键值正则按「裸关键词 + 状态字面量」放行;而 ``_BEARER_TEXT_RE`` +# 只认 ``bearer``。此前 ``Authorization: Basic `` 脱敏后逐字 +# 不变,base64 凭证原样落进日志。 +# +# base64 在运行时从明文占位符构造:源码里不落任何可被凭证扫描器(GitGuardian +# 等)误判为硬编码密钥的 base64 字面量——扫描器只看源码文本,认不出这是测试 +# 夹具。占位符明文写死在下一行,编码结果每次相同,断言语义与硬编码字面量一致。 +_BASIC_AUTH_CREDENTIAL = base64.b64encode(b"fixture-user:fixture-password").decode() + + +@pytest.mark.parametrize( + "header", + [ + f"Authorization: Basic {_BASIC_AUTH_CREDENTIAL}", + f"Proxy-Authorization: Basic {_BASIC_AUTH_CREDENTIAL}", + f"authorization=basic {_BASIC_AUTH_CREDENTIAL}", + ], +) +def test_security_redacts_basic_auth_credentials(header): + redacted = redact_sensitive_text(header) + assert _BASIC_AUTH_CREDENTIAL not in redacted + assert "[REDACTED]" in redacted + + +def test_security_does_not_redact_basic_as_ordinary_word(): + """前置 ``authorization`` 键名是判据:普通文本里的 ``basic`` 不是凭证。""" + assert "basic understanding of tokens" == redact_sensitive_text("basic understanding of tokens") + + # ── 回归:urlsplit 无法解析的值不能让凭证整体绕过边界 ── @@ -154,6 +244,26 @@ def test_security_rejects_credential_hidden_behind_unparseable_url(): validate_secret_free_options({"note": unparseable}, "Provider") +def test_security_unparseable_url_reports_the_real_field_path(): + """回退分支的凭证消息必须带**真实字段路径**,不能退化成占位串 ``value``。 + + ``fallback = [path or "value"] if ... else []`` 里的 ``path or "value"`` 是 + 「路径为空时用占位串」的兜底;正常调用路径下 ``path`` 恒非空(``visit`` 从 + 字段名拼出)。把 ``or`` 互换为 ``and`` 后,非空 ``path`` 反而被换成 + ``"value"``——凭证仍被拒(消息里仍有「凭证」二字),但用户看到的是 + 「凭证出现在 value」,无法定位是哪个配置字段写坏了。既有用例只 + ``match="凭证"``,不校验路径,因此这条从未被覆盖。 + """ + unparseable = "https://[::1 token sk-FAKE0000FAKE0000FAKE0000FAKE0000" + + with pytest.raises(ValueError) as excinfo: + validate_secret_free_options({"endpoint": unparseable}, "Provider") + + message = str(excinfo.value) + assert "endpoint" in message + assert "value" not in message + + def test_security_parseable_and_unparseable_urls_are_equally_strict(): """同样藏凭证的两个值,只因 URL 可解析与否而一个被拒一个放行,即为漏洞。""" secret = "token sk-FAKE0000FAKE0000FAKE0000FAKE0000" @@ -166,6 +276,24 @@ def test_security_parseable_and_unparseable_urls_are_equally_strict(): # ── 回归:面向用户的异常输出必须脱敏 ── +def test_redact_sensitive_text_survives_huge_integers(): + """位数超限的整数必须降级为量级描述,而不是抛 ``ValueError``。""" + for value in (10**4301, -(10**4301)): + result = redact_sensitive_text(value) + + assert isinstance(result, str) + assert "Exceeds the limit" not in result + + +def test_redact_sensitive_text_still_redacts_non_string_containers(): + """收紧不能误伤:容器形态的脱敏照常生效。""" + secret = "sk-live-abcdef123456" + + assert secret not in redact_sensitive_text({"api_key": secret}) + assert secret not in redact_sensitive_text({"a": [{"b": secret}]}) + assert secret not in redact_sensitive_text(f"Bearer {secret}") + + def test_safe_exception_text_redacts_credentials(): """SDK 异常常回显请求头或 URL,终端与日志是凭证最容易泄漏的出口。""" from src.utils.security import safe_exception_text @@ -184,6 +312,69 @@ def test_safe_exception_text_handles_empty_and_long_messages(): assert len(safe_exception_text(RuntimeError("x" * 500))) <= 260 +# ``safe_exception_text`` 是**最后一道展示边界**:22 处 ``except`` 都经它输出, +# 它自己再抛,用户看到的就是解释器默认 handler 打出的裸 traceback——连脱敏都 +# 没有,比「消息不好看」严重得多。 +# +# 触发条件是异常自身的 args 含位数超限的整数:``str(10**4301)`` 撞上 CPython +# 的 ``sys.get_int_max_str_digits()``(默认 4300)抛 ``ValueError``。可达性经 +# 核实:JSON 与 TOML 两条路径送不进来(解析器自己就抛),但 ``pickle`` 是二进制 +# 格式、不受十进制位数限制,实测 ``pickle.loads(pickle.dumps(10**4301))`` 正常 +# 往返,而本项目加载 pickle 知识快照。 +@pytest.mark.parametrize( + "exc", + [ + ValueError(10**4301), + KeyError(10**4301), + TypeError(-(10**4301)), + RuntimeError(10**4301, "第二"), + RuntimeError("前缀", 10**4301), + OSError(10**4301), + ], + ids=["value", "key", "type-negative", "two-args", "mixed-args", "oserror"], +) +def test_safe_exception_text_survives_unprintable_exception_args(exc): + """异常 args 含位数超限的整数时,展示边界不得自己抛异常。""" + from src.utils.security import safe_exception_text + + rendered = safe_exception_text(exc) + + assert type(exc).__name__ in rendered + assert "Exceeds the limit" not in rendered + + +def test_safe_exception_text_renders_each_arg_separately(): + """逐元素渲染:不能把整个 args 元组交给 ``repr`` 后只退回 ````。 + + 那样虽然不抛,却把「是哪个值出的问题」也丢掉了——本函数存在的意义就是让 + 用户看懂发生了什么。 + """ + from src.utils.security import safe_exception_text + + rendered = safe_exception_text(RuntimeError("前缀", 10**4301)) + + assert "前缀" in rendered, rendered + assert "整数" in rendered, rendered + + +def test_safe_exception_text_survives_pickle_roundtripped_huge_int(): + """走真实的 pickle 往返构造异常——这是该值在生产里的唯一入口。 + + ``pickle`` 是二进制格式,不受 ``sys.get_int_max_str_digits()`` 限制,因此 + 快照里的畸形值可以携带 4301 位以上的整数进入异常对象。 + """ + import pickle + + from src.utils.security import safe_exception_text + + huge = pickle.loads(pickle.dumps(10**4301)) + + rendered = safe_exception_text(RuntimeError(huge)) + + assert "RuntimeError" in rendered + assert "Exceeds the limit" not in rendered + + def test_user_facing_error_paths_do_not_print_raw_exceptions(): """面向用户的错误出口必须经 ``safe_exception_text``。 @@ -297,6 +488,20 @@ def test_security_parseable_and_unparseable_urls_check_query_keys_equally(): validate_secret_free_options({"endpoint": "https://host/v1?myApiKey="}, "Provider") +def test_security_unparseable_url_accepts_benign_query_keys(): + """畸形 URL 回退分支只对**敏感** query 键拒绝,普通键照常放行。 + + 回退分支逐个 query 键判断 ``if key and is_sensitive_option_key(key)``: + ``key`` 非空**且**是敏感键才登记。把 ``and`` 互换为 ``or`` 后,任何非空键 + (哪怕普通键如 ``a``)都被当成凭证——无害的畸形 URL 被误拒。既有 + ``test_security_unparseable_url_still_checks_query_key_names`` 只喂敏感键 + ``?myApiKey=``(两种变异都 raise),非敏感键这条从未被覆盖。 + """ + assert validate_secret_free_options({"endpoint": "https://[::1/v1?a=1"}, "Provider") == { + "endpoint": "https://[::1/v1?a=1" + } + + def test_security_accepts_benign_unparseable_url(): """严格度提升不能把无害的畸形 URL 也一并拒掉。""" assert validate_secret_free_options({"endpoint": "https://[::1/v1/models"}, "Provider") == { @@ -304,6 +509,28 @@ def test_security_accepts_benign_unparseable_url(): } +@pytest.mark.parametrize( + "url", + [ + "https://user@host/v1", + "https://:pw@host/v1", + "https://user:pw@host/v1", + ], + ids=["only-user", "only-password", "user-and-password"], +) +def test_security_rejects_url_userinfo_whichever_half_is_present(url): + """URL 里**任一**半 userinfo(用户名或密码)都必须判为凭证。 + + 判据是 ``parsed.username is not None or parsed.password is not None``:两半 + 各判各的(``https://user@h`` 只有用户名、``https://:pw@h`` 只有密码)。把 + ``or`` 互换为 ``and`` 后,只有两半同时存在才判为凭证——「只带用户名」或 + 「只带密码」的 URL 整体漏检,凭证边界静默失效。既有用例只覆盖了 query 键名 + 与值内令牌,未覆盖 userinfo 任一半单独出现的形态。 + """ + with pytest.raises(ValueError, match="凭证"): + validate_secret_free_options({"endpoint": url}, "Provider") + + # ── 回归:CJK 紧邻时词边界失效导致密钥整体漏检 ── @@ -629,3 +856,274 @@ def test_security_bearer_rule_redacts_alphabetic_token_adjacent_to_cjk(): assert token not in rendered assert "[REDACTED]" in rendered + + +# ── 回归:无键名的固定前缀凭证也要脱敏 ── +# +# SDK 的异常消息常回显请求头或 URL(``safe_exception_text`` 的 docstring 写明 +# 了这一点),此时凭证是**裸串**,没有键名可锚定。既有的 +# ``_TEXT_CREDENTIAL_KEYS`` 覆盖的是键名,``volc_access_key=...`` 这类会被 +# 脱敏,但 ``invalid access key 'AKLT...'`` 这种回显形态不会——实测 +# ``RedactingFormatter`` 对它原样输出。这与已存在的 ``AIza``(Google)规则 +# 是同一类:前缀可自证,因此不依赖上下文。 + + +@pytest.mark.parametrize( + "value", + [ + "invalid access key 'AKLTZmE3YjA0YzQt'", + "Auth failed for access key LTAI5tAbCdEfGhIjKlMnOpQr", + "请求被拒绝: AKLTZmE3YjA0YzQtABCDEF", + ], +) +def test_security_redacts_bare_prefixed_credentials(value): + """没有键名、只有固定前缀的凭证必须被脱敏。""" + redacted = redact_sensitive_text(value) + + assert "[REDACTED]" in redacted + assert "AKLTZmE3YjA0YzQt" not in redacted + assert "LTAI5tAbCdEfGhIjKlMnOpQr" not in redacted + + +@pytest.mark.parametrize( + "value", + [ + "AKLT 是火山引擎凭证的前缀", + "LTAI 开头的串", + "这是一段普通的中文说明文本", + "http://[::1]:8080/v1", + ], +) +def test_security_keeps_ordinary_text_with_short_prefixes(value): + """收紧不能误伤:前缀本身、或长度不足的串不得被改写。""" + assert redact_sensitive_text(value) == value + + +# ── 回归:PEM 私钥正文必须脱敏 ── +# +# 这是本项目**自己点名要防**的一类凭证——``SENSITIVE_OPTION_KEYS`` 里同时列了 +# ``privatekey``/``sshkey``/``sshprivatekey``,Google Vertex AI 的 ADC 服务账号 +# JSON 里就带 ``private_key``。但键名规则只挡得住「键名 + 值」的形态:私钥 +# **正文**作为一段独立文本出现时没有任何锚点,会原样落进终端与日志。 +# +# 实测两条真实泄漏路径: +# * ``validate_secret_free_options`` 把私钥当键名拒绝时,回显的消息里带着完整正文; +# * 裸 PEM 文本走 ``redact_sensitive_text`` 原样返回。 +# +# 覆盖 BEGIN/END 成对与裸 ``BEGIN`` 两种:``safe_exception_text`` 会把消息截到 +# 240 字符,END 标记可能已被切掉,只匹配成对形态会漏。 + +_PEM_BODY = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ" + + +@pytest.mark.parametrize( + "text,label", + [ + ( + f"-----BEGIN RSA PRIVATE KEY-----\n{_PEM_BODY}\n-----END RSA PRIVATE KEY-----", + "RSA 私钥块", + ), + ( + f"-----BEGIN PRIVATE KEY-----\n{_PEM_BODY}\n-----END PRIVATE KEY-----", + "通用 PRIVATE KEY", + ), + ( + ( + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n" + "-----END OPENSSH PRIVATE KEY-----" + ), + "OpenSSH 私钥", + ), + ("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIB\n-----END EC PRIVATE KEY-----", "EC 私钥"), + (f"-----BEGIN RSA PRIVATE KEY-----\n{_PEM_BODY}", "被截断(无 END 标记)"), + ( + ( + f"ValueError: load failed:\n-----BEGIN PRIVATE KEY-----\n{_PEM_BODY}\n" + "-----END PRIVATE KEY-----" + ), + "在异常消息里", + ), + ], +) +def test_security_redacts_pem_private_key_blocks(text, label): + """私钥正文不得出现在脱敏结果里。""" + redacted = redact_sensitive_text(text) + + assert _PEM_BODY not in redacted, label + assert "b3BlbnNzaC1rZXktdjEAAAAA" not in redacted, label + assert "MHcCAQEEIB" not in redacted, label + assert "[REDACTED PRIVATE KEY]" in redacted, label + + +def test_security_redacts_pem_key_echoed_as_an_option_key(): + """私钥作为 options 键名被拒绝时,回显的消息里不得带正文。 + + ``validate_secret_free_options`` 会把违规的键名放进错误消息,而消息随后经 + ``safe_exception_text`` 展示给用户。键名本身可以显示(用户需要知道改哪一 + 处),但若键名是一段 PEM,正文必须被吃掉。 + """ + pem = f"-----BEGIN RSA PRIVATE KEY-----\n{_PEM_BODY}\n-----END RSA PRIVATE KEY-----" + + with pytest.raises(ValueError) as excinfo: + validate_secret_free_options({pem: 1}, "Test") + + message = safe_exception_text(excinfo.value) + assert _PEM_BODY not in message + + +@pytest.mark.parametrize( + "text", + [ + "这是一个普通的 Markdown 文档,讲的是加密与密钥管理。", + "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", + "PRIVATE KEY 这个词单独出现不该被吃掉", + "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----", + ], +) +def test_security_keeps_non_private_pem_and_ordinary_text(text): + """收紧不能误伤:证书、公钥与普通文本不得被改写。 + + 只匹配 ``PRIVATE KEY``,因此 ``CERTIFICATE`` 与 ``PUBLIC KEY`` 照常保留—— + 它们不是凭证,脱敏掉会让用户拿不到有用的上下文。 + """ + assert redact_sensitive_text(text) == text + + +# ── 资源参数与路径形态判据的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,经真实入口): +# * 868 ``validate_secret_free_resource_args`` 的「位置参数必须是元组」→ +# **静默接受** 字符串 ``"abc"``(字符串是合法可迭代对象,逐字符遍历后 +# 每个字符被当作一个独立参数); +# * 794 ``validate_secret_free_resource_kwargs`` 的「键必须是字符串」→ 消息 +# 退化成下一层的「键必须是字符串」——同样是 ``ValueError``,但不再点名 +# ``资源参数`` 这个来源; +# * 715 ``validate_secret_free_options`` 的「options 必须是对象」→ 裸 +# ``TypeError: 'int' object is not iterable``(``set(5)``)。 +def test_resource_args_reject_non_tuple_sequence(): + """位置参数必须是元组;字符串是合法可迭代对象,必须显式拒绝。""" + from src.utils.security import validate_secret_free_resource_args + + with pytest.raises(ValueError, match="资源位置参数必须是元组"): + validate_secret_free_resource_args("abc", "OpenAI 资源") + + +def test_resource_kwargs_reject_non_mapping(): + """关键字参数必须是映射,不能静默接受列表。""" + from src.utils.security import validate_secret_free_resource_kwargs + + with pytest.raises(ValueError, match="资源参数必须是对象"): + validate_secret_free_resource_kwargs([1], "OpenAI 资源") + + +def test_options_must_be_a_mapping(): + """``options`` 非对象必须给带 provider 名的 ``ValueError``,不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="options 必须是对象"): + validate_secret_free_options(5, "Anthropic") + + +def test_secret_free_options_keeps_self_copied_sdk_model(): + """``model_copy`` 返回自身时必须退回 ``deepcopy``(``copied is not value`` 不是 ``is value``)。 + + ``is value`` 变异后判据反转:返回自身的模型被当成「已复制」直接返回, + 调用方拿到的是同一个可变对象——后续对返回值的修改会污染调用方的原对象。 + """ + from src.utils.security import validate_secret_free_options + + class SelfCopy: + def __init__(self): + self.value = 1 + + def model_copy(self, deep=False): + return self + + def __deepcopy__(self, memo): + clone = SelfCopy() + clone.value = 2 + return clone + + original = SelfCopy() + validated = validate_secret_free_options({"k": original}, "T") + + assert validated["k"] is not original + assert validated["k"].value == 2 + + +def test_secret_free_options_nested_non_string_key_reports_path(): + """嵌套层级的非字符串键消息必须带出路径(``label if not path else ...``)。 + + 删掉 ``not`` 后判据反转:嵌套层级丢掉 ``outer.inner``,用户拿到 + 「Jina Rerank options 的键必须是字符串」却不知道是哪一层子表写错了键。 + """ + with pytest.raises(ValueError, match=r"Jina Rerank options 的 outer\.inner 的键必须是字符串"): + validate_secret_free_options({"outer": {"inner": {1: "x"}}}, "Jina Rerank") + + +@pytest.mark.parametrize( + "credential", + [ + # GitHub 非 PAT 家族:OAuth / server-to-server / user-to-server / refresh。 + "gho_" + "A" * 26 + "0123456789" + "X", + "ghs_" + "B" * 26 + "0123456789" + "Y", + "ghu_" + "C" * 26 + "0123456789" + "Z", + "ghr_" + "D" * 26 + "0123456789" + "W", + # Slack refresh 与 app-level token。 + "xoxe-" + "E" * 30, + "xapp-" + "F" * 30, + ], +) +def test_security_redacts_github_and_slack_prefix_variants(credential): + redacted = redact_sensitive_text(credential) + assert credential not in redacted + assert "[REDACTED]" in redacted + + +@pytest.mark.parametrize( + "text", + [ + # 知识库正文里的标识符:体不含数字,不得误脱敏(实测修复前误伤)。 + "jina_" + "a" * 30, + "jina_embedding_model_v3_" + "x" * 30, + "github_pat_" + "_a" * 15, + "github_pat_" + "_" * 25, + ], +) +def test_security_does_not_redact_prefix_without_digit_in_body(text): + """体不含数字的自证前缀长串是普通文本,不是凭证。""" + assert "[REDACTED]" not in redact_sensitive_text(text) + assert redact_sensitive_text(text) == text + + +@pytest.mark.parametrize( + "text", + [ + # gh 短前缀家族同样不做纯字母体脱敏(第三轮 M1:误伤目标要全家族一致)。 + "leak: ghp_abcdefghijabcdefghijabcdefghijabcd end", + "leak: gho_abcdefghijabcdefghijabcdefghijabcd end", + # 体含 _ 的 github_pat_ 正文标识符。 + "github_pat_" + "_a" * 15, + ], +) +def test_security_does_not_redact_gh_prefix_without_digit_in_body(text): + """gh 家族纯字母体是正文标识符,不是凭证(与 jina_/github_pat_ 同判据)。""" + assert "[REDACTED]" not in redact_sensitive_text(text) + + +def test_security_prefix_re_is_linear_on_adjacent_prefix_runs(): + """相邻 ``github_pat_`` 连串不得平方级回溯(第三轮 H1)。 + + 体类含 ``_`` 时每个起点能把下一个前缀整个吞进体,直到串尾才失败—— + 无上界时实测 ``"github_pat_"*8000`` 17.8 秒。体上界截断回溯空间后必须 + 保持线性:32000 个前缀(38 万字符)应在百毫秒级。 + """ + import time + + from src.utils.security import _SELF_ATTESTING_PREFIX_RE as pattern + + for count in (8000, 32000): + text = "github_pat_" * count + start = time.perf_counter() + pattern.search(text) + elapsed = time.perf_counter() - start + assert elapsed < 1.0, f"n={count} 耗时 {elapsed:.2f}s,疑似平方回溯" diff --git a/tests/providers/test_silent_fallback_regressions.py b/tests/providers/test_silent_fallback_regressions.py new file mode 100644 index 0000000..e0299df --- /dev/null +++ b/tests/providers/test_silent_fallback_regressions.py @@ -0,0 +1,489 @@ +"""补齐「吞掉异常」处理器(fail-open / 静默降级)的测试覆盖缺口(Provider 批)。 + +来源:对 src/ 下 87 条「吞掉型 except」(body 无 raise)做变异扫描——把 +handler body 替换成 ``raise``(取消吞掉、让异常冒泡)后全量测试仍绿的存活项。 +逐条用真实入口确认「取消吞掉后行为确实改变」,再按守卫存在时的行为写断言。 +""" + +import asyncio +import types + +from src.providers.__base__.model_provider import ( + _is_provider_resource_method, + _provider_call_accepts_arguments, + retry_async_stream, + retry_async_stream_context, + retry_sync_stream_context, +) +from src.providers.anthropic import AnthropicProvider +from src.providers.google import GoogleProvider +from src.providers.openai_compatible import OpenAICompatibleProvider +from src.providers.volcengine import VolcengineProvider + +# --- close()/aclose():一个资源失败不得阻止其余资源被释放 ----------------- + + +class _SyncFailing: + def close(self): + raise RuntimeError("同步关闭失败") + + +class _AsyncFailing: + def close(self): + raise RuntimeError("异步关闭失败") + + +class _Recording: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + +class _AsyncRecording: + def __init__(self): + self.closed = 0 + + async def close(self): + self.closed += 1 + + +def _check_sync_close(provider): + """close():同步客户端失败后异步客户端仍要关闭,且报告第一个错误。""" + provider._client = _SyncFailing() + recorder = _Recording() + provider._aclient = recorder + try: + provider.close() + except RuntimeError as exc: + assert "同步关闭失败" in str(exc) + else: # pragma: no cover - 守卫被删时才会走到 + raise AssertionError("close() 必须报告第一个错误") + assert recorder.closed == 1, "同步客户端失败后异步客户端必须仍被关闭" + + +def _check_async_close(provider): + """aclose():第一个客户端失败后第二个仍要关闭,且报告第一个错误。""" + provider._aclient = _AsyncFailing() + recorder = _Recording() + provider._client = recorder + try: + asyncio.run(provider.aclose()) + except RuntimeError as exc: + assert "异步关闭失败" in str(exc) + else: # pragma: no cover - 守卫被删时才会走到 + raise AssertionError("aclose() 必须报告第一个错误") + assert recorder.closed == 1, "第一个客户端失败后第二个必须仍被关闭" + + +def test_anthropic_close_and_aclose_release_all_clients(): + provider = object.__new__(AnthropicProvider) + _check_sync_close(provider) + provider = object.__new__(AnthropicProvider) + _check_async_close(provider) + + +def test_google_close_and_aclose_release_all_clients(): + class _AioFailing: + async def aclose(self): + raise RuntimeError("异步关闭失败") + + class _ClientWithFailingClose: + def __init__(self): + self.aio = _AsyncRecording() + + def close(self): + raise RuntimeError("同步关闭失败") + + provider = object.__new__(GoogleProvider) + client = _ClientWithFailingClose() + provider._client = client + try: + provider.close() + except RuntimeError as exc: + assert "同步关闭失败" in str(exc) + else: # pragma: no cover + raise AssertionError("close() 必须报告第一个错误") + assert client.aio.closed == 1, "同步客户端失败后 aio 必须仍被关闭" + + class _ClientWithFailingAio: + def __init__(self): + self.aio = _AioFailing() + self.closed = 0 + + def close(self): + self.closed += 1 + + provider = object.__new__(GoogleProvider) + client = _ClientWithFailingAio() + provider._client = client + try: + asyncio.run(provider.aclose()) + except RuntimeError as exc: + assert "异步关闭失败" in str(exc) + else: # pragma: no cover + raise AssertionError("aclose() 必须报告第一个错误") + assert client.closed == 1, "aio 失败后同步客户端必须仍被关闭" + + +def test_openai_compatible_close_and_aclose_release_all_clients(): + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + _check_sync_close(provider) + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + _check_async_close(provider) + + +def test_volcengine_close_and_aclose_release_all_clients(): + provider = object.__new__(VolcengineProvider) + provider._provider = None + _check_sync_close(provider) + provider = object.__new__(VolcengineProvider) + provider._provider = None + _check_async_close(provider) + + +# --- 基础层:空流、签名探测 ------------------------------------------------ + + +def test_retry_async_stream_empty_stream_ends_cleanly(): + """空异步流以 ``StopAsyncIteration`` 正常收尾。 + + 取消吞掉 ``StopAsyncIteration`` 后,空流被当成「重试未返回结果」抛 + ``RuntimeError``——一个合法的空流变成失败。 + """ + + class _Empty: + def __aiter__(self): + async def gen(): + if False: + yield 1 + + return gen() + + async def consume(): + stream = retry_async_stream(lambda: _Empty()) + with pytest_raises_stop(): + await stream.__anext__() + + asyncio.run(consume()) + + +def pytest_raises_stop(): + import contextlib + + @contextlib.contextmanager + def _ctx(): + try: + yield + except StopAsyncIteration: + return + raise AssertionError("空流必须以 StopAsyncIteration 收尾") + + return _ctx() + + +def test_provider_call_accepts_arguments_returns_false_for_unsignaturable(): + """签名不可读的可调用对象返回 False(不抛)。 + + 取消吞掉后 ``inspect.signature`` 的 ``ValueError`` 直接冒泡——本函数 + 的契约是「探测」,形态奇怪的目标应当返回 False。 + """ + + class _NoSignature: + @property + def __signature__(self): + raise ValueError("无签名") + + def __call__(self): + pass + + assert _provider_call_accepts_arguments(_NoSignature(), object(), (), {}) is False + + +def test_is_provider_resource_method_returns_false_for_unsignaturable(): + """签名不可读的可调用对象不算资源方法。""" + + class _NoSignature: + @property + def __signature__(self): + raise ValueError("无签名") + + def __call__(self): + pass + + assert _is_provider_resource_method("op", _NoSignature()) is False + + +# --- Anthropic:类型注解解析、快照拒答探测 --------------------------------- + + +def test_anthropic_safe_type_hint_returns_none_for_unresolvable_annotation(): + """无法解析的注解返回 None,不影响其余成员。 + + 取消吞掉后 ``get_type_hints`` 的异常直接冒泡——一个成员的模型结构 + 变化会让整张表构造失败。 + """ + + from src.providers.anthropic import _safe_type_hint + + class _Bad: + __annotations__ = {"x": "NotDefined.Anywhere"} + + assert _safe_type_hint(_Bad, "x") is None + + +def test_anthropic_snapshot_refusal_probe_tolerates_missing_attribute(): + """响应对象没有 ``current_message_snapshot`` 时静默返回。 + + 取消吞掉后 ``AttributeError`` 冒泡——测试替身与旧版 SDK 的响应对象 + 都会让流消费在第一次探测时崩溃。 + """ + + class _NoSnapshot: + pass + + AnthropicProvider._raise_for_stream_snapshot_refusal(_NoSnapshot()) + + +# --- Google:HTTP 配置 URL 解析、响应文本回退 ------------------------------ + + +def test_google_validate_http_options_skips_unparsable_urls(): + """无法解析的 URL 跳过检查而不是抛错。 + + 取消吞掉后 ``urlsplit`` 的 ``ValueError: Invalid IPv6 URL`` 冒泡—— + 校验函数本身成了崩溃点。 + """ + GoogleProvider._validate_http_options({"headers": {"X": "http://[bad"}}) + + +def test_google_response_text_falls_back_when_accessor_raises(): + """``response.text`` 取值抛错时回退到候选部件提取。 + + 取消吞掉后异常冒泡——响应解析在用户已拿到部分输出后崩溃。 + """ + + class _Boom: + def __init__(self): + self.candidates: list = [] + + @property + def text(self): + raise RuntimeError("SDK 取值失败") + + assert GoogleProvider._response_text(_Boom()) == "" + + +# --- OpenAI 兼容:SDK 校验回退、端口解析、工具键跳过 ----------------------- + + +def test_openai_validate_sdk_resource_call_skips_unsignaturable_method(): + """方法签名不可读时跳过校验而不是抛错。""" + + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + + class _NoSignature: + @property + def __signature__(self): + raise ValueError("无签名") + + provider._validate_sdk_resource_call(_NoSignature(), (), {}, "op") + + +def test_openai_official_endpoint_detection_handles_bad_port(): + """URL 端口越界时返回 False 而不是抛 ``ValueError``。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + provider._base_url = "https://api.openai.com:99999/v1" + assert provider._uses_official_openai_endpoint() is False + + +def test_openai_response_tool_keys_skips_unhashable_values(): + """不可哈希的分量被跳过,其余分量仍进键元组。 + + 取消吞掉后 ``hash()`` 的 ``TypeError: unhashable type`` 冒泡。 + """ + keys = OpenAICompatibleProvider._response_tool_keys( + {"item_id": [1, 2], "output_index": 0, "call_id": "c1"} + ) + assert ("call_id", "c1") in keys + assert all(not isinstance(value, list) for _, value in keys) + + +def test_close_aggregates_errors_reporting_the_first_failure(): + """两个客户端都关闭失败时报告**第一个**错误,而不是被第二个顶替。 + + 删除 ``first_error = first_error or exc`` 后,后发生的错误覆盖先发生的 + ——调用方看到的失败原因与实际顺序不符。 + """ + + class _Failing: + def __init__(self, message): + self._message = message + + def close(self): + raise RuntimeError(self._message) + + cases = [ + (AnthropicProvider, "Anthropic"), + (OpenAICompatibleProvider, "OpenAI"), + (VolcengineProvider, "Ark"), + ] + for provider_class, label in cases: + provider = object.__new__(provider_class) + if provider_class is not AnthropicProvider: + provider._provider = None + provider._client = _Failing("第一个失败") + provider._aclient = _Failing("第二个失败") + try: + provider.close() + except RuntimeError as exc: + assert "第一个失败" in str(exc), f"{label} 报告了被顶替的错误" + else: # pragma: no cover + raise AssertionError(f"{label} close() 必须报告错误") + + +def test_google_close_aggregates_errors_reporting_the_first_failure(): + """Google 的 close()/aclose() 同样报告第一个错误。""" + + class _FailingSync: + def close(self): + raise RuntimeError("同步失败") + + class _FailingAsync: + async def aclose(self): + raise RuntimeError("aio 失败") + + def close(self): + raise RuntimeError("同步失败") + + class _ClientBothFailing: + def __init__(self): + # aio 的失败消息必须与同步的区分:两处都叫「同步失败」时, + # 「保留第一个错误」与「被第二个顶替」不可观测(等价变异)。 + self.aio = types.SimpleNamespace( + close=lambda: (_ for _ in ()).throw(RuntimeError("aio 失败")) + ) + + def close(self): + raise RuntimeError("同步失败") + + provider = object.__new__(GoogleProvider) + provider._client = _ClientBothFailing() + try: + provider.close() + except RuntimeError as exc: + assert "同步失败" in str(exc) + else: # pragma: no cover + raise AssertionError("close() 必须报告错误") + + # aio 是唯一失败源时,错误必须被报告出来(而不是被静默吞掉)。 + # 注意 close_resource_sync 优先调 close(),所以这个 aio 只有 aclose。 + class _OnlyAcloseFails: + async def aclose(self): + raise RuntimeError("aio 失败") + + class _ClientOnlyAioFails: + def __init__(self): + self.aio = _OnlyAcloseFails() + self.closed = 0 + + def close(self): + self.closed += 1 + + provider = object.__new__(GoogleProvider) + only_aio = _ClientOnlyAioFails() + provider._client = only_aio + try: + provider.close() + except RuntimeError as exc: + assert "aio 失败" in str(exc) + else: # pragma: no cover + raise AssertionError("aio 失败必须被报告") + assert only_aio.closed == 1 + + class _ClientBothFailingAio: + def __init__(self): + self.aio = types.SimpleNamespace(aclose=_failing_async_close) + + def close(self): + raise RuntimeError("同步失败") + + async def _failing_async_close(): + raise RuntimeError("aio 失败") + + provider = object.__new__(GoogleProvider) + provider._client = _ClientBothFailingAio() + try: + asyncio.run(provider.aclose()) + except RuntimeError as exc: + assert "aio 失败" in str(exc) + else: # pragma: no cover + raise AssertionError("aclose() 必须报告错误") + + +def test_stream_context_exit_suppresses_body_error_and_still_closes(): + """``__exit__`` 返回 True 表示抑制:body 异常不上抛,且上下文必须退出。 + + 删除吞掉后 body 异常直接上抛、``__exit__`` 不被调用——资源清理被跳过, + 调用方也无法声明「我处理了这个错误」。 + """ + + class _Manager: + def __init__(self): + self.exited = 0 + + def __enter__(self): + return self + + def __exit__(self, *args): + self.exited += 1 + return True + + manager = _Manager() + with retry_sync_stream_context(lambda: manager): + raise RuntimeError("body failed") + assert manager.exited == 1 + + class _AsyncManager: + def __init__(self): + self.exited = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + self.exited += 1 + return True + + async_manager = _AsyncManager() + + async def consume(): + async with retry_async_stream_context(lambda: async_manager): + raise RuntimeError("body failed") + + asyncio.run(consume()) + assert async_manager.exited == 1 + + +def test_sanitize_resource_call_keeps_call_shape_for_uninspectable_method(): + """签名不可读的动态 SDK 包装仍走 keyword/positional 校验路径。 + + 取消吞掉后 ``inspect.signature`` 的 ``ValueError`` 冒泡——返回时参数 + 仍会被清洗,但整个调用被诊断异常打断。 + """ + + from src.providers.__base__.model_provider import _sanitize_resource_call + + class _NoSignature: + @property + def __signature__(self): + raise ValueError("无签名") + + args, kwargs = _sanitize_resource_call(_NoSignature(), object(), (1,), {"model": "m"}) + assert args == (1,) + assert kwargs == {"model": "m"} diff --git a/tests/retrieval/test_parent_child_retrieval.py b/tests/retrieval/test_parent_child_retrieval.py index 9e7b34f..76037e1 100644 --- a/tests/retrieval/test_parent_child_retrieval.py +++ b/tests/retrieval/test_parent_child_retrieval.py @@ -4,6 +4,7 @@ import pytest +from src.retrieval import retriever from src.retrieval.retriever import retrieve_documents from src.services.retrieval_service import RetrievalService from src.utils.config import RetrievalMethod @@ -529,3 +530,179 @@ def test_validate_rerank_output_rejects_invalid_provider_results(indices, scores """契约失败必须是显式异常,而不是让错误顺序静默进入检索结果。""" with pytest.raises(RuntimeError, match=match): RetrievalService._validate_rerank_output(indices, scores, document_count=3) + + +# --------------------------------------------------------------------------- +# 超出双精度范围的整数 +# --------------------------------------------------------------------------- +# +# provider 的 score 来自网络 JSON,里面出现一个超大整数字面量就足以让 +# ``float(score)`` 抛 ``OverflowError``——它不是 ``ValueError``,原实现里的 +# ``except (TypeError, ValueError)`` 接不住,于是本该是 ``RuntimeError`` +# 的契约失败变成了裸异常。 +# +# 参数 id 必须显式给出:``10**4301`` 超过 ``sys.get_int_max_str_digits()``, +# pytest 自动生成 id 时调 ``str(val)`` 会自己抛 ``ValueError``。 +_BEYOND_DOUBLE_INTS = [ + pytest.param(10**400, id="400digits"), + pytest.param(-(10**400), id="negative-400digits"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), +] + + +@pytest.mark.parametrize("score", _BEYOND_DOUBLE_INTS) +def test_validate_rerank_output_rejects_scores_beyond_double_range(score): + """超出双精度范围的 score 必须报成 ``RuntimeError``,不是 ``OverflowError``。""" + with pytest.raises(RuntimeError, match="超出双精度范围"): + RetrievalService._validate_rerank_output([0], [score], document_count=2) + + +# ── 向量存储接口缺失与 rerank 返回值形态的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 742 ``_call_vector_store`` 的「store 未实现该接口」→ 裸 +# ``TypeError: 'NoneType' object is not callable``(拿 ``None`` 当方法调), +# 而调用方按 ``NotImplementedError`` 分类; +# * 753 ``_legacy_search`` 的「store 既没有 asearch 也没有 search」→ 同样 +# 退化成裸 ``TypeError``; +# * 1015 ``_validate_rerank_output`` 的「provider 返回不可迭代」→ 裸 +# ``UnboundLocalError: normalized_indices``(变量未绑定); +# * 1045 同一函数的 ``float(score)`` 失败分支 → ``None`` 这类取值裸 +# ``UnboundLocalError: normalized_score``。 +# +# 既有的 ``test_validate_rerank_output_rejects_invalid_provider_results`` 覆盖 +# 的是 ``bool``/越界/重复 index 等**取值**判据,两条**形态**判据从未被反向覆盖。 +def test_call_vector_store_reports_missing_interface_readably(): + """store 未实现该接口时必须抛 ``NotImplementedError``,不是裸 ``TypeError``。""" + + class BareStore: + pass + + service = object.__new__(RetrievalService) + service.vector_store = BareStore() + + with pytest.raises(NotImplementedError, match="未实现 semantic_search 接口"): + asyncio.run(service._call_vector_store("semantic_search", None)) + + +def test_legacy_search_reports_missing_interface_readably(): + """store 既无 asearch 也无 search 时必须抛 ``NotImplementedError``。""" + + class BareStore: + pass + + service = object.__new__(RetrievalService) + service.vector_store = BareStore() + + with pytest.raises(NotImplementedError, match="未实现兼容 search 接口"): + asyncio.run(service._legacy_search("query", 1, "semantic")) + + +def test_validate_rerank_output_rejects_non_iterable_result(): + """provider 返回不可迭代的取值时必须抛 ``RuntimeError``,不是裸 ``UnboundLocalError``。""" + with pytest.raises(RuntimeError, match="必须是可迭代的索引和分数"): + RetrievalService._validate_rerank_output(5, 5, document_count=3) + + +@pytest.mark.parametrize( + "bad_score", + [ + pytest.param(None, id="none"), + pytest.param([0.5], id="list"), + ], +) +def test_validate_rerank_output_rejects_unconvertible_score(bad_score): + """``float(score)`` 转换失败的取值必须抛 ``RuntimeError``,不是裸 ``UnboundLocalError``。""" + with pytest.raises(RuntimeError, match="score 无效"): + RetrievalService._validate_rerank_output([0], [bad_score], document_count=3) + + +class _CloseFailingService: + """检索成功但 ``aclose`` 抛错的服务替身。""" + + def __init__(self, results=None, close_error=None): + self._results = results or [] + self._close_error = close_error + + async def retrieve(self, **_kwargs): + return list(self._results) + + async def aclose(self): + if self._close_error is not None: + raise self._close_error + + +def _aretrieve_with_service(monkeypatch, service): + """用替身服务跑一次 ``aretrieve_documents``,绕开真实 embedding/工厂。""" + monkeypatch.setattr("src.retrieval.retriever.RetrievalService", lambda **_kwargs: service) + monkeypatch.setattr("src.retrieval.retriever.EmbeddingService", lambda *_a, **_k: object()) + return asyncio.run( + retriever.aretrieve_documents( + query="q", + vector_store=object(), + console=MagicMock(), + retrieval_method=RetrievalMethod.SEMANTIC_SEARCH, + top_k=3, + vector_weight=0.7, + keyword_weight=0.3, + rerank_enabled=False, + active_rerank_configuration="", + score_threshold=0.0, + ) + ) + + +def test_aretrieve_returns_results_when_service_close_fails(monkeypatch, caplog): + """关闭失败不能顶替掉一次成功的检索结果。 + + ``finally`` 里裸 ``await aclose()`` 时,检索已返回的命中结果被丢弃, + 调用方只看到与检索无关的关闭异常(实测返回 1 条命中的检索整体抛 + ``RuntimeError: 关闭失败``)。契约与 ``retrieval_test.core`` 的 + ``_aclose_quietly``、``Chatbot.close`` 一致:释放失败只记警告。 + """ + import logging + + service = _CloseFailingService( + results=[{"page_content": "命中", "score": 0.9, "metadata": {}}], + close_error=RuntimeError("关闭失败"), + ) + with caplog.at_level(logging.WARNING, logger="src.retrieval.retriever"): + results = _aretrieve_with_service(monkeypatch, service) + assert results == [{"page_content": "命中", "score": 0.9, "metadata": {}}] + assert "释放检索服务失败" in caplog.text + + +def test_aretrieve_keeps_original_error_when_both_retrieve_and_close_fail(monkeypatch, caplog): + """检索与关闭都失败时,保留**原始**异常,不被关闭异常顶替。 + + ``finally`` 里的异常会替换掉 ``try`` 中正在上抛的那个——真正的失败原因 + 从栈上消失,排查时看到的是与检索无关的关闭错误。 + """ + import logging + + service = _CloseFailingService(close_error=RuntimeError("关闭也失败")) + + async def _retrieve(**_kwargs): + raise ValueError("检索本身失败") + + service.retrieve = _retrieve + with ( + caplog.at_level(logging.WARNING, logger="src.retrieval.retriever"), + pytest.raises(ValueError, match="检索本身失败"), + ): + _aretrieve_with_service(monkeypatch, service) + assert "释放检索服务失败" in caplog.text + + +def test_aretrieve_close_failure_is_redacted_in_log(monkeypatch, caplog): + """关闭异常的日志必须脱敏——``aclose`` 的异常文本可能带凭证。""" + import logging + + service = _CloseFailingService( + results=[], + close_error=RuntimeError("api_key=sk-abcdefghijklmnopqrstuvwxyz123456"), + ) + with caplog.at_level(logging.WARNING, logger="src.retrieval.retriever"): + _aretrieve_with_service(monkeypatch, service) + assert "sk-abcdefghijklmnopqrstuvwxyz123456" not in caplog.text diff --git a/tests/retrieval/test_weighted_fusion.py b/tests/retrieval/test_weighted_fusion.py index 591c8df..03c2d1b 100644 --- a/tests/retrieval/test_weighted_fusion.py +++ b/tests/retrieval/test_weighted_fusion.py @@ -1,4 +1,12 @@ +import pytest + from src.retrieval.retriever import HybridReranker, _merge_hybrid_results +from src.services.retrieval_service import ( + _document_key, + _promote_parent_context, + _stable_fingerprint, + _unpack_rerank_result, +) def test_hybrid_reranker_normalizes_mixed_score_scales(): @@ -80,3 +88,623 @@ def test_rrf_reranker_uses_rank_positions_not_score_magnitudes(): assert [doc["page_content"] for doc in ranked] == ["doc-a", "doc-b"] assert ranked[0]["score"] > ranked[1]["score"] + + +def test_merge_hybrid_results_keeps_documents_sharing_a_long_prefix(): + """缺少 ``chunk_id``/``chunk_index`` 时,键不能靠截断内容生成。 + + 原实现取 ``page_content[:64]`` 作键。两份来源相同、前 64 字符相同而正文 + 不同的文档因此碰撞:语义结果里的那一篇被关键词结果覆盖, + ``_merge_hybrid_results`` 只返回一条,用户少拿一篇且没有任何提示。 + """ + prefix = "本文档描述了一个非常长的技术说明" * 6 # 远超 64 字符 + semantic_results = [ + {"page_content": prefix + "AAAA 第一部分", "score": 0.9, "metadata": {"source": "kb.md"}}, + ] + keyword_results = [ + {"page_content": prefix + "BBBB 第二部分", "score": 4.0, "metadata": {"source": "kb.md"}}, + ] + + merged = _merge_hybrid_results(semantic_results, keyword_results) + + assert len(merged) == 2, "前缀相同的两篇不同文档被并成了一条" + contents = {doc["page_content"] for doc in merged} + assert prefix + "AAAA 第一部分" in contents + assert prefix + "BBBB 第二部分" in contents + + +def test_merge_hybrid_results_still_merges_identical_documents_without_ids(): + """收紧不能误伤:同一篇文档在两路结果里出现时仍须合并计分。""" + document = {"page_content": "同一篇内容", "metadata": {"source": "kb.md"}} + semantic_results = [{**document, "score": 0.9}] + keyword_results = [{**document, "score": 4.0}] + + merged = _merge_hybrid_results(semantic_results, keyword_results) + + assert len(merged) == 1 + assert merged[0]["semantic_score"] == 0.9 + assert merged[0]["keyword_score"] == 4.0 + + +@pytest.mark.parametrize( + "vector_weight,keyword_weight", + [ + (float("nan"), 1.0), + (float("inf"), 1.0), + (-1.0, 1.0), + (0.0, 0.0), + ], +) +def test_hybrid_reranker_rejects_non_finite_or_non_positive_weights(vector_weight, keyword_weight): + """权重之和必须是正的有限值,否则得分是 ``nan`` 或全零。 + + ``nan`` 尤其隐蔽:``nan <= 0`` 为 False,只判「和是否为正」会放过去, + 随后每个文档得分都是 ``nan``——排序结果不确定,且 ``nan`` 与分数阈值的 + 比较恒为 False,整批结果被静默丢弃。 + """ + reranker = HybridReranker( + vector_weight=vector_weight, keyword_weight=keyword_weight, fusion_strategy="weighted" + ) + + with pytest.raises(ValueError, match="有限数值|不能同时为 0"): + reranker.rerank([{"page_content": "a", "semantic_score": 1.0, "keyword_score": 1.0}]) + + +def test_hybrid_reranker_accepts_valid_weights(): + """收紧不能误伤:合法权重照常算出得分。""" + reranker = HybridReranker(vector_weight=0.7, keyword_weight=0.3, fusion_strategy="weighted") + + ranked = reranker.rerank([{"page_content": "a", "semantic_score": 1.0, "keyword_score": 1.0}]) + + assert ranked[0]["score"] == 1.0 + + +# ── 回归:融合链路对分数字段的形态异常必须容忍 ── +# +# 展示层已有 ``document_score`` 收敛形态(``bool``/``nan``/非数字一律退回 +# 默认值),但**融合链路**没有:它直接 ``float(doc.get("score") or 0)``。检索结果 +# 可以来自任何 ``VectorStoreBase`` 实现——``FaissStore`` 归一化,第三方 store +# 不会,而抽象基类正是为替换实现而存在的。实测异常形态会分两种方式失败: +# +# 1. **崩溃**:``score="abc"`` 抛 ``ValueError``、``score=[1]`` 抛 ``TypeError``, +# 异常从 ``_merge_hybrid_results`` 一路冒到 chat 主循环之外。 +# 2. **静默错序**(更隐蔽):``_normalize_scores`` 里 ``max()``/``min()`` 一旦 +# 遇到 ``nan`` 就双双返回 ``nan``,``score_range`` 也是 ``nan``,每个文档的 +# 归一化结果都是 ``nan``——排序时 ``nan`` 的比较恒为 False,该文档会**留在 +# 首位**(Python 的 ``sorted`` 保持原相对顺序),于是一条分数不可用的文档被 +# 排到了正常文档前面,且没有任何提示。 +# +# 判据用「不抛异常 + 得分是有限数值」,而不是「等于某个具体值」:后者会把这套 +# 兜底规则写死在测试里,而这里要守的是「异常形态不会破坏排序与过滤」。 + + +def _score_of(doc): + return doc.get("score") + + +@pytest.mark.parametrize( + "malformed", + [ + {"page_content": "a", "score": None}, + {"page_content": "a", "score": "0.5"}, + {"page_content": "a", "score": "abc"}, + {"page_content": "a", "score": [1]}, + {"page_content": "a", "score": {"v": 1}}, + {"page_content": "a", "score": True}, + {"page_content": "a", "score": float("nan")}, + {"page_content": "a", "score": float("inf")}, + {"page_content": "a"}, + ], +) +def test_merge_hybrid_results_tolerates_malformed_scores(malformed): + """分数字段形态异常时不得抛异常,且得分必须是有限数值。""" + import math + + merged = _merge_hybrid_results([malformed], [malformed]) + + assert len(merged) == 1 + assert math.isfinite(float(merged[0]["score"])) + + +@pytest.mark.parametrize( + "malformed", + [ + {"page_content": "a", "semantic_score": "abc", "keyword_score": 0.5}, + {"page_content": "a", "semantic_score": [1], "keyword_score": 0.5}, + {"page_content": "a", "keyword_score": {"v": 1}}, + {"page_content": "a", "semantic_score": float("nan"), "keyword_score": 0.5}, + {"page_content": "a", "semantic_score": float("inf"), "keyword_score": 0.5}, + ], +) +@pytest.mark.parametrize("strategy", ["weighted", "rrf"]) +def test_hybrid_reranker_tolerates_malformed_scores(malformed, strategy): + """融合打分不得因分数字段形态异常而抛错,结果也必须是有限数值。""" + import math + + reranker = HybridReranker(0.5, 0.5, fusion_strategy=strategy) + + ranked = reranker.rerank([malformed]) + + assert len(ranked) == 1 + assert math.isfinite(float(ranked[0]["score"])) + + +def test_weighted_rerank_does_not_promote_a_nan_scored_document(): + """一条 ``nan`` 得分的文档不得因为比较恒为 False 而留在首位。 + + 这是本组问题里最隐蔽的一支:``sorted`` 对 ``nan`` 的比较恒为 False,该文档 + 保持原相对顺序、即留在第一位,用户看到一条分数不可用的结果排在正常结果 + 之前。 + """ + import math + + reranker = HybridReranker(0.5, 0.5, fusion_strategy="weighted") + documents = [ + {"page_content": "坏分数", "semantic_score": float("nan"), "keyword_score": 0.0}, + {"page_content": "好分数", "semantic_score": 1.0, "keyword_score": 1.0}, + ] + + ranked = reranker.rerank(documents) + + assert all(math.isfinite(float(doc["score"])) for doc in ranked) + assert ranked[0]["page_content"] == "好分数" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("0.5", 0.5), + (" 0.25 ", 0.25), + (3, 3.0), + (2.5, 2.5), + ], +) +def test_coerce_score_still_accepts_convertible_values(value, expected): + """收紧不能误伤:能转换的值必须照旧转换。 + + 旧实现是 ``float(x or 0)``,它接受 ``"0.5"`` 这类数字字符串。若改成「非数字 + 类型一律退回默认值」,一个返回字符串得分的第三方 store 会被静默降为 0.0, + 再被分数阈值全部过滤——从「能用」变成「静默返回空结果」。 + """ + from src.ui.display_utils import coerce_score + + assert coerce_score(value) == expected + + +def test_coerce_score_accepts_numpy_scalars(): + """numpy 标量必须被转换,不得因「不是 ``float`` 子类」而静默归零。 + + 守卫是 ``isinstance(value, (int, float)) or hasattr(value, "__float__")``: + numpy 标量(``np.float32`` 等)不是 ``float`` 子类,靠后半的 ``__float__`` + duck-typing 接住。把 ``or`` 互换为 ``and`` 后整条恒假,真实分数被静默降成 + 0.0——既影响融合排序,又被分数阈值全部过滤,从「能检索到」变成「静默返回 + 空结果」。第三方 store 的 score 常是 numpy 标量,而既有用例的取值全是 + Python 原生数字/字符串/None,这条分支从未被覆盖。 + """ + import numpy as np + + from src.ui.display_utils import coerce_score + + assert coerce_score(np.float32(0.5)) == 0.5 + assert coerce_score(np.float64(0.7)) == 0.7 + assert coerce_score(np.int64(3)) == 3.0 + + +@pytest.mark.parametrize( + "value", + ["abc", "", None, True, False, [1], {"v": 1}, float("nan"), float("inf"), float("-inf")], +) +def test_coerce_score_falls_back_for_unusable_values(value): + """真正不可用的值退回默认值,不抛异常、也不产出非有限数。""" + from src.ui.display_utils import coerce_score + + result = coerce_score(value) + + assert result == 0.0 + + +def test_retrieve_normalizes_score_at_the_exit_boundary(): + """``retrieve`` 出口的 ``score`` 必须是有限浮点,三条路径一致。 + + 单路检索(语义/关键词)不经过 ``HybridReranker``,因此不会重算 score—— + 第三方 store 返回的 ``"0.8"`` 会原样流到出口。结果是同一份结果出现 + 「阈值按 0.8 通过筛选、界面显示 0.0000」,而排序、去重与展示又各自按不同 + 规则解释它。这里让出口只有一种形态。 + """ + import asyncio + import math + + from src.retrieval.vdb.base import VectorStoreBase + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + class _StringScoreStore(VectorStoreBase): + def add_documents(self, documents): + raise NotImplementedError + + async def aadd_documents(self, documents): + raise NotImplementedError + + def search(self, query, top_k=5, search_type="semantic"): + raise NotImplementedError + + async def asearch(self, query, top_k=5, search_type="semantic"): + raise NotImplementedError + + def save(self, path): + raise NotImplementedError + + def load(self, path, *, trusted_paths=None): + raise NotImplementedError + + def get_embedding_model(self): + raise NotImplementedError + + def semantic_search(self, query_embedding, top_k=5): + return [{"page_content": "命中文档", "score": "0.8", "metadata": {}}] + + def resolve_parent_content(self, parent_id): + return None + + class _StubEmbedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config["retrieval_method"] = RetrievalMethod.SEMANTIC_SEARCH + config["score_threshold"] = 0.5 + + service = RetrievalService(vector_store=_StringScoreStore(), embedding_service=_StubEmbedding()) + results = asyncio.run(service.retrieve("查询", config)) + + assert results, "字符串得分通过了阈值,结果不该为空" + for document in results: + assert isinstance(document["score"], float), "出口 score 必须是 float" + assert math.isfinite(document["score"]) + assert results[0]["score"] == 0.8 + + +# ── 回归:融合策略与文档键的非字符串/超大取值 ── +# +# ``HybridReranker`` 原先直接调 ``fusion_strategy.strip()``,非字符串抛 +# ``AttributeError``,而契约是 ``ValueError``;``_document_key`` 对元数据做 +# ``str()``,而元数据来自知识快照(含 pickle 来源,二进制格式不受十进制位数 +# 限制),位数超限时 ``str()`` 抛 ``ValueError``。 + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(["rrf"], id="list"), + pytest.param({"a": 1}, id="dict"), + pytest.param(123, id="int"), + pytest.param(None, id="none"), + ], +) +def test_hybrid_reranker_rejects_non_string_fusion_strategy(bad_value): + with pytest.raises(ValueError, match="融合策略"): + HybridReranker(0.5, 0.5, fusion_strategy=bad_value) + + +@pytest.mark.parametrize("metadata_key", ["chunk_id", "doc_id", "chunk_index", "source"]) +def test_document_key_survives_huge_integer_metadata(metadata_key): + key = _document_key({"metadata": {metadata_key: 10**4301}, "page_content": "内容"}) + assert isinstance(key, str) and key + + +def test_document_key_stays_stable_for_equal_values(): + """同一取值必须得到同一键,否则去重会失效。""" + first = _document_key({"metadata": {"chunk_id": 10**4301}, "page_content": "a"}) + second = _document_key({"metadata": {"chunk_id": 10**4301}, "page_content": "b"}) + assert first == second + + +# ── 回归:rerank provider 返回值必须先验形态再解包 ── +# +# 直接写 ``a, b = await provider.arerank(...)`` 时,``None``/单值抛裸 +# ``TypeError``、三元组抛裸 ``ValueError``——都在 ``_validate_rerank_output`` +# (正是「校验 provider 返回值」的边界)之前,用户拿到的是「无法解包」而不是 +# 「provider 返回值不合法」。 + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(None, id="none"), + pytest.param(5, id="single-value"), + pytest.param(([0], [0.5], "extra"), id="three-elements"), + pytest.param("ab", id="string"), + ], +) +def test_rerank_result_must_be_a_pair(bad_value): + with pytest.raises(RuntimeError, match="二元组"): + _unpack_rerank_result(bad_value) + + +def test_rerank_result_accepts_valid_pairs(): + assert _unpack_rerank_result(([0], [0.5])) == ([0], [0.5]) + assert _unpack_rerank_result([[0], [0.5]]) == ([0], [0.5]) + + +# ── 回归:权重之和与 RRF 分母都是**派生值**,必须自己判可转换性 ── +# +# 两个权重各自都在 double 范围内(构造期已判),但它们的**和**可以超出; +# ``rrf_k + rank`` 同理。``math.isfinite`` 对超出范围的 int 自己抛 +# ``OverflowError``——``ArithmeticError`` 子类,**不是** ``ValueError``,穿透 +# 本类调用方的全部 ``except ValueError`` 分类。 + + +@pytest.mark.parametrize( + ("weight", "message"), + [ + pytest.param(10**308, "和为正的有限数值", id="just-over-double-sum"), + pytest.param(10**305, "必须在 0.0 到 1.0 之间", id="large-but-in-unit-range-violation"), + ], +) +def test_weight_sum_overflow_is_a_value_error(weight, message): + """两个权重各自合法但**派生值**越界时,收场必须是 ``ValueError``。 + + 实测两个大整数权重在 ``_weight_tuple`` 的两条守卫上分岔:``10**308 * 2`` + 在派生和的 ``isfinite`` 判据上被拒(转 float 溢出成 ``inf``);``10**305`` + 的和在 double 内,但单个权重超出 ``[0, 1]`` 值域判据,同样以带指引的 + ``ValueError`` 收场。两条都必须是 ``ValueError`` 而不是裸 + ``OverflowError``——守卫被删时用例红。 + """ + reranker = HybridReranker(weight, weight) + + with pytest.raises(ValueError, match=message): + reranker.rerank([{"metadata": {}, "page_content": "x", "score": 0.5}]) + + +@pytest.mark.parametrize( + "rank", + [ + pytest.param(10**400, id="400digits"), + pytest.param(10**4301, id="4301digits"), + pytest.param(-5, id="negative"), + pytest.param("abc", id="non-numeric-string"), + ], +) +def test_rrf_rank_out_of_range_is_treated_as_absent(rank): + """非法的 rank 视为「未参与该路检索」,不得抛 ``OverflowError``。""" + reranker = HybridReranker(0.5, 0.5) + + result = reranker.rerank([{"metadata": {}, "page_content": "x", "semantic_rank": rank}]) + + assert result[0]["score"] == 0.0 + + +@pytest.mark.parametrize("rank_field", ["semantic_rank", "keyword_rank"]) +def test_rrf_rank_in_range_still_counts(rank_field): + """两路 rank 都要计入 RRF——只验证 semantic 一侧会让 keyword 判据反向无人发现。 + + ``_rrf_rerank`` 里两行同构(``if semantic_rank > 0``/``if keyword_rank > 0``), + 原用例只喂 ``semantic_rank``,于是把 keyword 那行的 ``>`` 反向成 ``<`` 后, + 关键词一路的贡献被静默清零(实测双路 rank 都为正时总分恰好减半、只给 + ``keyword_rank`` 时得 0.0),全量套件仍然全绿。 + """ + reranker = HybridReranker(0.5, 0.5) + + result = reranker.rerank([{"metadata": {}, "page_content": "x", rank_field: 1}]) + + assert result[0]["score"] > 0.0 + + +# ── 回归:rerank 缓存键必须能区分含超大整数的 options ── +# +# ``json.dumps(default=str)`` 对 int 无效(``default=`` 只对未知类型生效), +# ``repr`` 兜底抛同一个 ``ValueError``;而 ``safe_repr`` 对容器只退回 ````, +# 会让两份不同的 options 撞到同一个 provider 实例。 + + +@pytest.mark.parametrize( + ("left", "right"), + [ + ({"x": 10**4301}, {"x": 10**4302}), + ({"x": 10**4301}, {"y": 10**4301}), + ({"x": 10**4301}, {"x": 10**4301 + 1}), + ], +) +def test_stable_fingerprint_distinguishes_huge_integers(left, right): + assert _stable_fingerprint(left) != _stable_fingerprint(right) + + +def test_stable_fingerprint_does_not_raise_on_huge_integers(): + assert _stable_fingerprint({"nested": [{"deep": 10**4301}]}) + + +# ── 回归:``_stable_fingerprint`` 的两条承诺在集合与自引用容器上都不成立 ── +# +# 该函数的 docstring 写了两条承诺,实测都只在 dict/list/tuple 上成立: +# +# 1.「递归到叶子」——集合走的是 ``repr()`` 兜底,**没有递归**。集合里含位数 +# 超限的整数时 ``repr()`` 抛 ``ValueError``,于是整个集合塌成 ````, +# 区分度归零。``ModelDetail.options`` 是 ``dict[str, Any]``, +# ``validate_secret_free_options`` 只查敏感键名、不做递归类型校验,实测 +# ``options={"x": {10**4301}}`` 与 ``{"x": {10**4302}}`` 都被接受,随后两者 +# 得到同一个缓存键——两份不同的 rerank 配置静默复用同一个 provider 实例, +# 比崩溃更糟(错误配置的请求打到错误的端点上,且没有任何报错)。 +# 2.「绝不抛异常」——自引用容器让它无限递归,抛 ``RecursionError``,而 +# ``RecursionError`` 继承 ``RuntimeError``,不在调用方 +# ``except (TypeError, ValueError)`` 的范围内,会直接穿透成未脱敏的 traceback。 +@pytest.mark.parametrize( + ("left", "right"), + [ + ({"x": {10**4301}}, {"x": {10**4302}}), + ({"x": frozenset({10**4301})}, {"x": frozenset({10**4302})}), + ({"x": {10**4301}}, {"x": frozenset({10**4301})}), + ({"x": {1, 2}}, {"x": {3, 4}}), + ], +) +def test_stable_fingerprint_distinguishes_set_members(left, right): + assert _stable_fingerprint(left) != _stable_fingerprint(right) + + +def test_stable_fingerprint_survives_self_referencing_containers(): + """自引用容器不得让指纹函数抛异常(docstring 承诺「绝不抛」)。""" + cycle_list: list = [] + cycle_list.append(cycle_list) + cycle_dict: dict = {} + cycle_dict["self"] = cycle_dict + + assert _stable_fingerprint(cycle_list) + assert _stable_fingerprint(cycle_dict) + # 两条自引用路径必须可区分,否则「不抛」是靠塌成同一个常量换来的。 + assert _stable_fingerprint(cycle_list) != _stable_fingerprint(cycle_dict) + + +# ── 回归:父块内容必须收敛成字符串,不能把 resolver 的返回值原样写进正文 ── +# +# ``_promote_parent_context`` 把 ``resolver(parent_id)`` 直接赋给 +# ``promoted_doc["page_content"]``,只判了真值(``if parent_content:``)、没判类型。 +# 而 ``page_content: str`` 是 ``Document`` 的契约(``src/models/document.py``), +# ``FaissStore._validate_document_shape`` 在快照载入期也按这条校验。 +# +# 可达性:``parent_resolver`` 取自 ``getattr(self.vector_store, +# "resolve_parent_content", None)``,而 ``VectorStoreBase`` 正是为替换实现而存在 +# 的——``resolve_parent_content`` 的签名是 ``-> str | None``,但第三方实现返回 +# 别的形态不会被任何地方拦下。实测一个返回列表的 store 让 +# ``page_content`` 变成 ``['a', 'b']``,返回整数变成 ``123``:展示层 +# ``document_content`` 把它收敛成 ``"N/A"``(用户看到「无内容」而不是报错), +# 而 prompt 构造对正文做 ``.lower()`` 时抛 ``AttributeError``——检索整体不可用, +# 报错现场离真正的原因(父块解析器返回了非字符串)很远。 +# +# 位数超限的整数是同一根因的另一面:``10**4301`` 作为 ``page_content`` 能通过 +# 本函数,直到下游任何一次 ``str()``/``repr()``/``.lower()`` 才抛 +# ``ValueError: Exceeds the limit``(pickle 是二进制格式,不受十进制位数限制, +# 畸形快照能把这种值带进来)。 +def _promote(document: dict, resolver) -> dict: + return _promote_parent_context([document], resolver)[0] + + +def test_promote_parent_context_coerces_non_string_parent_content(): + """非字符串父内容必须收敛成字符串,且正文里能看到它。""" + document = {"page_content": "子块正文", "metadata": {"parent_id": "p1"}, "score": 0.5} + + promoted = _promote(document, lambda pid: ["a", "b"]) + + assert isinstance(promoted["page_content"], str) + # 只断言「是 str」不够:一个把非字符串一律换成空串的实现也能通过, + # 而用户会看到空正文。父块内容必须仍然可见。 + assert "a" in promoted["page_content"] and "b" in promoted["page_content"] + + +def test_promote_parent_context_coerces_huge_integer_parent_content(): + """位数超限的整数父内容不得让下游的 ``str()`` 崩。""" + document = {"page_content": "子块正文", "metadata": {"parent_id": "p1"}, "score": 0.5} + + promoted = _promote(document, lambda pid: 10**4301) + + content = promoted["page_content"] + assert isinstance(content, str) + # 下游会做的事:prompt 构造与展示都会拿它当字符串处理。 + assert content.lower() == content.lower() + + +def test_promote_parent_context_keeps_string_parent_content_intact(): + """反向守卫:正常的字符串父内容必须原样保留,不能被改写。""" + document = {"page_content": "子块正文", "metadata": {"parent_id": "p1"}, "score": 0.5} + + promoted = _promote(document, lambda pid: "父块正文") + + assert promoted["page_content"] == "父块正文" + assert promoted["metadata"]["matched_chunk_content"] == "子块正文" + + +@pytest.mark.parametrize("empty_value", [None, "", 0, False, [], {}]) +def test_promote_parent_context_leaves_content_alone_when_nothing_to_promote(empty_value): + """空值仍表示「没有父块」,正文保持子块内容不变。""" + document = {"page_content": "子块正文", "metadata": {"parent_id": "p1"}, "score": 0.5} + + promoted = _promote(document, lambda pid: empty_value) + + assert promoted["page_content"] == "子块正文" + assert "matched_chunk_content" not in promoted["metadata"] + + +# ── 回归:``_merge_hybrid_results`` 的两个入参也要判形态 ── +# +# 本函数经 ``src/retrieval/retriever.py`` 的 ``__all__`` 对外导出,是公开边界 +# (与 ``HybridReranker`` 同一处置)。``retrieve`` 内部调用它之前已经用 +# ``_require_document_list`` 校验过两路结果,但**库调用方直接调用**这条路径没有 +# ——函数体第一件事就是 ``for rank, document in enumerate(semantic_results)`` 再 +# 对 ``document.get(...)`` 取值。实测非列表取值的收场都不在契约内: +# * ``5``/``None``/``object()`` 抛裸 ``TypeError: ... is not iterable``; +# * ``"abc"``/``{"a": 1}`` 抛 ``AttributeError: 'str' object has no attribute +# 'get'``(逐字符/逐键迭代)。 +# 两者都不是 ``ValueError``,穿透调用方按 ``ValueError`` 建立的分类。 + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param("abc", id="str"), + pytest.param({"a": 1}, id="dict"), + pytest.param(object(), id="object"), + ], +) +def test_merge_hybrid_results_rejects_non_document_lists(bad_value): + """任一路结果不是文档列表时,必须给可读异常,不得裸抛。""" + with pytest.raises((TypeError, ValueError), match="文档列表"): + _merge_hybrid_results(bad_value, []) + with pytest.raises((TypeError, ValueError), match="文档列表"): + _merge_hybrid_results([], bad_value) + + +def test_merge_hybrid_results_still_accepts_document_lists(): + """收紧不能误伤:两路合法文档列表照常合并。""" + semantic = [{"page_content": "s", "metadata": {"chunk_id": "a"}, "score": 0.9}] + keyword = [{"page_content": "k", "metadata": {"chunk_id": "b"}, "score": 0.5}] + + merged = _merge_hybrid_results(semantic, keyword) + + assert len(merged) == 2 + + +# ── 回归:权重归一化与 RRF 分母的算术必须被**取值**断言 ── +# +# ``arith`` 变异面(`+`↔`-`、`*`↔`/`)扫出的两处缺口。既有用例只覆盖了能 +# 「蒙对」的输入,未钉住实际算式: +# +# * ``_weight_tuple`` 的 ``self.vector_weight / total`` 与 +# ``self.keyword_weight / total``:既有断言(``HybridReranker(0.0, 1.0)``/ +# ``(0.5, 0.5)``)的 ``total`` 恰好都是 ``1.0``,而 ``x / 1.0`` 与 ``x * 1.0`` +# 恒等——把 ``/`` 变异成 ``*`` 后这些用例仍全绿。只有**非归一化**输入 +# (两权重和 ≠ 1)才能区分。 +# * ``_rrf_rerank`` 的 ``1.0 / (self.rrf_k + semantic_rank)``(keyword 同): +# 既有用例只断言排序与「前者分更高」,而 ``rrf_k + rank`` 变异成 +# ``rrf_k - rank`` 后**顺序不变**(RRF 由排名位置决定,仍是单调的)。必须 +# 断言具体的 RRF 分值 ``1/(k+rank)``。 +def test_weight_tuple_divides_by_total_not_multiplies(): + """两权重之和不为 1 时必须**除以**总和归一化,不是乘以总和。 + + ``(0.2, 0.3)`` 的 ``total = 0.5``:正解 ``(0.4, 0.6)``;``/``→``*`` 变异 + 得 ``(0.1, 0.15)``。两者逐位不同。 + """ + assert HybridReranker(0.2, 0.3)._weight_tuple() == (0.4, 0.6) + assert HybridReranker(0.1, 0.4)._weight_tuple() == pytest.approx((0.2, 0.8)) + + +def test_rrf_rerank_score_uses_k_plus_rank(): + """RRF 分值必须是 ``weight / (rrf_k + rank)``,不是 ``rrf_k - rank``。 + + **两路都要命中**:``semantic_rrf`` 与 ``keyword_rrf`` 各有一条 + ``1.0 / (self.rrf_k + )``,是同一行代码里的两个独立位点。只让一路 + 命中(``keyword_rank=0`` 走 ``else 0.0``)只能覆盖其中一个,另一路的 + ``+``→``-`` 变异仍存活——本用例让两路 rank 都为正,逐路钉住分值。 + + ``semantic_rank=2``、``keyword_rank=4``、``rrf_k=60``、两权重各 0.5: + 正解 ``0.5/(60+2) + 0.5/(60+4) = 1/124 + 1/128``;任一路变异成 + ``rrf_k - rank`` 后该路变成 ``1/58`` / ``1/56``,取值断言即红。 + """ + reranker = HybridReranker( + vector_weight=0.5, keyword_weight=0.5, fusion_strategy="rrf", rrf_k=60 + ) + documents = [{"page_content": "only", "semantic_rank": 2, "keyword_rank": 4}] + + ranked = reranker.rerank(documents) + + assert ranked[0]["score"] == pytest.approx(0.5 / (60 + 2) + 0.5 / (60 + 4)) diff --git a/tests/retrieval/vdb/test_factory.py b/tests/retrieval/vdb/test_factory.py index 40ead6b..5712fdd 100644 --- a/tests/retrieval/vdb/test_factory.py +++ b/tests/retrieval/vdb/test_factory.py @@ -1,5 +1,6 @@ import json import sys +from dataclasses import replace from typing import Any from unittest.mock import MagicMock, patch @@ -163,10 +164,12 @@ def _write_snapshot(root, snapshot_id, provider, model): chunk_count=1, ) (snapshot_dir / "manifest.toml").write_text(manifest.to_toml(), encoding="utf-8") - # 快照目录校验除了「文件齐全」还要求 chunks.pkl 与 embeddings.npy 的行数自洽 - # (见 SnapshotRepository._validate_snapshot_row_counts),因此这里写最小但 - # 可解析的内容:1 个分块 + 1 行向量。兼容性检测本身发生在 load_snapshot 之前, - # 这些内容不参与断言。 + # 快照目录校验除了「文件齐全」还要求 chunks.pkl/parents.pkl 可解析、且 + # chunks.pkl 与 embeddings.npy 的行数自洽(见 + # SnapshotRepository._validate_snapshot_row_counts),因此这里写最小但**真实** + # 的内容:1 个分块 + 1 行向量 + 空父块映射。兼容性检测本身发生在 load_snapshot + # 之前,这些内容不参与断言;但占位字节构造出的快照本来就无法被加载,等于绕过 + # 被测行为(``semantic.index`` 此前就是这么写的,已一并改为真实索引)。 import pickle import numpy as np @@ -174,8 +177,15 @@ def _write_snapshot(root, snapshot_id, provider, model): with (snapshot_dir / "chunks.pkl").open("wb") as file: pickle.dump([{"page_content": "占位分块", "metadata": {"source": "placeholder.md"}}], file) np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) - (snapshot_dir / "parents.pkl").write_bytes(b"") - (snapshot_dir / "semantic.index").write_bytes(b"") + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + # 可解析的 FAISS 索引:占位字节会让真实加载路径(faiss.read_index)失败, + # 构造出的快照本来就不可能被加载。 + import faiss + + index = faiss.IndexFlatL2(4) + index.add(np.zeros((1, 4), dtype=np.float32)) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) (snapshot_dir / "stats.json").write_text( json.dumps({"chunk_count": 1}, ensure_ascii=False), encoding="utf-8" ) @@ -288,3 +298,216 @@ def test_legacy_import_cleans_temp_dir_on_failure(tmp_path): ) assert (snapshot_root / "ACTIVE_SNAPSHOT").read_text(encoding="utf-8") == "previous" assert (previous / "keep.txt").read_text(encoding="utf-8") == "保留" + + +# ── 回归:legacy 分支缺 embedding 配置时给出与活动快照分支同类的错误 ── + + +def test_legacy_import_reports_missing_embedding_provider_readably(tmp_path): + """legacy 分支读不到活动 embedding 配置时,不能抛裸 ``KeyError``。 + + ``_load_existing_state`` 的两条分支都要按 ``default_embedding_provider`` + 去 ``embedding_configurations`` 里取 ``model_name``:活动快照分支显式判过 + 「键不在映射里」,给出带字段名与可选值指引的 ``ValueError``;legacy 分支 + 却直接下标取值。配置里把 provider 名打错(或漏写整段 + ``embedding_configurations``)时,用户拿到的是裸 ``KeyError: 'local-hash-typo'`` + —— 没有字段名、没有可选值,也不是调用方按 ``ValueError`` 建立的错误分类。 + 同一个「embedding 配置缺失」的错误,收场不该取决于磁盘上恰好有没有活动快照。 + """ + import pickle + from pathlib import Path + + import numpy as np + + from src.retrieval.vdb.factory import VectorStoreFactory as RealFactory + from src.runtime.contracts import build_run_config + from src.utils.config import get_settings + + settings = get_settings() + snapshot_root = Path(settings.snapshot_root) + snapshot_root.mkdir(parents=True, exist_ok=True) + + # 没有活动快照,但有 legacy pkl —— 走到 legacy 分支。 + legacy_path = Path(settings.pkl_path) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + with legacy_path.open("wb") as file: + pickle.dump( + { + "documents": [{"page_content": "旧数据", "metadata": {"source": "old.md"}}], + "embeddings": np.zeros((1, 4), dtype=np.float32), + "parent_documents": {}, + }, + file, + ) + + run_config = build_run_config(settings) + # 模拟「配置里把 provider 名打错」:活动 provider 不在映射里。 + run_config = replace(run_config, default_embedding_provider="local-hash-typo") + + # 走到 manifest 构造那一步之前不需要真实落盘:缺配置的错误早于 + # ``validate_snapshot_dir``。用一个只满足 ``load``/``save_snapshot`` 契约的 + # 替身,让失败点落在被考察的取值上。 + class _StubStore: + def __init__(self): + self.documents: list = [] + + def load(self, path, *, trusted_paths=None): + return + + def save_snapshot(self, snapshot_dir): + return + + with pytest.raises(ValueError, match="embedding"): + RealFactory._load_existing_state(_StubStore(), run_config) + + +def test_legacy_import_rejects_unusable_embedding_detail_readably(tmp_path): + """embedding 配置的**取值**不可用时,legacy 分支也要给 ``ValueError``。 + + 「键在」不证明「值可用」:``{"local-hash": None}`` 的键确实存在,于是不会走 + 「缺少活动提供商」的分支,而紧接着读 ``.model_name`` 时抛裸 + ``AttributeError``。同一形态在 ``EmbeddingService.embedding_model_detail`` + 早已收敛成带字段名与 key 的 ``ValueError``,两条路径必须同判据。 + """ + import pickle + from pathlib import Path + + import numpy as np + + from src.retrieval.vdb.factory import VectorStoreFactory as RealFactory + from src.runtime.contracts import build_run_config + from src.utils.config import get_settings + + settings = get_settings() + snapshot_root = Path(settings.snapshot_root) + snapshot_root.mkdir(parents=True, exist_ok=True) + + legacy_path = Path(settings.pkl_path) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + with legacy_path.open("wb") as file: + pickle.dump( + { + "documents": [{"page_content": "旧数据", "metadata": {"source": "old.md"}}], + "embeddings": np.zeros((1, 4), dtype=np.float32), + "parent_documents": {}, + }, + file, + ) + + run_config = build_run_config(settings) + # 键在、取值不可用(None 没有 provider/model_name 属性)。 + run_config = replace( + run_config, + embedding_configurations={settings.default_embedding_provider: None}, + ) + + class _StubStore: + def __init__(self): + self.documents: list = [] + + def load(self, path, *, trusted_paths=None): + return + + def save_snapshot(self, snapshot_dir): + return + + with pytest.raises(ValueError, match="取值必须是提供"): + RealFactory._load_existing_state(_StubStore(), run_config) + + +@pytest.mark.parametrize( + "bad_path", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_legacy_import_rejects_non_path_legacy_pkl_path(tmp_path, bad_path): + """legacy 分支对非路径的 ``legacy_pkl_path`` 必须给 ``ValueError``。 + + ``_load_existing_state`` 第一件事就是 ``Path(run_config.legacy_pkl_path)``,而 + ``RunConfig`` 是 frozen dataclass、字段没有校验器(同文件 + ``_require_embedding_detail`` 的 docstring 已认领这条可达性)。非路径取值在那里 + 抛裸 ``TypeError: expected str, bytes or os.PathLike object, not ...``,位数超限 + 的整数抛 ``ValueError: Exceeds the limit``——两条都不在本类其余拒绝路径承诺的 + 带指引 ``ValueError`` 契约内,而调用方(``get_default_vector_store`` 在 + ``Chatbot`` 初始化时调用)按它分类。 + """ + from src.retrieval.vdb.factory import VectorStoreFactory as RealFactory + from src.runtime.contracts import build_run_config + from src.utils.config import get_settings + + settings = get_settings() + run_config = build_run_config(settings) + run_config = replace(run_config, legacy_pkl_path=bad_path) + + class _StubStore: + def __init__(self): + self.documents: list = [] + + def load(self, path, *, trusted_paths=None): + return + + def save_snapshot(self, snapshot_dir): + return + + with pytest.raises(ValueError, match="legacy"): + RealFactory._load_existing_state(_StubStore(), run_config) + + +def test_legacy_import_reports_original_error_when_cleanup_fails(tmp_path, monkeypatch, caplog): + """临时快照清理失败时,抛出的仍是**原始**失败原因,而不是清理异常。 + + 删除吞掉后 ``cleanup_temp_snapshot_dir`` 的异常顶替原始失败——用户 + 看到的失败与实际根因不符(``vdb/factory`` 与 ``knowledge_build_service`` + 两处同判据)。 + """ + import logging + import pickle + from pathlib import Path + + import src.retrieval.vdb.factory as vdb_factory + + class FailingCleanupRepository: + def __init__(self, _config): + pass + + def generate_snapshot_id(self, prefix="kb"): + return f"{prefix}-cleanup-fail" + + def create_temp_snapshot_dir(self, snapshot_id): + path = tmp_path / f".tmp-{snapshot_id}" + path.mkdir(parents=True, exist_ok=True) + return path + + def cleanup_temp_snapshot_dir(self, snapshot_id): + raise RuntimeError("清理本身失败") + + def get_active_snapshot_dir(self): + return None + + class FailingStore: + def load(self, path, *, trusted_paths=None): + return None + + def save_snapshot(self, snapshot_dir): + raise RuntimeError("保存失败") + + from src.runtime.contracts import build_run_config + from src.utils.config import get_settings + + settings = get_settings() + legacy_path = Path(settings.pkl_path) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + with legacy_path.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + run_config = build_run_config(settings) + monkeypatch.setattr(vdb_factory, "SnapshotRepository", FailingCleanupRepository) + + with ( + caplog.at_level(logging.ERROR, logger="src.retrieval.vdb.factory"), + pytest.raises(RuntimeError, match="保存失败"), + ): + vdb_factory.VectorStoreFactory._load_existing_state(FailingStore(), run_config) + assert "清理临时知识快照失败" in caplog.text diff --git a/tests/retrieval/vdb/test_faiss_index_text.py b/tests/retrieval/vdb/test_faiss_index_text.py index 9885762..5af736c 100644 --- a/tests/retrieval/vdb/test_faiss_index_text.py +++ b/tests/retrieval/vdb/test_faiss_index_text.py @@ -1,3 +1,5 @@ +import pytest + from src.retrieval.vdb.faiss_store import FaissStore @@ -24,6 +26,28 @@ def test_build_index_text_keeps_original_content_when_source_missing(): assert index_text == "只有正文" +@pytest.mark.parametrize( + "source", + [123, ["a.md"], {"path": "a.md"}, b"a.md"], + ids=["int", "list", "dict", "bytes"], +) +def test_build_index_text_tolerates_non_string_source(source): + """``metadata['source']`` 非字符串时必须按「无来源」处理,不得抛 ``TypeError``。 + + 判据是 ``isinstance(source, str) and source``——前半个 ``isinstance`` 是**有意** + 短路:只有字符串才进 ``Path(source).stem``。把它后面的 ``and`` 互换为 ``or`` 后, + 非字符串的真值(``123``/``["a.md"]``/``{"path": ...}``)也命中 → + ``Path(123)`` 抛 ``TypeError: expected str, bytes or os.PathLike object``。 + 该路径在索引重建(``_rebuild_indices``)与快照加载都会走到,且检索结果可来自 + 任何 ``VectorStoreBase`` 实现(第三方 store 的 metadata 不受本项目的载入期 + 归一化约束)。既有 ``test_build_index_text_keeps_original_content_when_source_missing`` + 只喂空 metadata(``source`` 缺省为 ``""``,字符串),非字符串分支从未被覆盖。 + """ + document = {"page_content": "正文", "metadata": {"source": source}} + + assert FaissStore._build_index_text(document) == "正文" + + # ── 回归:BM25 分数全 0 时的静默空结果,以及空语料的除零 ── @@ -105,6 +129,11 @@ def test_load_snapshot_tolerates_empty_lexical_index(tmp_path): with (snapshot_dir / name).open("wb") as file: pickle.dump(payload, file) np.save(snapshot_dir / "embeddings.npy", np.zeros((0, 4), dtype=np.float32)) + # 空 store 也要带空索引(生产 save_snapshot 恒写出该文件;缺它 + 有 + # lexical.index 现在是载入期拒绝形态)。 + import faiss + + faiss.write_index(faiss.IndexFlatL2(4), str(snapshot_dir / "semantic.index")) (snapshot_root / "ACTIVE_SNAPSHOT").write_text("kb-empty", encoding="utf-8") store = FaissStore(file_path=None) @@ -112,3 +141,207 @@ def test_load_snapshot_tolerates_empty_lexical_index(tmp_path): assert store.bm25_index is None assert store.keyword_search("任意查询") == [] + + +# ── 回归:``semantic_search`` 的 top_k 必须有上界且给可读错误 ── +# +# 两个方向都会出问题,而配置层的上界拦不住任何一个:``_MAX_RETRIEVAL_TOP_K`` +# 只作用在 ``Settings.chat_top_k`` 与 ``SessionConfig.top_k`` 上,而 +# ``semantic_search`` 是 ``VectorStoreBase`` 的公开方法(库调用方与测试直接 +# 调用),``top_k`` 从入口一路透传给 ``faiss_index.search()``。 +# +# * 下界:``faiss.search(q, 0)`` 与 ``-1`` 触发 FAISS 内建的 ``assert k > 0``, +# 抛的是**没有消息的** ``builtins.AssertionError``。实测消息为空串,栈里只有 +# C 层,用户既不知道哪个参数错了、也不知道该怎么改;而 ``AssertionError`` +# 不是 ``ValueError``,调用方的错误分类接不住。``python -O`` 下断言被剥掉, +# 行为还会变成另一种未定义状态。 +# * 上界:``faiss.search(q, 10**9)`` 不报错也不截断,直接按 k 分配结果数组—— +# 实测 10**9 时挂起(>30s),10**8 时耗时 15.3s。这条路径在配置层被 +# ``_MAX_RETRIEVAL_TOP_K`` 挡住,但公开方法本身没有守卫。 +def _store_with_documents(count: int = 5) -> FaissStore: + import numpy as np + + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": f"doc{i}", "metadata": {}} for i in range(count)], + np.arange(count * 4, dtype=np.float32).reshape(count, 4), + ) + return store + + +def test_semantic_search_returns_empty_when_only_index_is_missing(): + """``faiss_index`` 缺失但 ``embeddings`` 非空时也必须返回空列表。 + + 守卫 ``if self.faiss_index is None or self.embeddings is None`` 的两个条件 + **不互斥**:只要任一为 None 就该短路返回 []。把第一个 ``or`` 互换为 ``and`` + 后,只有两者同时为 None 才短路——半初始化状态(``index`` 为 None、 + ``embeddings`` 非空,例如 ``_rebuild_indices`` 中途或外部注入的 store)会 + 越过守卫,落到 ``self.faiss_index.search(...)`` 抛 + ``AttributeError: 'NoneType' object has no attribute 'search'``。 + ``FaissStore`` 是 ``VectorStoreBase`` 的公开实现,第三方可直接构造。 + """ + import numpy as np + + store = FaissStore(file_path=None) + store.faiss_index = None + store.embeddings = np.zeros((3, 4), dtype=np.float32) + + assert store.semantic_search(np.ones(4, dtype=np.float32), 2) == [] + + +@pytest.mark.parametrize("bad_top_k", [0, -1, -100]) +def test_semantic_search_rejects_non_positive_top_k(bad_top_k): + """非正 ``top_k`` 必须报项目自己的 ``ValueError``,而不是 FAISS 的空断言。""" + import numpy as np + + store = _store_with_documents() + + with pytest.raises(ValueError) as excinfo: + store.semantic_search(np.ones(4, dtype=np.float32), bad_top_k) + + message = str(excinfo.value) + assert "top_k" in message + assert str(bad_top_k) in message + + +def test_semantic_search_rejects_oversized_top_k(): + """超过上界的 ``top_k`` 必须在分配数组**之前**被拒绝。""" + import numpy as np + + from src.utils.config import _MAX_RETRIEVAL_TOP_K + + store = _store_with_documents() + + with pytest.raises(ValueError) as excinfo: + store.semantic_search(np.ones(4, dtype=np.float32), _MAX_RETRIEVAL_TOP_K + 1) + + assert "top_k" in str(excinfo.value) + + +# 上界分支的消息里也必须用 ``safe_repr``。类型分支(上面那条)早就用了, +# 上界分支却是裸 ``{top_k}``——两处对同一个参数、同一份消息,只有一处防住了 +# 位数超限。实测 ``top_k=10**4301`` 时,正要报「top_k 越界」的那一步自己抛 +# ``ValueError: Exceeds the limit (4300 digits) for integer string conversion``: +# 用户拿到的是 CPython 的整数转换错误,与 ``top_k`` 毫无关系,也没有上界信息。 +# (``10**4000`` 不会复现——位数在上限内,只是消息长到 4000 位。) +# +# 这条路径完全可达:两个方法都是 ``VectorStoreBase`` 的公开方法,``top_k`` +# 一路透传,而 pickle 是二进制格式,畸形快照/第三方调用方都能带进这种整数。 +@pytest.mark.parametrize("huge_top_k", [10**4301, -(10**4301)], ids=["正", "负"]) +def test_semantic_search_rejects_huge_top_k_with_a_readable_message(huge_top_k): + """位数超限的 ``top_k`` 必须给出可读的越界消息,而不是整数转换错误。""" + import numpy as np + + store = _store_with_documents() + + with pytest.raises(ValueError) as excinfo: + store.semantic_search(np.ones(4, dtype=np.float32), huge_top_k) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message + assert "top_k" in message + assert "位十进制" in message # safe_repr 对超限整数的量级描述 + + +def test_keyword_search_rejects_huge_top_k_with_a_readable_message(): + """同一份判据必须在 ``keyword_search`` 上成立——两个方法共用 ``_require_top_k``。""" + store = _store_with_positive_keyword_hit() + + with pytest.raises(ValueError) as excinfo: + store.keyword_search("苹果", 10**4301) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message + assert "top_k" in message + assert "位十进制" in message + + +def test_semantic_search_rejects_non_integer_top_k(): + """布尔与浮点混入同样拒绝——``bool`` 是 ``int`` 子类,``True`` 会算成 1。""" + import numpy as np + + store = _store_with_documents() + + for bad_top_k in (True, 1.5, "3", None): + with pytest.raises(ValueError): + store.semantic_search(np.ones(4, dtype=np.float32), bad_top_k) + + +def test_semantic_search_still_accepts_top_k_larger_than_the_corpus(): + """反向守卫:``top_k`` 大于文档数仍然合法,只是返回全部文档。 + + 把上界写成「不得超过文档数」会误伤——``top_k`` 是候选规模,FAISS 本来就 + 允许它超过命中集合。 + """ + import numpy as np + + store = _store_with_documents(count=3) + + assert len(store.semantic_search(np.ones(4, dtype=np.float32), 50)) == 3 + + +# ── 回归:``keyword_search`` 的 top_k 与 ``semantic_search`` 判据必须一致 ── +# +# 两个方法同名参数、同一份上界来源,此前判据完全不同: +# +# * ``top_k=0``/负数:``semantic_search`` 抛 FAISS 的空 ``AssertionError``,而 +# ``keyword_search`` 走的是 ``if len(results) >= top_k: break``——循环体先 +# ``append`` 再判长度,于是 ``top_k=0`` **返回 1 条**。调用方要求「不要结果」 +# 却拿到一条,且没有任何提示。 +# * 非整数:``semantic_search`` 由 FAISS 报 ``TypeError: an integer is required``, +# ``keyword_search`` 在 ``int >= str`` 处报 ``TypeError: '>=' not supported`` +# ——两者都是裸 ``TypeError``,不是本项目的 ``ValueError``。 +# +# 同一个参数在两处给出三种不同收场方式,调用方无法按一种错误类型处理。 +def _store_with_positive_keyword_hit(count: int = 10) -> FaissStore: + """造一个 BM25 分数为正的语料:查询词只出现在一篇文档里。 + + 语料里必须真的存在正分文档,否则 ``keyword_search`` 会在「最高分 <= 0」 + 处提前返回空列表,把 ``top_k`` 的问题整个遮住——用全 0 分数的夹具测这条 + 判据是假绿。 + """ + import numpy as np + + documents = [{"page_content": f"普通 文档{i}", "metadata": {}} for i in range(count)] + documents[0]["page_content"] = "苹果 独特 词汇" + store = FaissStore(file_path=None) + store.upsert_embeddings(documents, np.arange(count * 4, dtype=np.float32).reshape(count, 4)) + return store + + +def test_keyword_search_fixture_really_produces_a_positive_score(): + """夹具自检:正分文档必须存在,否则下面两条断言守的是不存在的问题。""" + store = _store_with_positive_keyword_hit() + + assert len(store.keyword_search("苹果", 5)) == 1 + + +@pytest.mark.parametrize("bad_top_k", [0, -1, -100]) +def test_keyword_search_rejects_non_positive_top_k(bad_top_k): + store = _store_with_positive_keyword_hit() + + with pytest.raises(ValueError) as excinfo: + store.keyword_search("苹果", bad_top_k) + + message = str(excinfo.value) + assert "top_k" in message + assert str(bad_top_k) in message + + +@pytest.mark.parametrize("bad_top_k", [None, "3", 1.5, True]) +def test_keyword_search_rejects_non_integer_top_k(bad_top_k): + store = _store_with_positive_keyword_hit() + + with pytest.raises(ValueError) as excinfo: + store.keyword_search("苹果", bad_top_k) + + assert "top_k" in str(excinfo.value) + + +def test_keyword_search_rejects_oversized_top_k(): + from src.utils.config import _MAX_RETRIEVAL_TOP_K + + store = _store_with_positive_keyword_hit() + + with pytest.raises(ValueError): + store.keyword_search("苹果", _MAX_RETRIEVAL_TOP_K + 1) diff --git a/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py b/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py index c6af553..211904e 100644 --- a/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py +++ b/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py @@ -1,3 +1,7 @@ +import tempfile +from pathlib import Path + +import faiss import numpy as np import pytest @@ -13,6 +17,34 @@ def test_faiss_store_rejects_snapshot_without_embeddings_before_writing_npy(tmp_ assert not (tmp_path / "snapshot" / "embeddings.npy").exists() +@pytest.mark.parametrize( + "bad_path", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +@pytest.mark.parametrize("method", ["save", "save_snapshot"]) +def test_faiss_store_write_entries_reject_non_path_arguments(bad_path, method): + """写入侧入口的非路径入参必须收敛成 ``ValueError``,不是裸 ``TypeError``。 + + ``save``/``save_snapshot`` 是 ``load``/``load_snapshot`` 的写入侧兄弟 + (``VectorStoreBase`` 的契约方法,同一份公开接口),读取侧早已按同一判据 + 校验入参形态,写入侧这一半漏了:``Path(5)``/``Path(["a"])`` 抛裸 + ``TypeError: expected str, bytes or os.PathLike object, not ...``,位数超限 + 的整数抛 ``ValueError: Exceeds the limit``——类型对、内容却与目标路径无关。 + 两条都不在本类其余拒绝路径承诺的带指引 ``ValueError`` 契约内。 + """ + store = FaissStore(file_path=None) + store.documents = [] + store.embeddings = np.zeros((1, 4), dtype=np.float32) + store.faiss_index = faiss.IndexFlatL2(4) + + with pytest.raises(ValueError, match="必须是字符串或路径"): + getattr(store, method)(bad_path) + + def _activate_snapshot(snapshot_root, snapshot_id: str) -> None: """写入 ACTIVE_SNAPSHOT 标记,让快照目录成为「本应用管理的活动快照」。 @@ -80,6 +112,13 @@ def _make_valid_snapshot(snapshot_root, snapshot_id="kb-ok", *, activate=True): with (snapshot_dir / "parents.pkl").open("wb") as file: pickle.dump({}, file) np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + # 生产 ``save_snapshot`` 恒写出 semantic.index(``faiss_index is None`` 时 + # RuntimeError),夹具与之对齐——「有矩阵、无索引」现在是载入期拒绝形态。 + import faiss as _faiss + + index = _faiss.IndexFlatL2(4) + index.add(np.zeros((1, 4), dtype=np.float32)) + _faiss.write_index(index, str(snapshot_dir / "semantic.index")) # lexical.index 也要是合法 pickle:空 bytes 会让 pickle.load 抛 EOFError, # 那是「文件被截断」而不是本节要测的信任边界。 with (snapshot_dir / "lexical.index").open("wb") as file: @@ -89,6 +128,577 @@ def _make_valid_snapshot(snapshot_root, snapshot_id="kb-ok", *, activate=True): return snapshot_dir +# ``load_snapshot`` 在 ``lexical.index`` 存在时**直接使用盘上的语料**,不重建; +# 而 ``keyword_search`` 拿 ``np.argsort(doc_scores)`` 的下标去索引 +# ``self.documents``。语料条数与分块数不等时,下标就对不上: +# 语料 3 条、文档 2 条 -> ``IndexError: list index out of range`` +# 语料 1 条、文档 2 条 -> 静默丢结果(用户看到「搜不到」而不是「快照坏了」) +# 顶层不是序列时更早收场,且抛的是裸 ``TypeError``/``AttributeError``, +# 与「快照不自洽」无关、也没有重建指引。 +# +# 生产路径已由 ``SnapshotRepository.validate_snapshot_dir`` 挡住这些形态; +# ``load_snapshot`` 是 ``VectorStoreBase`` 的契约方法,直接调它的嵌入方不经过 +# 那道校验(与 ``_validate_document_shape`` 同一取舍),因此这里补最小不变量。 +@pytest.mark.parametrize( + ("lexical", "pattern"), + [ + ([["内容"]], "语料条数与分块数不一致"), # 1 条语料 vs 2 条文档 + ([["a"], ["b"], ["c"]], "语料条数与分块数不一致"), + ([], "语料条数与分块数不一致"), + (None, "语料条数与分块数不一致"), + ("ab", "顶层不是列表"), # len("ab") == 2 会静默通过,必须按类型拦 + ({"a": 1}, "顶层不是列表"), + (3, "顶层不是列表"), # 原先抛裸 TypeError: object of type 'int' has no len() + ], + ids=["少一条", "多一条", "空列表", "None", "字符串", "dict", "int"], +) +def test_load_snapshot_rejects_lexical_index_row_mismatch(tmp_path, lexical, pattern): + import pickle + + snapshot_dir = tmp_path / "kb-lex" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump( + [ + {"page_content": "alpha", "metadata": {}}, + {"page_content": "beta", "metadata": {}}, + ], + file, + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((2, 4), dtype=np.float32)) + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump(lexical, file) + _activate_snapshot(tmp_path, "kb-lex") + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match=pattern): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +def test_load_snapshot_accepts_matching_lexical_index(tmp_path): + """收紧不能误伤:条数一致时照常加载,且关键词检索可用。""" + snapshot_dir = _make_valid_snapshot(tmp_path, "kb-lex-ok") + # ``_make_valid_snapshot`` 只写 1 个分块,这里再补一个,保持与语料一致。 + import pickle + + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump( + [ + {"page_content": "内容", "metadata": {"source": "s.md"}}, + {"page_content": "另一个分块", "metadata": {"source": "s.md"}}, + ], + file, + ) + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump([["内容"], ["另一个分块"]], file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((2, 4), dtype=np.float32)) + import faiss as _faiss + + index = _faiss.IndexFlatL2(4) + index.add(np.zeros((2, 4), dtype=np.float32)) + _faiss.write_index(index, str(snapshot_dir / "semantic.index")) + + store = FaissStore(file_path=None) + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + assert len(store.documents) == 2 + assert len(store._tokenized_docs_cache) == 2 + assert store.bm25_index is not None + + +# ── 回归:embeddings 行数必须与分块数对齐 ── +# +# ``load_snapshot``/``load`` 此前只校验了 ``semantic.index`` 条数与 +# ``lexical.index`` 条数,**embeddings 矩阵的行数**从未与 ``documents`` 对齐过。 +# 实测 embeddings 3 行、文档 2 条的快照:载入成功、无任何提示,而 +# ``semantic_search`` 的分数按矩阵行算、文档按下标取——行数错配即张冠李戴或 +# ``IndexError``,报错现场离「这份快照坏了」很远。守卫收敛进 +# ``FaissStore._validate_row_alignment``,两个载入入口共用。 + + +def _write_alignment_snapshot(snapshot_dir, chunk_count, embedding_rows, index_rows=None): + """按真实结构写一个 chunk/embedding/索引行数可独立指定的快照。""" + import pickle + + import faiss + + snapshot_dir.mkdir(parents=True, exist_ok=True) + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump( + [ + {"page_content": f"chunk {position}", "metadata": {}} + for position in range(chunk_count) + ], + file, + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((embedding_rows, 4), dtype=np.float32)) + index = faiss.IndexFlatL2(4) + index.add( + np.zeros((index_rows if index_rows is not None else embedding_rows, 4), dtype=np.float32) + ) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) + return snapshot_dir + + +@pytest.mark.parametrize( + ("embedding_rows", "index_rows", "pattern"), + [ + (3, None, "向量矩阵行数与分块数不一致"), + (3, 2, "向量矩阵行数与分块数不一致"), # 索引对齐也必须拦——矩阵单独错 + (1, 2, "向量矩阵行数与分块数不一致"), # 矩阵先拦——索引分支由单独用例覆盖 + ], + ids=["embedding-rows-3", "embedding-rows-3-index-aligned", "embedding-rows-1"], +) +def test_load_snapshot_rejects_row_count_mismatch(tmp_path, embedding_rows, index_rows, pattern): + """embeddings 行数/语义索引条数与分块数不一致时必须拒绝。""" + snapshot_dir = _write_alignment_snapshot( + tmp_path / "kb-align", chunk_count=2, embedding_rows=embedding_rows, index_rows=index_rows + ) + _activate_snapshot(tmp_path, "kb-align") + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match=pattern): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +def test_load_snapshot_rejects_index_row_mismatch_alone(tmp_path): + """矩阵行数对齐、索引单独少一个向量时必须拦索引分支。""" + snapshot_dir = _write_alignment_snapshot( + tmp_path / "kb-idx", chunk_count=2, embedding_rows=2, index_rows=1 + ) + _activate_snapshot(tmp_path, "kb-idx") + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="语义索引条数与分块数不一致"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +def test_load_rejects_embedding_row_mismatch(): + """legacy ``load`` 同样必须拦 embeddings 行数错配。""" + import pickle + + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "legacy.pkl" + with path.open("wb") as file: + pickle.dump( + { + "documents": [ + {"page_content": "alpha", "metadata": {}}, + {"page_content": "beta", "metadata": {}}, + ], + "embeddings": np.zeros((3, 4), dtype=np.float32), + "parent_documents": {}, + }, + file, + ) + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="向量矩阵行数与分块数不一致"): + store.load(str(path), trusted_paths=[temp_dir]) + + +def test_row_alignment_accepts_self_consistent_snapshot(tmp_path): + """收紧不能误伤:三量对齐时照常加载。""" + snapshot_dir = _write_alignment_snapshot( + tmp_path / "kb-align-ok", chunk_count=2, embedding_rows=2, index_rows=2 + ) + _activate_snapshot(tmp_path, "kb-align-ok") + + store = FaissStore(file_path=None) + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + assert len(store.documents) == 2 + assert store.faiss_index.ntotal == 2 + + +# ── 回归:lexical.index 的**条目**形态也要拦 ── +# +# 上面那组用例只覆盖了 ``lexical.index`` 的**顶层**形态。条目本身不是序列时, +# 顶层「是列表、条数也对」——两道守卫全部放行,随后 ``BM25Okapi`` 在 +# ``_initialize`` 里对每条语料调 ``len(document)``,抛裸 +# ``TypeError: object of type 'int' has no len()``。 +# +# 这条路径在**生产**上可达:``SnapshotRepository.validate_snapshot_dir`` 只查 +# 顶层与条数,``FaissStore.load_snapshot`` 拿到盘上的语料后直接建 BM25。实测 +# 语料 ``[7]``、``[10**4301]``、``[1.5]`` 三种形态都能穿过 validate 并在 +# load_snapshot 抛 ``TypeError``——异常类型与「快照坏了」无关,也没有重建指引, +# 而同一个文件顶层写坏时给的正是有指引的 ``ValueError``。同类失败不该有两种 +# 收场方式。 +@pytest.mark.parametrize( + ("lexical", "label"), + [ + ([7], "int"), + ([10**4301], "huge-int"), + ([1.5], "float"), + ([None], "None"), + ([7, ["ok"]], "mixed"), + ], + ids=["int", "huge-int", "float", "None", "mixed"], +) +def test_load_snapshot_rejects_non_sequence_lexical_entries(tmp_path, lexical, label): + """语料条目不是序列时必须给「快照不自洽」的 ``ValueError``,不是裸 ``TypeError``。""" + import pickle + + snapshot_dir = tmp_path / f"kb-lex-entry-{label}" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "alpha", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump(lexical, file) + _activate_snapshot(tmp_path, snapshot_dir.name) + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="lexical.index"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +def test_validate_snapshot_dir_rejects_non_sequence_lexical_entries(tmp_path): + """生产路径的第一道守卫也要拦住,否则坏快照会被 finalize 成活动快照。""" + import pickle + + from src.retrieval.snapshot_repository import SnapshotRepository + from src.runtime.contracts import KnowledgeSnapshotManifest + + repository = SnapshotRepository( + type( + "RunConfig", + (), + {"snapshot_root": tmp_path, "active_snapshot_marker": tmp_path / "ACTIVE_SNAPSHOT"}, + )() + ) + snapshot_dir = tmp_path / "kb-lex-entry-validate" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "alpha", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + (snapshot_dir / "stats.json").write_text('{"chunk_count": 1}', encoding="utf-8") + import faiss + + index = faiss.IndexFlatIP(4) + index.add(np.zeros((1, 4), dtype=np.float32)) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) + repository.write_manifest( + snapshot_dir, + KnowledgeSnapshotManifest.create( + snapshot_id=snapshot_dir.name, + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=1, + chunk_count=1, + ), + ) + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump([7], file) + + with pytest.raises(ValueError, match="lexical.index"): + repository.validate_snapshot_dir(snapshot_dir) + + +# ── 回归:载入失败必须让 store 保持原状 ── +# +# 两个载入入口原先都是「边解析边写 self」:``load`` 先把 documents/ +# embeddings/parent_documents 赋进 self 再调归一化,``load_snapshot`` 每读一个 +# 文件就写一次。中途任何一步失败都留下**半更新**的 store——实测一次被拒的 +# ``load`` 之后 documents 与 embeddings 是新值,而 faiss_index 与 +# _tokenized_docs_cache 还是旧的(索引 2 条对应 1 条文档),随后 +# ``semantic_search`` 抛 ``TypeError: 'str' object does not support item +# assignment``:报错现场与真正的原因(载入被拒)毫无关联。 +# +# 调用方看到异常后本可以继续用旧数据,半更新状态让这个选项也消失了。 +def _legacy_file(tmp_path, payload): + import pickle + + path = tmp_path / "legacy.pkl" + with path.open("wb") as file: + pickle.dump(payload, file) + return path + + +def test_load_rejection_leaves_store_untouched(tmp_path): + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {}}, {"page_content": "beta", "metadata": {}}], + np.zeros((2, 4), dtype=np.float32), + ) + before = ( + [doc["page_content"] for doc in store.documents], + store.embeddings.shape, + store.faiss_index.ntotal, + list(store._tokenized_docs_cache), + ) + + path = _legacy_file(tmp_path, {"documents": ["bad orphan"], "parent_documents": {}}) + with pytest.raises(ValueError, match="不是映射"): + store.load(str(path), trusted_paths=[tmp_path]) + + after = ( + [doc["page_content"] for doc in store.documents], + store.embeddings.shape, + store.faiss_index.ntotal, + list(store._tokenized_docs_cache), + ) + assert before == after + # 半更新的直接症状:索引与文档下标错配,检索崩在与原因无关的位置。 + assert [hit["page_content"] for hit in store.semantic_search(np.zeros(4), top_k=5)] == [ + "alpha", + "beta", + ] + + +def test_load_snapshot_rejection_leaves_store_untouched(tmp_path): + snapshot_dir = tmp_path / "kb-bad" + snapshot_dir.mkdir() + import pickle + + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump(["bad orphan"], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + _activate_snapshot(tmp_path, "kb-bad") + + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + before = (list(store.documents), store.embeddings.shape, store.faiss_index.ntotal) + + with pytest.raises(ValueError, match="不是映射"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + after = (list(store.documents), store.embeddings.shape, store.faiss_index.ntotal) + assert before == after + assert store.faiss_index.ntotal == len(store.documents) + + +def test_load_snapshot_row_mismatch_rejection_leaves_store_untouched(tmp_path): + """行数错配的拒绝同样必须原子:store 保持原状。 + + 行数校验发生在 ``_normalize_documents`` 之后、批量赋值之前——失败落在局部 + 变量上,``self`` 必须仍是旧快照的完整状态。 + """ + snapshot_dir = _write_alignment_snapshot( + tmp_path / "kb-align-bad", chunk_count=2, embedding_rows=3 + ) + _activate_snapshot(tmp_path, "kb-align-bad") + + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + before = (list(store.documents), store.embeddings.shape, store.faiss_index.ntotal) + + with pytest.raises(ValueError, match="向量矩阵行数与分块数不一致"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + after = (list(store.documents), store.embeddings.shape, store.faiss_index.ntotal) + assert before == after + + +# ── 回归:embeddings 的形状要在**赋值之前**判 ── +# +# 写入侧 ``upsert_embeddings`` 早已断言 ``ndim == 2``,两个载入入口都没有: +# ``load`` 把 ``data.get("embeddings")`` 直接赋给 ``self``,``load_snapshot`` +# 直接 ``np.load`` 之后同样赋值,随后 ``_rebuild_indices`` 才去读 +# ``len(self.embeddings)`` 与 ``self.embeddings.shape[1]``。实测: +# 0 维(``np.float32(1.0)``)-> ``TypeError: len() of unsized object`` +# 1 维(``np.zeros(4)``) -> ``IndexError: tuple index out of range`` +# ``list``(legacy pkl) -> ``AttributeError: 'list' object has no attribute 'shape'`` +# 三者都不是 ``ValueError``,穿透调用方全部 ``except ValueError`` 分类。 +# +# 更严重的是它**同时破坏了两个载入入口承诺的原子性**:形状检查发生在 +# ``self.documents``/``self.embeddings`` 已经写进去之后,于是失败留下的正是 +# 那组测试要防的半更新状态。实测一次被拒的 ``load`` 之后 docs=2 而 faiss 索引 +# 仍是旧的 1 条——「失败必须让 store 保持原状」这条契约被绕过了。 +@pytest.mark.parametrize( + ("embeddings", "label"), + [ + (np.float32(1.0), "0d"), + (np.zeros(4, dtype=np.float32), "1d"), + (np.zeros((1, 4, 1), dtype=np.float32), "3d"), + ([[0.0, 0.0]], "list"), + (5, "int"), + ], + ids=["0d", "1d", "3d", "list", "int"], +) +def test_load_rejects_non_matrix_embeddings(tmp_path, embeddings, label): + """legacy 载入的形状错误必须是 ``ValueError``,且 store 保持原状。""" + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + before = ( + [doc["page_content"] for doc in store.documents], + store.embeddings.shape, + store.faiss_index.ntotal, + ) + + path = _legacy_file( + tmp_path, + { + "documents": [{"page_content": "a", "metadata": {}}], + "embeddings": embeddings, + }, + ) + with pytest.raises(ValueError, match="embeddings"): + store.load(str(path), trusted_paths=[tmp_path]) + + after = ( + [doc["page_content"] for doc in store.documents], + store.embeddings.shape, + store.faiss_index.ntotal, + ) + assert before == after, label + + +@pytest.mark.parametrize( + ("array", "label"), + [ + (np.float32(1.0), "0d"), + (np.zeros(4, dtype=np.float32), "1d"), + (np.zeros((1, 4, 1), dtype=np.float32), "3d"), + ], + ids=["0d", "1d", "3d"], +) +def test_load_snapshot_rejects_non_matrix_embeddings(tmp_path, array, label): + """快照载入的形状错误同样必须是 ``ValueError``。""" + import pickle + + snapshot_dir = tmp_path / f"kb-emb-{label}" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "alpha", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", array) + _activate_snapshot(tmp_path, snapshot_dir.name) + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="embeddings"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +# ── 回归:查询向量的形态校验要排在转换**之前** ── +# +# ``semantic_search`` 的守卫写的是 ``query_vector = np.asarray(...)`` 之后才判 +# ``ndim``,而 ``np.asarray(10**4301, dtype=np.float32)`` **自己就抛** +# ``OverflowError: int too large to convert to float``——``ArithmeticError`` +# 子类而**不是** ``ValueError``,穿透调用方全部 ``except ValueError`` 分类。 +# 本函数已按同一判据修过 ``_require_top_k``(那里拦住的是 FAISS 的裸 +# ``TypeError``/``AssertionError``),查询向量这条漏了:同一个「参数形态非法」 +# 的处置,两处收场不同。 +# +# 同类的还有 ``keyword_search`` 的 ``query_text``:非字符串直接进 +# ``jieba.cut``,抛 ``AttributeError: 'int' object has no attribute 'decode'``。 +@pytest.mark.parametrize( + "bad_query", + [10**4301, -(10**4301), 10**400], + ids=["4301digits", "negative-4301digits", "400digits"], +) +def test_semantic_search_rejects_unconvertible_query_with_value_error(bad_query): + """超出 double 范围的查询向量必须是 ``ValueError``,不是 ``OverflowError``。""" + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + + with pytest.raises(ValueError, match="查询向量"): + store.semantic_search(bad_query, top_k=1) + + +@pytest.mark.parametrize( + "bad_query", + [5, None, ["a"], 10**4301], + ids=["int", "None", "list", "huge-int"], +) +def test_keyword_search_rejects_non_string_query_with_value_error(bad_query): + """非字符串查询必须是 ``ValueError``,不是 jieba 的裸 ``AttributeError``。""" + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha beta", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + + with pytest.raises(ValueError, match="查询"): + store.keyword_search(bad_query, top_k=1) + + +# ``documents`` / ``parents`` 的**顶层**形态同样要有守卫。生产路径由 +# ``SnapshotRepository.validate_snapshot_dir`` 覆盖,但两个载入方法是 +# ``VectorStoreBase`` 的契约方法,直接调它们的嵌入方不经过那道校验。实测原先的 +# 收场方式都是与「快照不自洽」无关的裸异常: +# 分块列表 int/None -> TypeError: 'int' object is not iterable +# 分块列表 dict/str -> 逐键/逐字符当成文档,报「第 0 条文档不是映射」(误导) +# 父块索引 int/None/list -> AttributeError: 'int' object has no attribute 'items' +@pytest.mark.parametrize( + ("payload", "pattern"), + [ + (3, "分块列表的顶层不是序列"), + (None, "分块列表的顶层不是序列"), + ("ab", "分块列表的顶层不是序列"), # len("ab") == 2 曾让它逐字符当成文档 + ({"a": 1}, "分块列表的顶层不是序列"), + ], + ids=["int", "None", "str", "dict"], +) +def test_load_rejects_malformed_documents_top_level(tmp_path, payload, pattern): + store = FaissStore(file_path=None) + path = _legacy_file(tmp_path, {"documents": payload, "parent_documents": {}}) + + with pytest.raises(ValueError, match=pattern): + store.load(str(path), trusted_paths=[tmp_path]) + + +@pytest.mark.parametrize("payload", [3, None, [1, 2]], ids=["int", "None", "list"]) +def test_load_rejects_malformed_parent_documents_top_level(tmp_path, payload): + store = FaissStore(file_path=None) + path = _legacy_file( + tmp_path, + {"documents": [{"page_content": "a", "metadata": {}}], "parent_documents": payload}, + ) + + with pytest.raises(ValueError, match="父块索引的顶层不是映射"): + store.load(str(path), trusted_paths=[tmp_path]) + + +def test_load_rejects_non_mapping_payload(tmp_path): + """legacy pkl 的顶层不是映射时,原先抛裸 ``AttributeError``。""" + store = FaissStore(file_path=None) + path = _legacy_file(tmp_path, ["not", "a", "mapping"]) + + with pytest.raises(ValueError, match="顶层不是映射"): + store.load(str(path), trusted_paths=[tmp_path]) + + +def test_load_legacy_does_not_mutate_the_input_payload(tmp_path): + """归一化必须返回新对象:调用方持有的那份数据不该被 ``pop`` 掉父块内容。""" + + payload = { + "documents": [ + { + "page_content": "child", + "metadata": {"parent_id": "p1", "parent_content": "父块正文"}, + } + ], + "parent_documents": {}, + } + path = _legacy_file(tmp_path, payload) + + store = FaissStore(file_path=None) + store.load(str(path), trusted_paths=[tmp_path]) + + assert store.resolve_parent_content("p1") == "父块正文" + assert payload["documents"][0]["metadata"]["parent_content"] == "父块正文" + + def test_load_snapshot_requires_trust_root(tmp_path): """不声明 snapshot_root 时必须拒绝。 @@ -102,6 +712,35 @@ def test_load_snapshot_requires_trust_root(tmp_path): FaissStore(file_path=None).load_snapshot(str(snapshot_dir)) +@pytest.mark.parametrize( + "bad_argument", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +@pytest.mark.parametrize("which", ["snapshot_dir", "snapshot_root"]) +def test_load_snapshot_rejects_non_path_arguments(tmp_path, which, bad_argument): + """``load_snapshot`` 的非路径入参必须收敛成 ``ValueError``,不是裸 ``TypeError``。 + + ``load``(legacy 入口)早已按同一判据修过,而 ``load_snapshot`` 这一半漏了: + 它在调 ``resolve_within`` **之前**先写 ``Path(snapshot_dir)`` / ``Path(snapshot_root)``, + 非路径取值在那里抛裸 ``TypeError: expected str, bytes or os.PathLike object, + not ...``;位数超限的整数抛 ``ValueError: Exceeds the limit``——类型对、内容 + 却与快照路径无关。两条都不在本方法其余拒绝路径的 ``ValueError`` 契约内, + 调用方 ``VectorStoreFactory._load_existing_state`` 按它分类。 + """ + snapshot_dir = _make_valid_snapshot(tmp_path / "snapshots") + kwargs = {"snapshot_dir": str(snapshot_dir), "snapshot_root": str(tmp_path)} + kwargs[which] = bad_argument + + with pytest.raises(ValueError, match="必须是字符串或路径"): + FaissStore(file_path=None).load_snapshot( + kwargs["snapshot_dir"], snapshot_root=kwargs["snapshot_root"] + ) + + def test_load_snapshot_rejects_directory_outside_trust_root(tmp_path): """信任根之外的目录必须拒绝,即使目录结构看起来完全合法。""" outside = tmp_path / "outside" @@ -220,3 +859,1122 @@ def test_init_with_file_path_requires_trusted_paths(tmp_path): store = FaissStore(file_path=str(legacy), trusted_paths=[tmp_path]) assert store.documents == [] + + +# ── 回归:upsert_embeddings 失败时不得留下半更新的 store ── + + +def test_upsert_embeddings_rejects_dimension_mismatch_without_mutating(): + """维度不一致时必须拒绝,且 store 保持自洽。 + + 原实现先 ``self.documents.extend(...)`` 再判维度:维度不一致时抛异常, + 但 documents 已经多出一批没有对应向量的条目。实测拒绝之后 + ``documents=2`` 而 ``embeddings=(1,4)``,store 已不自洽,而 + ``save_snapshot`` 会照常把这份不自洽落盘。失败必须不留痕。 + """ + store = FaissStore(file_path=None) + store.upsert_embeddings([{"page_content": "a", "metadata": {}}], np.zeros((1, 4), np.float32)) + + with pytest.raises(ValueError, match="维度与现有索引不一致"): + store.upsert_embeddings( + [{"page_content": "b", "metadata": {}}], np.zeros((1, 8), np.float32) + ) + + assert len(store.documents) == 1 + assert store.embeddings.shape == (1, 4) + assert store.faiss_index.ntotal == len(store.documents) + + +def test_upsert_embeddings_rejects_count_mismatch_without_mutating(): + """文档数与向量数不一致时同样不得改动 store。""" + store = FaissStore(file_path=None) + + with pytest.raises(ValueError, match="数量不一致"): + store.upsert_embeddings( + [{"page_content": "a", "metadata": {}}, {"page_content": "b", "metadata": {}}], + np.zeros((1, 4), np.float32), + ) + + assert store.documents == [] + assert store.embeddings is None + + +# ``upsert_embeddings`` 原先只校验 embeddings,文档形态放任 ``extend`` 之后由 +# ``_rebuild_indices`` 在派生索引时撞出来。异常看似「拒绝写入」,实际上 +# ``self.documents`` 已经多出这条孤儿——实测拒绝之后 documents=1 而 +# ``_build_index_text`` 在下次重建索引时对同一条再次抛出,于是 store **永久 +# 卡死**:连续 3 次喂完全合法的文档全部报同一个 ``AttributeError``, +# documents 停在 5 条不再增长,向量库事实上报废。且 ``save_snapshot`` 会把这份 +# 不自洽落盘(实测盘上 chunks.pkl 有 2 条、embeddings 只有 1 条)。 +# +# 判据与载入边界 ``_validate_document_shape`` 共用同一份实现,因此这里按同一组 +# 形态参数化,确认写入侧也逐条拒绝、且**不留痕**。 +@pytest.mark.parametrize( + ("document", "label"), + [ + ("just a string", "条目不是映射(str)"), + (None, "条目不是映射(None)"), + (["page_content"], "条目不是映射(list)"), + ({"page_content": None, "metadata": {}}, "page_content=None"), + ({"page_content": ["a"], "metadata": {}}, "page_content=list"), + ({"page_content": 3, "metadata": {}}, "page_content=int"), + ({"page_content": "ok", "metadata": [1, 2]}, "metadata=list"), + ({"page_content": "ok", "metadata": "nope"}, "metadata=str"), + ], + ids=lambda value: value if isinstance(value, str) else "", +) +def test_upsert_embeddings_rejects_malformed_documents_without_mutating(document, label): + """形态非法的条目必须在写入前拒绝,且 store 保持自洽。""" + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "good", "metadata": {"source": "a"}}], np.zeros((1, 4), np.float32) + ) + + with pytest.raises(ValueError, match="Document 契约"): + store.upsert_embeddings([document], np.zeros((1, 4), np.float32)) + + assert len(store.documents) == 1, f"{label} 被拒后仍留下了孤儿条目" + assert store.embeddings.shape == (1, 4) + assert store.faiss_index.ntotal == 1 + assert len(store._tokenized_docs_cache) == len(store.documents) + + +def test_upsert_embeddings_rejection_does_not_wedge_the_store(): + """被拒一次之后,store 必须仍能接受合法写入。 + + 这是上一条的**后果**断言,单独成例是因为它才是真正致命的那个:孤儿条目留在 + ``self.documents`` 里时,``_rebuild_indices`` 每次都会对同一条重新抛异常, + 于是任何后续写入——包括完全合法的——都失败,store 再也无法增长。 + """ + store = FaissStore(file_path=None) + store.upsert_embeddings([{"page_content": "a", "metadata": {}}], np.zeros((1, 4), np.float32)) + + with pytest.raises(ValueError, match="Document 契约"): + store.upsert_embeddings(["orphan"], np.zeros((1, 4), np.float32)) + + for index in range(3): + store.upsert_embeddings( + [{"page_content": f"ok{index}", "metadata": {}}], np.zeros((1, 4), np.float32) + ) + + assert len(store.documents) == 4 + assert store.embeddings.shape == (4, 4) + assert store.faiss_index.ntotal == 4 + assert store.bm25_index is not None + assert store.bm25_index.corpus_size == 4 + + +def test_upsert_embeddings_rejection_message_points_at_the_writer(): + """写入侧的消息必须给出「修正来源后重试」,而不是「重建快照」。 + + 两个调用点共用同一份形态校验,但处置指引不同:载入侧遇到的是盘上已有的坏 + 数据(重建快照),写入侧遇到的是正在写入的坏数据(修正来源)。写死「请重建 + 快照」会让写入侧的用户去重建一个还没写坏的旧快照。 + """ + store = FaissStore(file_path=None) + + with pytest.raises(ValueError) as excinfo: + store.upsert_embeddings(["orphan"], np.zeros((1, 4), np.float32)) + + message = str(excinfo.value) + assert "已拒绝写入" in message + assert "重建快照" not in message + + +def test_rebuild_indices_tolerates_orphan_documents(): + """索引重建是派生缓存,不得因 ``documents`` 里的历史脏数据而抛异常。 + + ``documents`` 可能带着写入侧校验**之前**留下的形态(``load_snapshot`` 从 + pickle 读回的任意内容)。派生期一抛,整个 store 就卡死;这里要求它降级: + 脏条目缺席检索,其余条目照常可用。 + """ + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {"source": "a"}}], np.zeros((1, 4), np.float32) + ) + # 绕过写入侧校验,模拟历史遗留形态。 + store.documents.append("legacy orphan") + store.documents.append({"page_content": ["not", "text"], "metadata": "nope"}) + + store._rebuild_indices() + + assert len(store._tokenized_docs_cache) == len(store.documents) + assert store._tokenized_docs_cache[1] == [] + assert store._tokenized_docs_cache[2] == [] + # 合法条目照常可检索。 + hits = store.keyword_search("alpha", top_k=5) + assert [hit["page_content"] for hit in hits] == ["alpha"] + + +def test_upsert_embeddings_appends_consistently(): + """收紧不能误伤:维度一致的追加必须照常工作,且索引与文档保持同步。""" + store = FaissStore(file_path=None) + + store.upsert_embeddings([{"page_content": "a", "metadata": {}}], np.zeros((1, 4), np.float32)) + store.upsert_embeddings( + [{"page_content": "b", "metadata": {}}, {"page_content": "c", "metadata": {}}], + np.zeros((2, 4), np.float32), + ) + + assert len(store.documents) == 3 + assert store.embeddings.shape == (3, 4) + assert store.faiss_index.ntotal == 3 + assert store.bm25_index is not None + assert store.bm25_index.corpus_size == 3 + + +# ── 回归:载入的文档必须符合 Document 契约 ── +# +# ``FaissStore`` 载入时会把快照/legacy pkl 里的条目直接当成 ``Document`` 用, +# 而 ``Document`` 的契约是 ``content: str`` + ``metadata: dict`` +# (见 ``src/models/document.py``)。原实现只做 ``metadata or {}`` 的兜底,其余 +# 形态一律放行:实测 ``page_content=None`` 的快照能通过 ``validate_snapshot_dir``、 +# 加载成功,直到 ``display_results`` 里 ``.replace()`` 才抛 +# ``AttributeError: 'NoneType' object has no attribute 'replace'``—— +# 报错现场离真正的原因(快照里这一条不是字符串)很远。``metadata`` 非 dict 与 +# 条目本身不是 dict 同理。 +# +# 在载入边界拒绝,理由与 ``chunks.pkl`` 的解析检查一致:这是快照内容不自洽, +# 不是显示层的问题。 + + +def _write_legacy(tmp_path, documents): + """写一个最小可用的 legacy pkl 并返回路径。""" + import pickle + + import numpy as np + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump( + { + "documents": documents, + "embeddings": np.zeros((len(documents), 4), dtype=np.float32), + "parent_documents": {}, + }, + file, + ) + return legacy + + +@pytest.mark.parametrize( + "document,label", + [ + ({"page_content": None, "metadata": {"source": "a.md"}}, "page_content 为 None"), + ({"page_content": 42, "metadata": {"source": "a.md"}}, "page_content 为整数"), + ({"page_content": ["x"], "metadata": {"source": "a.md"}}, "page_content 为列表"), + ({"page_content": "x", "metadata": ["a"]}, "metadata 为列表"), + ({"page_content": "x", "metadata": "not-a-dict"}, "metadata 为字符串"), + ({"page_content": "x", "metadata": 42}, "metadata 为整数"), + ], +) +def test_load_rejects_documents_violating_the_document_contract(tmp_path, document, label): + """字段形态不符合 ``Document`` 契约时必须在载入期拒绝。""" + legacy = _write_legacy(tmp_path, [document]) + store = FaissStore() + + with pytest.raises(ValueError, match="文档"): + store.load(str(legacy), trusted_paths=[tmp_path]) + + +def test_load_rejects_non_mapping_document_entries(tmp_path): + """条目本身不是映射时同样要拒绝,而不是抛 ``'str' object has no attribute 'get'``。""" + legacy = _write_legacy(tmp_path, ["这不是一个文档"]) + store = FaissStore() + + with pytest.raises(ValueError, match="文档"): + store.load(str(legacy), trusted_paths=[tmp_path]) + + +def test_load_accepts_well_formed_documents(tmp_path): + """收紧不能误伤:合规文档照常载入,``metadata`` 缺省仍补成空 dict。""" + legacy = _write_legacy( + tmp_path, + [ + {"page_content": "正文", "metadata": {"source": "a.md", "chunk_id": "c1"}}, + {"page_content": "只有正文"}, + ], + ) + store = FaissStore() + + store.load(str(legacy), trusted_paths=[tmp_path]) + + assert store.documents[0]["page_content"] == "正文" + assert store.documents[0]["metadata"]["chunk_id"] == "c1" + assert store.documents[1]["metadata"] == {} + + +# ── 回归:``metadata`` 的 None 值不得让 save_snapshot 崩掉 ── +# +# ``doc.get("metadata", {})`` 的默认值只在**键缺失**时生效;键存在而值为 +# ``None`` 时它返回 ``None``,随后的 ``.get`` 抛 +# ``AttributeError: 'NoneType' object has no attribute 'get'``。同文件另外三处 +# (``_build_index_text``、``_normalize_loaded_documents``、 +# ``_normalize_parent_document``)用的都是 ``or {}``,只有 stats 计算这一处用了 +# ``.get(k, {})``。实测 ``upsert_embeddings`` 接受 ``metadata=None`` 的条目并 +# 正常建好索引(``_rebuild_indices`` 走的是 ``or {}``),到 ``save_snapshot`` +# 才崩——同一份数据两条路径的判据不一致,而崩溃发生在知识库构建的最后一步。 + + +def test_save_snapshot_survives_documents_with_none_metadata(tmp_path): + """``metadata=None`` 的条目必须能存快照,与索引构建路径的判据一致。""" + import json + + import numpy as np + + from src.retrieval.vdb.faiss_store import FaissStore + + store = FaissStore() + store.upsert_embeddings( + [ + {"page_content": "a", "metadata": {"source": "x.md"}}, + {"page_content": "b", "metadata": None}, + ], + np.zeros((2, 4), dtype=np.float32), + ) + + snapshot_dir = tmp_path / "snap" + store.save_snapshot(str(snapshot_dir)) + + stats = json.loads((snapshot_dir / "stats.json").read_text(encoding="utf-8")) + assert stats["chunk_count"] == 2 + # 断言与「metadata 缺键」这条既有路径**同值**,而不是断言某个具体数字: + # ``document_count`` 的口径是「distinct source」,没有 source 的条目会以 + # ``None`` 占一个桶。要紧的是 ``metadata=None`` 与 ``metadata={}`` 得到同一 + # 结果——此前前者崩、后者正常。 + missing_key_store = FaissStore() + missing_key_store.upsert_embeddings( + [ + {"page_content": "a", "metadata": {"source": "x.md"}}, + {"page_content": "b", "metadata": {}}, + ], + np.zeros((2, 4), dtype=np.float32), + ) + other_dir = tmp_path / "snap-missing-key" + missing_key_store.save_snapshot(str(other_dir)) + other_stats = json.loads((other_dir / "stats.json").read_text(encoding="utf-8")) + + assert stats["document_count"] == other_stats["document_count"] + + +def test_save_snapshot_still_counts_documents_normally(tmp_path): + """收紧不能误伤:正常 metadata 的来源计数不得改变。""" + import json + + import numpy as np + + from src.retrieval.vdb.faiss_store import FaissStore + + store = FaissStore() + store.upsert_embeddings( + [ + {"page_content": "a", "metadata": {"source": "x.md"}}, + {"page_content": "b", "metadata": {"source": "x.md"}}, + {"page_content": "c", "metadata": {"source": "y.md"}}, + ], + np.zeros((3, 4), dtype=np.float32), + ) + + snapshot_dir = tmp_path / "snap" + store.save_snapshot(str(snapshot_dir)) + + stats = json.loads((snapshot_dir / "stats.json").read_text(encoding="utf-8")) + assert stats["document_count"] == 2 + assert stats["chunk_count"] == 3 + + +# ── 回归:BM25 对「所有 token 都为空」不得除零 ── +# +# ``rank_bm25`` 的 ``_calc_idf`` 计算 ``idf_sum / len(self.idf)``,**所有 token +# 都为空**时该分母为 0。``jieba.cut("")`` 返回 ``[]``,因此一条 +# ``page_content=""`` 的文档就足以构造出 ``[[]]`` 这个形态——而空 +# ``page_content`` 在快照里是合法的(``_validate_document_shape`` 只要求它是 +# ``str``)。原守卫只判外层 ``not tokenized_docs``,挡不住它。 + + +@pytest.mark.parametrize( + "tokenized", + [ + pytest.param([[]], id="single-empty"), + pytest.param([[], []], id="all-empty"), + ], +) +def test_bm25_index_handles_all_empty_tokens(tokenized): + assert FaissStore._build_bm25_index(tokenized) is None + + +def test_bm25_index_still_builds_with_usable_corpus(): + assert FaissStore._build_bm25_index([["词"]]) is not None + + +def test_bm25_index_handles_mixed_empty_and_nonempty(): + """只有部分条目为空时仍应建出索引。""" + assert FaissStore._build_bm25_index([["词"], []]) is not None + + +# ── 回归:``parent_id`` 是位数超限的整数时不得崩 ── +# +# ``parent_id`` 来自知识快照的元数据,而本项目加载 pickle 格式的快照——pickle +# 是二进制格式、不受 CPython 十进制位数限制。四处都用裸 ``str()``,位数超限时 +# 抛 ``ValueError``:在 ``load_snapshot`` 里它让**加载宣布成功之后**的每一次 +# 检索都崩,在 ``resolve_parent_content`` 里它让父块召回整体不可用。 + + +def test_parent_id_survives_huge_integer_metadata(tmp_path): + """超长 parent_id 必须能正常往返,而不是让加载与检索崩掉。""" + huge_parent_id = 10**4301 + snapshot_dir = tmp_path / "snapshot" + store = FaissStore(file_path=None) + store.documents = [ + { + "page_content": "child", + "metadata": {"source": "kb.md", "chunk_id": "c1", "parent_id": huge_parent_id}, + } + ] + store.embeddings = np.array([[0.0, 0.0]], dtype=np.float32) + store.register_parent_documents( + {huge_parent_id: {"content": "父块正文", "metadata": {"source": "kb.md"}}} + ) + store._rebuild_indices() + store.save_snapshot(str(snapshot_dir)) + _activate_snapshot(tmp_path, "snapshot") + + reloaded = FaissStore(file_path=None) + reloaded.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + assert reloaded.resolve_parent_content(huge_parent_id) == "父块正文" + + +def test_integer_parent_id_key_matches_legacy_snapshots(): + """整数 parent_id 的键必须与 main 时代快照(``str(parent_id)``)一致。 + + 父块索引是写盘后长期存在的格式:main 上的代码把 ``parent_id=1`` 写成键 + ``"1"``。中途把整数 id 换成摘要键,旧快照载入后 ``resolve_parent_content`` + 对同一个 id 算出新键——检索 miss、父块召回整体返回 None(实测复现)。 + ``str()`` 对可表达的整数是精确转换、本身不塌键,塌键风险只来自 + ``safe_repr`` 的截断,因此键形态必须保持 ``str(parent_id)``。 + """ + from src.retrieval.vdb.faiss_store import _parent_key + + assert _parent_key(1) == "1" + assert _parent_key(0) == "0" + assert _parent_key(-7) == "-7" + assert _parent_key(12345678901234567890) == "12345678901234567890" + + # 超限整数(str() 表达不了)仍走摘要键——不塌键、不抛 ValueError。 + huge = 10**4301 + assert _parent_key(huge).startswith("<整数,") + assert _parent_key(huge) == _parent_key(huge) + assert _parent_key(huge + 1) != _parent_key(huge) + + # 旧形态快照数据过 _normalize_documents 后,整数 id 的父块仍可解析。 + legacy_parents = {"1": {"content": "旧父块正文", "metadata": {}}} + legacy_docs = [{"page_content": "chunk", "metadata": {"parent_id": 1}}] + _, normalized_parents = FaissStore._normalize_documents(legacy_docs, legacy_parents) + store = FaissStore(file_path=None) + store.parent_documents = normalized_parents + assert store.resolve_parent_content(1) == "旧父块正文" + + +def test_parent_key_normalizes_numpy_scalars_and_distinguishes_long_values(): + """numpy 标量键归一为与 main ``str()`` 一致;长 repr 取值不得塌键。 + + numpy 2.x 下 ``np.int64`` **不是** ``int`` 的子类,``safe_repr`` 会给 + ``"np.int64(3)"``——与 main 时代快照的键 ``"3"`` 不同,载入后召回静默 miss + (实测复现)。``.item()`` 归一后必须逐字等于 main 的 ``str()``。同时, + repr 超 80 字符的取值不能退回 ``safe_repr`` 的截断形态——两个仅差 1 字节的 + 长 bytes 必须得到不同键,否则父块字典推导静默并条。 + """ + import numpy as np + + from src.retrieval.vdb.faiss_store import _parent_key + + # 与 main 时代 ``str(parent_id)`` 逐字一致。 + assert _parent_key(np.int64(3)) == "3" + assert _parent_key(np.float64(2.0)) == "2.0" + assert _parent_key(np.bool_(True)) == "True" + + # 长 repr 取值不塌键、未超限零破坏。 + assert _parent_key(b"B" * 100) != _parent_key(b"B" * 100 + b"C") + assert _parent_key(b"x") == "b'x'" + + +# ── 回归:载入侧必须与写入侧一样拒绝非有限向量 ── +# +# 写入侧 ``EmbeddingService._as_float32_matrix`` 明确拒绝 NaN/inf,理由写在 +# 那里:FAISS 把 NaN 向量照单收下(``ntotal`` 正常增加),既不报错也不跳过, +# 直到查询时那个下标干脆不出现在结果里——用户看到的是「搜到了」而不是「这份 +# 快照坏了」。而两个载入入口的 ``_validate_embeddings_shape`` 此前只判 +# ``ndim``,于是同一份内容写入侧拒绝、载入侧放行。 +# +# 判据必须落在 ``astype(np.float32)`` **之后**:``1e300`` 在 float64 里是有限 +# 的,cast 成 float32 才溢出成 ``inf``。只对原数组调 ``isfinite`` 会在这里再次 +# 与写入侧分叉,因此四个方向都要有用例。 + + +@pytest.mark.parametrize( + ("embeddings", "label"), + [ + (np.full((2, 4), np.nan), "NaN"), + (np.full((2, 4), np.inf), "正无穷"), + (np.full((2, 4), -np.inf), "负无穷"), + (np.array([[1e300, 0.0, 0.0, 0.0], [1.0, 2.0, 3.0, 4.0]]), "float64 有限但 cast 后溢出"), + ], + ids=["nan", "inf", "neg-inf", "post-cast-overflow"], +) +def test_load_rejects_non_finite_embeddings(tmp_path, embeddings, label): + """legacy 载入的非有限向量必须是 ``ValueError``,且 store 保持原状。""" + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "alpha", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + before = ( + [doc["page_content"] for doc in store.documents], + store.embeddings.shape, + store.faiss_index.ntotal, + ) + + path = _legacy_file( + tmp_path, + { + "documents": [{"page_content": "a", "metadata": {}}], + "embeddings": embeddings, + }, + ) + with pytest.raises(ValueError, match="非有限值"): + store.load(str(path), trusted_paths=[tmp_path]) + + after = ( + [doc["page_content"] for doc in store.documents], + store.embeddings.shape, + store.faiss_index.ntotal, + ) + assert before == after, label + + +@pytest.mark.parametrize( + ("array", "label"), + [ + (np.full((1, 4), np.nan), "nan"), + (np.full((1, 4), np.inf), "inf"), + (np.array([[1e300, 0.0, 0.0, 0.0]]), "post-cast-overflow"), + ], + ids=["nan", "inf", "post-cast-overflow"], +) +def test_load_snapshot_rejects_non_finite_embeddings(tmp_path, array, label): + """快照载入的非有限向量同样必须是 ``ValueError``。""" + import pickle + + snapshot_dir = tmp_path / f"kb-finite-{label}" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "alpha", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", array) + _activate_snapshot(tmp_path, snapshot_dir.name) + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="非有限值"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +def test_load_accepts_finite_embeddings_without_leaking_a_warning(tmp_path): + """收紧不能误伤:正常向量照常载入,且不泄漏无谓的 RuntimeWarning。 + + cast 那份副本对正常取值不会溢出,因此这里不该有任何 ``RuntimeWarning``—— + 守卫用 ``catch_warnings`` 包住 cast 是为了压掉**预期内**的溢出警告,不是 + 把整类警告静默掉。 + """ + import pickle + import warnings + + snapshot_dir = tmp_path / "kb-finite-ok" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "alpha", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float64)) + _activate_snapshot(tmp_path, snapshot_dir.name) + + store = FaissStore(file_path=None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + assert store.embeddings.shape == (1, 4) + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + + +# ── 回归:父块索引的顶层形态两个入口收场一致 ── +# +# 载入侧 ``_normalize_documents`` 早就拒绝非映射的父块索引,给的是带指引的 +# ``ValueError``;而写入侧 ``register_parent_documents`` 直接在 ``.items()`` 上 +# 抛裸 ``AttributeError: 'int' object has no attribute 'items'``。两者都是 +# ``VectorStoreBase`` 的公开契约方法,`_normalize_documents` 的注释也已写明 +# 「直接调它们的嵌入方不经过快照校验」——同一取舍本应两侧都执行。 +# +# 本仓自带流程到不了写入侧这条分支(``knowledge_build_service`` 传的是 dict, +# 且前面已用 isinstance 过滤),因此这是一致性修复而非崩溃修复。 + + +@pytest.mark.parametrize( + "bad_index", [5, None, [1, 2], "x", 10**4301], ids=["int", "None", "list", "str", "huge-int"] +) +def test_register_parent_documents_rejects_non_mapping(bad_index): + """写入侧非映射必须是 ``ValueError``,不是裸 ``AttributeError``。""" + store = FaissStore(file_path=None) + + with pytest.raises(ValueError, match="父块索引"): + store.register_parent_documents(bad_index) + + assert store.parent_documents == {} + + +def test_register_and_load_agree_on_non_mapping_parent_index(): + """同一形态在写入侧与载入侧必须同为 ``ValueError``(收场一致)。""" + store = FaissStore(file_path=None) + with pytest.raises(ValueError) as write_error: + store.register_parent_documents(5) + + with pytest.raises(ValueError) as load_error: + FaissStore._normalize_documents([], 5) + + assert "父块索引的顶层不是映射" in str(write_error.value) + assert "父块索引的顶层不是映射" in str(load_error.value) + + +def test_register_parent_documents_still_accepts_a_mapping(): + """收紧不能误伤:正常映射照常注册,空 content 仍按原语义丢弃。""" + store = FaissStore(file_path=None) + + store.register_parent_documents( + { + "p-ok": {"content": "父块正文", "metadata": {"k": 1}}, + "p-empty": {"content": "", "metadata": {"k": 2}}, + } + ) + + assert set(store.parent_documents) == {"p-ok"} + assert store.resolve_parent_content("p-ok") == "父块正文" + + +# ── 回归:trusted_paths 传成字符串会被逐字符拆开,边界静默失效 ── + + +def test_load_rejects_string_trusted_paths_instead_of_splitting_it(tmp_path): + """``trusted_paths`` 是**可迭代**,传裸字符串会被逐字符拆成信任根。 + + ``ensure_trusted_source`` 对 ``trusted_roots`` 直接 ``for root in ...``, + 而 ``str`` 是合法的可迭代——实测 ``trusted_paths="/tmp/trusted"`` 会拆成 + ``"/"``、``"t"``、``"m"``… 一堆单字符根,其中 ``"/"`` 解析为文件系统根, + 于是**任意绝对路径都被接受**:``/anywhere/evil.pkl`` 在 ``trusted_paths= + "/etc"`` 下也返回成功。这正是「不导入不可信来源的 .pkl」那条边界要挡的形态, + 却因为参数写错而静默失效。同一文件对 ``Sequence`` 参数的判据早已显式排除 + ``str``/``bytes``/``bytearray``(见 ``_require_documents``/ + ``normalize_messages``),这里必须对齐。 + """ + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + with pytest.raises(ValueError, match="受信"): + FaissStore(file_path=None).load(str(legacy), trusted_paths=str(tmp_path)) + + +def test_ensure_trusted_source_rejects_non_path_element_in_mixed_roots(tmp_path): + """信任根列表含非路径元素时必须整组拒绝,不能静默跳过该元素。 + + ``Path(root)`` 对 ``5``/``None`` 抛裸 ``TypeError``,守卫本应把它收敛成带 + label 的 ``ValueError``(「受信来源必须是路径,但得到 int」)。删除守卫后 + ``except TypeError`` 变成空跳过:``[tmp_path, 5]`` 里坏元素被无声丢弃、好 + 元素照常生效,**静默通过**(变异扫描 + 实测)——配置写错了一半,信任边界 + 却看似成立,直到运行方以为「整组根都校验过」。 + """ + from src.utils.security import ensure_trusted_source + + with pytest.raises(ValueError, match="受信来源必须是路径,但得到 int"): + ensure_trusted_source(tmp_path / "snapshot", [tmp_path, 5], label="快照目录") + + # 收紧不能误伤:全部元素合法时照常工作 + assert ensure_trusted_source(tmp_path / "snapshot", [tmp_path], label="快照目录") is not None + + +# ── 回归:``ensure_trusted_source`` 的 ``trusted_roots`` 非可迭代时要给可读异常 ── +# +# 该函数已显式排除 ``str``/``bytes``/``bytearray``(它们是可迭代但会被逐字符 +# 拆开),却漏了**其它不可迭代**的形态:``for root in trusted_roots`` 对 +# ``5``/``True``/``object()`` 抛裸 ``TypeError: 'int' object is not iterable`` +# ——不是本函数承诺的带 label 的 ``ValueError``,而调用方 ``FaissStore.load`` +# 按 ``ValueError`` 分类。可达路径:``load`` 是 ``VectorStoreBase`` 的契约方法, +# 嵌入方直接调用时把 ``trusted_paths`` 写错形态即命中。 + + +@pytest.mark.parametrize( + "trusted_paths", + [ + pytest.param(5, id="int"), + pytest.param(True, id="bool"), + pytest.param(object(), id="object"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_load_rejects_non_iterable_trusted_paths_readably(tmp_path, trusted_paths): + """``trusted_paths`` 不可迭代时必须给带指引的 ``ValueError``,不得裸抛。""" + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + with pytest.raises(ValueError, match="受信"): + FaissStore(file_path=None).load(str(legacy), trusted_paths=trusted_paths) + + +def test_load_still_accepts_a_path_list_as_trusted_paths(tmp_path): + """收紧不能误伤:合法的路径列表照常加载。""" + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + store = FaissStore(file_path=None) + store.load(str(legacy), trusted_paths=[tmp_path]) + + assert store.documents == [] + + +def test_load_rejects_non_path_trusted_roots_readably(tmp_path): + """信任根不是路径时给可读的 ``ValueError``,而不是裸 ``TypeError``。 + + ``Path(root)`` 对 ``None``/``int``/``list`` 抛的是裸 + ``TypeError: expected str, bytes or os.PathLike object, not ...``——它不是 + ``ValueError``,穿透 ``load`` 调用方按 ``ValueError`` 建立的错误分类,且 + 消息里没有任何字段名。同一模块的 ``resolve_within`` 对**候选路径**的同类 + 形态给出的是带 label 的 ``ValueError``,两条判据必须一致。 + """ + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + with pytest.raises(ValueError, match="受信"): + FaissStore(file_path=None).load(str(legacy), trusted_paths=[None]) + with pytest.raises(ValueError, match="受信"): + FaissStore(file_path=None).load(str(legacy), trusted_paths=[5]) + + +def test_load_rejects_non_path_candidate_readably(tmp_path): + """候选路径不是路径时给可读的 ``ValueError``,而不是裸 ``TypeError``。 + + ``FaissStore.load`` 把路径交给 ``ensure_trusted_source``。候选路径形态非法 + 时,``resolve_within`` 的守卫抛 ``ValueError``,但 ``ensure_trusted_source`` + 的逐根试探会把它吞掉(``except ValueError: continue``),最后落到兜底消息里 + 的 ``Path(candidate)``——那里对 ``None``/``int``/``list`` 抛裸 + ``TypeError``。本函数的契约是带 label 的 ``ValueError``。 + """ + with pytest.raises(ValueError, match="路径必须是字符串或路径"): + FaissStore(file_path=None).load(None, trusted_paths=[tmp_path]) + with pytest.raises(ValueError, match="路径必须是字符串或路径"): + FaissStore(file_path=None).load(5, trusted_paths=[tmp_path]) + + +def test_resolve_within_rejects_non_path_arguments_readably(tmp_path): + """``resolve_within`` 对非路径的候选/根给带 label 的 ``ValueError``。 + + 同模块的 ``(OSError, RuntimeError)`` 收敛判据早已认领「pathlib 的内部错误 + 不该穿透 ``ValueError`` 契约」,``Path(...)`` 对非路径取值抛的 ``TypeError`` + 是同一类:不是 ``ValueError``、消息里没有字段名,而两个调用方都按 + ``ValueError`` 分类。 + """ + from src.utils.security import resolve_within + + with pytest.raises(ValueError, match="路径必须是字符串或路径"): + resolve_within(None, tmp_path, label="知识快照目录") + with pytest.raises(ValueError, match="受信根必须是字符串或路径"): + resolve_within(tmp_path, 5, label="知识快照目录") + + +# ── 回归:``FaissStore.__init__`` 的 ``file_path`` 也要判形态 ── +# +# 构造器第一件事就是 ``os.path.exists(file_path)``,而它早于 ``load`` 里那道 +# 形态守卫——本类的 ``save``/``load``/``save_snapshot``/``load_snapshot`` +# 都已按同一判据收紧,构造器这条公开入口漏了。实测非路径取值的收场是裸异常, +# 都不在本类的 ``ValueError`` 契约内: +# * ``["a"]``/``{"a": 1}``/``object()`` -> 裸 +# ``TypeError: stat: path should be string, bytes, os.PathLike or integer, +# not ...``; +# * ``10**4301``(位数超限的整数)-> 裸 ``OverflowError: fd is greater than +# maximum``——它是 ``ArithmeticError`` 子类而**不是** ``ValueError``,穿透 +# 调用方全部 ``except ValueError`` 分类; +# * ``5`` 更隐蔽:``os.path.exists(5)`` 把整数当成**文件描述符**探测(实测 +# 返回 ``False`` 而**不报错**),于是构造静默成功、``file_path`` 留着这个 +# 整数——与 ``save(5)`` 把字节写进 fd 5 是同一类「整数被当成 fd」的问题。 +# 可达路径:``FaissStore`` 是公开类(测试与库调用方直接构造),且 +# ``VectorStoreFactory.get_vector_store(store_type, file_path)`` 把 ``file_path`` +# 原样透传到这里。 + + +@pytest.mark.parametrize( + "bad_path", + [ + pytest.param(["a"], id="list"), + pytest.param({"a": 1}, id="dict"), + pytest.param(object(), id="object"), + pytest.param(10**4301, id="beyond-double"), + pytest.param(5, id="int-as-fd"), + pytest.param(b"x", id="bytes"), + ], +) +def test_init_rejects_non_path_file_path(bad_path): + """``file_path`` 非路径时必须给带指引的 ``ValueError``,不得裸抛或静默接受。""" + with pytest.raises(ValueError, match="file_path|路径"): + FaissStore(file_path=bad_path, trusted_paths=[Path("/tmp")]) + + +def test_init_still_accepts_a_missing_path(tmp_path): + """收紧不能误伤:指向不存在文件的合法路径照常构造出空 store。""" + store = FaissStore(file_path=str(tmp_path / "missing.pkl"), trusted_paths=[tmp_path]) + + assert store.documents == [] + assert store.embeddings is None + + +# ── 回归:upsert_embeddings 的 documents 顶层容器缺形态判据 ── +# +# 上面那一组参数化覆盖的是**条目**形态(``_validate_document_shape`` 逐条判), +# 顶层容器这一半漏了:``upsert_embeddings`` 的第一行就是 ``if not documents: +# return``,随后 ``len(documents)``。实测: +# * ``documents=5`` -> 裸 ``TypeError: object of type 'int' has no len()``, +# 不是本方法其余拒绝路径承诺的 ``ValueError``(载入侧 +# ``_normalize_documents`` 对同一形态给的是带指引的 ``ValueError``); +# * ``documents=None`` -> **静默返回**,调用方以为写入成功,而 store 一个字 +# 都没变——写入路径上的静默无操作比报错更难查。 +# 判据与载入侧 ``_normalize_documents`` 的同名守卫一致(拒绝 str/bytes/映射, +# 要求有 ``__len__``)。 + + +@pytest.mark.parametrize( + "documents", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param("abc", id="str"), + pytest.param({"a": 1}, id="dict"), + ], +) +def test_upsert_embeddings_rejects_non_sequence_documents(documents): + """非序列的 ``documents`` 必须给出带指引的 ``ValueError``,不得静默返回。""" + store = FaissStore(file_path=None) + + with pytest.raises(ValueError, match="文档列表"): + store.upsert_embeddings(documents, np.zeros((1, 4), np.float32)) + + assert store.documents == [] + + +def test_upsert_embeddings_still_accepts_document_sequences(): + """收紧不能误伤:正常文档列表照常写入。""" + store = FaissStore(file_path=None) + + store.upsert_embeddings( + [{"page_content": "good", "metadata": {"source": "a"}}], np.zeros((1, 4), np.float32) + ) + + assert len(store.documents) == 1 + assert store.faiss_index.ntotal == 1 + + +# ── 回归:upsert_embeddings 的 embeddings 入参缺形态判据 ── +# +# 上面那组覆盖的是 ``documents``,而同一方法的**兄弟入参** ``embeddings`` +# 此前完全没有守卫:``np.array(..., dtype=np.float32)`` 的三类失败全部裸抛, +# 而 ``documents`` 那一半已经给出带指引的 ``ValueError``——同一方法的两个入参, +# 收场一个可读一个裸抛。实测: +# * ``10**4301`` -> ``OverflowError: int too large to convert to float`` +# (``ArithmeticError`` 子类,**不是** ``ValueError``,穿透调用方全部 +# ``except ValueError`` 分类); +# * ``{"a": 1}`` -> ``TypeError: float() argument must be a string or a real +# number, not 'dict'``; +# * ``"abc"`` -> numpy 的 ``ValueError: could not convert string to float``。 +# 判据与同文件 ``semantic_search`` 的查询向量转换逐字相同(那里早已按同一分类 +# 收敛),两者必须一致。 + + +@pytest.mark.parametrize( + "embeddings", + [ + pytest.param(10**4301, id="int-beyond-double"), + pytest.param("abc", id="str"), + pytest.param({"a": 1}, id="dict"), + pytest.param(object(), id="object"), + ], +) +def test_upsert_embeddings_rejects_unconvertible_embeddings(embeddings): + """形态无法转成 float32 矩阵时,收场必须是带指引的 ``ValueError``。""" + store = FaissStore(file_path=None) + + with pytest.raises(ValueError, match="embeddings"): + store.upsert_embeddings([{"page_content": "a", "metadata": {}}], embeddings) + + assert store.documents == [] + assert store.embeddings is None + + +# ── 回归:upsert_embeddings 的一维守卫(变异测试翻出的覆盖空洞)── +# +# 写入侧 `upsert_embeddings` 与载入侧 `_validate_embeddings_shape` 都判 +# `ndim != 2`,但只有载入侧那条有测试钉住:把写入侧这条守卫的 `raise` 删掉 +# 后,全量套件仍然全绿。它的可达性不是理论上的——**等长**的一维输入会先通过 +# 「数量一致」判据,然后 store 被写坏,直到 `_rebuild_indices` 读 +# `.shape[1]` 时才以 `IndexError: tuple index out of range` 崩掉(实测)。 + + +def test_upsert_embeddings_rejects_a_one_dimensional_matrix_without_mutating(): + """一维 embeddings 必须被拒,而不是写进 store 后再于派生期崩掉。 + + ``[4 条文档]`` + ``np.zeros(4)`` 的一维数组:删掉 ``ndim != 2`` 守卫后 + ``len(documents) == len(embeddings) == 4`` 会骗过「数量一致」判据,随后 + ``_rebuild_indices`` 读 ``self.embeddings.shape[1]`` 抛 + ``IndexError``——不是本方法的 ``ValueError`` 契约,且 store 已被写坏。 + """ + store = FaissStore(file_path=None) + + with pytest.raises(ValueError, match="二维数组"): + store.upsert_embeddings( + [{"page_content": "a", "metadata": {}}] * 4, + np.zeros(4, dtype=np.float32), + ) + + assert store.documents == [] + assert store.embeddings is None + + +@pytest.mark.parametrize( + "shape", + [ + pytest.param((4,), id="one-dimensional"), + pytest.param((2, 2, 2), id="three-dimensional"), + pytest.param((), id="zero-dimensional"), + ], +) +def test_upsert_embeddings_rejects_non_matrix_shapes(shape): + """一/三/零维数组都要被拒——判据是 ``ndim != 2``,不是「恰好一维」。""" + store = FaissStore(file_path=None) + documents = [{"page_content": "a", "metadata": {}}] * max( + 1, int(np.prod(shape) if shape else 1) + ) + + with pytest.raises(ValueError, match="二维数组"): + store.upsert_embeddings(documents, np.zeros(shape, dtype=np.float32)) + + assert store.documents == [] + + +# ── 废弃入口与信任边界判据的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 554/559/668/675/995 五个废弃入口(``add_documents``/``aadd_documents``/ +# ``search``/``asearch``/``get_embedding_model``)→ **静默返回 None**(async +# 入口返回一个什么都不做的协程),调用方拿到 None 当结果用,崩在更远处; +# * 723 ``load`` 对不存在的文件 → 消息从「向量存储文件未找到: {path}」退化成 +# ``open()`` 的 errno 消息,丢了本模块的定位语义; +# * 831 ``load_snapshot`` 对不存在的目录 → 从 ``FileNotFoundError``(带路径) +# 退化成下游的「未被激活,缺少标记文件」``ValueError``——把「目录不存在」 +# 报成「快照没激活」,指向完全错误的排查方向; +# * 595 ``semantic_search`` 的查询向量形态 → 二维/空向量不再报「必须是一维 +# 且非空」,落到维度不一致的消息(二维 ``(2,2)`` 报「维度 2 与索引维度 4 +# 不一致」,空向量报「维度 0」),用户按消息去换模型也修不好。 +def test_faiss_store_deprecated_add_documents_reports_readably(): + """``add_documents`` 已废弃,必须报错而不是静默返回 None。""" + store = FaissStore(file_path=None) + + with pytest.raises(RuntimeError, match="add_documents 已废弃"): + store.add_documents([]) + + +def test_faiss_store_deprecated_aadd_documents_reports_readably(): + """``aadd_documents`` 已废弃,await 后必须报错而不是静默返回 None。""" + import asyncio + + store = FaissStore(file_path=None) + + with pytest.raises(RuntimeError, match="aadd_documents 已废弃"): + asyncio.run(store.aadd_documents([])) + + +def test_faiss_store_deprecated_search_reports_readably(): + """``search`` 已废弃,必须报错而不是静默返回 None。""" + store = FaissStore(file_path=None) + + with pytest.raises(RuntimeError, match="search 已废弃"): + store.search("query") + + +def test_faiss_store_deprecated_asearch_reports_readably(): + """``asearch`` 已废弃,await 后必须报错而不是静默返回 None。""" + import asyncio + + store = FaissStore(file_path=None) + + with pytest.raises(RuntimeError, match="asearch 已废弃"): + asyncio.run(store.asearch("query")) + + +def test_faiss_store_get_embedding_model_reports_readably(): + """``get_embedding_model`` 不再持有嵌入模型,必须报错而不是静默返回 None。""" + store = FaissStore(file_path=None) + + with pytest.raises(RuntimeError, match="EmbeddingService"): + store.get_embedding_model() + + +def test_faiss_store_load_reports_missing_file_with_its_path(tmp_path): + """``load`` 对不存在的文件必须报带路径的 ``FileNotFoundError``。""" + store = FaissStore(file_path=None) + missing = tmp_path / "missing.pkl" + + with pytest.raises(FileNotFoundError, match="向量存储文件未找到"): + store.load(str(missing), trusted_paths=[str(tmp_path)]) + + +def test_faiss_store_load_snapshot_reports_missing_directory(tmp_path): + """``load_snapshot`` 对不存在的目录必须报 ``FileNotFoundError``。 + + 删除守卫后落到「快照未被激活,缺少标记文件」的 ``ValueError``——把 + 「目录不存在」误报成「快照没激活」。 + """ + store = FaissStore(file_path=None) + missing = tmp_path / "missing-snapshot" + + with pytest.raises(FileNotFoundError, match="知识快照目录不存在"): + store.load_snapshot(str(missing), snapshot_root=str(tmp_path)) + + +@pytest.mark.parametrize( + "query_vector", + [ + pytest.param(np.zeros((2, 2), dtype=np.float32), id="two-dimensional"), + pytest.param(np.zeros((0,), dtype=np.float32), id="empty"), + ], +) +def test_faiss_store_rejects_non_1d_or_empty_query_vector(query_vector): + """二维/空查询向量必须报「一维且非空」,不能落到维度不一致的消息。""" + store = FaissStore(file_path=None) + store.documents = [{"page_content": "doc", "metadata": {}}] + store.embeddings = np.zeros((1, 4), dtype=np.float32) + store._rebuild_indices() + + with pytest.raises(ValueError, match="一维且非空"): + store.semantic_search(query_vector, top_k=1) + + +@pytest.mark.parametrize( + ("missing", "dim_e", "dim_i", "pattern"), + [ + ("index", 4, 4, "向量矩阵存在而语义索引缺失"), + ("embeddings", 4, 4, "向量矩阵缺失而语义索引存在"), + (None, 4, 3, "维度与向量矩阵不一致"), + ], + ids=["missing-index", "missing-embeddings", "dim-mismatch"], +) +def test_load_snapshot_rejects_broken_existence_combinations( + tmp_path, missing, dim_e, dim_i, pattern +): + """矩阵与索引的存在性组合与维度必须对齐(库级直调不经过 validate_snapshot_dir)。 + + 三种残缺组合修复前都能载入成功:缺 semantic.index(有 lexical.index 时 + 不重建)→ ``semantic_search`` 静默空结果;缺 embeddings.npy → 矩阵与索引 + 并存的自相矛盾形态,同样静默空;维度错配 → 查询时 FAISS 抛空消息 + ``AssertionError``。用户看到的都不是「这份快照坏了」。 + """ + snapshot_dir = _write_alignment_snapshot(tmp_path / "kb-combo", chunk_count=2, embedding_rows=2) + if missing == "index": + (snapshot_dir / "semantic.index").unlink() + # 缺 semantic.index 时必须同时有 lexical.index 才会走到 else 分支之外 + # 的静默形态(有 lexical 就不重建索引)。 + import pickle as _pickle + + with (snapshot_dir / "lexical.index").open("wb") as file: + _pickle.dump([["a"], ["b"]], file) + elif missing == "embeddings": + (snapshot_dir / "embeddings.npy").unlink() + else: + import faiss as _faiss + + idx = _faiss.IndexFlatL2(dim_i) + idx.add(np.zeros((2, dim_i), dtype=np.float32)) + _faiss.write_index(idx, str(snapshot_dir / "semantic.index")) + _activate_snapshot(tmp_path, "kb-combo") + + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match=pattern): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + +@pytest.mark.parametrize( + ("content", "label"), + [(b"", "empty"), (b"\x80\x04\x95", "truncated")], +) +def test_load_converges_unreadable_pickle_to_value_error(tmp_path, content, label): + """空/截断的 legacy pkl 必须收敛为带指引的 ``ValueError``。 + + 裸 ``EOFError``/``UnpicklingError`` 会穿透 ``Chatbot.__init__`` 被 + ``main.py`` 的 ``except (KeyboardInterrupt, EOFError)`` 误判为用户中断, + 程序以「检测到中断信号」正常退出——真因(文件损坏)无任何提示。 + """ + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / f"{label}.pkl" + path.write_bytes(content) + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="无法读取"): + store.load(str(path), trusted_paths=[temp_dir]) + + +def test_load_converges_directory_path_to_value_error(tmp_path): + """pkl_path 指向目录时同样收敛为 ``ValueError``(裸 IsADirectoryError 同链路)。""" + target = tmp_path / "adir" + target.mkdir() + store = FaissStore(file_path=None) + with pytest.raises(ValueError, match="无法读取"): + store.load(str(target), trusted_paths=[str(tmp_path)]) + + +def test_load_snapshot_existence_rejection_leaves_store_untouched(tmp_path): + """「有 lexical 而索引缺失」的拒绝必须原子:store 保持原状。 + + 该守卫只读局部变量,放批量赋值**之后**时抛 ``ValueError`` 前已把 + documents/embeddings/faiss_index/_tokenized_docs_cache 全部改写(实测 + 五字段全变)——同文件三处注释声明的「拒绝的载入不改 store」契约被破坏。 + """ + import pickle as _pickle + + snapshot_dir = tmp_path / "kb-atomic" + snapshot_dir.mkdir() + with (snapshot_dir / "chunks.pkl").open("wb") as file: + _pickle.dump([{"page_content": "new-doc", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + _pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + with (snapshot_dir / "lexical.index").open("wb") as file: + _pickle.dump([["new-doc"]], file) + # 不写 semantic.index + _activate_snapshot(tmp_path, "kb-atomic") + + store = FaissStore(file_path=None) + store.upsert_embeddings( + [{"page_content": "old-doc", "metadata": {}}], np.zeros((1, 4), dtype=np.float32) + ) + before = (list(store.documents), store.embeddings.shape, store.faiss_index.ntotal) + + with pytest.raises(ValueError, match="向量矩阵存在而语义索引缺失"): + store.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) + + after = (list(store.documents), store.embeddings.shape, store.faiss_index.ntotal) + assert before == after diff --git a/tests/retrieval/vdb/test_snapshot_repository.py b/tests/retrieval/vdb/test_snapshot_repository.py index cb5a123..c1612bd 100644 --- a/tests/retrieval/vdb/test_snapshot_repository.py +++ b/tests/retrieval/vdb/test_snapshot_repository.py @@ -1,3 +1,4 @@ +import pickle from pathlib import Path import pytest @@ -65,9 +66,14 @@ def test_snapshot_repository_cleans_only_requested_temp_dir(tmp_path): def _write_row_counts_snapshot( - root, snapshot_id, chunk_count, embedding_rows, declared_chunk_count=None + root, + snapshot_id, + chunk_count, + embedding_rows, + declared_chunk_count=None, + index_rows=None, ): - """按真实结构写一个快照,chunk/embedding 行数可独立指定。""" + """按真实结构写一个快照,chunk/embedding/索引行数可独立指定。""" import json import pickle @@ -99,7 +105,16 @@ def _write_row_counts_snapshot( with (snapshot_dir / "parents.pkl").open("wb") as file: pickle.dump({}, file) np.save(snapshot_dir / "embeddings.npy", np.zeros((embedding_rows, 4), dtype=np.float32)) - (snapshot_dir / "semantic.index").write_bytes(b"") + # 写一个**可解析**的 FAISS 索引:占位字节会让 ``faiss.read_index`` 抛 + # RuntimeError,而真实加载路径(``FaissStore.load_snapshot``)同样会读它, + # 所以占位文件构造出的快照本来就不可能被加载。 + import faiss + + index = faiss.IndexFlatL2(4) + index.add( + np.zeros((index_rows if index_rows is not None else embedding_rows, 4), dtype=np.float32) + ) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) (snapshot_dir / "stats.json").write_text( json.dumps( { @@ -133,6 +148,32 @@ def test_validate_snapshot_dir_rejects_chunk_embedding_row_mismatch(tmp_path): repository.validate_snapshot_dir(snapshot_dir) +def test_validate_snapshot_dir_rejects_embedding_row_gap_when_index_aligned(tmp_path): + """``embeddings`` 行数与分块数不一致、索引却对齐时必须拒绝。 + + 既有 ``row_mismatch`` 用例的 ``semantic.index`` 与 ``embeddings`` 同步缺行, + 删除 422 的行数判据后由下游 516 的「向量索引条数与分块数不一致」兜住, + 用例仍然变绿——变异扫描证实 422 因此在整套测试里存活。本用例把索引行数 + 固定为与分块一致(``index_rows=chunk_count``),让 422 成为唯一防线: + 删除后**静默通过**(实测),行数不一致的快照被 finalize 成活动快照,此后 + ``semantic_search`` 拿 ``indices`` 去索引 ``self.documents`` 才会越界。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, + "kb-gap-embedding-rows", + chunk_count=3, + embedding_rows=2, + index_rows=3, + ) + + with pytest.raises(ValueError, match="分块数与向量数不一致"): + repository.validate_snapshot_dir(snapshot_dir) + + def test_validate_snapshot_dir_rejects_stats_chunk_count_mismatch(tmp_path): """``stats.json`` 声明的 chunk_count 与实际分块数不符也必须拒绝。""" from src.retrieval.snapshot_repository import SnapshotRepository @@ -293,6 +334,56 @@ def test_validate_snapshot_dir_rejects_symlinked_snapshot_dir(tmp_path, monkeypa repository.validate_snapshot_dir(link) +# ── 回归:``validate_snapshot_dir`` 的字符串路径不得抛裸 ``AttributeError`` ── +# +# 符号链接判据看的是**原始**(未解析)取值——``resolve_within`` 已把链接解析掉, +# 对 ``resolved`` 调 ``is_symlink()`` 恒为 False。而原始取值的形态不受本方法标注 +# 约束:``resolve_within`` 显式接受 ``str | os.PathLike``,因此调用方传字符串是 +# 合法输入,而 ``str`` 没有 ``is_symlink``。实测传字符串时本方法在 +# ``resolve_within`` 通过之后抛裸 ``AttributeError: 'str' object has no attribute +# 'is_symlink'``——本类其余拒绝路径一律给带指引的 ``ValueError``。 +# +# 现实性:``FaissStore.save_snapshot(snapshot_dir: str)`` 与 +# ``FaissStore.load_snapshot(snapshot_dir: str | os.PathLike[str])`` 的标注都是 +# ``str``,调用方按那个惯例传字符串就会命中这里。 + + +def test_validate_snapshot_dir_accepts_string_path(tmp_path, monkeypatch): + """字符串形态的合法快照目录必须照常通过,不得抛裸 ``AttributeError``。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-str", chunk_count=1, embedding_rows=1 + ) + + repository.validate_snapshot_dir(str(snapshot_dir)) + + +def test_validate_snapshot_dir_rejects_string_symlink(tmp_path, monkeypatch): + """字符串形态的符号链接同样必须被那条判据拦下,不能因形态转换而漏掉。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + real = repository.root / "kb-real" + real.mkdir() + link = repository.root / "kb-link" + link.symlink_to(real) + + with pytest.raises(ValueError, match="符号链接"): + repository.validate_snapshot_dir(str(link)) + + def test_validate_snapshot_dir_rejects_nested_snapshot_dir(tmp_path, monkeypatch): """快照目录必须是根的直接子目录,不能嵌在更深层级。""" from src.retrieval.snapshot_repository import SnapshotRepository @@ -309,3 +400,1339 @@ def test_validate_snapshot_dir_rejects_nested_snapshot_dir(tmp_path, monkeypatch with pytest.raises(ValueError, match="直接位于快照根目录之下"): repository.validate_snapshot_dir(nested) + + +@pytest.fixture +def run_config(tmp_path, monkeypatch): + """构造一个快照根目录落在 ``tmp_path`` 下的 RunConfig。""" + get_settings.cache_clear() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + monkeypatch.setattr("src.utils.config.CONFIG_TOML_PATH", tmp_path / "config.toml") + (tmp_path / "config.toml").write_text('snapshot_root = "data/kb"\n', encoding="utf-8") + return build_run_config(get_settings()) + + +# ── 回归:标记文件的内容会被拼进路径,必须是合法的快照 id ── + + +@pytest.mark.parametrize( + "bad_root", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(None, id="none"), + ], +) +def test_snapshot_repository_rejects_non_path_snapshot_root(run_config, bad_root): + """``snapshot_root`` 非 ``Path`` 时必须给 ``ValueError``,不是裸 ``AttributeError``。 + + ``SnapshotRepository.__init__`` 直接对 ``run_config.snapshot_root`` 调 + ``.mkdir()``,而 ``RunConfig`` 是 frozen dataclass、字段没有校验器(同仓库多处 + 已认领这条可达性),嵌入方可以自拼一个 ``snapshot_root=5`` 的配置。实测抛裸 + ``AttributeError: 'int' object has no attribute 'mkdir'``——不是本类其余拒绝 + 路径承诺的带指引 ``ValueError``。``Path`` 之外的形态一律拒绝,不做静默强转: + ``snapshot_root`` 的契约就是 ``Path``(``RunConfig`` 的标注)。 + """ + from dataclasses import replace + + with pytest.raises(ValueError, match="snapshot_root"): + SnapshotRepository(replace(run_config, snapshot_root=bad_root)) + + +@pytest.mark.parametrize( + "payload", + [ + "../../../tmp/elsewhere", # 相对逃逸 + "/tmp/elsewhere", # 绝对路径会顶掉 root + "kb-abc/../../../etc", # 含分隔符 + "sub/dir", + "..", + ], +) +def test_active_snapshot_id_rejects_path_like_values(run_config, payload): + """标记文件是磁盘上的任意文本,不能直接当路径分量用。 + + ``Path("/root") / "/tmp/x"`` 得 ``/tmp/x``——绝对路径会**顶掉** root, + ``../`` 则向上逃逸。两种都会让「活动快照」指到快照根目录之外。 + """ + # 先构造仓库(它会建出快照根目录),再写标记文件——与真实流程一致。 + repository = SnapshotRepository(run_config) + run_config.active_snapshot_marker.write_text(payload, encoding="utf-8") + + with pytest.raises(ValueError, match="快照 id 不合法"): + repository.get_active_snapshot_id() + + +def test_active_snapshot_id_accepts_generated_ids(run_config): + """收紧不能误伤:本应用自己生成的 id 形态必须照常接受。""" + repository = SnapshotRepository(run_config) + snapshot_id = repository.generate_snapshot_id() + run_config.active_snapshot_marker.write_text(snapshot_id, encoding="utf-8") + + assert repository.get_active_snapshot_id() == snapshot_id + + +@pytest.mark.parametrize( + "hostile", + ["../outside", "../../etc", "/tmp/elsewhere", "a/../..", ".."], +) +def test_snapshot_id_is_rejected_by_every_path_building_method(run_config, hostile): + """四个把 snapshot_id 拼进路径的方法都必须先校验。 + + ``create_temp_snapshot_dir`` / ``cleanup_temp_snapshot_dir`` / + ``finalize_snapshot`` 都对拼出的路径执行 ``rmtree``。实测 + ``finalize_snapshot(root/".tmp-x", "../../")`` 会把快照根目录**之外** + 的目录整个删掉——逃逸即等于删掉不属于自己的东西。 + """ + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match="快照 id 不合法"): + repository.create_temp_snapshot_dir(hostile) + with pytest.raises(ValueError, match="快照 id 不合法"): + repository.cleanup_temp_snapshot_dir(hostile) + with pytest.raises(ValueError, match="快照 id 不合法"): + repository.finalize_snapshot(run_config.snapshot_root / ".tmp-x", hostile) + + +def test_finalize_snapshot_does_not_delete_outside_the_root(run_config, tmp_path): + """端到端:恶意 id 不得让 root 外的目录被删掉。 + + ``snapshot_root`` 是 ``tmp_path/data/kb``,因此 ``tmp_path`` 本身就在 + root 之外——用 ``../victim-dir`` 就能构造出「删除会波及 root 外」的场景, + 且目标目录随用例唯一,不会被其他用例的残留污染。 + """ + victim = tmp_path / "victim-dir" + victim.mkdir() + (victim / "important.txt").write_text("用户数据", encoding="utf-8") + + repository = SnapshotRepository(run_config) + with pytest.raises(ValueError, match="快照 id 不合法"): + repository.finalize_snapshot(run_config.snapshot_root / ".tmp-x", f"../{victim.name}") + + assert victim.exists(), "快照根目录之外的目录被删掉了" + assert (victim / "important.txt").is_file() + + +@pytest.mark.parametrize( + "hostile_id", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(10**4301, id="beyond-int-str-digits"), + pytest.param(b"abc", id="bytes"), + ], +) +def test_snapshot_id_guard_rejects_non_path_forms(run_config, hostile_id): + """非路径形态的 ``snapshot_id`` 必须收敛成 ``ValueError``,不是裸 ``TypeError``。 + + ``_require_snapshot_id_within_root`` 第一行就写 ``Path(snapshot_id)``,而 + ``Path(5)``/``Path(["a"])`` 抛裸 ``TypeError: expected str, bytes or + os.PathLike object, not ...``——不是本类承诺的带指引 ``ValueError``,也没有 + 「快照 id 不合法」的语义。位数超限的整数更隐蔽:``Path(10**4301)`` 抛的是 + ``ValueError: Exceeds the limit``,类型对、内容却与快照 id 无关。 + + 三个 ``*_temp_snapshot_dir``/``finalize_snapshot`` 方法都对拼出的路径做 + ``rmtree``,而快照 id 的来源之一是磁盘上的标记文件;形态判据必须与同仓库的 + ``resolve_within``/``ensure_trusted_source`` 一致(构造 ``Path`` 之前先判 + 形态)。 + """ + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match="快照 id"): + repository.create_temp_snapshot_dir(hostile_id) + with pytest.raises(ValueError, match="快照 id"): + repository.cleanup_temp_snapshot_dir(hostile_id) + with pytest.raises(ValueError, match="快照 id"): + repository.finalize_snapshot(run_config.snapshot_root / ".tmp-x", hostile_id) + + +def test_snapshot_id_guard_does_not_break_the_normal_flow(run_config): + """收紧不能误伤:生成 id 的完整流程必须照常走通。""" + repository = SnapshotRepository(run_config) + snapshot_id = repository.generate_snapshot_id() + temp_dir = repository.create_temp_snapshot_dir(snapshot_id) + (temp_dir / "chunks.pkl").write_bytes(b"x") + + final_dir = repository.finalize_snapshot(temp_dir, snapshot_id) + assert final_dir.exists() + assert final_dir.parent == Path(run_config.snapshot_root).resolve() + + repository.cleanup_temp_snapshot_dir(snapshot_id) + + +def test_validate_snapshot_dir_rejects_index_row_mismatch(tmp_path): + """向量索引条数与分块数不一致时必须拒绝。 + + ``FaissStore.load_snapshot`` 在 ``lexical.index`` 存在时**不会**重建 FAISS + 索引,直接使用盘上的 ``semantic.index``。实测 ntotal 比 chunks 多 + (3 vs 5)时本方法曾放行,加载后 ``semantic_search`` 拿 ``indices`` 去索引 + ``self.documents`` 抛 ``IndexError: list index out of range``——正是本方法 + 要防的那类失败。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-index-mismatch", chunk_count=3, embedding_rows=3, index_rows=5 + ) + + with pytest.raises(ValueError, match="向量索引条数与分块数不一致"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_accepts_matching_index_rows(tmp_path): + """收紧不能误伤:索引条数与分块数一致时必须通过。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-index-ok", chunk_count=3, embedding_rows=3, index_rows=3 + ) + + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_rejects_lexical_row_mismatch(tmp_path): + """关键词索引语料条数与分块数不一致时必须拒绝。 + + ``keyword_search`` 拿 ``np.argsort(doc_scores)`` 的下标去索引 + ``self.documents``;语料比分块多时,命中越界位置的文档会让下标超出 + ``documents`` 而抛 ``IndexError``(实测语料 5 条、分块 3 条时复现)。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-lexical-mismatch", chunk_count=3, embedding_rows=3 + ) + import pickle + + (snapshot_dir / "lexical.index").write_bytes(pickle.dumps([["c"]] * 5)) + + with pytest.raises(ValueError, match="关键词索引语料条数与分块数不一致"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_accepts_matching_lexical_rows(tmp_path): + """收紧不能误伤:语料条数与分块数一致时必须通过。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-lexical-ok", chunk_count=3, embedding_rows=3 + ) + import pickle + + (snapshot_dir / "lexical.index").write_bytes(pickle.dumps([["c"]] * 3)) + + repository.validate_snapshot_dir(snapshot_dir) + + +# ── 回归:parents.pkl 与另外两个 pickle 一样必须可解析、形态正确 ── +# +# ``validate_snapshot_dir`` 把 ``chunks.pkl`` 与 ``lexical.index`` 的解析失败都 +# 转成了「快照不自洽」的 ValueError,却漏了 ``parents.pkl``——它是同一批 +# ``pickle.load`` 的目标之一,同样是 required file。实测 ``parents.pkl`` 写坏时 +# 校验照常通过,直到 ``FaissStore.load_snapshot`` 抛裸的 ``UnpicklingError``: +# 用户看到的是 ``UnpicklingError: invalid load key, 'n'.``,既没有「快照坏了」 +# 的语义,也没有「请重建」的指引;形态不对(``None``/``int``)则抛 +# ``TypeError: 'NoneType' object is not iterable``。同一份快照里 +# ``chunks.pkl`` 坏掉时给出的却是有指引的 ValueError,两者不一致。 + + +def test_validate_snapshot_dir_rejects_unparseable_parents(): + """``parents.pkl`` 无法解析时必须报「快照不自洽」而不是漏到加载期。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-parentsbad01", chunk_count=3, embedding_rows=3 + ) + (snapshot_dir / "parents.pkl").write_bytes(b"not a pickle at all") + + with pytest.raises(ValueError, match="parents.pkl"): + repository.validate_snapshot_dir(snapshot_dir) + + +@pytest.mark.parametrize("payload", [None, 42, ["p0", "p1"], "p0"]) +def test_validate_snapshot_dir_rejects_non_mapping_parents(payload): + """``parents.pkl`` 的顶层必须是映射。 + + 非映射在加载期会以 ``TypeError: 'NoneType' object is not iterable`` 这类 + 与快照无关的形态抛出(``dict(self.parent_documents)``)。 + """ + import pickle + + from src.retrieval.snapshot_repository import SnapshotRepository + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-parentsshp1", chunk_count=3, embedding_rows=3 + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump(payload, file) + + with pytest.raises(ValueError, match="parents.pkl"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_rejects_parent_entries_without_content(): + """父条目不是 dict/str 时无法取出内容,必须在加载前拒绝。""" + import pickle + + from src.retrieval.snapshot_repository import SnapshotRepository + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-parentsent1", chunk_count=3, embedding_rows=3 + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({"p0": ["not", "a", "document"]}, file) + + with pytest.raises(ValueError, match="parents.pkl"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_accepts_well_formed_parents(): + """收紧不能误伤:正常形态的 ``parents.pkl`` 必须照常通过。""" + import pickle + + from src.retrieval.snapshot_repository import SnapshotRepository + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-parentsok01", chunk_count=3, embedding_rows=3 + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump( + { + "p0": {"content": "父内容", "metadata": {"source": "s0.md"}}, + "p1": "字符串形态的父内容", + }, + file, + ) + + repository.validate_snapshot_dir(snapshot_dir) + + +# ── 回归:manifest.toml 解析失败要给出「快照坏了」的语义 ── +# +# ``validate_snapshot_dir`` 把 chunks.pkl / parents.pkl / stats.json / +# semantic.index / lexical.index 的解析失败都转成了「快照不自洽」的 ValueError +# 并附带重建指引,唯独 **manifest.toml** 没走这一步:它只查文件是否存在,不查 +# 能否解析。实测把 manifest 写成 `a = "unterminated` 后 +# ``validate_snapshot_dir`` 照常通过,紧接着 ``load_manifest`` 抛裸的 +# ``TOMLDecodeError: Unterminated string``——而 ``VectorStoreFactory`` 在启动时 +# 正是「先 validate 再 load_manifest」,用户看到的是一个与「快照坏了」无关的 +# 异常类型,也没有重建指引。同类失败不该有两种收场方式。 + + +def test_load_manifest_reports_malformed_toml_as_inconsistent_snapshot(tmp_path, monkeypatch): + """manifest 语法错要给「快照不自洽 + 重建指引」,而不是裸 TOMLDecodeError。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + snapshot_dir = snapshot_root / "kb-broken-manifest" + snapshot_dir.mkdir() + (snapshot_dir / "manifest.toml").write_text('a = "unterminated', encoding="utf-8") + + with pytest.raises(ValueError, match="重建") as excinfo: + repository.load_manifest(snapshot_dir) + + # 原始异常类型保留在消息里,便于定位(与其它快照文件的处理一致) + assert "TOMLDecodeError" in str(excinfo.value) + + +def test_validate_snapshot_dir_rejects_unparsable_manifest(tmp_path, monkeypatch): + """校验入口就要拦下坏 manifest——否则「先 validate 再 load」的调用方白跑一趟。""" + import pickle + + import numpy as np + + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + snapshot_dir = snapshot_root / "kb-broken-manifest" + snapshot_dir.mkdir() + (snapshot_dir / "manifest.toml").write_text('a = "unterminated', encoding="utf-8") + # 其余文件保持自洽,确保拒绝的原因就是 manifest 本身 + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "x", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + (snapshot_dir / "stats.json").write_text('{"chunk_count": 1}', encoding="utf-8") + + import faiss + + index = faiss.IndexFlatL2(4) + index.add(np.zeros((1, 4), dtype=np.float32)) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) + + with pytest.raises(ValueError, match="manifest"): + repository.validate_snapshot_dir(snapshot_dir) + + +# ── 回归:快照 id 必须是**单一目录名** ── +# +# 原先的判定是「解析后的父目录等于 root」,它拦不住 ``"x/../victim"``:该值 +# 解析成 ``root/victim``,父目录**正是** root,于是放行。而三个方法实际操作的 +# 路径是 ``self.root / f".tmp-{snapshot_id}"``——前缀 ``.tmp-`` 会**改变** ``..`` +# 的消解结果(``.tmp-x`` 是一个真实路径分量),``".tmp-x/../victim"`` 最终落在 +# ``root/victim``,即一个**正式快照**。实测 +# ``cleanup_temp_snapshot_dir("x/../victim")`` 对 ``root/victim`` 执行了 rmtree: +# 「只清理任务临时目录」的意图被绕过,删的是正式快照。 + + +@pytest.mark.parametrize( + "snapshot_id", + ["x/../victim", "../../victim", "..", ".", "/absolute", "a/b", ""], +) +def test_snapshot_id_must_be_a_single_path_component(tmp_path, monkeypatch, snapshot_id): + """含分隔符、``.``/``..``、绝对路径或空的快照 id 一律拒绝。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + with pytest.raises(ValueError, match="快照 id 不合法"): + repository.cleanup_temp_snapshot_dir(snapshot_id) + + +# ── 回归:``activate_snapshot`` 也要过同一份快照 id 判据 ── +# +# 它是四个接收 ``snapshot_id`` 的公开方法里**唯一**漏掉 +# ``_require_snapshot_id_within_root`` 的:``create_temp_snapshot_dir``/ +# ``cleanup_temp_snapshot_dir``/``finalize_snapshot`` 都有,它没有。 +# +# 它把取值直接写进 ``ACTIVE_SNAPSHOT``,而标记文件正是两个读取侧 +# (``get_active_snapshot_id``/``get_active_snapshot_dir``)的输入,两者都会用 +# 同一份判据校验。于是同一个值在**写**侧被接受、在**读**侧被拒绝——实测 +# ``activate_snapshot("../evil")`` 正常返回并把 ``../evil`` 写进标记文件,随后 +# ``get_active_snapshot_id()`` 抛「快照 id 不合法」。非字符串(``5``/``None``) +# 与位数超限的整数则在这一行抛裸 ``TypeError: data must be str``/ +# ``ValueError: Exceeds the limit``——都不是本类承诺的带指引 ``ValueError``。 + + +@pytest.mark.parametrize( + "snapshot_id", + [ + pytest.param("../evil", id="relative-escape"), + pytest.param("/tmp/elsewhere", id="absolute"), + pytest.param("a/b", id="separator"), + pytest.param("..", id="dotdot"), + pytest.param("", id="empty"), + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_activate_snapshot_rejects_hostile_ids(run_config, snapshot_id): + """写入标记的取值必须与读取侧的判据一致,且失败时不留痕。""" + repository = SnapshotRepository(run_config) + marker = run_config.active_snapshot_marker + marker.write_text("kb-existing", encoding="utf-8") + + with pytest.raises(ValueError, match="快照 id"): + repository.activate_snapshot(snapshot_id) + + # 失败不得改写既有的活动标记(``.tmp`` 中间文件也不该留下)。 + assert marker.read_text(encoding="utf-8") == "kb-existing" + assert repository.get_active_snapshot_id() == "kb-existing" + + +def test_activate_snapshot_still_accepts_generated_ids(run_config): + """收紧不能误伤:生成 id 的激活路径照常走通。""" + repository = SnapshotRepository(run_config) + snapshot_id = repository.generate_snapshot_id() + + repository.activate_snapshot(snapshot_id) + + assert repository.get_active_snapshot_id() == snapshot_id + + +# ── 回归:``finalize_snapshot`` 的 ``temp_dir`` 也要判形态 ── +# +# 同一方法的两个参数,此前只有 ``snapshot_id`` 有判据(``_require_snapshot_id_ +# within_root``),``temp_dir`` 这一半没有。``temp_dir.replace(final_dir)`` 是 +# ``Path`` 的方法,非 ``Path`` 取值在那里抛的是与「快照目录写错了」无关的裸异常: +# ``5``/``None``/``["a"]`` 抛 ``AttributeError: ... object has no attribute +# 'replace'``,而 ``str`` 更隐蔽——字符串**有** ``replace``,只是语义完全不同, +# 抛 ``TypeError: replace expected at least 2 arguments, got 1``。 +# +# 字符串形态尤其现实:同项目里目录参数普遍标注 ``str | os.PathLike`` +# (``FaissStore.save_snapshot(snapshot_dir: str)`` 等),调用方按那个惯例传 +# 字符串就会命中这里。本类其余拒绝路径一律给带指引的 ``ValueError``。 + + +@pytest.mark.parametrize( + "temp_dir", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(["a"], id="list"), + pytest.param("not-a-path", id="str"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_finalize_snapshot_rejects_non_path_temp_dir(run_config, temp_dir): + """``temp_dir`` 非 ``Path`` 时必须给带指引的 ``ValueError``,不得裸抛。""" + repository = SnapshotRepository(run_config) + snapshot_id = repository.generate_snapshot_id() + repository.create_temp_snapshot_dir(snapshot_id) + + with pytest.raises(ValueError, match="temp_dir"): + repository.finalize_snapshot(temp_dir, snapshot_id) + + +def test_finalize_snapshot_still_accepts_path_temp_dir(run_config): + """收紧不能误伤:``create_temp_snapshot_dir()`` 的返回值照常可用。""" + repository = SnapshotRepository(run_config) + snapshot_id = repository.generate_snapshot_id() + temp_dir = repository.create_temp_snapshot_dir(snapshot_id) + (temp_dir / "chunks.pkl").write_bytes(b"x") + + final_dir = repository.finalize_snapshot(temp_dir, snapshot_id) + + assert final_dir.exists() + assert (final_dir / "chunks.pkl").read_bytes() == b"x" + + +# ── 回归:``write_manifest`` 的 ``snapshot_dir`` 也要判形态 ── +# +# 与 ``__init__``(``snapshot_root``)、``finalize_snapshot``(``temp_dir``)同 +# 一处判据,这一处此前漏了:非 ``Path`` 取值在 ``snapshot_dir / "manifest.toml"`` +# 处抛裸 ``TypeError: unsupported operand type(s) for /: 'str' and 'str'``——没有 +# 字段名、也没有「该传什么」的指引,而本类的其余拒绝路径一律给带指引的 +# ``ValueError``。 +# +# 字符串形态尤其现实:同一个 ``temp_dir`` 在 ``KnowledgeBuildService.build`` 里 +# 紧邻的两行分别以 ``str``(``vector_store.save_snapshot(str(temp_dir))``)与 +# ``Path``(``write_manifest(temp_dir, ...)``)传给两个 API,调用方按前者的惯例 +# 传字符串就会命中这里。 + + +@pytest.mark.parametrize( + "snapshot_dir", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(["a"], id="list"), + pytest.param("not-a-path", id="str"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_write_manifest_rejects_non_path_snapshot_dir(run_config, snapshot_dir): + """``snapshot_dir`` 非 ``Path`` 时必须给带指引的 ``ValueError``,不得裸抛。""" + from src.runtime.contracts import KnowledgeSnapshotManifest + + repository = SnapshotRepository(run_config) + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-m", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=0, + chunk_count=0, + ) + + with pytest.raises(ValueError, match="snapshot_dir"): + repository.write_manifest(snapshot_dir, manifest) + + +def test_write_manifest_still_accepts_path_snapshot_dir(run_config): + """收紧不能误伤:``create_temp_snapshot_dir()`` 的返回值照常可用。""" + from src.runtime.contracts import KnowledgeSnapshotManifest + + repository = SnapshotRepository(run_config) + temp_dir = repository.create_temp_snapshot_dir(repository.generate_snapshot_id()) + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-m", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=0, + chunk_count=0, + ) + + repository.write_manifest(temp_dir, manifest) + + assert (temp_dir / "manifest.toml").is_file() + + +# ── 回归:``load_manifest`` 是 ``write_manifest`` 的读取侧兄弟,形态判据也要对齐 ── +# +# ``write_manifest`` 已在上一组用例里按 ``__init__``/``finalize_snapshot`` 的 +# 同一判据收紧(只接受 ``Path``),读取侧这一半此前漏了,而它的失败形态比写入侧 +# 更坏:``snapshot_dir`` 是 ``str`` 时 ``snapshot_dir / "manifest.toml"`` 抛 +# ``TypeError: unsupported operand type(s) for /: 'str' and 'str'``,被下面的 +# ``except Exception`` 接住后**误报成「知识快照不自洽,请重建」**——一个完好的 +# 快照会被要求重建,而真正的错因(路径传成了字符串)在消息里完全不可见。 +# +# 字符串形态尤其现实:``VectorStoreFactory._load_existing_state`` 里相邻两行分别 +# 以 ``Path``(``validate_snapshot_dir(active_snapshot_dir)``)与同一变量传给 +# ``load_manifest``,而 ``save_snapshot`` 的惯例又是 ``str``;调用方按任一侧的 +# 惯例取值都会命中这里。判据与 ``write_manifest`` 逐字对齐。 + + +@pytest.mark.parametrize( + "snapshot_dir", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(["a"], id="list"), + pytest.param("not-a-path", id="str"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_load_manifest_rejects_non_path_snapshot_dir(run_config, snapshot_dir): + """``snapshot_dir`` 非 ``Path`` 时必须给带指引的 ``ValueError``,不得误报快照不自洽。""" + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match="snapshot_dir") as excinfo: + repository.load_manifest(snapshot_dir) + + # 关键:错误消息必须指向「入参形态」,不能把完好的快照说成损坏。 + message = str(excinfo.value) + assert "不自洽" not in message + assert "请重建" not in message + + +def test_load_manifest_still_accepts_path_snapshot_dir(run_config): + """收紧不能误伤:``create_temp_snapshot_dir()`` 的返回值照常可读。""" + from src.runtime.contracts import KnowledgeSnapshotManifest + + repository = SnapshotRepository(run_config) + temp_dir = repository.create_temp_snapshot_dir(repository.generate_snapshot_id()) + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-m", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=0, + chunk_count=0, + ) + repository.write_manifest(temp_dir, manifest) + + loaded = repository.load_manifest(temp_dir) + + assert loaded.chunk_count == 0 + + +def test_snapshot_id_accepts_ids_produced_by_the_generator(tmp_path, monkeypatch): + """收紧不能误伤:``generate_snapshot_id()`` 的产物必须照常可用。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + for prefix in ("kb", "legacy"): + snapshot_id = repository.generate_snapshot_id(prefix=prefix) + temp_dir = repository.create_temp_snapshot_dir(snapshot_id) + + assert temp_dir.exists() + repository.cleanup_temp_snapshot_dir(snapshot_id) + + +# ── 回归:chunks.pkl 的顶层与向量维度 ── + + +def test_validate_rejects_chunks_pickle_with_none_top_level(tmp_path, monkeypatch): + """``chunks.pkl=None`` 原先被当作 0 个分块放行,加载时才抛裸 ``TypeError``。""" + import pickle + + import faiss + import numpy as np + + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_dir = _build_minimal_snapshot(tmp_path, monkeypatch) + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump(None, file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((0, 4), dtype=np.float32)) + faiss.write_index(faiss.IndexFlatL2(4), str(snapshot_dir / "semantic.index")) + (snapshot_dir / "stats.json").write_text('{"chunk_count": 0}', encoding="utf-8") + (snapshot_dir / "lexical.index").unlink(missing_ok=True) + + repository = SnapshotRepository(type("RunConfig", (), {"snapshot_root": tmp_path})()) + with pytest.raises(ValueError, match="chunks.pkl 的顶层"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_rejects_embedding_index_dimension_mismatch(tmp_path, monkeypatch): + """行数一致但**维度**不一致时也必须拒绝。 + + 实测分块数/embedding 行数/ntotal 都是 1,而 ``embeddings.npy`` 是 4 维、 + ``semantic.index.d`` 是 3 时校验放行;加载后 ``semantic_search`` 把 4 维查询 + 向量送进 3 维索引,FAISS 抛的是**空消息的 AssertionError**。 + """ + import pickle + + import faiss + import numpy as np + + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_dir = _build_minimal_snapshot(tmp_path, monkeypatch) + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "x", "metadata": {}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + index = faiss.IndexFlatL2(3) + index.add(np.zeros((1, 3), dtype=np.float32)) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) + (snapshot_dir / "stats.json").write_text('{"chunk_count": 1}', encoding="utf-8") + (snapshot_dir / "lexical.index").unlink(missing_ok=True) + + repository = SnapshotRepository(type("RunConfig", (), {"snapshot_root": tmp_path})()) + with pytest.raises(ValueError, match="维度"): + repository.validate_snapshot_dir(snapshot_dir) + + +def _build_minimal_snapshot(tmp_path, monkeypatch): + """造一个受信根目录下的空快照目录,供上面两个用例填充。 + + 信任根用 ``RunConfig`` 替身声明(与 ``test_snapshot_repository_cleans_only_ + requested_temp_dir`` 同一约定),而不是改 ``config.toml``:``build_run_config`` + 解析出的根与这里必须一致,否则 ``validate_snapshot_dir`` 会先报「不在受信 + 根目录内」,掩盖本用例要验的内容校验。 + """ + snapshot_dir = tmp_path / "kb-probe" + snapshot_dir.mkdir() + (snapshot_dir / "manifest.toml").write_text( + KnowledgeSnapshotManifest.create( + snapshot_id="kb-probe", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=0, + chunk_count=0, + ).to_toml(), + encoding="utf-8", + ) + return snapshot_dir + + +# ── 回归:manifest 与 stats.json 的字段解析失败也要有重建指引 ── +# +# ``load_manifest`` 与 ``validate_snapshot_dir`` 此前只包住了 ``tomllib.load`` +# 的失败,**解析之后的字段取值**没有包:``from_mapping`` 里 ``int(data[...])`` +# 与 ``data[...]`` 直接取值,于是 +# * ``chunk_count = "abc"`` -> 裸 ``ValueError: invalid literal for int()`` +# * ``chunk_count = "9"*5000`` -> 裸 ``ValueError: Exceeds the limit (4300 digits)`` +# * 字段缺失 -> 裸 ``KeyError: 'chunk_count'`` +# 三者都没有「快照不自洽」的语义与重建指引,而同一份文件语法错时给的正是那种 +# 消息——同类失败不该有两种收场方式。``stats.json`` 的 ``int(declared)`` 同理 +# (``"abc"``/列表/超长数字串分别抛 ValueError/TypeError/ValueError)。 + + +def _write_manifest_snapshot(root, snapshot_id, manifest_text): + """写一个只有 manifest.toml 内容可控的快照目录。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = Path(root) / snapshot_id + snapshot_dir.mkdir(parents=True, exist_ok=True) + (snapshot_dir / "manifest.toml").write_text(manifest_text, encoding="utf-8") + return repository, snapshot_dir + + +def _manifest_toml(): + from src.runtime.contracts import KnowledgeSnapshotManifest + + return KnowledgeSnapshotManifest.create( + snapshot_id="kb-manifest", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="test-digest", + document_count=3, + chunk_count=3, + ).to_toml() + + +def test_load_manifest_reports_unparseable_counts_as_inconsistent_snapshot(): + """``chunk_count`` 写成非数值时必须是带重建指引的 ``ValueError``。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + _, snapshot_dir = _write_manifest_snapshot( + repository.root, + "kb-bad-count", + _manifest_toml().replace("chunk_count = 3", 'chunk_count = "abc"'), + ) + + with pytest.raises(ValueError, match="重建") as excinfo: + repository.load_manifest(snapshot_dir) + + assert "不自洽" in str(excinfo.value) + # 原始异常类型保留在消息里,便于定位 + assert "ValueError" in str(excinfo.value) + + +def test_load_manifest_reports_missing_fields_as_inconsistent_snapshot(): + """字段缺失时不能抛裸 ``KeyError``。 + + ``KeyError`` **不继承** ``ValueError``,所以它既逃出本方法的收场约定,也 + 逃出 ``VectorStoreFactory`` 启动路径上任何 ``except ValueError``。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + manifest_without_count = "\n".join( + line for line in _manifest_toml().splitlines() if not line.startswith("chunk_count") + ) + _, snapshot_dir = _write_manifest_snapshot( + repository.root, "kb-missing-count", manifest_without_count + ) + + with pytest.raises(ValueError, match="重建") as excinfo: + repository.load_manifest(snapshot_dir) + + assert "KeyError" in str(excinfo.value) + + +def test_load_manifest_reports_oversized_count_literal_as_inconsistent_snapshot(): + """位数超限的数字串同样要走「快照不自洽」,而不是裸的位数超限错误。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + _, snapshot_dir = _write_manifest_snapshot( + repository.root, + "kb-huge-count", + _manifest_toml().replace("chunk_count = 3", 'chunk_count = "' + "9" * 5000 + '"'), + ) + + with pytest.raises(ValueError, match="重建") as excinfo: + repository.load_manifest(snapshot_dir) + + assert "不自洽" in str(excinfo.value) + + +# ── 回归:清单的**计数字段**读写两侧判据必须一致 ── +# +# ``to_toml``(写入侧)经 ``_manifest_count`` 收敛:拒绝 ``bool``、拒绝非整数 +# 浮点、拒绝负数、拒绝超过 ``2**64`` 的取值。而 ``from_mapping``(读取侧)写的是 +# 裸 ``int(data[...])``——这些取值**全部被静默接受**,读出来的计数与磁盘上写的 +# 不是同一个数: +# +# * ``document_count = 1.5`` -> ``1``(截断) +# * ``document_count = true`` -> ``1``(bool 是 int 子类) +# * ``document_count = -5`` -> ``-5``(负的分块数) +# * ``document_count = 10**300`` -> 原样接受(写入侧判它超出 2**64) +# +# manifest 是快照的准入凭据(``load_manifest`` 的返回值被 +# ``VectorStoreFactory`` 拿去比对 embedding 配置),一个「读得出来但写不进去」 +# 的计数意味着准入校验建立在一个磁盘上并不存在的数字上。两侧的 docstring 都 +# 写着「与另一侧同源」,判据却只对齐了异常分类那一半。 + + +@pytest.mark.parametrize( + "literal", + [ + pytest.param("1.5", id="float"), + pytest.param("true", id="bool"), + pytest.param("-5", id="negative"), + pytest.param("1" + "0" * 300, id="beyond-2**64"), + ], +) +def test_load_manifest_rejects_count_values_the_write_side_refuses(literal): + """写入侧拒绝的计数取值,读取侧同样要拒绝并给出重建指引。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + toml_text = _manifest_toml() + for field in ("document_count", "chunk_count"): + toml_text = toml_text.replace(f"{field} = 3", f"{field} = {literal}") + _, snapshot_dir = _write_manifest_snapshot(repository.root, "kb-count-shape", toml_text) + + with pytest.raises(ValueError, match="重建") as excinfo: + repository.load_manifest(snapshot_dir) + + assert "不自洽" in str(excinfo.value) + + +def test_load_manifest_keeps_accepting_a_decimal_string_count(): + """收紧不能误伤:``.env``/TOML 来源本来就会以数字字符串到达。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + toml_text = _manifest_toml() + for field in ("document_count", "chunk_count"): + toml_text = toml_text.replace(f"{field} = 3", f'{field} = "3"') + _, snapshot_dir = _write_manifest_snapshot(repository.root, "kb-count-str", toml_text) + + manifest = repository.load_manifest(snapshot_dir) + + assert manifest.document_count == 3 + assert manifest.chunk_count == 3 + + +def test_load_manifest_still_accepts_a_valid_manifest(): + """收紧不能误伤:合法 manifest 照常通过。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + _, snapshot_dir = _write_manifest_snapshot(repository.root, "kb-ok", _manifest_toml()) + + manifest = repository.load_manifest(snapshot_dir) + + assert manifest.chunk_count == 3 + + +@pytest.mark.parametrize( + "declared,label", + [ + ("abc", "非数值字符串"), + ("1.5", "浮点字符串"), + ([1], "列表"), + ("9" * 5000, "位数超限的数字串"), + ], +) +def test_validate_snapshot_dir_reports_unparseable_stats_count(declared, label): + """``stats.json`` 的 ``chunk_count`` 形态异常时必须给重建指引。 + + ``int(declared)`` 直接调用会抛裸的 ``ValueError``/``TypeError``——都没有 + 「快照不自洽」的语义,而紧邻的「数值不等」分支给的正是那种消息。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-bad-stats-shape", chunk_count=3, embedding_rows=3 + ) + import json + + (snapshot_dir / "stats.json").write_text( + json.dumps({"chunk_count": declared}), encoding="utf-8" + ) + + with pytest.raises(ValueError, match="重建") as excinfo: + repository.validate_snapshot_dir(snapshot_dir) + + assert "stats.json" in str(excinfo.value), label + + +# ── 回归:快照 id 的长度上界 ── +# +# ``_require_snapshot_id_within_root`` 原判据对超长名字**全部为真**(单分量、 +# 相对、非 ``.``),但拼成路径后 ``.exists()`` 抛 +# ``OSError: [Errno 63] File name too long``。而 +# ``VectorStoreFactory._load_existing_state`` 的第一步就是 +# ``get_active_snapshot_dir()``,因此一个被外部工具写坏的标记文件会让**启动** +# 以 ``OSError`` 失败,而本类的契约是带指引的 ``ValueError``。 + + +def test_active_snapshot_id_rejects_overlong_value(run_config): + """超长 id 必须在拼接路径之前被拒,不能等到 ``stat`` 抛 ``OSError``。""" + repository = SnapshotRepository(run_config) + run_config.active_snapshot_marker.write_text("z" * 300, encoding="utf-8") + + with pytest.raises(ValueError, match="过长"): + repository.get_active_snapshot_dir() + + +def test_active_snapshot_id_length_bound_does_not_reject_generated_ids(run_config): + """上界不能误伤本应用自己生成的 id。""" + repository = SnapshotRepository(run_config) + snapshot_id = repository.generate_snapshot_id(prefix="legacy") + run_config.active_snapshot_marker.write_text(snapshot_id, encoding="utf-8") + + assert repository.get_active_snapshot_id() == snapshot_id + + +# ── 回归:``parents.pkl`` 的错误消息不得自己抛 ── +# +# ``invalid_parents`` 用 ``str(key)`` 拼装,而键来自磁盘上的 parents.pkl +# (pickle 是二进制格式,不受 CPython 十进制位数限制)。位数超限时,**正要报 +# 「父条目形态不对」**的那一步自己抛 ``ValueError: Exceeds the limit``——用户 +# 拿到与快照无关的整数转换错误。 + + +def test_validate_snapshot_dir_reports_huge_integer_parent_key(tmp_path): + """父条目非法且键是超大整数时,必须是带重建指引的 ``ValueError``。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-huge-parent-key", chunk_count=1, embedding_rows=1 + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({10**4301: [1, 2]}, file) + + with pytest.raises(ValueError, match="父条目"): + repository.validate_snapshot_dir(snapshot_dir) + + +# ── 回归:符号链接环必须收敛成 ``ValueError`` ── +# +# ``_require_snapshot_id_within_root`` 里 ``Path(self.root / snapshot_id) +# .resolve()`` 对**符号链接环**抛 ``RuntimeError: Symlink loop from ...``。 +# 它**不是** ``ValueError``,穿透本类的契约(见上方 +# ``test_active_snapshot_id_rejects_overlong_value`` 的注释:「本类的契约是带 +# 指引的 ``ValueError``」)。同项目的两个兄弟判据早已为同一情况收敛: +# +# * ``utils/security.resolve_within`` 明确把 ``(OSError, RuntimeError)`` +# 收成 ``ValueError``,注释写明「一条『拒绝加载』的消息变成 pathlib 的 +# 内部错误」; +# * ``utils/cleanup._reject_unsafe_cache_dir`` 同样收敛,理由同源。 +# +# 可达性:``ACTIVE_SNAPSHOT`` 是磁盘上的任意文本,标记内容直接拼进路径。用户 +# 手工摆一个环(或外部工具写坏)即触发,而 ``VectorStoreFactory +# ._load_existing_state`` 的第一步就是 ``get_active_snapshot_dir()``——启动 +# 会以 pathlib 的内部错误收场,而不是「标记文件坏了,请重建快照」。 + + +def test_active_snapshot_id_rejects_symlink_loop(tmp_path, monkeypatch): + """标记内容指向符号链接环时,必须抛 ``ValueError`` 而不是 ``RuntimeError``。""" + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + loop = repository.root / "loop" + loop.symlink_to(loop) + repository.run_config.active_snapshot_marker.write_text("loop", encoding="utf-8") + + with pytest.raises(ValueError): + repository.get_active_snapshot_id() + + +def test_active_snapshot_id_rejects_a_directory_marker(tmp_path, monkeypatch): + """标记路径是**目录**时必须给 ``ValueError``,不是裸 ``IsADirectoryError``。 + + ``get_active_snapshot_id`` 只判 ``marker.exists()``(目录也为真)就直接 + ``read_text``,``IsADirectoryError``(``OSError`` 子类)原样冒到调用方。 + 本类的契约是带指引的 ``ValueError``,而 ``VectorStoreFactory + ._load_existing_state`` 的第一步就是本方法——启动会以裸 ``OSError`` 收场。 + """ + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + repository.run_config.active_snapshot_marker.mkdir() + + with pytest.raises(ValueError): + repository.get_active_snapshot_id() + + +def test_active_snapshot_id_rejects_a_non_utf8_marker(tmp_path, monkeypatch): + """标记文件不是 UTF-8 时必须给 ``ValueError``,不是裸 ``UnicodeDecodeError``。 + + ``UnicodeDecodeError`` 继承 ``ValueError``,但它**不是**本类其余拒绝路径 + 给出的那种带重建指引的消息;调用方按消息内容分类/展示时接不住。标记文件 + 是磁盘上的任意字节,外部工具写坏即触发。 + """ + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + repository.run_config.active_snapshot_marker.write_bytes(b"\xff\xfe bad") + + with pytest.raises(ValueError) as excinfo: + repository.get_active_snapshot_id() + + message = str(excinfo.value) + assert "ACTIVE_SNAPSHOT" in message or "标记" in message, message + + +def test_load_snapshot_rejects_a_directory_marker(tmp_path): + """``FaissStore.load_snapshot`` 对目录形态的标记文件给 ``ValueError``。 + + 与 ``SnapshotRepository.get_active_snapshot_id`` 同一处、同一判据:两者都 + 在 ``VectorStoreFactory._load_existing_state`` 的启动路径上,标记路径是目录 + 时 ``read_text`` 抛裸 ``IsADirectoryError``。 + """ + from src.retrieval.vdb.faiss_store import FaissStore + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + snapshot = snapshot_root / "kb-x" + snapshot.mkdir() + (snapshot_root / "ACTIVE_SNAPSHOT").mkdir() + + store = FaissStore() + with pytest.raises(ValueError) as excinfo: + store.load_snapshot(str(snapshot), snapshot_root=snapshot_root) + + message = str(excinfo.value) + assert "ACTIVE_SNAPSHOT" in message, message + + +def test_load_snapshot_rejects_a_non_utf8_marker(tmp_path): + """标记文件不是 UTF-8 时 ``FaissStore.load_snapshot`` 给 ``ValueError``。""" + from src.retrieval.vdb.faiss_store import FaissStore + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + snapshot = snapshot_root / "kb-x" + snapshot.mkdir() + (snapshot_root / "ACTIVE_SNAPSHOT").write_bytes(b"\xff\xfe bad") + + store = FaissStore() + with pytest.raises(ValueError) as excinfo: + store.load_snapshot(str(snapshot), snapshot_root=snapshot_root) + + message = str(excinfo.value) + assert "ACTIVE_SNAPSHOT" in message, message + + +# ── 快照形态与解析判据的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,均经 +# ``validate_snapshot_dir`` 真实入口): +# * 149 快照 id 解析后不在根目录之下(符号链接环)→ **静默通过**,把「这份 +# id 会逃出快照根」放行到后续路径拼接; +# * 367 manifest.toml 无法解析 → **静默通过校验**,把「快照被截断」留到加载 +# 时才以别的异常暴露; +# * 398/470/514/550 四个文件(embeddings.npy/stats.json/semantic.index/ +# lexical.index)解析失败 → **裸 ``UnboundLocalError``**(变量未绑定), +# 而调用方按「快照不自洽」的 ``ValueError`` 分类; +# * 412/562 ``chunks.pkl``/``lexical.index`` 顶层非列表 → **裸 ``TypeError: +# object of type 'int' has no len()``**;475 ``stats.json`` 顶层非对象 → +# **裸 ``AttributeError: 'list' object has no attribute 'get'``; +# * 418 ``embeddings.npy`` 非二维 → 消息退化成「分块数与向量数不一致」 +# (一维 (4,) 报「embeddings.npy=4」),指向错误的修复方向。 +def _build_self_consistent_snapshot(root, snapshot_id="kb-shape"): + """造一个行数自洽的最小快照,供逐文件破坏。""" + import json + + import faiss + import numpy as np + + snapshot_dir = Path(root) / snapshot_id + snapshot_dir.mkdir(parents=True, exist_ok=True) + manifest = KnowledgeSnapshotManifest.create( + snapshot_id=snapshot_id, + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="shape-digest", + document_count=2, + chunk_count=2, + ) + (snapshot_dir / "manifest.toml").write_text(manifest.to_toml(), encoding="utf-8") + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump( + [ + {"page_content": "a", "metadata": {"source": "a.md"}}, + {"page_content": "b", "metadata": {"source": "b.md"}}, + ], + file, + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((2, 4), dtype=np.float32)) + index = faiss.IndexFlatL2(4) + index.add(np.zeros((2, 4), dtype=np.float32)) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) + (snapshot_dir / "stats.json").write_text(json.dumps({"chunk_count": 2}), encoding="utf-8") + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump([{"t": 1}, {"t": 2}], file) + return snapshot_dir + + +def _restore_snapshot_files(snapshot_dir): + """把 _build_self_consistent_snapshot 写出的文件按原样重写(供逐条破坏后恢复)。""" + import json + + import faiss + import numpy as np + + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump( + [ + {"page_content": "a", "metadata": {"source": "a.md"}}, + {"page_content": "b", "metadata": {"source": "b.md"}}, + ], + file, + ) + np.save(snapshot_dir / "embeddings.npy", np.zeros((2, 4), dtype=np.float32)) + index = faiss.IndexFlatL2(4) + index.add(np.zeros((2, 4), dtype=np.float32)) + faiss.write_index(index, str(snapshot_dir / "semantic.index")) + (snapshot_dir / "stats.json").write_text(json.dumps({"chunk_count": 2}), encoding="utf-8") + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump([{"t": 1}, {"t": 2}], file) + + +def test_snapshot_id_resolving_outside_root_is_rejected(run_config, tmp_path): + """解析后落在快照根之外的 id(符号链接环)必须报错,不能静默通过。""" + import os + + root = Path(run_config.snapshot_root) + root.mkdir(parents=True, exist_ok=True) + os.symlink(str(root), str(root / "loop")) + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match="不在快照根目录"): + repository._require_snapshot_id_within_root("loop", source="test") + + +@pytest.mark.parametrize( + "corrupt_file", + [ + pytest.param("manifest.toml", id="manifest-truncated"), + pytest.param("embeddings.npy", id="embeddings-truncated"), + pytest.param("stats.json", id="stats-truncated"), + pytest.param("semantic.index", id="semantic-truncated"), + pytest.param("lexical.index", id="lexical-truncated"), + ], +) +def test_validate_snapshot_dir_reports_truncated_files_as_value_error( + run_config, + corrupt_file, +): + """任一文件被截断都必须报「快照不自洽」的 ``ValueError``。 + + 删除守卫后分别是:manifest 静默通过、其余四个抛裸 ``UnboundLocalError``。 + """ + snapshot_dir = _build_self_consistent_snapshot(run_config.snapshot_root) + (snapshot_dir / corrupt_file).write_bytes(b"truncated") + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match="无法解析"): + repository.validate_snapshot_dir(snapshot_dir) + + +def _write_pickled(snapshot_dir, filename, value): + with (snapshot_dir / filename).open("wb") as file: + pickle.dump(value, file) + + +@pytest.mark.parametrize( + "corrupt_file,writer,payload,pattern", + [ + pytest.param( + "chunks.pkl", _write_pickled, 42, "chunks.pkl 的顶层不是列表", id="chunks-int" + ), + pytest.param( + "chunks.pkl", _write_pickled, {"a": 1}, "chunks.pkl 的顶层不是列表", id="chunks-dict" + ), + pytest.param( + "stats.json", + lambda d, f, v: (d / f).write_text(v, encoding="utf-8"), + "[2, 2]", + "stats.json 的顶层不是对象", + id="stats-list", + ), + pytest.param( + "lexical.index", _write_pickled, 42, "lexical.index 的顶层不是列表", id="lexical-int" + ), + pytest.param( + "lexical.index", _write_pickled, "ab", "lexical.index 的顶层不是列表", id="lexical-str" + ), + ], +) +def test_validate_snapshot_dir_rejects_wrong_top_level_shapes( + run_config, + corrupt_file, + writer, + payload, + pattern, +): + """顶层形态错误必须报带文件名与形态的 ``ValueError``,不是裸 TypeError/AttributeError。""" + snapshot_dir = _build_self_consistent_snapshot(run_config.snapshot_root) + writer(snapshot_dir, corrupt_file, payload) + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match=pattern): + repository.validate_snapshot_dir(snapshot_dir) + + +@pytest.mark.parametrize( + "shape", + [ + pytest.param((4,), id="one-dimensional"), + pytest.param((2, 2, 2), id="three-dimensional"), + ], +) +def test_validate_snapshot_dir_rejects_non_matrix_embeddings(run_config, shape): + """``embeddings.npy`` 非二维必须报「不是二维数组」,不能退化成行数不一致。""" + import numpy as np + + snapshot_dir = _build_self_consistent_snapshot(run_config.snapshot_root) + np.save(snapshot_dir / "embeddings.npy", np.zeros(shape, dtype=np.float32)) + repository = SnapshotRepository(run_config) + + with pytest.raises(ValueError, match="不是二维数组"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_get_active_snapshot_dir_raises_when_target_dir_missing(tmp_path): + """标记存在、id 合法、目录消失时必须显式报错,不得静默回退为「无快照」。 + + 返回 ``None`` 会让 ``_load_existing_state`` 落入 legacy/空 store 分支: + legacy 文件恰好存在时旧数据被导入并改写激活标记(用户误以为重建了快照), + 否则静默以空 store 启动、检索永远空结果——真实状态(激活标记已损坏) + 被掩盖成「没有快照」。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-vanished", chunk_count=1, embedding_rows=1 + ) + (repository.root / "ACTIVE_SNAPSHOT").write_text("kb-vanished", encoding="utf-8") + # 目录消失,标记仍在。 + import shutil + + shutil.rmtree(snapshot_dir) + + with pytest.raises(FileNotFoundError, match="快照目录不存在"): + repository.get_active_snapshot_dir() diff --git a/tests/retrieval/vdb/test_store_type_guard.py b/tests/retrieval/vdb/test_store_type_guard.py new file mode 100644 index 0000000..9fdbe63 --- /dev/null +++ b/tests/retrieval/vdb/test_store_type_guard.py @@ -0,0 +1,110 @@ +"""``VectorStoreFactory.get_vector_store`` 的 ``store_type`` 类型守卫。 + +单独成文件而不是放进 ``test_factory.py``:那个文件有一个 ``autouse`` 的 +``patch_settings`` fixture,会把模块级 ``VectorStoreFactory`` 全局替换成 mock +版本(其中仍是旧的 ``store_type.lower()`` 实现),因此在那里断言「非字符串抛 +``ValueError``」测到的是 mock,不是被测代码。 +""" + +import pytest + +from src.retrieval.vdb.base import VectorStoreBase +from src.retrieval.vdb.factory import VectorStoreFactory + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(["faiss"], id="list"), + pytest.param({"a": 1}, id="dict"), + pytest.param(123, id="int"), + pytest.param(None, id="none"), + ], +) +def test_get_vector_store_rejects_non_string_store_type(bad_value): + """非字符串必须是 ``ValueError``,而不是 ``AttributeError``。 + + 原实现直接调 ``store_type.lower()``。``Settings.default_vector_store`` 能拦 + 住配置来源的非字符串,但本方法是公开入口,测试与库调用方会直接调用。 + """ + with pytest.raises(ValueError, match="向量存储类型"): + VectorStoreFactory.get_vector_store(bad_value) + + +def test_get_vector_store_still_accepts_faiss(): + """正常取值不受守卫影响。""" + store = VectorStoreFactory.get_vector_store("faiss") + assert store is not None + + +# ── VectorStoreBase 的四个默认实现必须报「未实现」而不是静默返回 None ── +# +# 这四个默认方法在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 +# ``raise`` 换成 ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测, +# 经一个只实现抽象方法的子类):``upsert_embeddings``/``semantic_search``/ +# ``save_snapshot``/``load_snapshot`` 全部**静默返回 None**。 +# +# 后果按调用方分两类:``RetrievalService._get_vector_store_method`` 会把 +# 「与基类同名的实现」判为未实现(靠的是 ``__func__ is`` 比对,不看返回值), +# 所以那条路径不受影响;但**第三方 store**(本仓的 ``VectorStoreBase`` 是公开 +# 扩展点)直接实现子类、忘掉某个方法时,拿到的 ``None`` 会被当成「没有结果」 +# 继续往下走——一次本该显式报错的接口缺失变成静默空结果。 +class _MinimalStore(VectorStoreBase): + """只实现抽象方法的最小 store,用于探测四个默认实现。""" + + def save(self, path): + return "saved" + + def load(self, path, *, trusted_paths=None): + return "loaded" + + def get_embedding_model(self): + return None + + def add_documents(self, documents): + return None + + def aadd_documents(self, documents): + return None + + def search(self, query, top_k=5, search_type="semantic"): + return [] + + def asearch(self, query, top_k=5, search_type="semantic"): + return [] + + +@pytest.mark.parametrize( + "method_name,args", + [ + pytest.param("upsert_embeddings", ([], None), id="upsert-embeddings"), + pytest.param("semantic_search", (None,), id="semantic-search"), + pytest.param("save_snapshot", ("/tmp/x",), id="save-snapshot"), + pytest.param("load_snapshot", ("/tmp/x",), id="load-snapshot"), + ], +) +def test_vector_store_base_defaults_report_not_implemented(method_name, args): + """未实现的接口必须抛 ``NotImplementedError``,不能静默返回 None。""" + store = _MinimalStore() + + with pytest.raises(NotImplementedError, match="未实现"): + getattr(store, method_name)(*args) + + +def test_get_vector_store_rejects_unsupported_string_store_type(): + """字符串但不支持的 store 类型必须抛 ``ValueError``,不能静默返回 ``None``。 + + 删除守卫后 ``get_vector_store("unsupported")`` 返回 ``None``——调用方拿到 + ``None`` 当向量存储用,崩在更远处。``test_factory.py`` 里那条同名用例断言 + 的是 ``autouse`` fixture 换上的 **mock**(mock 里仍有旧的 ``raise``), + 真代码的这条守卫从未被反向覆盖。 + """ + with pytest.raises(ValueError, match="不支持的向量存储类型: unsupported"): + VectorStoreFactory.get_vector_store("unsupported") + + +def test_get_vector_store_still_accepts_faiss_with_uppercase(): + """反向守卫:``FaissStore`` 的大小写归一化照常生效。""" + store = VectorStoreFactory.get_vector_store("FAISS") + + assert store is not None diff --git a/tests/retrieval_test/test_retrieval_cli.py b/tests/retrieval_test/test_retrieval_cli.py index 07a60ad..9322310 100644 --- a/tests/retrieval_test/test_retrieval_cli.py +++ b/tests/retrieval_test/test_retrieval_cli.py @@ -1,4 +1,7 @@ import asyncio +import os + +import pytest from src.retrieval_test.core import run_retrieval_test_async @@ -103,3 +106,900 @@ async def retrieve(self, *_args, **_kwargs): combined = " ".join(printed_messages + logged_messages) assert secret not in combined, combined assert "REDACTED" in combined, combined + + +# ── 回归:文档正文与用户输入里的标记形态不得打断召回测试 ── +# +# rich 把 ``console.print(str)``/``Table.add_row(str)`` 的字符串按标记解析。 +# 知识库文档正文里出现字面量 ``[/]``(例如「匹配斜杠用 [/] 表示」)时, +# ``display_results`` 抛 ``MarkupError``;而异常处理本身走的是同一个通道—— +# ``safe_exception_text()`` 拼出的消息里带着 ``[/]``,于是内层 except 抛、 +# ``main()`` 的兜底 except 也抛、``run_cli`` 的兜底 except 再抛,三层全废, +# 程序以裸 traceback 退出。修复前实测确认了这条完整链路。 + + +def _console_buffer(monkeypatch): + from io import StringIO + + from rich.console import Console + + from src.retrieval_test import core + + buffer = StringIO() + monkeypatch.setattr( + core, "console", Console(file=buffer, width=200, force_terminal=False, no_color=True) + ) + return buffer + + +MARKUP_LIKE_DOCUMENT = "配置说明:匹配斜杠用 [/] 表示。" + + +def test_display_results_survives_markup_like_document_content(monkeypatch): + """文档正文含 ``[/]`` 时表格必须照常渲染,且内容原样显示。""" + from src.retrieval_test.core import display_results + + buffer = _console_buffer(monkeypatch) + + display_results( + "斜杠怎么写", + [ + { + "page_content": MARKUP_LIKE_DOCUMENT, + "metadata": {"source": "/kb/[/]说明.md"}, + "score": 0.9, + } + ], + ) + + rendered = buffer.getvalue() + assert "[/]" in rendered + + +def test_display_results_survives_markup_like_query(monkeypatch): + """用户自己输入的查询含 ``[/]`` 时,无结果提示面板也必须照常渲染。""" + from src.retrieval_test.core import display_results + + buffer = _console_buffer(monkeypatch) + + display_results("正则里 [/] 是什么意思", []) + + assert "正则里 [/] 是什么意思" in buffer.getvalue() + + +def test_retrieval_error_path_survives_markup_like_exception(monkeypatch): + """异常消息含标记形态时,错误提示必须能打出来。 + + 这条覆盖的是级联失效:修复前 ``safe_exception_text()`` 的结果再次进入 + rich 标记解析并抛错,把「报告错误」这一步本身也变成崩溃。 + """ + printed = [] + + class MarkupLikeFailure: + def __init__(self, *_args, **_kwargs): + return None + + async def retrieve(self, *_args, **_kwargs): + raise RuntimeError("bad tag [/] in message") + + monkeypatch.setattr( + "src.retrieval_test.core.PromptSession", + lambda: FakePromptSession(["query", "/quit"]), + ) + monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) + monkeypatch.setattr("src.retrieval_test.core.RetrievalService", MarkupLikeFailure) + monkeypatch.setattr( + "src.retrieval_test.core.EmbeddingService", lambda *_args, **_kwargs: object() + ) + monkeypatch.setattr( + "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", lambda: object() + ) + + from io import StringIO + + from rich.console import Console + + from src.retrieval_test import core + + buffer = StringIO() + monkeypatch.setattr( + core, "console", Console(file=buffer, width=200, force_terminal=False, no_color=True) + ) + monkeypatch.setattr(core.logger, "error", lambda message, *a, **k: printed.append(message)) + + asyncio.run(run_retrieval_test_async()) + + assert "召回测试出错" in buffer.getvalue() + + +# ── 回归:Excel 导出不得把文档内容当公式写入 ── +# +# ``openpyxl`` 按值猜类型:以 ``=`` 开头的字符串会被存成**公式** +# (``data_type='f'``),Excel 打开日志时求值。实测文档内容 ``=1+1`` 落盘后 +# 是公式而非文本,``=cmd|'/c calc'!A0`` 这类 DDE 载荷同样照单全收。 +# 两条后果:日志不再忠实记录「检索到了什么」(内容列显示的是计算结果), +# 以及在打开日志的机器上执行文档里的内容。 + + +def test_excel_log_keeps_formula_like_content_as_text(tmp_path): + """以 ``=`` 开头的内容必须按文本落盘,不能被当作公式。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "=1+1", + [ + {"page_content": "=1+1", "metadata": {"source": "=a.md"}, "score": 0.5}, + {"page_content": "=cmd|'/c calc'!A0", "metadata": {"source": "b.md"}, "score": 0.4}, + ], + ) + + worksheet = load_workbook(logger.filepath).active + content_column = [ + row[6] for row in worksheet.iter_rows(min_row=2, max_col=8) if row[6].value is not None + ] + assert content_column, "日志里没有任何内容行" + for cell in content_column: + assert cell.data_type == "s", f"{cell.value!r} 被存成了 {cell.data_type!r}(非文本)" + + query_cell = worksheet.cell(row=2, column=2) + assert query_cell.data_type == "s", f"查询 {query_cell.value!r} 被存成了公式" + source_cell = worksheet.cell(row=2, column=5) + assert source_cell.data_type == "s", f"来源 {source_cell.value!r} 被存成了公式" + + +# ── 回归:同一秒内的日志文件名必须互不覆盖 ── +# +# 文件名原先只有到**秒**的时间戳,同一秒内构造的第二个实例会拿到同一个路径并 +# 覆盖前一个文件——实测两个实例的 ``filepath`` 完全相同。召回测试的**唯一产出** +# 就是这份日志,覆盖等于悄悄丢掉一次测试的结果。可达路径:从召回测试菜单返回 +# 主菜单后再次进入(``main.py`` 的菜单循环),或嵌入方在同一秒内构造两个 logger。 + + +def test_excel_loggers_in_the_same_second_get_distinct_paths(tmp_path): + """同一秒内的两个实例必须落在不同文件上,且互不覆盖对方的内容。""" + from src.retrieval_test.excel_logger import ExcelLogger + + first = ExcelLogger(log_dir=str(tmp_path)) + first.log_results( + "第一个实例的查询", + [{"page_content": "第一条", "metadata": {"source": "a.md"}, "score": 0.5}], + ) + second = ExcelLogger(log_dir=str(tmp_path)) + second.log_results( + "第二个实例的查询", + [{"page_content": "第二条", "metadata": {"source": "b.md"}, "score": 0.6}], + ) + + assert first.filepath != second.filepath + assert os.path.exists(first.filepath) + assert os.path.exists(second.filepath) + + # 两边的内容都必须还在——覆盖的话第一个文件里会只剩第二个实例的行。 + from openpyxl import load_workbook + + first_rows = list(load_workbook(first.filepath).active.iter_rows(values_only=True)) + second_rows = list(load_workbook(second.filepath).active.iter_rows(values_only=True)) + + assert any("第一个实例的查询" in str(cell) for row in first_rows for cell in row if cell) + assert not any("第二个实例的查询" in str(cell) for row in first_rows for cell in row if cell) + assert any("第二个实例的查询" in str(cell) for row in second_rows for cell in row if cell) + + +def test_excel_logger_keeps_the_plain_name_when_nothing_collides(tmp_path): + """反向守卫:无冲突时文件名保持原形态,不加多余后缀。""" + import re + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + + assert re.fullmatch(r"recall_test_log_\d{8}_\d{6}\.xlsx", os.path.basename(logger.filepath)) + + +def test_excel_logger_skips_names_taken_by_other_files(tmp_path, monkeypatch): + """已存在的同名文件(例如上一次运行留下的)必须被跳过,而不是被覆盖。 + + 判据是 ``O_CREAT | O_EXCL`` 而不是 ``os.path.exists``:后者是「先查后写」, + 两个进程同时构造时会双双认为路径可用、然后互相覆盖。 + """ + import re + from datetime import datetime + + from src.retrieval_test.excel_logger import ExcelLogger + + # 把时钟钉在固定秒:真实时钟在「预置文件」与 logger 内部第二次取时间之间 + # 跨秒的话,logger 会用新时间戳、不撞预置名,用例在断言上静默变成空转。 + frozen = datetime(2026, 10, 13, 12, 0, 0).astimezone() + + class _FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return frozen if tz is None else frozen.astimezone(tz) + + monkeypatch.setattr("src.retrieval_test.excel_logger.datetime", _FrozenDatetime) + + timestamp = frozen.strftime("%Y%m%d_%H%M%S") + occupied = tmp_path / f"recall_test_log_{timestamp}.xlsx" + occupied.write_bytes(b"PREEXISTING") + + logger = ExcelLogger(log_dir=str(tmp_path)) + + assert logger.filepath != str(occupied) + assert re.fullmatch( + rf"recall_test_log_{timestamp}_\d+\.xlsx", os.path.basename(logger.filepath) + ) + # 已有文件的内容不得被动过。 + assert occupied.read_bytes() == b"PREEXISTING" + + +def test_excel_logger_cleans_up_the_placeholder_when_construction_fails(tmp_path, monkeypatch): + """构造中途失败必须回收已独占创建的占位文件。 + + ``_reserve_log_path`` 先把文件建出来才能独占,若随后建工作簿/写表头失败, + 日志目录里会留下一个 0 字节的 ``.xlsx``——看起来像一次「没有结果的召回测试」。 + """ + from src.retrieval_test.excel_logger import ExcelLogger + + def boom(self): + raise RuntimeError("写表头失败") + + monkeypatch.setattr(ExcelLogger, "_write_header", boom) + + with pytest.raises(RuntimeError, match="写表头失败"): + ExcelLogger(log_dir=str(tmp_path)) + + assert list(tmp_path.iterdir()) == [], "失败后不得留下占位文件" + + +def test_excel_log_keeps_all_content_verbatim(tmp_path): + """收紧不能误伤:内容必须逐字保留,不加前缀也不改写。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + payloads = ["=1+1", "+1+1", "-1+1", "@SUM(1,1)", "普通文本", "含 [/] 的正文"] + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "查询", + [ + {"page_content": text, "metadata": {"source": f"s{i}.md"}, "score": 0.5} + for i, text in enumerate(payloads) + ], + ) + + worksheet = load_workbook(logger.filepath).active + stored = [ + row[6].value + for row in worksheet.iter_rows(min_row=2, max_col=8) + if row[6].value is not None + ] + assert stored == payloads + + +# ── 回归:日志文件必须能被读回(写入成功 ≠ 可读) ── +# +# xlsx 是 XML,两处各自拒绝不同的码位:``openpyxl`` 的 ``check_string()`` 在 +# ``append()`` 期拒绝 C0 控制符;XML 1.0 不允许的码位在 ``save()`` 期被写成非法 +# 字符引用——**写入成功、读回失败**。只处理前者时 ``load_workbook`` 抛 +# ``ParseError``,而 ``log_results`` 的 ``except Exception`` 把它吞成一行日志, +# 用户看到「召回测试完成」而文件打不开。 + + +@pytest.mark.parametrize( + "codepoint,label", + [ + pytest.param(0xD800, "孤立高代理项起", id="surrogate-high-start"), + pytest.param(0xDC80, "孤立低代理项", id="surrogate-low"), + pytest.param(0xDFFF, "孤立高代理项止", id="surrogate-high-end"), + pytest.param(0xFFFE, "非字符 FFFE", id="noncharacter-fffe"), + pytest.param(0xFFFF, "非字符 FFFF", id="noncharacter-ffff"), + ], +) +def test_excel_log_workbook_stays_readable_for_xml_hostile_codepoints(tmp_path, codepoint, label): + """XML 不接受的码位必须被替换掉,否则日志文件打不开。 + + 代理项经 legacy pickle 路径可达(pickle 是二进制格式,不受 UTF-8 约束, + 实测 ``pickle.loads(pickle.dumps('caf\\udce9'))`` 正常往返);``U+FFFE``/ + ``U+FFFF`` 是**合法 UTF-8 码位**,``read_text(encoding="utf-8")`` 与 + ``BasicCleaner`` 的 ``re.sub(r"\\s+", " ")`` 都放行。断言落在 + ``load_workbook`` 能读回上——只断言 ``save()`` 没抛是假绿,因为原实现正是 + 「写入成功、读回失败」。 + """ + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + hostile = chr(codepoint) + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + f"查询{hostile}", + [ + { + "page_content": f"正文{hostile}内容", + "metadata": {"source": f"a{hostile}.md"}, + "score": 0.5, + } + ], + ) + + worksheet = load_workbook(logger.filepath).active + stored = [cell.value for row in worksheet.iter_rows() for cell in row if cell.value is not None] + + # 读得回是硬要求;同时断言内容没被整段吃掉,只有那一个码位被替换。 + assert any("正文" in str(value) and "内容" in str(value) for value in stored) + assert not any(hostile in str(value) for value in stored) + + +def test_excel_log_character_filter_does_not_touch_legal_characters(tmp_path): + """收紧不能误伤:合法码位(含 emoji、CJK、``U+D7FF``/``U+E000`` 边界、 + 合法空白与 ``U+FFFD``)必须逐字保留。 + + 载荷刻意不含 ``\r``:XML 规范要求解析器把行尾规范化为 ``\n``,那是 XML 的 + 性质而非本过滤器所为(``\r`` 单列在下面断言)。 + """ + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + legal = "\t\n 中文 \U0001f600 \ufffd\ud7ff\ue000" + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "查询", + [{"page_content": legal, "metadata": {"source": "a.md"}, "score": 0.5}], + ) + + worksheet = load_workbook(logger.filepath).active + stored = [cell.value for row in worksheet.iter_rows() for cell in row if cell.value is not None] + + assert legal in stored + + +def test_excel_log_carriage_return_is_normalized_by_xml_not_replaced(tmp_path): + """``\r`` 被 XML 规范化成 ``\n``,**不是**被本过滤器替换成 ``\ufffd``。 + + 两者后果不同:规范化只是行尾形态变化(内容仍在),而替换会引入一个用户 + 从未写过的字符。这条断言把两种情形区分开,避免把 XML 的固有行为误当成 + 过滤器的误伤去「修」。 + """ + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "查询", + [{"page_content": "a\rb", "metadata": {"source": "a.md"}, "score": 0.5}], + ) + + worksheet = load_workbook(logger.filepath).active + stored = [cell.value for row in worksheet.iter_rows() for cell in row if cell.value is not None] + + assert "a\nb" in stored + assert not any("\ufffd" in str(value) for value in stored) + + +@pytest.mark.parametrize( + "document,label", + [ + ({"page_content": None, "metadata": {"source": "a.md"}, "score": 0.5}, "内容为 None"), + ({"page_content": ["x"], "metadata": {"source": "a.md"}, "score": 0.5}, "内容为列表"), + ({"page_content": "x", "metadata": None, "score": 0.5}, "元数据为 None"), + ({"page_content": "x", "metadata": ["a"], "score": 0.5}, "元数据为列表"), + ({"page_content": "x", "metadata": {"source": ["a"]}, "score": 0.5}, "来源为列表"), + ({"page_content": "x", "metadata": {"source": "a.md"}, "score": None}, "得分为 None"), + ({"page_content": "x", "metadata": {"source": "a.md"}, "score": "0.5"}, "得分为字符串"), + ("不是映射", "条目本身不是映射"), + ], +) +def test_display_results_survives_malformed_documents(monkeypatch, document, label): + """字段形态异常时展示必须照常完成。 + + 检索结果可以来自任何 ``VectorStoreBase`` 实现——``FaissStore`` 会在载入期 + 校验并归一化,但第三方 store 不会,而抽象基类正是为替换实现而存在的。 + 展示是错误处理之后的最后一环,它本身不该成为新的失败点。 + """ + from src.retrieval_test.core import display_results + + buffer = _console_buffer(monkeypatch) + + display_results("查询", [document]) + + assert buffer.getvalue(), label + + +# ── 回归:Excel 日志必须容忍形态异常的检索结果 ── +# +# ``log_results`` 直接对每个条目做 ``doc.get(...)``/``metadata.get(...)`` 并把 +# 值原样交给 ``openpyxl`` 与 ``json.dumps``。检索结果可以来自任何 +# ``VectorStoreBase`` 实现——``FaissStore`` 归一化,第三方 store 不会,而抽象 +# 基类正是为替换实现而存在的。实测六类形态会抛异常,且都发生在**写日志**这一步: +# +# * ``metadata`` 为 ``None``/列表 → ``AttributeError: 'NoneType' object has no +# attribute 'get'``; +# * 条目本身不是映射 → 同样的 ``AttributeError``; +# * ``page`` 为映射/列表 → ``openpyxl`` 抛 ``ValueError: Cannot convert {...} +# to Excel``; +# * ``semantic_score`` 为 ``set`` → ``json.dumps`` 抛 ``TypeError: Object of +# type set is not JSON serializable``; +# * ``score`` 为映射/列表 → 同样进不了 Excel。 +# +# 日志是**旁路**:它失败不该让一次成功的召回测试以异常收场,也不该丢掉其余 +# 正常条目的记录。判据因此是「不抛异常且正常条目仍被写入」,而不是「异常字段 +# 被写成某个特定值」。 + + +@pytest.mark.parametrize( + "document,label", + [ + ({"page_content": "c", "metadata": None, "score": 0.1}, "元数据为 None"), + ({"page_content": "c", "metadata": ["a"], "score": 0.1}, "元数据为列表"), + ( + {"page_content": "c", "metadata": {"source": "s", "page": {"v": 1}}, "score": 0.1}, + "页码为映射", + ), + ( + {"page_content": "c", "metadata": {"source": "s", "page": [1]}, "score": 0.1}, + "页码为列表", + ), + ({"page_content": "c", "metadata": {"source": "s"}, "score": {"v": 1}}, "得分为映射"), + ({"page_content": "c", "metadata": {"source": "s"}, "score": [1]}, "得分为列表"), + ({"page_content": "c", "metadata": {"source": "s"}, "score": float("nan")}, "得分为 nan"), + ({"page_content": "c", "metadata": {"source": "s"}, "score": float("inf")}, "得分为 inf"), + ( + { + "page_content": "c", + "metadata": {"source": "s"}, + "score": 0.1, + "semantic_score": {1, 2}, + }, + "语义得分为集合", + ), + # 超大整数是**另一类**失败:形态上完全合法(就是 int),值却过不了下游。 + # 两条下游路径的容忍度不同,因此两个量级都要测: + # * ``10**400``(401 位)——``json.dumps`` **能**序列化,但 ``openpyxl`` + # 写单元格前会调 ``isnan(value)``,那里抛 ``OverflowError: int too + # large to convert to float``(双精度上界约 1.8e308,即 309 位)。 + # 所以这一条只有 ``page``(走 openpyxl)会红,``semantic_score``(走 + # json)不会——保留它正是为了钉住「json 侧不该被误收紧」。 + # * ``10**4301``——连 ``str()`` 都抛 ``ValueError: Exceeds the limit + # (4300 digits)``(CPython 的 ``sys.get_int_max_str_digits()``), + # 两条路径都会红。 + # + # 实测修复前这两种量级都让**整个工作簿一个字都没落盘**:异常在 + # ``workbook.save()`` 处被 ``except Exception: logger.exception(...)`` + # 吞掉,用户只看到日志里一行「保存 Excel 日志文件失败」,整批召回测试 + # 记录静默丢失。 + ( + {"page_content": "c", "metadata": {"source": "s", "page": 10**400}, "score": 0.1}, + "页码超出双精度范围", + ), + ( + {"page_content": "c", "metadata": {"source": "s", "page": 10**4301}, "score": 0.1}, + "页码超出整数位数上限", + ), + ( + { + "page_content": "c", + "metadata": {"source": "s"}, + "score": 0.1, + "semantic_score": 10**400, + }, + "语义得分超出双精度范围", + ), + ( + { + "page_content": "c", + "metadata": {"source": "s"}, + "score": 0.1, + "keyword_score": 10**4301, + }, + "关键词得分超出整数位数上限", + ), + ("不是映射", "条目本身不是映射"), + ], +) +def test_excel_log_survives_malformed_documents(tmp_path, document, label): + """形态异常的条目不得让写日志抛异常,且日志必须**真的**落盘。 + + 判据是「能读回且数据行在里面」,不是「文件存在」——后者是假绿:``save()`` + 失败时 ``openpyxl`` 已经写下了部分 ZIP 内容,文件存在且非空,但 + ``load_workbook`` 抛 ``KeyError: There is no item named '[Content_Types].xml' + in the archive``。实测这条路径确实会留下这样一个 2290 字节的坏文件,因此 + 「文件存在」在**未修复**的代码上也成立。 + """ + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + + logger.log_results("查询", [document]) + + # 表头 + 一条数据行。只查「文件存在」会在 save() 失败时假绿。 + worksheet = load_workbook(logger.filepath).active + assert worksheet.max_row >= 2, f"{label}: 数据行未写入" + + +def test_excel_log_still_records_good_rows_next_to_malformed_ones(tmp_path): + """一条坏条目不得带走整批记录——其余条目照旧写入。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "查询", + [ + {"page_content": "第一条", "metadata": {"source": "a.md"}, "score": 0.5}, + {"page_content": "坏条目", "metadata": None, "score": 0.4}, + {"page_content": "第三条", "metadata": {"source": "c.md"}, "score": 0.3}, + ], + ) + + worksheet = load_workbook(logger.filepath).active + stored = [ + row[6].value + for row in worksheet.iter_rows(min_row=2, max_col=8) + if row[6].value is not None + ] + assert "第一条" in stored + assert "第三条" in stored + + +# ── 回归:``log_results`` 的 ``query`` 列必须与其余列同判据 ── +# +# ``query`` 是 ``log_results`` 里唯一没走收敛的列:其余列分别经 +# ``document_*``/``_cell_value``/``_json_safe``,而 ``query`` 直接进 ``row``。 +# 实测 ``query=10**4301``(位数超限的整数)时 ``openpyxl`` 在 ``save()`` 期对 +# 每个数值调 ``safe_string`` -> ``isnan(value)``,抛 +# ``OverflowError: int too large to convert to float``。后果正是 ``_cell_value`` +# 的 docstring 描述的那一种:``save`` 外的 ``except Exception`` 把它吞成一行 +# 日志,整个工作簿**一个字都没落盘**——同一次查询里完全正常的结果一并丢失, +# 用户看到「召回测试完成」而日志文件是坏的。 + + +def test_excel_log_survives_huge_integer_query(tmp_path): + """``query`` 是位数超限的整数时,整批结果仍必须落盘。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + 10**4301, + [ + {"page_content": "正文1", "metadata": {"source": "s1"}, "score": 0.9}, + {"page_content": "正文2", "metadata": {"source": "s2"}, "score": 0.8}, + {"page_content": "正文3", "metadata": {"source": "s3"}, "score": 0.7}, + ], + ) + + worksheet = load_workbook(logger.filepath).active + # 表头 + 3 条结果。修复前 save 中途失败,文件连 [Content_Types].xml 都没有, + # load_workbook 会直接抛 KeyError。 + assert worksheet.max_row == 4, f"期望表头 + 3 条结果,实际 {worksheet.max_row} 行" + # query 列降级成量级描述,而不是抛异常。 + assert worksheet.cell(row=2, column=2).value + + +def test_excel_log_keeps_string_query_verbatim(tmp_path): + """收紧不能误伤:正常字符串 query 逐字落盘。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "如何清除浏览器缓存?", + [{"page_content": "正文", "metadata": {"source": "s"}, "score": 0.5}], + ) + + worksheet = load_workbook(logger.filepath).active + assert worksheet.cell(row=2, column=2).value == "如何清除浏览器缓存?" + + +# ── 回归:log_results 对非列表的 results 静默写出垃圾行 ── +# +# ``log_results(query, results)`` 声明 ``results: list[dict]``,但它是**公开 +# 方法**,且本模块的既有契约是「日志是旁路、失败不该丢记录」(见 ``_cell_value`` +# 与 ``query`` 的收敛说明)。实测非列表取值有两种收场,都不在契约内: +# +# * ``results = "abc"`` —— 字符串是合法可迭代对象,``for i, doc in +# enumerate(results)`` 逐**字符**迭代,每个字符经 +# ``document_*`` 助手收敛成默认值,于是写出 3 条内容与来源都是占位符的 +# 「结果」行。用户看到日志里写着 3 条命中,而实际一次检索返回的是字符串。 +# * ``results = 5`` / ``True`` / ``1.5`` —— 抛裸 ``TypeError: 'int' object is +# not iterable``,而 ``log_results`` 的调用点位于 ``try`` **之外** +# (见 ``_append_text_row`` 的说明),异常直接冒到 +# ``run_retrieval_test_async`` 的 ``except Exception``,一次**成功**的召回被 +# 显示成「召回测试出错」。 +# +# 形态判据与 ``display_results`` 一致(那里 ``if not documents`` 后直接迭代) +# ——但那里只判空、不判类型,因此这里按「非列表一律视为无结果」收敛:写入一行 +# 「无结果」比写出三条占位垃圾诚实。 + + +@pytest.mark.parametrize( + "results", + [ + pytest.param("abc", id="str"), + pytest.param(5, id="int"), + pytest.param(True, id="bool"), + pytest.param(1.5, id="float"), + ], +) +def test_excel_logger_treats_non_list_results_as_empty(tmp_path, results): + """非列表 ``results`` 不得逐字符迭代出垃圾行,也不得抛裸 ``TypeError``。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results("查询", results) + + rows = list(load_workbook(logger.filepath).active.iter_rows(values_only=True)) + # 表头 + 一行「无结果」,不得出现逐字符生成的伪结果行。 + assert len(rows) == 2, rows + assert rows[1][4] == "无结果" + + +def test_excel_logger_still_logs_real_result_lists(tmp_path): + """收紧不能误伤:正常的文档列表照常逐条落盘。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "查询", + [ + {"page_content": "第一条", "metadata": {"source": "a.md"}, "score": 0.5}, + {"page_content": "第二条", "metadata": {"source": "b.md"}, "score": 0.6}, + ], + ) + + rows = list(load_workbook(logger.filepath).active.iter_rows(values_only=True)) + assert len(rows) == 3 + assert {row[6] for row in rows[1:]} == {"第一条", "第二条"} + + +# ── 回归:display_results 对非列表 documents 静默渲染伪结果 ── +# +# ``display_results`` 与 ``ExcelLogger.log_results`` 同处、同类(两者都在召回 +# 测试主循环里被同一个 ``documents`` 调用,且本文件已给后者补了判据)。实测: +# * ``documents="abc"`` —— 字符串是合法可迭代对象,逐**字符**迭代后每个字符 +# 经 ``document_*`` 助手收敛成占位符,于是渲染出 3 行内容与来源都是 ``N/A`` +# 的伪结果表格。用户看到「召回了 3 条」,而实际返回的是字符串。 +# * ``documents=5`` —— 抛裸 ``TypeError: 'int' object is not iterable``。 + + +@pytest.mark.parametrize( + "documents", + [ + pytest.param("abc", id="str"), + pytest.param(5, id="int"), + pytest.param(True, id="bool"), + ], +) +def test_display_results_treats_non_list_documents_as_empty(monkeypatch, documents): + """非列表 ``documents`` 必须走「无结果」分支,不得逐字符渲染伪结果。""" + from src.retrieval_test.core import display_results + + buffer = _console_buffer(monkeypatch) + + display_results("查询", documents) + + rendered = buffer.getvalue() + assert "无结果" in rendered, rendered + assert "N/A" not in rendered, rendered + + +def test_display_results_still_renders_real_document_lists(monkeypatch): + """收紧不能误伤:正常的文档列表照常逐条渲染。""" + from src.retrieval_test.core import display_results + + buffer = _console_buffer(monkeypatch) + + display_results( + "查询", + [{"page_content": "第一条", "metadata": {"source": "a.md"}, "score": 0.9}], + ) + + rendered = buffer.getvalue() + assert "第一条" in rendered + assert "无结果" not in rendered + + +def test_excel_logger_reports_exhausted_name_attempts(tmp_path, monkeypatch): + """后缀耗尽时必须报 ``RuntimeError``,不能静默返回一个未创建的路径。 + + 删除守卫后 ``_reserve_log_path`` 在 ``for`` 循环结束后隐式返回 ``None``, + 调用方拿到 ``None`` 当路径用——崩在更远处,且与「同一秒内日志太多」 + 这个真实原因完全无关。``_MAX_LOG_NAME_ATTEMPTS`` 有 100,全量预置不现实, + 这里把它收到 2 再预置两个候选文件。 + """ + from src.retrieval_test import excel_logger + from src.retrieval_test.excel_logger import ExcelLogger + + monkeypatch.setattr(excel_logger, "_MAX_LOG_NAME_ATTEMPTS", 2) + timestamp = "20260101_000000" + (tmp_path / f"recall_test_log_{timestamp}.xlsx").write_bytes(b"taken-0") + (tmp_path / f"recall_test_log_{timestamp}_1.xlsx").write_bytes(b"taken-1") + + with pytest.raises(RuntimeError, match="无法再创建新文件"): + ExcelLogger._reserve_log_path(str(tmp_path), timestamp) + + +def test_run_retrieval_test_async_exits_cleanly_on_eof(monkeypatch): + """``prompt_async`` 抛 ``EOFError`` 时干净退出,而不是冒泡。 + + ``except (KeyboardInterrupt, EOFError)`` 的 ``EOFError`` 此前无测试触发—— + 收窄成 ``except KeyboardInterrupt`` 后,非交互输入(管道、CI)下 EOF + 让测试循环以未捕获异常终止。 + """ + + class EofPromptSession: + async def prompt_async(self, *_args, **_kwargs): + raise EOFError() + + monkeypatch.setattr( + "src.retrieval_test.core.PromptSession", + lambda: EofPromptSession(), + ) + monkeypatch.setattr( + "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", + lambda: object(), + ) + monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) + + asyncio.run(run_retrieval_test_async()) + + +def test_excel_log_drops_none_scores_from_all_scores_column(tmp_path): + """``all_scores`` 列必须过滤掉 ``None`` 分数(``v is not None`` 不是 ``is None``)。 + + ``is None`` 变异后字典推导只保留**值为 None** 的分数,真实的 ``score`` + 被丢掉、``semantic_score``/``keyword_score`` 反而以 ``null`` 落盘—— + 日志里再也看不到本次检索的实际得分。 + """ + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results("q", [{"content": "c", "source": "s", "score": 0.5}]) + + worksheet = load_workbook(logger.filepath).active + row = next(worksheet.iter_rows(min_row=2, max_col=8, values_only=True)) + assert row[7] == '{"score": 0.5}' + + +def test_quit_command_exits_before_running_retrieval(monkeypatch): + """``/quit`` 必须直接退出循环,不能当作查询送进检索(``==`` 不是 ``!=``)。 + + ``==``→``!=`` 变异后判据反转:``/quit`` 不退出并被送去检索(实测 + ``retrieve`` 收到 ``"/quit"``),而其它任何输入都立即退出。 + """ + calls = [] + + class RecordingService: + def __init__(self, *_args, **_kwargs): + return None + + async def retrieve(self, query, *_args, **_kwargs): + calls.append(query) + return [] + + class QuitOnceSession: + def __init__(self): + self._first = True + + async def prompt_async(self, *_args, **_kwargs): + if self._first: + self._first = False + return "/quit" + raise KeyboardInterrupt + + monkeypatch.setattr("src.retrieval_test.core.PromptSession", QuitOnceSession) + monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) + monkeypatch.setattr("src.retrieval_test.core.RetrievalService", RecordingService) + monkeypatch.setattr("src.retrieval_test.core.EmbeddingService", lambda *_a, **_k: object()) + monkeypatch.setattr( + "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", + lambda: object(), + ) + monkeypatch.setattr("src.retrieval_test.core.console.print", lambda *a, **k: None) + + asyncio.run(run_retrieval_test_async()) + + assert calls == [] + + +def test_display_results_renders_the_column_header_row(monkeypatch): + """召回测试表必须渲染**列头**(``show_header=True``)。 + + ``show_header`` 反向成 ``False`` 后 rich 不再输出列头行,表格只剩数据行: + 第一列的排名与第四列的小数失去列名(``排名``/``内容``/``来源``/``分数``), + 用户看到四列数据却无从判断哪列是分数。既有用例只断言了内容与标记形态。 + """ + from src.retrieval_test.core import display_results + + buffer = _console_buffer(monkeypatch) + + display_results( + "查询", + [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.5}], + ) + + rendered = buffer.getvalue() + for column in ("排名", "内容", "来源", "分数"): + assert column in rendered, f"列头 {column!r} 未渲染:{rendered!r}" + + +# ── 回归:召回结果表的排名必须从 1 起(``index + 1``,不是 ``index - 1``)── +# +# ``arith`` 变异面扫出的缺口:``display_results`` 的 ``table.add_row(str(index + 1), +# ...)`` 决定第一列的排名号,而既有用例只断言正文/来源/查询文本,从未断言 +# 排名列——把 ``+ 1`` 变异成 ``- 1``(首条排名变 ``0`` 或负数)后整套测试仍全绿。 +# 排名是用户读结果表的定位依据,必须钉住。 +def test_display_results_ranks_start_at_one(monkeypatch): + """两条结果的排名分别是 ``"1"``、``"2"``(``index + 1`` 的正解)。 + + 直接捕获 ``Table.add_row`` 的实参,逐行断言排名单元格——比在渲染输出里 + ``in`` 匹配可靠(``"1"`` 会命中正文/分数里的任意一位数字,``+1``→``-1`` + 变异后那些字符仍在,断言不会红)。 + """ + from rich.table import Table + + from src.retrieval_test.core import display_results + + _console_buffer(monkeypatch) + added: list[tuple] = [] + original = Table.add_row + + def _capture(self, *args, **kwargs): + added.append(args) + return original(self, *args, **kwargs) + + monkeypatch.setattr(Table, "add_row", _capture) + + display_results( + "任意查询", + [ + {"page_content": "第一条", "metadata": {"source": "a.md"}, "score": 0.9}, + {"page_content": "第二条", "metadata": {"source": "b.md"}, "score": 0.5}, + ], + ) + + assert added, "没有向结果表添加任何行" + ranks = [str(row[0]) for row in added] + assert ranks == ["1", "2"], ranks + + +# ── 回归:Excel 日志的排名列必须从 1 起(``i + 1``,不是 ``i - 1``)── +# +# 与 ``display_results`` 的排名同源缺口:``ExcelLogger.log_results`` 的 +# ``rank = i + 1`` 写进第 3 列,而既有 Excel 用例只断言第 2/5/6 列 +# (查询/来源/内容),从未断言排名列——把 ``+ 1`` 变异成 ``- 1`` 后整套测试 +# 仍全绿,日志里的排名会从 ``-1``/``0`` 起,与召回测试界面的 ``1`` 起不一致。 +def test_excel_log_ranks_start_at_one(tmp_path): + """两条结果的排名列分别是 ``1``、``2``。""" + from openpyxl import load_workbook + + from src.retrieval_test.excel_logger import ExcelLogger + + logger = ExcelLogger(log_dir=str(tmp_path)) + logger.log_results( + "任意查询", + [ + {"page_content": "第一条", "metadata": {"source": "a.md"}, "score": 0.9}, + {"page_content": "第二条", "metadata": {"source": "b.md"}, "score": 0.5}, + ], + ) + + worksheet = load_workbook(logger.filepath).active + ranks = [worksheet.cell(row=row, column=3).value for row in (2, 3)] + assert ranks == [1, 2], ranks diff --git a/tests/runtime/test_contracts.py b/tests/runtime/test_contracts.py index d010f5f..0ed7c2b 100644 --- a/tests/runtime/test_contracts.py +++ b/tests/runtime/test_contracts.py @@ -6,9 +6,15 @@ 也不截断,按 10 亿条分配(实测 10**6 时就已分配满槽位并耗时 1.56s)。 """ +import pickle + import pytest -from src.runtime.contracts import SessionConfig, build_session_config +from src.runtime.contracts import ( + KnowledgeSnapshotManifest, + SessionConfig, + build_session_config, +) from src.utils.config import ( _MAX_RETRIEVAL_MULTIPLIER, _MAX_RETRIEVAL_TOP_K, @@ -53,17 +59,311 @@ def test_session_config_rejects_illegal_values(session_config, field_name, bad_v session_config[field_name] = bad_value +# ── 回归:不可哈希取值让 ``ValueError`` 契约漏成 ``TypeError`` ── +# +# ``_validate_fusion_strategy`` 原写 ``value not in {"rrf", "weighted"}``。集合 +# 成员判断对不可哈希的值抛 ``TypeError: unhashable type``,而 ``SessionConfig`` +# 的契约是 ``ValueError``——``__setitem__`` 与构造器都据此分类,调用方的 +# ``except ValueError`` 接不住 ``TypeError``。输入可达:配置来自 TOML, +# ``hybrid_fusion_strategy = ["rrf"]`` 是合法的 TOML 数组。 +# +# 同文件里的 ``retrieval_method``/``rerank_enabled`` 早已显式判类型,只有这一 +# 条漏了——所以本用例按字段参数化,逐个断言,而不是只测出问题的那一个。 +@pytest.mark.parametrize( + ("field_name", "bad_value"), + [ + ("hybrid_fusion_strategy", ["rrf"]), + ("hybrid_fusion_strategy", {"strategy": "rrf"}), + ("hybrid_fusion_strategy", {"rrf"}), + ("retrieval_method", ["向量检索"]), + ("retrieval_method", {"a": 1}), + ("rerank_enabled", ["yes"]), + ], + ids=[ + "fusion-list", + "fusion-dict", + "fusion-set", + "method-list", + "method-dict", + "rerank-list", + ], +) +def test_session_config_unhashable_values_raise_value_error(session_config, field_name, bad_value): + """不可哈希的取值必须收敛成 ``ValueError``,不能漏出 ``TypeError``。 + + 断言异常类型而不是消息:这里守的是「调用方的 ``except ValueError`` 能不能 + 接住」,消息措辞无关紧要。 + """ + with pytest.raises(ValueError): + session_config[field_name] = bad_value + + with pytest.raises(ValueError): + SessionConfig(**{**session_config.to_dict(), field_name: bad_value}) + + +def test_session_config_rejects_both_weights_zero(session_config): + """两个融合权重不得同时为零。 + + 同时为零时 ``weighted`` 融合的每个文档得分都是 0.0,再被 + ``chat_score_threshold`` 全部过滤——检索静默返回空结果且无任何报错, + 把配置错误伪装成检索质量差。``Settings`` 启动时校验过,但 + ``SessionConfig`` 是运行期独立入口,必须自己守住。 + """ + session_config["vector_weight"] = 0.0 + with pytest.raises(ValueError, match="不能同时为 0"): + session_config["keyword_weight"] = 0.0 + # 顺序反过来也要拒绝。 + session_config["vector_weight"] = 0.5 + session_config["keyword_weight"] = 0.0 + with pytest.raises(ValueError, match="不能同时为 0"): + session_config["vector_weight"] = 0.0 + + +def test_session_config_constructor_rejects_both_weights_zero(session_config): + """构造器同样要拒绝,否则 ``SessionConfig(...)`` 是绕过守卫的口子。""" + payload = {**session_config.to_dict(), "vector_weight": 0.0, "keyword_weight": 0.0} + with pytest.raises(ValueError, match="不能同时为 0"): + SessionConfig(**payload) + + def test_session_config_rejects_unknown_field(session_config): """拼错字段名应报错,而不是静默新增一个属性。""" with pytest.raises(KeyError, match="没有字段"): session_config["不存在的字段"] = 1 +# ── 回归:字段名校验与兄弟字段取值不能漏出非 ValueError ── +# +# ``key not in self.to_dict()`` 对不可哈希的 key 抛 ``TypeError: unhashable +# type``,而 ``f"{key!r}"`` 对位数超限的整数抛 ``ValueError: Exceeds the limit`` +# ——两条都与「字段名写错了」无关。同文件的 ``_validate_fusion_strategy`` 早已 +# 认领同一类线索(见其 docstring),只有字段名这一处漏了。 +# +# 兄弟字段的取值可能**没经过 validator**:``SessionConfig`` 是公开类,普通属性 +# 没有 ``__setattr__`` 守卫,嵌入方可以直接 ``cfg.vector_weight = 10**400``。 +# 于是组合校验里的 ``float(...)``/``int(...)`` 分别抛 ``OverflowError``(它是 +# ``ArithmeticError`` 子类,**不是** ``ValueError``)与 ``ValueError``—— +# 调用方按 ``ValueError`` 写的分类接不住前者。 + + +@pytest.mark.parametrize( + ("label", "bad_key"), + [ + ("不可哈希的列表", [1, 2]), + ("不可哈希的字典", {"a": 1}), + ("位数超限的整数", 10**4301), + ("None", None), + ], + ids=["list", "dict", "huge-int", "none"], +) +def test_session_config_setitem_rejects_malformed_field_names(session_config, label, bad_key): + """字段名形态非法时必须报 ``ValueError``,不能漏出 ``TypeError`` 或位数上限错误。""" + with pytest.raises(ValueError) as excinfo: + session_config[bad_key] = 1 + + message = str(excinfo.value) + assert "字段名必须是字符串" in message, f"{label}: {message}" + assert "Exceeds the limit" not in message, f"{label}: {message}" + + +@pytest.mark.parametrize( + ("label", "bad_key"), + [ + ("位数超限的整数", 10**4301), + ("None", None), + ], + ids=["huge-int", "none"], +) +def test_session_config_set_many_rejects_malformed_field_names(session_config, label, bad_key): + """``set_many`` 走同一个字段名校验,不能只修 ``__setitem__``。 + + 这里不测不可哈希的 key(列表/字典):``set_many`` 收的是 ``Mapping``, + 构造 ``{[1, 2]: 1}`` 这一步在调用方就抛 ``TypeError``,够不到被测代码。 + """ + with pytest.raises(ValueError) as excinfo: + session_config.set_many({bad_key: 1}) + + assert "字段名必须是字符串" in str(excinfo.value), label + + +def test_session_config_setitem_still_rejects_unknown_string_fields(session_config): + """收紧不能误伤:未知的**字符串**字段名仍然报 ``KeyError``。""" + with pytest.raises(KeyError, match="没有字段"): + session_config["不存在的字段"] = 1 + + +# ── 回归:``__getitem__`` 必须与 ``__setitem__`` 同判据 ── +# +# 本类声明 ``MutableMapping[str, Any]``,而 ``__setitem__`` 会经 +# ``_require_known_field`` 拒绝未知字段(``KeyError``)与非法形态 +# (``ValueError``)。读取侧此前直接写 ``getattr(self, key)``,对同一 key 抛的 +# 是 ``AttributeError``——判据只做了一半。 +# +# 后果不是「消息不好看」:``in``/``.get(k, default)``/``.pop(k, default)``/ +# ``.setdefault`` 这些 ``MutableMapping`` 的混入实现**只接 ``KeyError``**。实测 +# ``"nope" in cfg`` 与 ``cfg.get("nope", "d")`` 都抛 ``AttributeError``,而它们 +# 本该分别返回 ``False`` 与 ``"d"``。另外 ``getattr`` 会把 ``__class__``/ +# ``__dict__``/``to_dict`` 这类非字段属性一并返回(实测 ``cfg["to_dict"]`` 拿到 +# 绑定方法),而写入侧对同名 key 抛 ``KeyError``。 + + +@pytest.mark.parametrize( + ("label", "bad_key"), + [ + ("未知字符串字段", "不存在的字段"), + ("非字段属性名", "to_dict"), + ("dunder", "__class__"), + ], + ids=["unknown", "method-name", "dunder"], +) +def test_session_config_getitem_rejects_unknown_fields(session_config, label, bad_key): + """未知字段名(含非字段属性)在读取侧同样报 ``KeyError``。""" + with pytest.raises(KeyError, match="没有字段"): + session_config[bad_key] + + +@pytest.mark.parametrize( + ("label", "bad_key"), + [ + ("不可哈希的列表", [1, 2]), + ("位数超限的整数", 10**4301), + ("None", None), + ], + ids=["list", "huge-int", "none"], +) +def test_session_config_getitem_rejects_malformed_field_names(session_config, label, bad_key): + """字段名形态非法时必须报 ``KeyError``,不能漏出 ``TypeError`` 或位数上限错误。 + + 读路径用 ``KeyError`` 而不是 ``ValueError``:``Mapping`` 协议要求未知键抛 + ``KeyError``,而 ``_collections_abc`` 的 ``__contains__``/``get`` 只接 + ``KeyError``——非字符串键抛 ``ValueError`` 会让 ``5 in cfg`` 直接炸,而不是 + 返回 ``False``。写路径(``__setitem__``)保持 ``ValueError``,见下一条用例。 + """ + with pytest.raises(KeyError) as excinfo: + session_config[bad_key] + + message = str(excinfo.value) + assert "没有字段" in message, f"{label}: {message}" + assert "Exceeds the limit" not in message, f"{label}: {message}" + + +@pytest.mark.parametrize( + ("label", "bad_key"), + [ + ("不可哈希的列表", [1, 2]), + ("位数超限的整数", 10**4301), + ("None", None), + ("整数", 5), + ], + ids=["list", "huge-int", "none", "int"], +) +def test_session_config_setitem_keeps_value_error_for_malformed_names( + session_config, label, bad_key +): + """写入侧对非法形态仍报 ``ValueError``(参数错误),与读路径的 ``KeyError`` 分离。""" + with pytest.raises(ValueError) as excinfo: + session_config[bad_key] = 1 + + message = str(excinfo.value) + assert "字段名必须是字符串" in message, f"{label}: {message}" + assert "Exceeds the limit" not in message, f"{label}: {message}" + + +@pytest.mark.parametrize( + ("label", "bad_key"), + [ + ("不可哈希的列表", [1, 2]), + ("位数超限的整数", 10**4301), + ("None", None), + ("整数", 5), + ], + ids=["list", "huge-int", "none", "int"], +) +def test_session_config_membership_probe_never_raises_for_malformed_names( + session_config, label, bad_key +): + """``in``/``get`` 探测非字符串键必须返回「不存在」而不是抛异常。 + + 这是上一条修复的**目的**:``10**4301 in cfg`` 此前在拼 ``ValueError`` 消息时 + 二次撞上 ``Exceeds the limit``,读一个键把调用炸死。 + """ + assert bad_key not in session_config, label + assert session_config.get(bad_key, "dflt") == "dflt", label + + +def test_session_config_mapping_protocol_works_for_unknown_keys(session_config): + """``MutableMapping`` 的混入实现(只接 ``KeyError``)必须照常工作。""" + assert "不存在的字段" not in session_config + assert session_config.get("不存在的字段", "dflt") == "dflt" + assert session_config.pop("不存在的字段", "dflt") == "dflt" + assert "top_k" in session_config + assert session_config.get("top_k") == session_config["top_k"] + + +def test_session_config_getitem_still_returns_known_fields(session_config): + """收紧不能误伤:已知字段照常可取,且与属性访问一致。""" + assert session_config["top_k"] == session_config.top_k + assert session_config["chat_temperature"] == session_config.chat_temperature + + +@pytest.mark.parametrize( + ("label", "attribute", "value"), + [ + ("权重超出双精度", "vector_weight", 10**400), + ("权重是 inf", "vector_weight", float("inf")), + ("权重是 nan", "vector_weight", float("nan")), + ("top_k 是 inf", "top_k", float("inf")), + ("top_k 是 nan", "top_k", float("nan")), + ], + ids=["weight-huge", "weight-inf", "weight-nan", "topk-inf", "topk-nan"], +) +def test_session_config_combination_check_survives_direct_attribute_writes( + session_config, label, attribute, value +): + """属性直写绕过 validator 后,组合校验必须仍报 ``ValueError``。 + + ``SessionConfig`` 是公开类,普通属性没有 ``__setattr__`` 守卫——嵌入方 + (或携带超大整数的 pickle 快照)可以直接写 ``cfg.vector_weight = 10**400``。 + 随后任一次 ``set_many``/``__setitem__`` 都会读到该兄弟字段并做运算: + ``float(10**400)`` 抛 ``OverflowError``(``ArithmeticError`` 子类, + **不是** ``ValueError``)、``int(inf)`` 同样抛 ``OverflowError``、 + ``int(nan)`` 抛 ``ValueError``。前两条会穿透只接 ``ValueError`` 的调用方。 + """ + object.__setattr__(session_config, attribute, value) + + with pytest.raises(ValueError) as excinfo: + session_config.set_many({"retrieval_candidate_multiplier": 5}) + + message = str(excinfo.value) + assert attribute in message, f"{label}: {message}" + assert "Exceeds the limit" not in message, f"{label}: {message}" + + +def test_candidate_pool_message_survives_huge_integer_siblings(session_config): + """``top_k`` 被直写成位数超限整数时,组合校验的消息自己不能抛。 + + 消息里插值 ``{candidates}``(= ``top_k * multiplier``)撞上 CPython 的整数 + 位数上限,于是在**正要报「候选规模过大」**的那一步抛 ``ValueError: + Exceeds the limit``——类型对但内容与字段无关。 + """ + object.__setattr__(session_config, "top_k", 10**4301) + + with pytest.raises(ValueError) as excinfo: + session_config.set_many({"retrieval_candidate_multiplier": 5}) + + message = str(excinfo.value) + assert "不能超过" in message + assert "Exceeds the limit" not in message + + @pytest.mark.parametrize( "field_name,good_value,expected", [ ("top_k", 20, 20), - ("top_k", _MAX_RETRIEVAL_TOP_K, _MAX_RETRIEVAL_TOP_K), + # 单字段上界:fixture 的倍率是 3,而乘积上界是 2000,直接用 + # _MAX_RETRIEVAL_TOP_K 会超界——那正是组合校验要拦的情形(另有用例 + # 覆盖),这里取「上界内且乘积不越界」的值。 + ("top_k", _MAX_RETRIEVAL_TOP_K // 4, _MAX_RETRIEVAL_TOP_K // 4), ("retrieval_candidate_multiplier", _MAX_RETRIEVAL_MULTIPLIER, _MAX_RETRIEVAL_MULTIPLIER), ("vector_weight", 0.3, 0.3), ("score_threshold", 0.0, 0.0), @@ -115,3 +415,466 @@ def test_session_config_to_dict_still_reflects_writes(session_config): """写入后 ``to_dict`` 必须能看到新值(否则 UI 显示会与实际不符)。""" session_config["top_k"] = 42 assert session_config.to_dict()["top_k"] == 42 + + +# ── 回归:构造器必须走与 __setitem__ 同一组校验器 ── + + +@pytest.mark.parametrize( + "vector_weight,keyword_weight", + [ + (float("nan"), 1.0), + (float("inf"), 1.0), + (float("-inf"), 1.0), + (-1.0, 1.0), + (2.0, 1.0), + (1.0, float("nan")), + ], +) +def test_session_config_constructor_rejects_invalid_weights( + session_config, vector_weight, keyword_weight +): + """``SessionConfig(...)`` 不能绕过 ``__setitem__`` 的逐字段校验。 + + 原构造器只判「两个权重是否同时为零」,放过了 ``nan``/``inf`` 与越界值。 + 实测 ``SessionConfig(vector_weight=float("nan"), ...)`` 被接受, + ``HybridReranker`` 随后算出 ``nan`` 得分:排序结果不确定,且 ``nan`` 与 + 分数阈值的比较恒为 False,整批结果被静默丢弃。 + """ + payload = { + **session_config.to_dict(), + "vector_weight": vector_weight, + "keyword_weight": keyword_weight, + } + + with pytest.raises(ValueError, match="vector_weight|keyword_weight"): + SessionConfig(**payload) + + +def test_session_config_constructor_accepts_valid_weights(session_config): + """收紧不能误伤:合法权重照常接受。""" + payload = {**session_config.to_dict(), "vector_weight": 0.7, "keyword_weight": 0.3} + + config = SessionConfig(**payload) + + assert config["vector_weight"] == 0.7 + assert config["keyword_weight"] == 0.3 + + +# 构造器绕过校验是**整类**问题,不只是权重:实测修复前它对 top_k、score_threshold、 +# chat_temperature 等每一个受校验字段都照单全收。这里逐字段比对两条入口的结论, +# 避免以后新增字段时只补了 __setitem__ 那一侧。 + + +@pytest.mark.parametrize( + "field_name,bad_value", + [ + ("top_k", 10**9), + ("top_k", 0), + ("top_k", True), + ("top_k", 5.5), + ("retrieval_candidate_multiplier", 10**6), + ("score_threshold", float("nan")), + ("score_threshold", -999), + ("chat_temperature", float("nan")), + ("chat_temperature", -1.0), + ("hybrid_fusion_strategy", "bogus"), + ("rerank_enabled", "yes"), + ], +) +def test_session_config_constructor_matches_setitem_rejection( + session_config, field_name, bad_value +): + """构造器与 ``__setitem__`` 对同一非法值必须都拒绝。 + + 只测其中一条入口会漏掉另一条的绕过口子——``top_k=10**9`` 就是先在 + ``__setitem__`` 上修好、而构造器仍然放行,一路走到 ``faiss_index.search()``, + FAISS 既不报错也不截断,按 10 亿条分配。 + """ + with pytest.raises(ValueError): + SessionConfig(**{**session_config.to_dict(), field_name: bad_value}) + + writable = SessionConfig(**session_config.to_dict()) + with pytest.raises(ValueError): + writable[field_name] = bad_value + + +def test_session_config_constructor_and_setitem_agree_on_legal_values(session_config): + """收紧不能误伤:合法取值在两条入口上都必须通过。""" + payload = session_config.to_dict() + + from_constructor = SessionConfig(**payload) + writable = SessionConfig(**payload) + for field_name, value in payload.items(): + writable[field_name] = value + + assert from_constructor.to_dict() == writable.to_dict() + + +# ── 回归:候选规模的上界必须落在 top_k × multiplier 的乘积上 ── +# +# 两个字段各自有上界(top_k ≤ 2000、multiplier ≤ 100),但真正送进 +# faiss_index.search() 的是乘积,而 FAISS 对超过索引条数的 k 既不报错也不 +# 截断,按 k 全量分配。实测 dim=1536、k=10**6 时峰值 RSS 涨约 35 GB。 + + +def test_session_config_rejects_oversized_candidate_pool(session_config): + """两个字段各自合法、乘积越界时必须拒绝。""" + payload = session_config.to_dict() + + with pytest.raises(ValueError, match="top_k × retrieval_candidate_multiplier"): + SessionConfig(**{**payload, "top_k": 2000, "retrieval_candidate_multiplier": 100}) + + +def test_session_config_setitem_rejects_oversized_candidate_pool(session_config): + """``__setitem__`` 改任一个字段后都要按新组合判一次。""" + config = SessionConfig(**{**session_config.to_dict(), "top_k": 20}) + config["retrieval_candidate_multiplier"] = 100 # 20 × 100 = 2000,边界内 + + with pytest.raises(ValueError, match="top_k × retrieval_candidate_multiplier"): + config["top_k"] = 2000 # 2000 × 100 越界 + + +def test_session_config_accepts_candidate_pool_at_the_bound(session_config): + """收紧不能误伤:恰好等于上界的组合必须接受。""" + config = SessionConfig( + **{**session_config.to_dict(), "top_k": 2000, "retrieval_candidate_multiplier": 1} + ) + + assert config["top_k"] == 2000 + + +# ── 回归:manifest 的 TOML 序列化必须能原样读回 ── +# +# ``to_toml`` 用 ``f'key = "{value}"'`` 直接拼字符串,没有做 TOML 字符串转义, +# 而这些值都来自 ``config.toml``/``.env``:``embedding_model`` 可以是自建网关的 +# 模型路径(``models\qwen-7b``)、``chunk_mode`` 与 ``snapshot_id`` 也由调用方决定。 +# 实测模型名含反斜杠时 ``write_manifest`` 正常落盘,``load_manifest`` 抛 +# ``TOMLDecodeError: Unescaped '\' in a string``——快照写得出、读不回,而报错 +# 现场离真正的原因(写入时未转义)很远;含引号或换行的值则连落盘的 TOML 都 +# 不可解析。同一份 manifest 在内存里是好的,落盘一圈就坏了。 + + +@pytest.mark.parametrize( + "value", + [ + r"models\qwen-7b", # 反斜杠:自建网关的模型路径写法 + 'model"name', # 双引号 + "model'name", # 单引号 + "model\nname", # 换行 + "model\tname", # 制表符 + "model\x00name", # 控制字符 + "model[name]", # 方括号 + "model#name", # 井号(TOML 注释起始) + "model = name", # 等号 + "普通模型名", # 中文 + ], +) +def test_manifest_to_toml_round_trips_arbitrary_values(value): + """任意取值都必须能写进 TOML 并原样读回。""" + import tomllib + + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-roundtrip", + store_type="faiss", + embedding_provider="openai", + embedding_model=value, + chunk_mode="standard", + source_digest="digest", + document_count=1, + chunk_count=1, + ) + + parsed = tomllib.loads(manifest.to_toml()) + + assert parsed["embedding_model"] == value + + +@pytest.mark.parametrize( + "field_name", + ["snapshot_id", "store_type", "embedding_provider", "chunk_mode", "source_digest"], +) +def test_manifest_to_toml_round_trips_arbitrary_values_in_every_string_field(field_name): + """每个字符串字段都要转义,不能只修被报告的那一个。""" + import tomllib + + hostile = 'v"al\\ue\n[x]' + payload = { + "snapshot_id": "kb-roundtrip", + "store_type": "faiss", + "embedding_provider": "openai", + "embedding_model": "m", + "chunk_mode": "standard", + "source_digest": "digest", + "document_count": 1, + "chunk_count": 1, + field_name: hostile, + } + + parsed = tomllib.loads(KnowledgeSnapshotManifest.create(**payload).to_toml()) + + assert parsed[field_name] == hostile + + +def test_manifest_to_toml_keeps_counts_as_integers(): + """收紧不能误伤:计数字段必须仍是 TOML 整数而不是被引号包成字符串。""" + import tomllib + + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-counts", + store_type="faiss", + embedding_provider="openai", + embedding_model="m", + chunk_mode="standard", + source_digest="d", + document_count=7, + chunk_count=11, + ) + + parsed = tomllib.loads(manifest.to_toml()) + + assert parsed["document_count"] == 7 + assert parsed["chunk_count"] == 11 + assert isinstance(parsed["document_count"], int) + + +# --------------------------------------------------------------------------- +# 超出双精度范围的整数 +# --------------------------------------------------------------------------- +# +# ``float(10**400)`` 抛 ``OverflowError``——它是 ``ArithmeticError`` 的子类, +# **不是** ``ValueError``。``SessionConfig`` 的每个受校验字段都承诺抛 +# ``ValueError``,库调用方与 UI 都只接 ``ValueError``;漏出 ``OverflowError`` +# 会让整条 ``except ValueError`` 链失效。而 ``config.toml`` 的整数是无上界的 +# TOML 整数,``SessionConfig`` 又是运行期可变入口,这条路径完全可达。 +# +# 参数 id 必须显式给出:``10**4301`` 超过 ``sys.get_int_max_str_digits()``, +# pytest 为它自动生成 id 时调 ``str(val)`` 会自己抛 ``ValueError``,测试在 +# **收集阶段**就崩掉。 +_BEYOND_DOUBLE_INTS = [ + pytest.param(10**400, id="400digits"), + pytest.param(-(10**400), id="negative-400digits"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), +] + + +# ── 回归:清单的**写入**侧也要收敛非字符串取值 ── +# +# ``from_mapping``(读取侧)早已按同一判据修过,但 ``to_toml``(写入侧)原先 +# 写的是 ``str(value)`` 与 ``int(value)``。同一条缺陷只修了一个方向:写入侧 +# 抛出的消息与「快照写入失败」毫无关系,而 ``int(inf)`` 更抛 ``OverflowError`` +# ——``ArithmeticError`` 子类,**不是** ``ValueError``。 +# +# 值的来源与读取侧相同:``config.toml``、以及磁盘上的畸形快照。 + + +@pytest.mark.parametrize("bad_value", _BEYOND_DOUBLE_INTS) +@pytest.mark.parametrize("field_name", ["snapshot_id", "store_type", "chunk_mode"]) +def test_manifest_to_toml_survives_huge_integer_text_fields(field_name, bad_value): + """文本字段是位数超限的整数时,``to_toml`` 必须给出可读结果或可读错误。""" + payload = { + "snapshot_id": "kb-write", + "store_type": "faiss", + "embedding_provider": "openai", + "embedding_model": "m", + "chunk_mode": "standard", + "source_digest": "digest", + "document_count": 1, + "chunk_count": 1, + field_name: bad_value, + } + manifest = KnowledgeSnapshotManifest.create(**payload) + + try: + text = manifest.to_toml() + except ValueError as exc: + # 允许拒绝,但消息里不能是「整数转字符串超限」这种与字段无关的内容。 + assert "Exceeds the limit" not in str(exc), str(exc) + return + # 或者降级成量级描述并成功落盘。 + assert "Exceeds the limit" not in text + + +@pytest.mark.parametrize( + ("label", "bad_value"), + [ + ("inf", float("inf")), + ("nan", float("nan")), + ("负数", -1), + ("小数", 1.5), + ], + ids=["inf", "nan", "negative", "float"], +) +@pytest.mark.parametrize("field_name", ["document_count", "chunk_count"]) +def test_manifest_to_toml_rejects_malformed_counts(field_name, label, bad_value): + """计数字段非法时必须报 ``ValueError``(带字段名),不能漏出 ``OverflowError``。 + + ``int(inf)`` 抛 ``OverflowError``——``ArithmeticError`` 子类而**不是** + ``ValueError``,会穿透 ``write_manifest`` 调用方的错误分类。 + """ + payload = { + "snapshot_id": "kb-counts", + "store_type": "faiss", + "embedding_provider": "openai", + "embedding_model": "m", + "chunk_mode": "standard", + "source_digest": "digest", + "document_count": 1, + "chunk_count": 1, + field_name: bad_value, + } + manifest = KnowledgeSnapshotManifest.create(**payload) + + with pytest.raises(ValueError, match=field_name) as excinfo: + manifest.to_toml() + + assert "Exceeds the limit" not in str(excinfo.value), label + + +@pytest.mark.parametrize("bad_value", _BEYOND_DOUBLE_INTS) +@pytest.mark.parametrize("field_name", ["document_count", "chunk_count"]) +def test_manifest_to_toml_rejects_out_of_range_counts(field_name, bad_value): + """位数超限的计数必须在**写入前**被拒,且消息里带字段名。 + + 原先它一路走到 f-string 插值才抛 ``ValueError: Exceeds the limit``——类型对, + 但用户看到的是整数转换问题,而真实原因是计数不合法。 + """ + payload = { + "snapshot_id": "kb-range", + "store_type": "faiss", + "embedding_provider": "openai", + "embedding_model": "m", + "chunk_mode": "standard", + "source_digest": "digest", + "document_count": 1, + "chunk_count": 1, + field_name: bad_value, + } + manifest = KnowledgeSnapshotManifest.create(**payload) + + with pytest.raises(ValueError, match=field_name) as excinfo: + manifest.to_toml() + + assert "Exceeds the limit" not in str(excinfo.value) + + +def test_manifest_to_toml_accepts_realistic_counts(): + """收紧不能误伤:真实量级的计数照常落盘且是 TOML 整数。""" + import tomllib + + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-ok", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=39, + chunk_count=115, + ) + + parsed = tomllib.loads(manifest.to_toml()) + + assert parsed["document_count"] == 39 + assert parsed["chunk_count"] == 115 + assert isinstance(parsed["document_count"], int) + + +@pytest.mark.parametrize("bad_value", _BEYOND_DOUBLE_INTS) +@pytest.mark.parametrize("field_name", ["vector_weight", "keyword_weight", "score_threshold"]) +def test_session_config_rejects_weights_beyond_double_range(session_config, field_name, bad_value): + """权重与阈值超出双精度范围时必须是 ``ValueError``,不是 ``OverflowError``。""" + with pytest.raises(ValueError, match=field_name): + session_config[field_name] = bad_value + + +@pytest.mark.parametrize("bad_value", _BEYOND_DOUBLE_INTS) +def test_session_config_constructor_rejects_temperature_beyond_double_range(bad_value): + """构造器与 ``__setitem__`` 走同一组校验器,**两条**路径都必须收敛成 ``ValueError``。""" + from src.runtime.contracts import SessionConfig + + config = build_session_config(Settings(_env_file=None)) + with pytest.raises(ValueError, match="chat_temperature"): + config["chat_temperature"] = bad_value + # 用例名承诺的构造器路径:合法实例上逐字段替换出非法值再重建, + # ``__init__`` 的校验器注册表必须同样拒绝。 + with pytest.raises(ValueError, match="chat_temperature"): + SessionConfig( + **{ + **config.to_dict(), + "chat_temperature": bad_value, + } + ) + + +# ── 回归:快照清单字段是位数超限的整数时不得崩 ── +# +# ``from_mapping`` 原写 ``str(data[key])``。清单文件来自磁盘,而本项目加载 +# pickle 格式的快照——pickle 是二进制格式、不受 CPython 十进制位数限制 +# (实测 ``pickle.loads(pickle.dumps(10**4301))`` 正常往返),因此畸形快照能让 +# ``str()`` 在正要解析清单时抛 ``ValueError: Exceeds the limit``,异常类型与 +# 内容都与「清单字段不合法」无关。 + + +def _manifest_mapping() -> dict[str, object]: + return { + "schema_version": "2", + "snapshot_id": "snap-1", + "created_at": "2026-01-01T00:00:00Z", + "store_type": "faiss", + "embedding_provider": "local-hash", + "embedding_model": "demo", + "chunk_mode": "standard", + "source_digest": "deadbeef", + "document_count": 1, + "chunk_count": 1, + } + + +@pytest.mark.parametrize( + "field_name", + ["schema_version", "snapshot_id", "created_at", "store_type", "source_digest"], +) +def test_snapshot_manifest_survives_huge_integer_field(field_name): + data = _manifest_mapping() + data[field_name] = pickle.loads(pickle.dumps(10**4301)) + + manifest = KnowledgeSnapshotManifest.from_mapping(data) + + assert isinstance(getattr(manifest, field_name), str) + assert getattr(manifest, field_name) # 非空,仍可用于日志与报错 + + +# ── set_many 与 __delitem__ 的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测,经真实入口): +# * 244 ``set_many`` 的「两个权重同时为零」组合校验 → **静默接受** +# (``(0.0, 0.0)`` 写进配置),检索时每个文档得分都是 0.0 并被分数阈值全部 +# 过滤——用户看到的是「搜不到」,而配置本身「合法」。逐条 ``__setitem__`` +# 的等价判据(``_reject_both_weights_zero``,385)有覆盖且仍然生效,但 +# ``set_many`` 是**批量入口**、按终态判,与它不是同一条路径; +# * 428 ``__delitem__`` → ``del cfg["top_k"]`` **静默成功**,字段数不变但 +# 配置进入「声称少一个字段、实际没少」的状态,``to_dict()`` 仍照原样导出。 +@pytest.mark.parametrize( + "values", + [ + pytest.param({"vector_weight": 0.0, "keyword_weight": 0.0}, id="both-zero"), + ], +) +def test_set_many_rejects_terminal_state_with_both_weights_zero(session_config, values): + """``set_many`` 按**终态**做组合校验,两个权重同时为零必须报错。 + + 逐条 ``__setitem__`` 的等价判据(``_reject_both_weights_zero``)已覆盖, + 但批量入口是另一条路径:它先逐字段过单字段校验、再按终态判组合约束, + 删掉守卫后 ``(0.0, 0.0)`` 会被写进配置。 + """ + with pytest.raises(ValueError, match="不能同时为 0"): + session_config.set_many(values) + + +@pytest.mark.parametrize("field", ["top_k", "vector_weight", "chat_temperature"]) +def test_session_config_rejects_field_deletion(session_config, field): + """``SessionConfig`` 不支持删除字段:``del`` 必须报错而不是静默「成功」。""" + with pytest.raises(TypeError, match="不支持删除字段"): + del session_config[field] diff --git a/tests/scripts/test_audit_tooling.py b/tests/scripts/test_audit_tooling.py new file mode 100644 index 0000000..402e7f4 --- /dev/null +++ b/tests/scripts/test_audit_tooling.py @@ -0,0 +1,591 @@ +"""scripts/audit 变异验证工具链的回归测试。 + +覆盖三类防呆保护:退出码分类(pytest 4/5 不得被当成「已杀死」)、选择器 +预检(不存在的测试名必须报 COLLECT_ERROR 而不是假红)、漂移定位(唯一 +最近才采用,平局/无匹配必须 MUTFAIL)。 +""" + +import ast +import pathlib +import subprocess +import sys + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "scripts" / "audit")) + +import mutate +from mutate import ( + MutantSyntaxError, + SiteNotFound, + apply_mutation, + find_nearest_site, + find_site, +) +from verify_sites import ( + VERDICT_COLLECT_ERROR, + VERDICT_KILLED, + VERDICT_SURVIVED, + classify_pytest_exit, + parse_sites, + prepare_worktree, +) + +VERIFY_SITES = REPO_ROOT / "scripts" / "audit" / "verify_sites.py" + + +def test_classify_pytest_exit_only_zero_and_one_are_verdicts(): + assert classify_pytest_exit(0) == VERDICT_SURVIVED + assert classify_pytest_exit(1) == VERDICT_KILLED + # 退出码 4/5 是「没有收集到测试」——历史上被 returncode != 0 误判成红。 + assert classify_pytest_exit(4) == VERDICT_COLLECT_ERROR + assert classify_pytest_exit(5) == VERDICT_COLLECT_ERROR + assert classify_pytest_exit(2) not in (VERDICT_KILLED, VERDICT_SURVIVED) + assert classify_pytest_exit(3) not in (VERDICT_KILLED, VERDICT_SURVIVED) + + +def test_parse_sites_fields_and_validation(): + sites = parse_sites( + "# 注释\n\nsrc/a.py|10|4|drop\nsrc/b.py|20|8|eqneq|tests/t.py::test_x|KILLED\n" + ) + assert len(sites) == 2 + assert sites[0]["selector"] == "" and sites[0]["expected"] == "" + assert sites[1]["selector"] == "tests/t.py::test_x" + assert sites[1]["expected"] == "KILLED" + with pytest.raises(ValueError): + parse_sites("src/a.py|10|4|bogus") + with pytest.raises(ValueError): + parse_sites("src/a.py|10|4|drop||NOPE") + + +def test_mutate_drop_removes_not(tmp_path): + target = tmp_path / "mod.py" + target.write_text('def f(flag):\n if not flag:\n return "off"\n return "on"\n') + apply_mutation(target, 2, 7, "drop") + assert "if flag:" in target.read_text(encoding="utf-8") + + +def test_mutate_eqneq_both_directions(tmp_path): + target = tmp_path / "mod.py" + target.write_text("def f(a, b):\n if a == b:\n return 1\n return 0\n") + apply_mutation(target, 2, 7, "eqneq") + assert "a != b" in target.read_text(encoding="utf-8") + target.write_text("def f(a, b):\n if a != b:\n return 1\n return 0\n") + apply_mutation(target, 2, 7, "eqneq") + assert "a == b" in target.read_text(encoding="utf-8") + + +def test_mutate_cmpdir_reverses_direction_in_both_shapes(tmp_path): + """``<``↔``>`` 与 ``<=``↔``>=`` 各自反向;``<=`` 不能被切出 ``> =``。""" + target = tmp_path / "mod.py" + target.write_text("def f(a, b):\n return a <= b\n") + apply_mutation(target, 2, 11, "cmpdir") + assert "a >= b" in target.read_text(encoding="utf-8") + + target.write_text("def f(a, b):\n return a >= b\n") + apply_mutation(target, 2, 11, "cmpdir") + assert "a <= b" in target.read_text(encoding="utf-8") + + target.write_text("def f(a, b):\n return a < b\n") + apply_mutation(target, 2, 11, "cmpdir") + assert "a > b" in target.read_text(encoding="utf-8") + + target.write_text("def f(a, b):\n return a > b\n") + apply_mutation(target, 2, 11, "cmpdir") + assert "a < b" in target.read_text(encoding="utf-8") + + +def test_mutate_cmpdir_flips_only_first_operator_of_chain(tmp_path): + """链式比较 ``0 <= x < 10`` 只翻转第一个运算符(与 eqneq 同约定)。""" + target = tmp_path / "mod.py" + target.write_text("def f(x):\n return 0 <= x < 10\n") + apply_mutation(target, 2, 11, "cmpdir") + assert "0 >= x < 10" in target.read_text(encoding="utf-8") + + +def test_mutate_boolc_flips_literal_both_directions(tmp_path): + """``True``↔``False`` 两条方向都要翻转。""" + target = tmp_path / "mod.py" + target.write_text("def f(flag=True):\n return flag\n") + apply_mutation(target, 1, 11, "boolc") + assert "flag=False" in target.read_text(encoding="utf-8") + + target.write_text("def f(flag=False):\n return flag\n") + apply_mutation(target, 1, 11, "boolc") + assert "flag=True" in target.read_text(encoding="utf-8") + + +def test_mutate_boolc_ignores_non_boolean_constants(): + """``1``/``0``/``"True"``/``None`` 取值不同型,不是 boolc 的位点。 + + ``bool`` 是 ``int`` 的子类,判据必须先按取值类型筛(``isinstance(value, + bool)``)再收位点;只按 ``Constant`` 收会把 ``1``/``0`` 也当成布尔字面量, + 变异落到数字上、``boolc`` 的结论随之失真。 + """ + tree = ast.parse('x = 1\ny = "True"\nz = None\nw = True\nv = False\n') + assert [(ln, col) for ln, col, _ in mutate.iter_sites(tree, "boolc")] == [(4, 4), (5, 4)] + + +def test_mutate_boolop_swaps_and_or_both_directions(tmp_path): + """``and``→``or``、``or``→``and`` 两个方向都可施加。""" + target = tmp_path / "mod.py" + target.write_text("ok = a and b\n") + # 站点键是第一个值 ``a`` 的结尾 (1, 6)。 + apply_mutation(target, 1, 6, "boolop") + assert "ok = a or b" in target.read_text(encoding="utf-8") + + target.write_text("ok = a or b\n") + apply_mutation(target, 1, 6, "boolop") + assert "ok = a and b" in target.read_text(encoding="utf-8") + + +def test_mutate_boolop_uses_first_value_end_as_site_key(): + """链式 ``a and b or c`` 的两层 BoolOp 起点相同,故键取第一个值的结尾。""" + tree = ast.parse("x = a and b or c\n") + assert sorted((ln, col) for ln, col, _ in mutate.iter_sites(tree, "boolop")) == [ + (1, 5), + (1, 11), + ] + + +def test_mutate_boolop_flips_only_first_operator(tmp_path): + """``a and b and c`` 只翻最内层第一个运算符,后面的保持不变。""" + target = tmp_path / "mod.py" + target.write_text("ok = a and b and c\n") + apply_mutation(target, 1, 6, "boolop") + assert "ok = a or b and c" in target.read_text(encoding="utf-8") + + +def test_mutate_boolop_comment_mentioning_keyword_is_not_flipped(tmp_path): + """夹缝里有注释且注释含 and/or 文本时,必须翻真运算符而非注释词。 + + ``src/`` 生产代码不这么写,但正则按关键字找会先命中注释(实测形态 + ``a # keep and note`` 后换行 ``and b``),变异落进注释即无声无效。 + """ + target = tmp_path / "mod.py" + target.write_text("ok = (\n a # keep and note\n and b\n)\n") + apply_mutation(target, 2, 5, "boolop") + source = target.read_text(encoding="utf-8") + assert "# keep and note" in source # 注释原样保留 + assert " or b\n" in source # 真运算符被翻转 + + +def test_mutate_arith_swaps_add_sub_and_mul_div(tmp_path): + """加减互换、乘除互换各一条,且各自只翻转到对面运算符。""" + target = tmp_path / "mod.py" + target.write_text("total = a + b\navg = total / n\n") + # ``total = a + b``:站点键是左操作数 ``a`` 的结尾 (1, 9)。 + apply_mutation(target, 1, 9, "arith") + assert "total = a - b" in target.read_text(encoding="utf-8") + + target.write_text("total = a - b\navg = total / n\n") + apply_mutation(target, 1, 9, "arith") + assert "total = a + b" in target.read_text(encoding="utf-8") + + target.write_text("avg = total * n\n") + apply_mutation(target, 1, 11, "arith") + assert "avg = total / n" in target.read_text(encoding="utf-8") + + +def test_mutate_arith_uses_left_operand_end_as_site_key(): + """链式 ``a + b + c`` 的两层 BinOp 起点相同,故键取左操作数结尾。 + + 若按 BinOp 的 ``col_offset`` 作键,两层都指向第一个 ``a``,``find_site`` + 会命中两个节点而判 MUTFAIL,链式位点整片漏掉。 + """ + tree = ast.parse("r = a + b + c\n") + assert sorted((ln, col) for ln, col, _ in mutate.iter_sites(tree, "arith")) == [ + (1, 5), + (1, 9), + ] + + +def test_mutate_arith_ignores_string_concatenation(): + """字符串字面量参与的 ``+`` 是拼接不是算术,不进 arith 位点。 + + 其变异体(``"a" - "b"``)在运行期抛 ``TypeError`` 而非改变计算,属噪声。 + """ + tree = ast.parse('x = "a" + "b"\ny = a + b\nz = a + "s"\n') + assert [(ln, col) for ln, col, _ in mutate.iter_sites(tree, "arith")] == [(2, 5)] + + +def test_mutate_arith_flips_only_first_operator_of_chain(tmp_path): + """``x / y / z`` 的两个 ``/`` 各是一个位点,一次只翻一个。""" + target = tmp_path / "mod.py" + target.write_text("w = x / y / z\n") + apply_mutation(target, 1, 5, "arith") + assert "w = x * y / z" in target.read_text(encoding="utf-8") + + target.write_text("w = x / y / z\n") + apply_mutation(target, 1, 9, "arith") + assert "w = x / y * z" in target.read_text(encoding="utf-8") + + +def test_mutate_column_is_utf8_byte_offset(tmp_path): + """含中文的行:col_offset 是 UTF-8 字节偏移,不是字符偏移。""" + target = tmp_path / "mod.py" + target.write_text('x = "中文" if not flag else "b"\n') + tree = ast.parse(target.read_text(encoding="utf-8")) + node = next(n for n in ast.walk(tree) if isinstance(n, ast.UnaryOp)) + apply_mutation(target, node.lineno, node.col_offset, "drop") + assert target.read_text(encoding="utf-8") == 'x = "中文" if flag else "b"\n' + + +def test_mutate_unknown_site_raises(tmp_path): + target = tmp_path / "mod.py" + target.write_text("x = 1\n") + with pytest.raises(SiteNotFound): + apply_mutation(target, 99, 0, "drop") + + +def test_apply_mutation_rejects_syntax_error_without_writing(tmp_path, monkeypatch): + """变异体语法错误必须报 MutantSyntaxError 且不写盘。 + + 语法错误的变异体会让 pytest 在收集期失败,历史上被扫描器记成 + 「已杀死」——那是假阴性,会把真缺口藏起来。 + """ + target = tmp_path / "mod.py" + target.write_text("if a == b:\n pass\n") + monkeypatch.setattr(mutate, "mutate_source", lambda src, node, kind: "if (a == b:\n") + with pytest.raises(MutantSyntaxError): + apply_mutation(target, 1, 3, "eqneq") + assert target.read_text(encoding="utf-8") == "if a == b:\n pass\n" + + +def test_find_nearest_site_unique_and_tie_break(): + tree = ast.parse("x = 1\nif a == b:\n pass\nif c != d:\n pass\n") + assert find_nearest_site(tree, 1, 3, "eqneq", 10) == (2, 3) + assert find_nearest_site(tree, 3, 3, "eqneq", 10) is None # 平局 + assert find_nearest_site(tree, 1, 3, "eqneq", 0) is None # 窗口外 + tie_tree = ast.parse("x = 1\nif a == b:\n pass\n if c == d:\n pass\n") + assert find_nearest_site(tie_tree, 3, 3, "eqneq", 10) == (2, 3) # 同距同列消解 + assert find_nearest_site(tie_tree, 3, 9, "eqneq", 10) is None # 同距异列 + + +def test_find_site_requires_exactly_one_match(): + tree = ast.parse("if a == b:\n pass\n") + assert find_site(tree, 1, 3, "eqneq") is not None + with pytest.raises(SiteNotFound): + find_site(tree, 99, 3, "eqneq") + + +def test_source_inspecting_tests_are_excluded_from_verdicts(): + """读工作区源码文本的测试必须被排除在判定之外。 + + 实测:green38 夹具里 4 个 SURVIVED 位点恰好落进 + ``test_green38_fixture_sites_resolve_at_head`` 引用的节点,元测试在变异下必红 + ——扫描于是把 4 个存活点全部误报 KILLED。这些测试在**未变异**的源码上仍然 + 有价值,所以是排除而非删除;但排除列表必须是**存在**的,``--deselect`` 对 + 不存在的 node id 静默通过,写错名字不会报错。 + """ + from verify_sites import _SOURCE_INSPECTING_TESTS + + assert _SOURCE_INSPECTING_TESTS, "排除列表不得为空——空列表意味着自指测试又被算进判定" + for node in _SOURCE_INSPECTING_TESTS: + file_part, _, test_name = node.partition("::") + assert file_part.endswith(".py") and test_name, node + path = REPO_ROOT / file_part + assert path.exists(), f"排除项指向不存在的文件: {file_part}" + tree = ast.parse(path.read_text(encoding="utf-8")) + found = any( + isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name == test_name + for top in tree.body + for fn in _iter_functions(top) + ) + assert found, f"排除项指向不存在的测试: {node}" + + +def test_source_inspecting_registry_is_complete(): + """排除清单必须覆盖探测器发现的**每一个**自指测试。 + + 上面的用例只验证「清单里的条目存在」——一个新增的、读源码的测试不会自己 + 进清单,扫描便再次把它算进判定(假 KILLED 静默复现)。这里用静态探测器 + 做完整性核对:凡「锚定仓库源码 + 读文件/解析/导入」的测试都必须已登记。 + """ + from verify_sites import _SOURCE_INSPECTING_TESTS, detect_source_inspecting_tests + + detected = set(detect_source_inspecting_tests(REPO_ROOT)) + registered = set(_SOURCE_INSPECTING_TESTS) + # 探测器先自证非空,否则「⊆」在空集上恒真、这条用例形同虚设。 + assert len(detected) >= 5, f"探测器只发现 {len(detected)} 个,锚点匹配可能已失效" + assert detected <= registered, ( + "以下测试读仓库源码但未登记进 _SOURCE_INSPECTING_TESTS," + f"变异扫描会把它们算进判定并制造假 KILLED:{sorted(detected - registered)}" + ) + + +def test_detect_source_inspecting_distinguishes_source_from_artifacts(tmp_path): + """探测器要抓读源码的、放过只读临时产物的——后者是变异的合法判据。""" + from verify_sites import detect_source_inspecting_tests + + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "test_probe.py").write_text( + "import ast\n" + "import pathlib\n" + "\n" + "REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]\n" + "\n" + "\n" + "def test_reads_source_via_literal():\n" + " return pathlib.Path('src/providers/factory.py').read_text()\n" + "\n" + "\n" + "def test_reads_source_via_module_global():\n" + " return ast.parse((REPO_ROOT / 'scripts' / 'audit' / 'mutate.py').read_text())\n" + "\n" + "\n" + "def test_reads_only_temp_artifacts(tmp_path):\n" + " return (tmp_path / 'chunks.pkl').read_bytes()\n" + "\n" + "\n" + "def test_docstring_mention_is_not_a_read(tmp_path):\n" + ' """这个测试从不真的 read_text,只在文档里提到 src/ 而已。"""\n' + " return tmp_path.name\n", + encoding="utf-8", + ) + detected = set(detect_source_inspecting_tests(tmp_path)) + assert "tests/test_probe.py::test_reads_source_via_literal" in detected + assert "tests/test_probe.py::test_reads_source_via_module_global" in detected + assert "tests/test_probe.py::test_reads_only_temp_artifacts" not in detected + assert "tests/test_probe.py::test_docstring_mention_is_not_a_read" not in detected + + +def _iter_functions(top): + """遍历模块级 ClassDef 内的函数(含嵌套类里的方法)。""" + if isinstance(top, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield top + elif isinstance(top, ast.ClassDef): + for stmt in top.body: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield stmt + elif isinstance(stmt, ast.ClassDef): + yield from _iter_functions(stmt) + + +def test_verify_sites_selftest_passes(): + proc = subprocess.run( + [sys.executable, str(VERIFY_SITES), "--selftest"], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "selftest 失败: 0" in proc.stdout + + +def test_green38_fixture_parses_with_expected_verdicts(): + fixture = REPO_ROOT / "scripts" / "audit" / "regressions" / "green38.txt" + sites = parse_sites(fixture.read_text(encoding="utf-8")) + assert len(sites) == 38 + counts: dict[str, int] = {} + for site in sites: + counts[site["expected"]] = counts.get(site["expected"], 0) + 1 + assert counts == {"KILLED": 34, "SURVIVED": 4} + + +def test_green38_fixture_sites_resolve_at_head(): + """夹具坐标必须在当前 HEAD 上精确命中,否则它在静默失效。 + + 历史翻车:夹具记录在扫描当轮的坐标上,源码后续演化(行号漂移、守卫随修复 + 移动、死代码删除整段移位)后,39 个位点里 10 个已无法定位、10 个需漂移。 + 夹具却只按「字段可解析」放行,于是「已知答案」失去锚定意义。这条断言把 + 「坐标命中当前 HEAD」钉死:凡报 SiteNotFound 即夹具需重新基线化。 + """ + fixture = REPO_ROOT / "scripts" / "audit" / "regressions" / "green38.txt" + sites = parse_sites(fixture.read_text(encoding="utf-8")) + stale = [] + for site in sites: + target = REPO_ROOT / site["target"] + tree = ast.parse(target.read_text(encoding="utf-8")) + try: + find_site(tree, site["line"], site["col"], site["kind"]) + except SiteNotFound: + stale.append(f"{site['target']}:{site['line']}:{site['col']}:{site['kind']}") + assert not stale, "夹具坐标已在 HEAD 上失效,需重新基线化:\n" + "\n".join(stale) + + +def test_pytest_infrastructure_failure_is_not_killed(): + """解释器/依赖不可用(退出码也是 1)不得被当成 KILLED。 + + ``--python`` 指到没装 pytest 的解释器时,整轮位点会全部误报 KILLED; + 这层嗅探把「基础设施不可用」与「测试失败」区分开。 + """ + from verify_sites import pytest_output_is_infrastructure_failure + + assert pytest_output_is_infrastructure_failure("/usr/bin/python: No module named pytest") + assert pytest_output_is_infrastructure_failure("INTERNALERROR> ...") + assert not pytest_output_is_infrastructure_failure("1 failed, 10 passed in 2.0s") + + +def test_selector_precheck_recognizes_class_methods_and_async(tmp_path): + """选择器预检必须用 AST 而非 ``^def`` 正则:类方法与 async def 也不能误判。""" + from verify_sites import check_selector + + source = ( + "def test_plain():\n" + " pass\n" + "class TestX:\n" + " def test_method_inside_class(self):\n" + " pass\n" + "async def test_async_top_level():\n" + " pass\n" + ) + (tmp_path / "t.py").write_text(source, encoding="utf-8") + assert check_selector(tmp_path, "t.py::test_plain") is None + assert check_selector(tmp_path, "t.py::test_method_inside_class") is None + assert check_selector(tmp_path, "t.py::test_async_top_level") is None + assert check_selector(tmp_path, "t.py::test_missing") is not None + + +def _git(repo: pathlib.Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout + + +@pytest.fixture +def audit_repo(tmp_path): + """最小 git 仓库 + 一个 worktree:守卫、能杀死它的测试、无覆盖的守卫。""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + (repo / "tests").mkdir() + (repo / ".gitignore").write_text("__pycache__/\n") + # 夹具仓库必须与 pytest 走同一套导入路径,否则 ``python -m pytest`` 在 + # worktree 里收集不到 ``src``。 + (repo / "conftest.py").write_text( + "import pathlib, sys\n" + "ROOT = pathlib.Path(__file__).resolve().parent\n" + "if str(ROOT) not in sys.path:\n" + " sys.path.insert(0, str(ROOT))\n", + encoding="utf-8", + ) + (repo / "src" / "guard.py").write_text( + "def check(value):\n" + ' if value == "expected":\n' + ' return "ok"\n' + ' return "bad"\n' + "\n" + "def other(value):\n" + ' return value == "x"\n' + ) + (repo / "tests" / "test_guard.py").write_text( + 'from src.guard import check\n\n\ndef test_check():\n assert check("expected") == "ok"\n' + ) + _git(repo, "init") + _git(repo, "config", "user.email", "audit@example.com") + _git(repo, "config", "user.name", "audit") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "init") + worktree = tmp_path / "wt" + _git(repo, "worktree", "add", "--detach", str(worktree)) + return repo, worktree + + +def test_prepare_worktree_refuses_main_repo(audit_repo): + repo, _ = audit_repo + head = _git(repo, "rev-parse", "HEAD").strip() + with pytest.raises(RuntimeError, match="主仓"): + prepare_worktree(repo, str(repo), head) + + +def test_prepare_worktree_refuses_dirty(audit_repo): + repo, worktree = audit_repo + head = _git(repo, "rev-parse", "HEAD").strip() + (worktree / "src" / "guard.py").write_text("# 残留改动\n") + with pytest.raises(RuntimeError, match="不干净"): + prepare_worktree(repo, str(worktree), head) + + +def test_verify_sites_end_to_end(audit_repo, tmp_path): + """KILLED / SURVIVED / COLLECT_ERROR 三种判定各一,退出码 0,worktree 无残留。""" + repo, worktree = audit_repo + sites_file = tmp_path / "sites.txt" + sites_file.write_text( + "# 端到端\n" + "src/guard.py|2|7|eqneq|tests/test_guard.py::test_check|KILLED\n" + "src/guard.py|7|11|eqneq||SURVIVED\n" + "src/guard.py|2|7|eqneq|tests/test_guard.py::test_missing|COLLECT_ERROR\n" + ) + proc = subprocess.run( + [ + sys.executable, + str(VERIFY_SITES), + "--repo", + str(repo), + "--worktrees", + str(worktree), + str(sites_file), + ], + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "RESULT KILLED src/guard.py:2:7:eqneq" in proc.stdout + assert "RESULT SURVIVED src/guard.py:7:11:eqneq" in proc.stdout + assert "RESULT COLLECT_ERROR src/guard.py:2:7:eqneq" in proc.stdout + status = subprocess.run( + ["git", "status", "--short"], cwd=worktree, capture_output=True, text=True, check=False + ).stdout + assert status.strip() == "" + + +def test_verify_sites_baseline_guard_rejects_red_suite(tmp_path): + """基线不绿时整轮必须中止,不得把存活点误判成 KILLED。 + + 这是「无基线对照」那条历史翻车的回归:红套件会让真存活点报 KILLED, + 把真缺口藏起来。夹具仓库里直接提交一个常红测试(不能是 worktree 未跟踪 + 文件——``prepare_worktree`` 会拒绝脏 worktree),verify_sites 必须以非零 + 退出并在输出里说明基线不绿,而不是照常输出 KILLED。 + """ + repo = tmp_path / "red-repo" + (repo / "src").mkdir(parents=True) + (repo / "tests").mkdir() + (repo / ".gitignore").write_text("__pycache__/\n", encoding="utf-8") + (repo / "conftest.py").write_text( + "import pathlib, sys\n" + "ROOT = pathlib.Path(__file__).resolve().parent\n" + "if str(ROOT) not in sys.path:\n" + " sys.path.insert(0, str(ROOT))\n", + encoding="utf-8", + ) + (repo / "src" / "guard.py").write_text( + 'def check(value):\n if value == "expected":\n return "ok"\n return "bad"\n', + encoding="utf-8", + ) + (repo / "tests" / "test_always_red.py").write_text( + "def test_always_red():\n assert False, 'baseline is red'\n", + encoding="utf-8", + ) + _git(repo, "init") + _git(repo, "config", "user.email", "audit@example.com") + _git(repo, "config", "user.name", "audit") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "init") + worktree = tmp_path / "red-wt" + _git(repo, "worktree", "add", "--detach", str(worktree)) + + sites_file = tmp_path / "sites.txt" + sites_file.write_text("src/guard.py|2|7|eqneq||SURVIVED\n") + proc = subprocess.run( + [ + sys.executable, + str(VERIFY_SITES), + "--repo", + str(repo), + "--worktrees", + str(worktree), + str(sites_file), + ], + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert proc.returncode != 0, "基线不绿时 verify_sites 必须非零退出" + assert "基线" in proc.stdout + proc.stderr + assert "RESULT KILLED" not in proc.stdout diff --git a/tests/services/test_chat_service.py b/tests/services/test_chat_service.py index 5852c59..e58954a 100644 --- a/tests/services/test_chat_service.py +++ b/tests/services/test_chat_service.py @@ -94,6 +94,32 @@ async def consume(): assert "temperature" not in model.calls[0] +def test_generate_reply_requests_a_streaming_completion(): + """``generate_reply`` 必须**流式**请求:整段回复要逐块 yield 给调用方。 + + ``stream`` 反向成 ``False`` 后 provider 在内部把整段回复缓冲完再返回, + ``chat_async`` 的逐块 yield 名存实亡——用户看到的是「先卡住、再一次性 + 刷出全文」,而终端流式输出正是这个接口存在的理由。既有的三条 + ``generate_reply`` 用例只断言了采样字段,没有一条断言过 ``stream``。 + """ + model = _ResponsesReasoningModel() + service = ChatService(model, retrieval_service=object()) + + async def consume(): + return [ + chunk + async for chunk in service.generate_reply( + "问题", + "意图", + [], + type("SessionConfig", (), {"chat_temperature": 0.7})(), + ) + ] + + assert asyncio.run(consume()) == ["检索"] + assert model.calls[0]["stream"] is True + + def test_chat_service_passes_sampling_when_capability_is_declared(): model = _SamplingModel() service = ChatService(model, retrieval_service=object()) @@ -125,3 +151,611 @@ def test_build_prompt_handles_documents_without_source_metadata(): assert "来源: 未知来源" in prompt assert "内容: 知识内容" in prompt + + +# ── 回归:prompt 构造不得被形态异常的文档打断,也不得把 None 当正文喂给 LLM ── +# +# ``_build_prompt`` 直接对检索结果做 ``doc.get('metadata') or {}`` 与 +# ``doc.get('page_content', '')``。检索结果可以来自任何 ``VectorStoreBase`` +# 实现——``FaissStore`` 会在载入期校验并归一化,第三方 store 不会。实测 +# ``metadata`` 为 list、条目本身不是映射时抛 ``AttributeError``,而更隐蔽的一类 +# 是 ``page_content=None``:它不抛错,而是把字面量 ``内容: None`` 当作知识喂给 +# LLM——模型会把「None」当成一条真实的知识内容来回答。 + + +@pytest.mark.parametrize( + "document,label", + [ + ({"page_content": "正文", "metadata": ["a"]}, "metadata 为列表"), + ({"page_content": "正文", "metadata": "not-a-dict"}, "metadata 为字符串"), + ({"page_content": "正文", "metadata": 42}, "metadata 为整数"), + ("不是映射", "条目本身不是映射"), + (None, "条目为 None"), + (42, "条目为整数"), + ], +) +def test_build_prompt_survives_malformed_documents(document, label): + """形态异常时必须照常产出 prompt,而不是抛 ``AttributeError``。 + + 断言分两层,缺一不可: + + 1. 不抛异常,且产出了 prompt(``"问题"`` 在文本里)。 + 2. **有可用正文时,正文必须真的进入 prompt**。 + + 第 2 条是后补的:原先只断言 ``"问题" in prompt``,而那个字符串在「没有找到 + 相关知识」那一支里也有。实测把 ``_build_prompt`` 换成一个「丢弃全部知识、 + 只回显问题」的实现,这条用例照样通过——它能发现崩溃,却**发现不了知识正文 + 被静默丢弃**(那正是本项目最该防的失败:检索退化被伪装成模型答不出来)。 + + ``metadata`` 异常的那几组正文都是 ``"正文"``,因此必须出现在 prompt 里; + 条目本身不是映射/是 ``None`` /是整数的那几组没有正文可取,走无知识分支。 + """ + prompt = ChatService._build_prompt("问题", "意图", [document]) + + assert "问题" in prompt + has_usable_content = ( + isinstance(document, dict) + and isinstance(document.get("page_content"), str) + and document["page_content"] + ) + if has_usable_content: + assert document["page_content"] in prompt, f"{label}: 可用正文被丢弃了" + + +@pytest.mark.parametrize( + "document", + [ + {"page_content": None, "metadata": {"source": "a.md"}}, + {"page_content": ["x"], "metadata": {"source": "a.md"}}, + {"page_content": 42, "metadata": {"source": "a.md"}}, + {"metadata": {"source": "a.md"}}, + ], +) +def test_build_prompt_does_not_feed_malformed_content_to_the_model(document): + """非字符串内容不得以 ``内容: None`` 这类字面量进入 prompt。 + + 这类形态不抛错,但会把「None」当作一条真实知识交给模型——比崩溃更难发现。 + """ + prompt = ChatService._build_prompt("问题", "意图", [document]) + + assert "内容: None" not in prompt + assert "内容: ['x']" not in prompt + assert "内容: 42" not in prompt + + +def test_build_prompt_still_includes_well_formed_content(): + """收紧不能误伤:正常文档仍要完整进入 prompt。""" + prompt = ChatService._build_prompt( + "问题", "意图", [{"page_content": "知识内容", "metadata": {"source": "kb.md"}}] + ) + + assert "来源: kb.md" in prompt + assert "内容: 知识内容" in prompt + + +# ── 回归:检索服务对 store 返回值的形态要求 ── +# +# ``RetrievalService`` 是三个消费者(聊天 prompt、召回测试表格、聊天详情表格) +# 的共同上游。检索结果可以来自任何 ``VectorStoreBase`` 实现,而抽象基类正是为 +# 替换实现而存在的——实测返回 ``None`` 或 ``list[str]`` 的 store 会在这里抛 +# ``TypeError``/``AttributeError``,报错现场离「你的 store 返回了什么」很远。 + + +def _retrieval_service_with_store(store): + import numpy as np + + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, Settings + + class _Embedding: + async def embed_query(self, _query): + return np.zeros(4, dtype=np.float32) + + config = build_session_config(Settings(_env_file=None)) + config["retrieval_method"] = RetrievalMethod.SEMANTIC_SEARCH + config["score_threshold"] = 0.0 + return RetrievalService(store, _Embedding()), config + + +@pytest.mark.parametrize( + "payload,label,expected", + [ + (None, "返回 None", "检索返回的必须是文档列表"), + ({"a": 1}, "返回映射", "检索返回的必须是文档列表"), + (["x"], "返回字符串列表", "检索返回的第 0 条不是文档映射"), + ([None], "列表元素为 None", "检索返回的第 0 条不是文档映射"), + ], +) +def test_retrieve_rejects_malformed_store_results(payload, label, expected): + """store 返回非文档列表时必须给出可读的错误,而不是 ``TypeError``。 + + 断言必须带完整模板:只 match="文档" 时把任一标签换成别的词都仍绿(假绿)。 + 两种收场各钉各的模板——「不是 list」与「list 里第 0 条不是映射」。 + """ + import asyncio + import io + + from rich.console import Console + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return payload + + service, config = _retrieval_service_with_store(_Store()) + + with pytest.raises(TypeError, match=expected): + asyncio.run(service.retrieve("查询", config, console=Console(file=io.StringIO()))) + + +def test_retrieve_accepts_well_formed_store_results(): + """收紧不能误伤:正常结果照常返回。""" + import asyncio + import io + + from rich.console import Console + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "正文", "metadata": {"source": "a.md"}, "score": 0.5}] + + service, config = _retrieval_service_with_store(_Store()) + + documents = asyncio.run(service.retrieve("查询", config, console=Console(file=io.StringIO()))) + + assert len(documents) == 1 + assert documents[0]["page_content"] == "正文" + + +@pytest.mark.parametrize( + "metadata,label", + [ + (["a"], "元数据为列表"), + ("not-a-dict", "元数据为字符串"), + (42, "元数据为整数"), + ({"source": "a.md"}, "正常元数据"), + (None, "元数据为 None"), + ], +) +def test_retrieve_survives_malformed_metadata(metadata, label): + """``metadata`` 非映射时检索必须照常完成。 + + ``document.get("metadata") or {}`` 只挡得住 ``None`` 与空值:**非空的非映射** + 会原样留下,随后 ``_deduplicate_parent_documents`` 里的 ``.get("parent_id")`` + 抛 ``AttributeError: 'list' object has no attribute 'get'``——而这是检索管线 + 内部,报错现场离「你的 store 返回了什么」很远。 + """ + import asyncio + import io + + from rich.console import Console + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "正文", "metadata": metadata, "score": 0.5}] + + service, config = _retrieval_service_with_store(_Store()) + + documents = asyncio.run(service.retrieve("查询", config, console=Console(file=io.StringIO()))) + + assert len(documents) == 1 + assert isinstance(documents[0]["metadata"], dict) + + +@pytest.mark.parametrize( + "page_content,expected,label", + [ + ("正文", "正文", "正常内容"), + (None, "", "内容为 None"), + (["x"], "", "内容为列表"), + (42, "", "内容为整数"), + ("", "", "内容为空串"), + ], +) +def test_rerank_receives_only_strings(page_content, expected, label): + """送给 Rerank provider 的必须是字符串列表。 + + ``docs_to_rerank = [doc.get("page_content", "") for doc in ranked_results]`` + 在内容非字符串时会把 ``None``/``42`` 这类值直接交给 provider,由 provider 的 + ``_validate_inputs`` 抛「documents 必须全部是字符串」——错误信息指向 provider + 的参数,而真正的原因是上游 store 返回了形态异常的文档。与展示层同理, + 在这里收敛成字符串更靠近原因。 + """ + import asyncio + import io + + from rich.console import Console + + received: list[list[str]] = [] + + class _Rerank: + async def arerank(self, _query, documents, *, top_n): + received.append(list(documents)) + return list(range(len(documents))), [0.5] * len(documents) + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": page_content, "metadata": {"source": "a.md"}, "score": 0.5}] + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + service._get_rerank_provider = lambda *_args, **_kwargs: _Rerank() + + asyncio.run(service.retrieve("查询", config, console=Console(file=io.StringIO()))) + + assert received == [[expected]], label + + +# ── 回归:hybrid 检索要在**合并之前**校验两路结果的形态 ── +# +# ``_require_document_list`` 原先只在合并与 rerank **之后**运行,而 +# ``_merge_hybrid_results`` 已经对每个条目调了 ``.get(...)``——store 返回 +# ``[None]`` 时抛的是 ``AttributeError: 'NoneType' object has no attribute 'get'``, +# 而不是该校验承诺的可定位 ``TypeError``(附带是哪一路、第几条出的问题)。 +# 三条检索路径的收场方式因此不一致:semantic/keyword 单路给可读错误, +# hybrid 给裸 AttributeError。 + + +@pytest.mark.parametrize( + ("label", "semantic", "keyword", "expected"), + [ + ("语义路返回 [None]", [None], [], "语义检索返回的第 0 条不是文档映射"), + ("关键词路返回 [None]", [], [None], "关键词检索返回的第 0 条不是文档映射"), + ("语义路返回 None", None, [], "语义检索返回的必须是文档列表"), + ("关键词路返回字符串", [], "不是列表", "关键词检索返回的必须是文档列表"), + ], +) +def test_hybrid_retrieval_reports_which_side_violated_the_contract( + label, semantic, keyword, expected +): + """两路结果的形态问题必须报成可定位的 TypeError,且指明是哪一路。 + + ``pytest.raises`` 的 ``match`` 必须带**具体那一侧**的完整阶段标签(``语义检索`` + /``关键词检索``):两路消息模板同构、仅标签不同,只 match="检索" 时任一标签被 + 替换都仍绿(假绿)。列表级与元素级是两条不同模板,各钉各的。 + """ + import asyncio + + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + class _Store: + def semantic_search(self, query_embedding, top_k=5): + return semantic + + def keyword_search(self, query_text, top_k=5): + return keyword + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config["retrieval_method"] = RetrievalMethod.HYBRID_SEARCH + service = RetrievalService(vector_store=_Store(), embedding_service=_Embedding()) + + # 断言必须能区分「语义路」与「关键词路」——两路消息模板同构、仅 stage 标签不同。 + # 只 match="检索" 时把任一标签换掉都仍绿(假绿);改成精确前缀后,标签替换必然 + # 红,用例才真正钉住「哪一路违反契约」这条信息。 + with pytest.raises(TypeError, match=expected): + asyncio.run(service.retrieve("查询", config)) + + +# ── 回归:`retrieve` 对 top_k/候选倍率的形态要求 ── +# +# `retrieve` 直接算 `max(1, session_config.top_k) * max(1, ...)`,而 +# `SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫(见 +# `_require_finite_number` 的 docstring)。嵌入方、或携带超大整数的 pickle +# 快照,可以把这两个字段直写成任何形态,此时 `max()` 抛裸 +# `TypeError: '>' not supported between instances of 'NoneType' and 'int'`。 +# +# 这是**生产入口**:`Chatbot.chat_async` 每次提问都调它,而异常落在 +# `_require_document_list` 等本文件既有的错误分类之外——`retrieve` 的契约是 +# 返回文档列表或给出可读错误,不是把比较运算的 `TypeError` 漏给调用方。 +# 同文件的 `_coerce_rank`/`_require_finite_number` 早已按同一判据收敛兄弟字段。 + + +@pytest.mark.parametrize( + "bad_value", + [None, "3", [3], {"a": 1}, 10**4301, float("nan"), float("inf")], + ids=["None", "str", "list", "dict", "huge-int", "nan", "inf"], +) +@pytest.mark.parametrize("field_name", ["top_k", "retrieval_candidate_multiplier"]) +def test_retrieve_rejects_malformed_top_k_fields(field_name, bad_value): + """`top_k`/候选倍率形态非法时必须给 ``ValueError``,不是裸 ``TypeError``。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [] + + service, config = _retrieval_service_with_store(_Store()) + object.__setattr__(config, field_name, bad_value) + + with pytest.raises(ValueError, match=field_name): + asyncio.run(service.retrieve("查询", config)) + + +@pytest.mark.parametrize( + "bad_value", [-5, 0, -(10**4301)], ids=["negative", "zero", "huge-negative"] +) +@pytest.mark.parametrize("field_name", ["top_k", "retrieval_candidate_multiplier"]) +def test_retrieve_clamps_non_positive_scale_fields(field_name, bad_value): + """非正数沿用既有的 ``max(1, ...)`` 语义:收敛成 1,而不是报错。 + + 这不是放宽:原实现就是 ``max(1, session_config.top_k)``,负数与 0 一直被 + 收敛成 1。收敛语义必须保持不变,否则「直接写属性」这条路径会从「静默取 + 一条」变成「抛错」,是行为变更而不是修缺陷。超长负整数同理——它的绝对值 + 再大也不影响 ``max(1, ...)`` 的结果。 + """ + + class _Store: + def semantic_search(self, _query_embedding, top_k): + # store 拿到的是 ``top_k × multiplier`` 的乘积:被写的那个字段收敛 + # 成 1,另一个保持默认值(``top_k=5``、``multiplier=3``),因此乘积 + # 是 5 或 3,取决于被写的是哪一个。 + assert top_k == (3 if field_name == "top_k" else 5), top_k + return [] + + service, config = _retrieval_service_with_store(_Store()) + object.__setattr__(config, field_name, bad_value) + + assert asyncio.run(service.retrieve("查询", config)) == [] + + +def test_retrieve_accepts_legal_top_k_fields(): + """收紧不能误伤:合法取值照常检索。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + config["top_k"] = 5 + config["retrieval_candidate_multiplier"] = 3 + + results = asyncio.run(service.retrieve("查询", config)) + + assert [doc["page_content"] for doc in results] == ["内容"] + + +# ── 回归:`retrieve` 对 score_threshold/rerank_enabled/融合策略的形态要求 ── +# +# 同一类缺陷的**其余字段**:`retrieve` 还直接读 +# * `session_config.score_threshold`——`coerce_score(...) >= threshold`,非数值 +# 抛裸 `TypeError: '>=' not supported between instances of 'float' and 'NoneType'`; +# * `session_config.rerank_enabled`——`if not ... or not ...`,真值语义掩盖形态; +# * `session_config.hybrid_fusion_strategy`——与 `"rrf"` 比较、并传给 +# `HybridReranker`(后者自己会拒绝非字符串,但 `_should_apply_score_threshold` +# 先按它判分支)。 +# 直写属性是既有前提(见 `_require_finite_number` 的 docstring)。上面那组用例只 +# 覆盖了 `top_k`/候选倍率两个字段,同一函数里的其余字段没有覆盖——同类失败不该 +# 只修一部分。 + + +@pytest.mark.parametrize( + "bad_value", + [None, "0.5", [0.5], {"a": 0.5}, 10**4301, float("nan"), float("inf")], + ids=["None", "str", "list", "dict", "huge-int", "nan", "inf"], +) +def test_retrieve_rejects_malformed_score_threshold(bad_value): + """阈值形态非法时必须给 ``ValueError``,不是裸 ``TypeError``。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + object.__setattr__(config, "score_threshold", bad_value) + + with pytest.raises(ValueError, match="score_threshold"): + asyncio.run(service.retrieve("查询", config)) + + +@pytest.mark.parametrize( + "bad_value", [None, "yes", 1, 0, [], {}], ids=["None", "str", "int", "zero", "list", "dict"] +) +def test_retrieve_rejects_malformed_rerank_enabled(bad_value): + """``rerank_enabled`` 必须是真正的布尔值,不能靠真值语义掩盖形态。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + object.__setattr__(config, "rerank_enabled", bad_value) + + with pytest.raises(ValueError, match="rerank_enabled"): + asyncio.run(service.retrieve("查询", config)) + + +@pytest.mark.parametrize( + "bad_value", [None, 1, ["rrf"], {"rrf": 1}], ids=["None", "int", "list", "dict"] +) +def test_retrieve_rejects_malformed_fusion_strategy(bad_value): + """融合策略形态非法时必须给带字段名的 ``ValueError``。""" + from src.utils.config import RetrievalMethod + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [] + + def keyword_search(self, _query_text, _top_k): + return [] + + service, config = _retrieval_service_with_store(_Store()) + config["retrieval_method"] = RetrievalMethod.HYBRID_SEARCH + object.__setattr__(config, "hybrid_fusion_strategy", bad_value) + + with pytest.raises(ValueError, match="hybrid_fusion_strategy"): + asyncio.run(service.retrieve("查询", config)) + + +# ── 回归:rerank provider key 的形态校验 ── +# +# `_get_rerank_provider` 直接写 `configurations.get(provider_key)`,而 +# `provider_key` 来自可直写的 `session_config.active_rerank_configuration`。 +# 不可哈希的取值在 `.get()` 处就抛裸 `TypeError: unhashable type: 'list'`—— +# 早于 `ModelProviderFactory` 的守卫,因此工厂那一侧的修复覆盖不到这条路径。 +# 这是 `retrieve` 的 rerank 分支,`rerank_enabled=True` 时每次检索都会走到。 + + +@pytest.mark.parametrize("bad_key", [[1], {1: 2}, {1, 2}], ids=["list", "dict", "set"]) +def test_rerank_provider_lookup_rejects_unhashable_key(bad_key): + """不可哈希的 rerank key 必须给 ``ValueError``,不是裸 ``TypeError``。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "active_rerank_configuration", bad_key) + + with pytest.raises(ValueError, match="active_rerank_configuration"): + asyncio.run(service.retrieve("查询", config)) + + +@pytest.mark.parametrize( + "bad_configurations", + [5, [1], "x", True, 10**4301], + ids=["int", "list", "str", "bool", "huge-int"], +) +def test_rerank_provider_lookup_rejects_non_mapping_configurations(bad_configurations): + """`rerank_configurations` 非映射时必须给 ``ValueError``,不是裸 ``AttributeError``。 + + `_get_rerank_provider` 直接对它调 `.get()`,而该字段来自可直写的 + `session_config.rerank_configurations`。`AttributeError` 落在 `retrieve` + 既有的错误分类之外,报错现场离「配置项形态错了」很远。 + """ + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "rerank_configurations", bad_configurations) + + with pytest.raises(ValueError, match="rerank_configurations"): + asyncio.run(service.retrieve("查询", config)) + + +# ── 回归:rerank 配置**取值**的形态校验 ── +# +# `_get_rerank_provider` 取 `configurations[key]` 后: +# * `getattr(detail, "provider", "")` 只是有默认值,**不校验类型**——该值会进 +# `cache_key` 元组,非可哈希形态在 `self._rerank_providers.get(cache_key)` +# 处抛裸 `TypeError: unhashable type: 'list'`; +# * detail 本身不是 `ModelDetail`(dict/None/int)时, +# `ModelProviderFactory.get_rerank_provider` 里的 `config.provider` 抛裸 +# `AttributeError`。 +# 两者来源都是可直写的 `session_config.rerank_configurations`(映射的**值**), +# 而 `rerank_enabled=True` 时每次检索都会走到。 +@pytest.mark.parametrize("bad_provider", [["p"], {"p": 1}, {1, 2}], ids=["list", "dict", "set"]) +def test_rerank_provider_lookup_rejects_unhashable_provider_field(bad_provider): + """`detail.provider` 不可哈希时必须给 ``ValueError``,不是裸 ``TypeError``。 + + `ModelDetail` 是 pydantic 模型,**构造**时会拒绝非字符串的 provider,但 + 普通属性没有赋值守卫(`validate_assignment` 未开)——实测 + `detail.provider = ["p"]` 直接成功。因此这条路径是可达的。 + """ + from src.utils.config import ModelDetail + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + detail = ModelDetail(provider="siliconflow", model_name="m") + detail.provider = bad_provider + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "rerank_configurations", {"siliconflow": detail}) + + with pytest.raises(ValueError, match="provider"): + asyncio.run(service.retrieve("查询", config)) + + +@pytest.mark.parametrize( + "bad_detail", [{"provider": "siliconflow"}, None, 5, "x"], ids=["dict", "None", "int", "str"] +) +def test_rerank_provider_lookup_rejects_non_detail_value(bad_detail): + """配置值不是 `ModelDetail` 时必须给 ``ValueError``,不是裸 ``AttributeError``。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "rerank_configurations", {"siliconflow": bad_detail}) + + with pytest.raises(ValueError, match="rerank_configurations"): + asyncio.run(service.retrieve("查询", config)) + + +@pytest.mark.parametrize("bad_model_name", [["m"], {"m": 1}, {1, 2}], ids=["list", "dict", "set"]) +def test_rerank_provider_lookup_rejects_unhashable_model_name(bad_model_name): + """`detail.model_name` 不可哈希时必须给 ``ValueError``,不是裸 ``TypeError``。 + + 它与 `provider` 同进 `cache_key` 元组,同样只有 `getattr` 的默认值、没有 + 类型判据——实测 `detail.model_name = ["m"]` 直接成功(`ModelDetail` 未开 + `validate_assignment`)。 + """ + from src.utils.config import ModelDetail + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + detail = ModelDetail(provider="siliconflow", model_name="m") + detail.model_name = bad_model_name + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "rerank_configurations", {"siliconflow": detail}) + + with pytest.raises(ValueError, match="model_name"): + asyncio.run(service.retrieve("查询", config)) + + +def test_rerank_provider_lookup_rejects_deleted_provider_attribute(): + """`del detail.provider` 后必须给 ``ValueError``。 + + `getattr(detail, "provider", "")` 只兜「属性缺失」,兜出来的空串会把「属性 + 没了」伪装成「provider 名为空」,一路带到工厂再抛裸 ``AttributeError``。 + """ + from src.utils.config import ModelDetail + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + detail = ModelDetail(provider="siliconflow", model_name="m") + del detail.provider + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "rerank_configurations", {"siliconflow": detail}) + + with pytest.raises(ValueError, match="provider"): + asyncio.run(service.retrieve("查询", config)) + + +def test_rerank_provider_lookup_still_reports_missing_key(): + """收紧不能误伤:key 真的不存在时保持原有「未找到 key」的 ``ValueError``。""" + + class _Store: + def semantic_search(self, _query_embedding, _top_k): + return [{"page_content": "内容", "metadata": {"source": "a.md"}, "score": 0.9}] + + service, config = _retrieval_service_with_store(_Store()) + config["rerank_enabled"] = True + object.__setattr__(config, "rerank_configurations", {}) + + with pytest.raises(ValueError, match="未找到key"): + asyncio.run(service.retrieve("查询", config)) diff --git a/tests/services/test_embedding_service.py b/tests/services/test_embedding_service.py index c1cbca8..5f0f1e2 100644 --- a/tests/services/test_embedding_service.py +++ b/tests/services/test_embedding_service.py @@ -1,4 +1,5 @@ import asyncio +import warnings from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -6,8 +7,54 @@ import pytest from src.providers.openai_compatible import OpenAICompatibleProvider -from src.services.embedding_service import EmbeddingService -from src.utils.config import ModelDetail +from src.runtime.contracts import build_run_config +from src.services.embedding_service import ( + EmbeddingService, + _reject_unconvertible_numbers, +) +from src.utils.config import ModelDetail, Settings + +# ── 回归:provider key 打错字必须是 ValueError,不是裸 KeyError ── +# +# ``default_embedding_provider`` 没有任何 validator(``Settings`` 里只是普通 +# ``str``),而 ``embedding_model_detail`` 原先直接下标。打错一个名字时这里抛 +# ``KeyError``,同一份配置走 ``ModelProviderFactory.get_embedding_provider`` +# 得到的却是带可选值列表的 ``ValueError``——两条路径的报错契约本该一致,否则 +# 调用方按 ``ValueError`` 写的错误分类与日志脱敏接不住 ``KeyError``。 +# +# 调用点在检索与建库的主路径上:``embed_query`` 的 Google 分支、 +# ``knowledge_build_service`` 写清单、``embed_knowledge_base.py`` 的展示。 + + +def test_embedding_model_detail_reports_unknown_provider_as_value_error(): + """未知 provider key 必须抛带可选值的 ``ValueError``,而不是 ``KeyError``。""" + run_config = SimpleNamespace( + default_embedding_provider="gogle", + embedding_configurations={ + "local-hash": ModelDetail(provider="local-hash", model_name="local-hash-32") + }, + ) + + service = EmbeddingService(run_config) + + with pytest.raises(ValueError, match="未找到key") as excinfo: + _ = service.embedding_model_detail + + message = str(excinfo.value) + assert "gogle" in message + # 可选值列表是这条消息的用途——没有它用户只能去翻 config.toml。 + assert "local-hash" in message + + +def test_embedding_model_detail_returns_the_configured_detail(): + """合法 key 照常返回详情(收紧不能误伤)。""" + detail = ModelDetail(provider="local-hash", model_name="local-hash-32") + run_config = SimpleNamespace( + default_embedding_provider="local-hash", + embedding_configurations={"local-hash": detail}, + ) + + assert EmbeddingService(run_config).embedding_model_detail is detail def test_embedding_service_passes_runtime_configurations_to_factory(monkeypatch): @@ -210,10 +257,23 @@ def test_as_float32_matrix_rejects_non_finite_values(bad_row, label): 3.4e38 的哨兵距离暴露出来——那时「哪个文档坏了」已经无法定位。 ``np.array(..., dtype=np.float32)`` 对超出 float32 范围的值只发 RuntimeWarning 并静默溢出成 inf,所以在 cast **之后**检查。 + + 溢出那条用例会触发 numpy 的 ``RuntimeWarning``:它是被测行为本身的一部分 + (正是这个 warning 说明「静默溢出」确实发生了),因此在这里显式断言它, + 而不是让它泄漏到测试输出里——泄漏的 warning 会掩盖真正的新警告。 """ - with pytest.raises(ValueError, match="非有限值"): + with ( + pytest.raises(ValueError, match="非有限值"), + warnings.catch_warnings(record=True) as caught, + ): + warnings.simplefilter("always") EmbeddingService._as_float32_matrix([[1.0, 2.0], bad_row], expected_rows=2) + overflow = [w for w in caught if issubclass(w.category, RuntimeWarning)] + if bad_row == [1e300, 1.0]: + assert overflow, "超出 float32 范围时应触发 numpy 溢出警告" + assert "overflow" in str(overflow[0].message) + def test_as_float32_matrix_accepts_finite_values(): """收紧不能误伤:正常向量(含 0 与负值)必须可用。""" @@ -223,3 +283,104 @@ def test_as_float32_matrix_accepts_finite_values(): assert np.isfinite(matrix).all() assert matrix.shape == (2, 3) + + +# ── 回归:向量维度必须有上界 ── +# +# ``local-hash`` 的维度直接来自 ``model_name`` 的数字后缀 +# (``local-hash-256`` -> 256),而 ``embedding_configurations..model_name`` +# 是 ``config.toml`` 里用户可写的字段,没有任何校验。实测把它配成 +# ``local-hash-100000000`` 时 ``Settings`` 照单全收,一次 ``embed_texts(["测试"])`` +# 就分配出 ``(1, 100000000)`` 的 float32 矩阵:耗时 180 秒、峰值内存 3.9 GB; +# 按真实知识库 115 个分块算,落盘前的 ``np.vstack`` 需要约 4.3 GB。 +# +# 这与已经加过界的 ``chat_top_k × retrieval_candidate_multiplier`` 是同一类问题: +# 用户可配的规模没有上界,代价是进程被 OOM 杀掉而不是一条可读的错误。 +# 上界取 65536:现有各家的最大维度是 3072(text-embedding-3-large),留了 +# 20 倍余量,而 65536 维的 float32 单块也只有 256 KB。 + + +@pytest.mark.parametrize("dimension", [1, 256, 3072, 65536]) +def test_as_float32_matrix_accepts_dimensions_at_or_below_the_bound(dimension): + """收紧不能误伤:上界以内(含边界)的维度必须可用。""" + matrix = EmbeddingService._as_float32_matrix([[1.0] * dimension], expected_rows=1) + + assert matrix.shape == (1, dimension) + + +@pytest.mark.parametrize("dimension", [65537, 1_000_000, 100_000_000]) +def test_as_float32_matrix_rejects_oversized_dimensions(dimension): + """超过上界的维度必须在分配矩阵**之前**拒绝。 + + 这条断言的意义在于「拒绝得够早」:修复前走到这里时内存已经按该维度 + 分配过了,进程要么已经付出代价、要么已经被 OOM 杀掉——那时再报错也晚了。 + 因此断言拒绝,而不是断言某种内存表现。 + """ + with pytest.raises(ValueError, match="维度"): + EmbeddingService._as_float32_matrix([[1.0] * dimension], expected_rows=1) + + +def test_embedding_service_rejects_oversized_dimension_from_provider(): + """走 provider 的真实返回路径:超大维度同样要拒绝。""" + service = EmbeddingService(build_run_config(Settings(_env_file=None))) + + oversized = [[0.0] * 65537] + with pytest.raises(ValueError, match="维度"): + EmbeddingService._as_float32_matrix(oversized, expected_rows=1) + # 服务实例本身仍可构造,说明拒绝发生在向量校验而不是构造期。 + assert service is not None + + +# ── 回归:``_reject_unconvertible_numbers`` 必须递归 ``ndarray`` ── +# +# ``ndarray`` 与「``ndarray`` 的 list」都是 ``_probe_dimension`` 显式支持的输入 +# 形态。守卫只递归 ``list``/``tuple`` 时,``dtype=object`` 的数组带着 +# ``10**400`` 直接进 ``np.array(..., dtype=np.float32)``,由 numpy 抛裸 +# ``OverflowError``——同一批数据,``list`` 形态得到干净的 ``ValueError``, +# ``ndarray`` 形态得到 ``ArithmeticError`` 子类,收场方式不一致,且穿透调用方 +# 的 ``except ValueError`` 分类与日志脱敏。 + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param([[10**400, 1.0]], id="list-of-list"), + pytest.param(np.array([[10**400, 1.0]], dtype=object), id="np-object-2d"), + pytest.param(np.array([10**400, 1.0], dtype=object), id="np-object-1d"), + pytest.param([np.array([10**400, 1.0], dtype=object)], id="list-of-np-1d"), + ], +) +def test_reject_unconvertible_numbers_covers_ndarray(payload): + with pytest.raises(ValueError, match="超出双精度范围"): + _reject_unconvertible_numbers(payload, "测试向量") + + +def test_reject_unconvertible_numbers_accepts_normal_ndarray(): + """数值 dtype 的数组不应被遍历或误判。""" + _reject_unconvertible_numbers(np.zeros((2, 3), dtype="float32"), "测试向量") + + +# ── 回归:条数不一致的消息必须可读 ── +# +# ``expected_rows`` 是调用方给的期望条数,形态不受控,而这条消息原先裸插值: +# 位数超过 ``sys.get_int_max_str_digits()``(默认 4300)时,正要报「条数不一致」 +# 的那一步自己抛 ``ValueError: Exceeds the limit``——用户拿到的是 CPython 的 +# 整数转换错误,与向量条数毫无关系。同一条消息里的 ``matrix.shape[0]`` 来自刚 +# 建好的 ndarray,必是小整数,不需要处理。 +@pytest.mark.parametrize("expected_rows", [10**4301, -(10**4301)], ids=["正", "负"]) +def test_as_float32_matrix_row_mismatch_message_is_readable(expected_rows): + with pytest.raises(ValueError) as excinfo: + EmbeddingService._as_float32_matrix([[1.0, 2.0]], expected_rows=expected_rows) + + message = str(excinfo.value) + assert "Exceeds the limit" not in message + assert "条数不一致" in message or "数量" in message + assert "位十进制" in message # safe_repr 对超限整数的量级描述 + + +def test_as_float32_matrix_row_mismatch_keeps_small_values_verbatim(): + """收紧不能误伤:上限内的条数照旧原样显示。""" + with pytest.raises(ValueError) as excinfo: + EmbeddingService._as_float32_matrix([[1.0, 2.0]], expected_rows=7) + + assert "输入 7 条" in str(excinfo.value) diff --git a/tests/services/test_knowledge_build_service.py b/tests/services/test_knowledge_build_service.py index 6a74550..1d7caa1 100644 --- a/tests/services/test_knowledge_build_service.py +++ b/tests/services/test_knowledge_build_service.py @@ -70,3 +70,523 @@ def test_build_validates_temp_snapshot_before_activation(tmp_path, monkeypatch): assert (previous_snapshot / "keep.txt").read_text(encoding="utf-8") == "保留" assert not (snapshot_root / "kb-new").exists() assert not (snapshot_root / ".tmp-kb-new").exists() + + +def test_build_reports_which_file_is_not_utf8(tmp_path, monkeypatch): + """非 UTF-8 文件的错误消息必须带上**是哪个文件**。 + + ``UnicodeDecodeError`` 自带的消息只有字节偏移,而知识库目录通常有几十个 + 文件;用户面对「程序运行期间发生错误」加一串偏移量无从下手。这里断言消息 + 里出现了出问题的那个文件名,并且临时快照目录不残留。 + """ + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "正常文档.md").write_text("# 正常\n\n正文", encoding="utf-8") + (knowledge_base / "损坏的文档.md").write_bytes(b"\xff\xfe invalid \xc3\x28") + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _EmptyPipeline(), + ) + embedding_service = SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + embedding_service, + SnapshotRepository(run_config), + ) + + with pytest.raises(ValueError) as excinfo: + asyncio.run(service.build("standard")) + + message = str(excinfo.value) + assert "损坏的文档.md" in message, f"错误消息里没有出问题的文件名: {message}" + assert "UTF-8" in message + assert not list(snapshot_root.glob(".tmp-*")), "失败后临时快照目录必须被清理" + + +def test_build_still_accepts_utf8_knowledge_base(tmp_path, monkeypatch): + """收紧不能误伤:全是 UTF-8 的知识库照常构建。""" + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "文档.md").write_text("# 标题\n\n正文", encoding="utf-8") + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _EmptyPipeline(), + ) + embedding_service = SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + embedding_service, + SnapshotRepository(run_config), + ) + + # 空分片会被 ``_IncompleteVectorStore`` 判为不完整快照,说明文件读取这一步过了。 + with pytest.raises(FileNotFoundError, match="知识快照不完整"): + asyncio.run(service.build("standard")) + + +@pytest.mark.parametrize( + "bad_path", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(None, id="none"), + ], +) +def test_build_rejects_non_path_knowledge_base_path(tmp_path, bad_path): + """``knowledge_base_path`` 非 ``Path`` 时必须给 ``ValueError``。 + + ``build`` 第一行就写 ``self.run_config.knowledge_base_path.glob("*.md")``,而 + ``RunConfig`` 是 frozen dataclass、字段没有校验器(同仓库多处已认领这条 + 可达性)。非 ``Path`` 取值在那里抛裸 + ``AttributeError: 'int' object has no attribute 'glob'``——不是本服务其余拒绝 + 路径承诺的带指引 ``ValueError``,而 ``build`` 由 CLI 与 ``main.py`` 的知识库 + 向量化入口直接调用。 + """ + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + run_config = _RunConfig(bad_path, snapshot_root) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ), + SnapshotRepository(run_config), + ) + + with pytest.raises(ValueError, match="knowledge_base_path"): + asyncio.run(service.build("standard")) + + +def test_build_does_not_crash_on_a_directory_named_like_markdown(tmp_path, monkeypatch): + """``*.md`` 会匹配**目录**,``build`` 不得因此抛裸 ``OSError``。 + + ``Path.glob("*.md")`` 只按名字匹配、不看类型,因此一个名为 ``notes.md`` 的 + **目录**(或 FIFO、符号链接)会被当成知识库文件。修复前它一路进 + ``_process_file`` 的 ``read_text``,抛 ``IsADirectoryError``(``OSError`` + 子类)——不是 ``build`` 其余拒绝路径承诺的带指引消息,而它由 CLI 与 + ``main.py`` 的知识库向量化入口直接调用。修复后非文件条目被跳过,正常文档 + 照常构建(这里走到「快照不完整」,说明读取这一步已过)。 + """ + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "正常文档.md").write_text("# 正常\n\n正文", encoding="utf-8") + (knowledge_base / "同名目录.md").mkdir() + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _EmptyPipeline(), + ) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ), + SnapshotRepository(run_config), + ) + + with pytest.raises(FileNotFoundError, match="知识快照不完整"): + asyncio.run(service.build("standard")) + + +def test_build_reports_when_only_non_file_entries_match(tmp_path, monkeypatch): + """知识库里**只有**非文件的 ``*.md`` 条目时,应给「未找到 Markdown 文件」。 + + 跳过非文件条目之后,若一个可读文件都不剩,仍是原来的 ``FileNotFoundError`` + ——不能因为「glob 有结果」就以为有文档可构建。 + """ + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "只是目录.md").mkdir() + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _EmptyPipeline(), + ) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ), + SnapshotRepository(run_config), + ) + + with pytest.raises(FileNotFoundError, match="未找到 Markdown 文件"): + asyncio.run(service.build("standard")) + assert not list(snapshot_root.glob(".tmp-*")), "失败后临时快照目录必须被清理" + + +def test_build_reports_which_file_is_unreadable(tmp_path, monkeypatch): + """读取失败(``OSError``)的消息必须带上**文件名**,与 UTF-8 那条同判据。 + + ``read_text`` 还会因权限不足抛 ``PermissionError``、因条目是特殊文件抛 + ``IsADirectoryError``——都是 ``OSError``,消息里只有系统错误码,没有 + 「知识库目录里的哪个文件」这一层定位。这里用权限位构造该失败(POSIX 下 + 生效;root 运行时会跳过)。 + """ + import os + + if os.geteuid() == 0: + pytest.skip("以 root 运行时权限位不生效") + + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "正常文档.md").write_text("# 正常\n\n正文", encoding="utf-8") + unreadable = knowledge_base / "无权限文档.md" + unreadable.write_text("# 秘密\n\n正文", encoding="utf-8") + unreadable.chmod(0o000) + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _EmptyPipeline(), + ) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ), + SnapshotRepository(run_config), + ) + + try: + with pytest.raises(ValueError) as excinfo: + asyncio.run(service.build("standard")) + message = str(excinfo.value) + assert "无权限文档.md" in message, f"错误消息里没有出问题的文件名: {message}" + assert not list(snapshot_root.glob(".tmp-*")), "失败后临时快照目录必须被清理" + finally: + unreadable.chmod(0o644) + + +# ── 文档数与 embedding 数不一致、活动快照切换失败的覆盖缺口 ── +# +# 两条判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测): +# * 92 文档数与 embedding 行数不一致 → ``np.vstack`` 后的错位数据被 +# **静默写入** store(或由 store 以形态无关的错误拒绝),而不是在 +# ``build`` 里以「知识快照中的文档数量与 embedding 数量不一致」报出; +# * 113 ``finalize_snapshot`` 后拿不到活动快照目录 → 报错缺失, +# ``build`` 会继续走到 ``validate_snapshot_dir(None)`` 的裸异常。 +# +# 既有用例里 ``_IncompleteVectorStore`` 只覆盖了「临时快照不自洽」这条 +# **先于**两条判据的路径,两条判据本身从未被反向覆盖。 +class _MismatchedEmbeddingService: + """embed 返回的行数与输入文本数不一致,用于触发 92 行判据。""" + + embedding_provider_key = "local-hash" + embedding_model_detail = SimpleNamespace(model_name="local-hash-32") + + async def embed_in_batches(self, texts): + import numpy as np + + return np.zeros((len(texts) + 1, 4), dtype=np.float32) + + +class _CountingPipeline: + """每个文件产出一个分块。""" + + class _Splitter: + def __init__(self): + self.parent_documents: dict = {} + + splitter = _Splitter() + + def process(self, document): + from src.models.document import Document + + return [Document(content="chunk", metadata={"source": "a.md"})] + + +def test_build_rejects_document_embedding_count_mismatch(tmp_path, monkeypatch): + """embedding 行数与文档数不一致时必须在写入 store 之前报错。""" + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "article.md").write_text("内容", encoding="utf-8") + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + repository = SnapshotRepository(run_config) + monkeypatch.setattr(repository, "generate_snapshot_id", lambda: "kb-mismatch") + + class _Store: + def save_snapshot(self, snapshot_dir): + raise AssertionError("不一致的数据不应走到 save_snapshot") + + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _CountingPipeline(), + ) + service = KnowledgeBuildService(run_config, _Store(), _MismatchedEmbeddingService(), repository) + + with pytest.raises(ValueError, match="文档数量与 embedding 数量不一致"): + asyncio.run(service.build("standard")) + + assert not (snapshot_root / "kb-mismatch").exists() + assert not (snapshot_root / ".tmp-kb-mismatch").exists() + + +def test_build_reports_missing_active_snapshot_after_finalize(tmp_path, monkeypatch): + """``finalize_snapshot`` 之后拿不到活动快照目录时必须显式报错。 + + 删除守卫后 ``build`` 会继续用 ``None`` 调 ``validate_snapshot_dir``, + 崩在路径处理内部,消息与真实原因(快照切换失败)无关。 + """ + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "article.md").write_text("内容", encoding="utf-8") + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + repository = SnapshotRepository(run_config) + monkeypatch.setattr(repository, "generate_snapshot_id", lambda: "kb-lost") + monkeypatch.setattr(repository, "get_active_snapshot_dir", lambda: None) + + class _Store: + def upsert_embeddings(self, documents, embeddings): + return None + + def save_snapshot(self, snapshot_dir): + # 写出通过 ``validate_snapshot_dir`` 所需的最小文件集 + import json + import pickle + + import faiss + import numpy as np + + target = Path(snapshot_dir) + with (target / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "c", "metadata": {"source": "a.md"}}], file) + with (target / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(target / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + index = faiss.IndexFlatL2(4) + index.add(np.zeros((1, 4), dtype=np.float32)) + faiss.write_index(index, str(target / "semantic.index")) + (target / "stats.json").write_text(json.dumps({"chunk_count": 1}), encoding="utf-8") + with (target / "lexical.index").open("wb") as file: + pickle.dump([{"t": 1}], file) + + class _Embedding: + embedding_provider_key = "local-hash" + embedding_model_detail = SimpleNamespace(model_name="local-hash-32") + + async def embed_in_batches(self, texts): + import numpy as np + + return np.zeros((len(texts), 4), dtype=np.float32) + + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _CountingPipeline(), + ) + service = KnowledgeBuildService(run_config, _Store(), _Embedding(), repository) + + with pytest.raises(RuntimeError, match="活动知识快照切换失败"): + asyncio.run(service.build("standard")) + + +def test_build_reports_original_error_when_temp_cleanup_fails(tmp_path, monkeypatch, caplog): + """临时快照清理失败时,抛出的仍是**原始**失败原因,且清理失败被记录。 + + 删除吞掉后 ``cleanup_temp_snapshot_dir`` 的异常顶替原始失败——用户 + 看到的失败与实际根因不符。 + """ + import logging + + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + (knowledge_base / "article.md").write_text("内容", encoding="utf-8") + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + + run_config = _RunConfig(knowledge_base, snapshot_root) + repository = SnapshotRepository(run_config) + monkeypatch.setattr(repository, "generate_snapshot_id", lambda: "kb-cleanup-fail") + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _EmptyPipeline(), + ) + + def failing_cleanup(_snapshot_id): + raise RuntimeError("清理本身失败") + + monkeypatch.setattr(repository, "cleanup_temp_snapshot_dir", failing_cleanup) + + embedding_service = SimpleNamespace( + embedding_provider_key="local-hash", + embedding_model_detail=SimpleNamespace(model_name="local-hash-32"), + ) + service = KnowledgeBuildService( + run_config, + _IncompleteVectorStore(), + embedding_service, + repository, + ) + + with ( + caplog.at_level(logging.ERROR, logger="src.services.knowledge_build_service"), + pytest.raises(FileNotFoundError, match="知识快照不完整"), + ): + asyncio.run(service.build("standard")) + assert "清理临时知识快照失败" in caplog.text + + +# ── 源码摘要的第二次读取同样要带文件名的指引 ── +# +# ``_compute_source_digest`` 是 ``*.md`` 清单的**第二处**读取点(``06f6a67`` +# 只修了 ``_process_file`` 那半边),且它跑在全部 embedding 完成**之后**: +# 此时最贵的一步已经做完,失败收场却是一条没有定位、没有「重试什么」指引的 +# 裸 ``OSError``。 + + +def test_compute_source_digest_reports_filename_on_permission_error(tmp_path): + """读取失败必须给出与 ``_process_file`` 同一契约的带文件名 ``ValueError``。""" + import os + + if os.geteuid() == 0: + pytest.skip("以 root 运行时权限位不生效") + + document = tmp_path / "文档.md" + document.write_text("# 正常", encoding="utf-8") + files = sorted(tmp_path.glob("*.md")) + document.chmod(0o000) + try: + with pytest.raises(ValueError) as excinfo: + KnowledgeBuildService._compute_source_digest(files) + message = str(excinfo.value) + assert "知识库文件无法读取" in message, message + assert "文档.md" in message, f"消息里没有出问题的文件名: {message}" + finally: + document.chmod(0o644) + + +def test_compute_source_digest_reports_filename_when_file_disappears(tmp_path): + """``glob`` 之后文件被删(TOCTOU)同样要给带文件名的 ``ValueError``。""" + document = tmp_path / "文档.md" + document.write_text("# 正常", encoding="utf-8") + files = sorted(tmp_path.glob("*.md")) + document.unlink() + + with pytest.raises(ValueError) as excinfo: + KnowledgeBuildService._compute_source_digest(files) + + message = str(excinfo.value) + assert "知识库文件无法读取" in message, message + assert "文档.md" in message, f"消息里没有出问题的文件名: {message}" + + +def test_compute_source_digest_normal_path_is_hex_digest(tmp_path): + """正常路径不受影响:仍是同一份内容的十六进制 SHA-256。""" + (tmp_path / "a.md").write_text("内容A", encoding="utf-8") + (tmp_path / "b.md").write_text("内容B", encoding="utf-8") + + digest = KnowledgeBuildService._compute_source_digest(sorted(tmp_path.glob("*.md"))) + + assert len(digest) == 64 + assert set(digest) <= set("0123456789abcdef") + + +def test_build_digest_failure_after_embedding_still_names_the_file(tmp_path, monkeypatch): + """embedding 之后才变成不可读时,失败消息仍要带文件名。 + + 这是该缺陷的真实时序:``_process_file`` 读成功 → embedding 跑完 → + 文件被删/失去权限 → ``_compute_source_digest`` 读第二次。原实现抛裸 + ``PermissionError``(``build`` 的其余拒绝路径不承诺这种收场),而此时 + embedding 已经全部完成。 + """ + import os + + if os.geteuid() == 0: + pytest.skip("以 root 运行时权限位不生效") + + knowledge_base = tmp_path / "knowledge_base" + knowledge_base.mkdir() + victim = knowledge_base / "文档.md" + victim.write_text("# 正常\n\n正文", encoding="utf-8") + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + run_config = _RunConfig(knowledge_base, snapshot_root) + + monkeypatch.setattr( + "src.services.knowledge_build_service.Pipeline.from_file_path", + lambda *args, **kwargs: _CountingPipeline(), + ) + + class _RevokingEmbedding: + """模拟「embedding 期间文件被删」的时序。""" + + embedding_provider_key = "local-hash" + embedding_model_detail = SimpleNamespace(model_name="local-hash-32") + + async def embed_in_batches(self, texts): + victim.unlink() + import numpy as np + + return np.zeros((len(texts), 4), dtype=np.float32) + + class _TolerantStore: + """upsert 容忍写入;digest 失败必须发生在 save_snapshot 之前。""" + + def upsert_embeddings(self, documents, embeddings) -> None: + pass + + def save_snapshot(self, snapshot_dir: str) -> None: + raise AssertionError("digest 失败时不应走到 save_snapshot") + + service = KnowledgeBuildService( + run_config, + _TolerantStore(), + _RevokingEmbedding(), + SnapshotRepository(run_config), + ) + + with pytest.raises(ValueError) as excinfo: + asyncio.run(service.build("standard")) + + message = str(excinfo.value) + assert "知识库文件无法读取" in message, message + assert "文档.md" in message, f"消息里没有出问题的文件名: {message}" + assert not list(snapshot_root.glob(".tmp-*")), "失败后临时快照目录必须被清理" diff --git a/tests/test_boundary_hardening_regressions.py b/tests/test_boundary_hardening_regressions.py new file mode 100644 index 0000000..e8bd1c3 --- /dev/null +++ b/tests/test_boundary_hardening_regressions.py @@ -0,0 +1,5924 @@ +"""本轮边界加固的回归测试。 + +每一条都对应一个**已实测复现**的缺陷:先确认它在未修源码时变红,再确认修复后 +变绿。命名沿用缺陷类别(异常分类泄漏、读写不对称、静默语义破坏),便于日后按 +类别排查同类问题。 +""" + +import asyncio +import pickle +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import faiss +import httpx +import numpy as np +import pytest +from openai._models import construct_type +from openai.types.chat import ChatCompletionChunk + +import src.utils.config as config_module +from src.providers.__base__.model_provider import merge_tool_call_fragment +from src.providers.factory import ModelProviderFactory +from src.providers.google import GoogleProvider +from src.providers.openai_compatible import OpenAICompatibleProvider +from src.retrieval.vdb.faiss_store import FaissStore +from src.runtime.contracts import RetrievalMethod, SessionConfig +from src.services.retrieval_service import _coerce_fusion_strategy, _key_text +from src.utils.config import Settings, get_settings +from src.utils.log_manager import cleanup_old_logs + +# pytest 自动生成 id 时会对参数调 ``str()``,位数超限的整数会让收集阶段自己崩掉。 +_HUGE_INT = 10**4301 + + +def _patch_provider_settings(monkeypatch, module_name: str, **values): + """替换 provider 模块里的 ``get_settings``,避免依赖真实凭证。 + + provider 构造器在缺 key 时直接拒绝,而 ``get_settings`` 带 ``lru_cache``—— + 设置环境变量不会生效(缓存里已有实例)。仓库既有用例同样打这个模块级名字。 + """ + monkeypatch.setattr(f"{module_name}.get_settings", lambda: SimpleNamespace(**values)) + + +def _log_settings(log_path: str, *, retention_days: int): + """构造 ``cleanup_old_logs`` 需要的 settings 替身。 + + ``get_module_logger`` 在清理过程中会被调用,它要读 ``log_level``——只给 + ``log_path``/``log_retention_days`` 会让清理路径本身因属性缺失而失败。 + """ + return SimpleNamespace( + log_path=log_path, + log_retention_days=retention_days, + log_level="INFO", + ) + + +def _session_config(**overrides): + """构造一个合法 SessionConfig,按需覆盖字段。""" + values = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 2, + "rerank_enabled": False, + "top_k": 5, + "score_threshold": 0.0, + "active_llm_configuration": "x", + "active_rerank_configuration": "y", + "llm_configurations": {}, + "rerank_configurations": {}, + "chat_temperature": 0.7, + } + values.update(overrides) + return SessionConfig(**values) + + +# --------------------------------------------------------------------------- +# 一、异常分类泄漏:守卫抛出的异常必须在调用方的 except ValueError/RuntimeError 之内 +# --------------------------------------------------------------------------- + + +def _chunk_with_tool_call_index(index): + """用真实 SDK 解析构造一个 chunk(``construct_type`` 跳过已声明字段的校验)。""" + return construct_type( + type_=ChatCompletionChunk, + value={ + "id": "c", + "object": "chat.completion.chunk", + "created": 0, + "model": "m", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + {"index": index, "id": "a", "function": {"name": "f", "arguments": ""}} + ] + }, + "finish_reason": None, + } + ], + }, + ) + + +@pytest.mark.parametrize("index", [[1, 2], {"a": 1}, {1}, ["a"]]) +def test_merge_tool_call_fragment_tolerates_unhashable_index(index): + """网关把 ``tool_calls[].index`` 写成数组/对象时不得抛裸 ``TypeError``。 + + ``openai`` SDK 的流式解析走 ``construct_type``,**跳过已声明字段的 + Literal/int 校验**,因此 ``index=[1, 2]``(声明类型是 ``int``)会原样到达 + provider。修复前 ``accumulator.setdefault(("index", [1, 2]), ...)`` 抛 + ``TypeError: unhashable type``——调用方按 ``ValueError``/``RuntimeError`` + 分类,接不住。 + """ + chunk = _chunk_with_tool_call_index(index) + tool_call = chunk.choices[0].delta.tool_calls[0] + key, merged = merge_tool_call_fragment({}, {"index": tool_call.index, "id": tool_call.id}) + # 不可哈希的 index 等价于「没有 index」,按既有优先级退回 id 键。 + assert key == ("id", "a") + assert merged["id"] == "a" + # 没有 id 可退时才落到位置键——两条都不得抛异常。 + key, _ = merge_tool_call_fragment({}, {"index": tool_call.index}) + assert key == ("position", 0) + + +def test_merge_tool_call_fragment_keeps_hashable_index_verbatim(): + """收紧不能误伤:可哈希的 index 仍然作为稳定键。""" + key, _ = merge_tool_call_fragment({}, {"index": 0}) + assert key == ("index", 0) + key, _ = merge_tool_call_fragment({}, {"index": "s"}) + assert key == ("index", "s") + + +def test_merge_response_tool_call_tolerates_unhashable_identity(): + """Responses 路径的 ``_merge_response_tool_call`` 与同文件既有守卫对齐。""" + for tool_call in ({"index": [1, 2]}, {"call_id": ["x"]}, {"index": {}}): + key, _ = OpenAICompatibleProvider._merge_response_tool_call({}, tool_call) + assert key == ("position", 0) + + +@pytest.mark.parametrize("choices", [{"0": {"index": 0}}, 3, "abc"]) +def test_chat_response_error_rejects_non_sequence_choices(choices): + """``choices`` 非序列时必须报成 ``RuntimeError``,而不是裸 ``KeyError``/``TypeError``。""" + response = {"choices": choices} + with pytest.raises(RuntimeError, match="choices 必须是列表"): + OpenAICompatibleProvider._raise_for_chat_response_error(response) + + +def test_chat_response_error_still_allows_usage_chunk_without_choices(): + """收紧不能误伤:流式的 usage chunk 不带 choices,仍按原语义放行。""" + OpenAICompatibleProvider._raise_for_chat_response_error({}, allow_empty_choices=True) + + +@pytest.mark.parametrize("timeout", [1e306, "abc", float("inf"), 0, -1]) +def test_request_timeout_is_validated_before_reaching_the_sdk(timeout): + """请求级 ``timeout`` 与构造期 ``options.timeout`` 用同一判据。 + + 修复前非法值直达 httpx,SDK 把形态各异的异常统一包成 + ``APIConnectionError('Connection error.')``,而 ``is_retryable_error`` 按类名 + 匹配到 ``connection`` 判定可重试——同一个注定失败的请求被重试 3 次。 + """ + provider = OpenAICompatibleProvider( + "m", + "openai", + settings=SimpleNamespace(openai_api_key="k", openai_base_url="http://127.0.0.1:1/v1"), + ) + with pytest.raises(ValueError): + provider._build_chat_request(prompt="hi", timeout=timeout) + + +def test_request_timeout_accepts_httpx_timeout_object(): + """收紧不能误伤:``httpx.Timeout`` 对象原样保留(SDK 接受,TOML 无法表达)。""" + provider = OpenAICompatibleProvider( + "m", + "openai", + settings=SimpleNamespace(openai_api_key="k", openai_base_url="http://127.0.0.1:1/v1"), + ) + timeout = httpx.Timeout(5.0) + assert provider._build_chat_request(prompt="hi", timeout=timeout)["timeout"] is timeout + + +@pytest.mark.parametrize( + "tools", + [ + [{"function": "not-an-object"}], + [{"name": "f", "function": None}], + [{"type": ["google_search"]}], + [{"type": {"a": 1}}], + ["oops"], + ], +) +def test_google_convert_tools_rejects_malformed_definitions(tools): + """Google 的畸形工具定义必须报成 ``ValueError``(与 Anthropic 同形态一致)。""" + with pytest.raises(ValueError, match=r"Google 工具定义\[0\]"): + GoogleProvider._convert_tools(tools) + + +def test_google_convert_tools_still_accepts_valid_definitions(): + """收紧不能误伤:合法的 function schema 与原生工具照旧通过。""" + converted = GoogleProvider._convert_tools( + [{"function": {"name": "lookup", "parameters": []}}, {"type": "google_search"}] + ) + assert len(converted) == 2 + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(_HUGE_INT, id="beyond-int-max-str-digits"), + pytest.param(["RRF"], id="list"), + pytest.param(None, id="none"), + pytest.param(123, id="int"), + ], +) +def test_display_and_menu_render_fusion_strategy_beyond_conversion_limit(value): + """融合策略的显示路径不得因非字符串或位数超限而崩。 + + ``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫,嵌入方或携带 + 超大整数的 pickle 快照可以直写该字段。修复前裸 ``.upper()`` 抛 + ``AttributeError``、裸 ``str()`` 抛 ``ValueError: Exceeds the limit``。 + """ + from src.ui.display_utils import _render_value + + config = _session_config() + config.hybrid_fusion_strategy = value + assert _render_value(config.hybrid_fusion_strategy).upper() + + +def test_render_value_keeps_normal_strings_verbatim(): + """收紧不能误伤:正常字符串的显示形态不变(不加引号)。""" + from src.ui.display_utils import _render_value + + assert _render_value("rrf").upper() == "RRF" + + +@pytest.mark.parametrize("key,value", [("top_k", 5), ("retrieval_candidate_multiplier", 2)]) +def test_setitem_rejects_non_integer_sibling_before_comparison(key, value): + """``__setitem__`` 读兄弟字段时也要收敛,与 ``set_many`` 对齐。 + + 修复前 ``getattr`` 拿到 list/str 后 ``top_k * multiplier`` 与随后的 ``>`` + 比较抛裸 ``TypeError``,穿透调用方按 ``ValueError`` 建立的分类。 + """ + sibling = "retrieval_candidate_multiplier" if key == "top_k" else "top_k" + config = _session_config() + setattr(config, sibling, [1, 2]) + with pytest.raises(ValueError, match="必须是整数"): + config[key] = value + + +def test_setitem_rejects_nan_sibling_weight(): + """``nan == 0.0`` 恒为 False,不收敛兄弟字段会静默放行两个权重同时失效。""" + config = _session_config() + config.keyword_weight = float("nan") + with pytest.raises(ValueError, match="必须是有限数值"): + config["vector_weight"] = 0.0 + + +def test_setitem_still_accepts_legal_values(): + """收紧不能误伤:合法写入照旧生效。""" + config = _session_config() + config["top_k"] = 7 + assert config.top_k == 7 + config["vector_weight"] = 0.4 + assert config.vector_weight == 0.4 + + +@pytest.mark.parametrize("options", [["http_options"], [], "http_options", 5]) +def test_capability_report_rejects_non_mapping_options(options): + """公开诊断入口对非 Mapping 的 options 必须与其它渠道同分类(``ValueError``)。 + + 修复前 google 分支的 ``"http_options" in options`` 对整数抛裸 ``TypeError``、 + 命中后 ``options.items()`` 抛裸 ``AttributeError``;非 google 渠道走 else + 分支本来就得到 ``ValueError``。 + """ + with pytest.raises(ValueError, match="options 必须是对象"): + ModelProviderFactory.capability_report("google", "llm", options) + + +def test_capability_report_still_accepts_mapping_options(): + """收紧不能误伤:合法的 options 映射照旧通过。""" + report = ModelProviderFactory.capability_report("google", "llm", {}) + assert report["provider"] == "google" + + +def test_deeply_nested_toml_reports_redacted_value_error(tmp_path): + """深层嵌套的 TOML 必须收敛成脱敏 ``ValueError``,而不是穿透的 ``RecursionError``。 + + ``RecursionError`` 继承 ``RuntimeError`` 而**不是** ``ValueError``,修复前 + ``get_settings`` 的四个 handler 一个都接不住——``import main`` 直接以数千帧 + 裸 traceback 终止。 + """ + config_file = tmp_path / "config.toml" + config_file.write_text("a = " + "[" * 500 + "]" * 500, encoding="utf-8") + # ``get_settings`` 带 ``lru_cache``:不清缓存的话这次调用直接返回上一个用例 + # 缓存的 Settings,根本不会去读这份坏配置。 + config_module.get_settings.cache_clear() + try: + with ( + patch.object(config_module, "CONFIG_TOML_PATH", config_file), + pytest.raises(ValueError, match="配置解析失败"), + ): + config_module.get_settings() + finally: + config_module.get_settings.cache_clear() + + +# --------------------------------------------------------------------------- +# 二、读写 / 入口校验不对称 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("arguments", [{"a": 1}, [1, 2], 7]) +def test_streaming_tool_arguments_match_the_non_streaming_form(arguments): + """流式 ``arguments`` 要与 ``_extract_tool_calls`` 一样归一成 JSON 字符串。 + + 修复前同一个输入在两条路径上给出不同类型:流式交付 dict/list/int,非流式 + 交付 JSON 文本,调用方按字符串 ``json.loads`` 时拿到 dict 并抛 ``TypeError``。 + """ + chunk = construct_type( + type_=ChatCompletionChunk, + value={ + "id": "c", + "object": "chat.completion.chunk", + "created": 0, + "model": "m", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "x", + "function": {"name": "f", "arguments": arguments}, + } + ] + }, + "finish_reason": None, + } + ], + }, + ) + events = OpenAICompatibleProvider._chat_stream_events(chunk) + accumulator: dict = {} + for event in events: + if event.type == "tool_call_delta": + merge_tool_call_fragment(accumulator, event.tool_call) + delivered = next(iter(accumulator.values()))["arguments"] + assert isinstance(delivered, str) + + +def test_streaming_tool_argument_fragments_still_concatenate(): + """收紧不能误伤:字符串分片的拼接语义不变。""" + accumulator: dict = {} + for fragment in ('{"a":', "1}"): + chunk = construct_type( + type_=ChatCompletionChunk, + value={ + "id": "c", + "object": "chat.completion.chunk", + "created": 0, + "model": "m", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "x", + "function": {"name": "f", "arguments": fragment}, + } + ] + }, + "finish_reason": None, + } + ], + }, + ) + for event in OpenAICompatibleProvider._chat_stream_events(chunk): + if event.type == "tool_call_delta": + merge_tool_call_fragment(accumulator, event.tool_call) + assert next(iter(accumulator.values()))["arguments"] == '{"a":1}' + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(-1, id="negative"), + pytest.param(0, id="zero"), + pytest.param(True, id="bool"), + pytest.param(10**400, id="beyond-double"), + ], +) +def test_google_options_max_tokens_is_validated(value): + """``options.max_tokens`` 与 ``max_output_tokens`` 必须同一判据。 + + 修复前这条赋值绕过了 ``_validate_generation_count``,非法值原样进入 + ``GenerateContentConfig``(pydantic 对 ``-1``/``True``/超大整数都不报错)。 + """ + provider = GoogleProvider("gemini-2.0-flash", {"max_tokens": value}) + with pytest.raises(ValueError, match="max_tokens"): + provider._build_generation_config(None, None, None) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(-1, id="negative"), + pytest.param(0, id="zero"), + pytest.param("abc", id="string"), + pytest.param(1.5, id="float"), + pytest.param(True, id="bool"), + pytest.param(10**400, id="beyond-double"), + ], +) +def test_google_embedding_dimensionality_is_validated(value): + """``output_dimensionality`` 此前只过键名白名单,取值原样透传。""" + provider = GoogleProvider("text-embedding-004", {"output_dimensionality": value}) + with pytest.raises(ValueError, match="output_dimensionality"): + provider._embedding_options() + + +def test_google_embedding_dimensionality_accepts_legal_value(): + """收紧不能误伤:合法维度照旧通过,未设该键时不受影响。""" + provider = GoogleProvider("text-embedding-004", {"output_dimensionality": 768}) + assert provider._embedding_options()["output_dimensionality"] == 768 + assert ( + "output_dimensionality" not in GoogleProvider("m", {"task_type": "x"})._embedding_options() + ) + + +def test_parent_splitter_rejects_overlap_equal_to_size(): + """父分片器的 ``overlap < size`` 与子分片器同一判据,两种结构模式都要过。 + + 修复前 ``_validate_hierarchical_sizes`` 只校验**子**分片;``Settings`` 的 + ``overlap < size`` 拦不住直接构造 ``RecursiveTextSplitter`` 的调用方,而 + LangChain 对 ``overlap == size`` **静默接受**(实测 size=100/ov=100 通过、 + ov=101 抛 ValueError)——于是 standard 分支既不报错也不推进。 + """ + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + splitter = RecursiveTextSplitter( + parent_chunk_size=1500, parent_chunk_overlap=1500, structure_mode="standard" + ) + with pytest.raises(ValueError, match="kb_chunk_overlap 必须小于 kb_chunk_size"): + splitter._validate_parent_sizes() + + +def test_parent_splitter_accepts_legal_overlap(): + """收紧不能误伤:默认比例照旧通过。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + RecursiveTextSplitter( + parent_chunk_size=1500, parent_chunk_overlap=150, structure_mode="standard" + )._validate_parent_sizes() + + +@pytest.mark.parametrize( + "embeddings", + [ + np.array([[float("nan"), 0.1]], dtype=np.float32), + np.array([[float("inf"), 0.1]], dtype=np.float32), + np.array([[1e300, 0.1]], dtype=np.float64), + ], +) +def test_upsert_embeddings_rejects_non_finite_vectors(embeddings): + """写入侧要与载入侧同一判据。 + + 修复前只有载入侧 ``_validate_embeddings_shape`` 校验,写入侧放行——FAISS 把 + NaN/inf 照单收下(``ntotal`` 正常增加),那个下标在查询时静默消失,而 + ``save_snapshot`` 会把它落盘。 + """ + store = FaissStore() + with pytest.raises(ValueError, match="非有限值"): + store.upsert_embeddings([{"page_content": "x", "metadata": {}}], embeddings) + # 失败必须不留痕。 + assert store.documents == [] + assert store.embeddings is None + + +def test_upsert_embeddings_accepts_finite_vectors(): + """收紧不能误伤:有限向量照旧写入。""" + store = FaissStore() + store.upsert_embeddings( + [{"page_content": "x", "metadata": {}}], np.array([[0.1, 0.2]], dtype=np.float32) + ) + assert len(store.documents) == 1 + + +def _write_snapshot(root: Path, snapshot_id: str, document_count: int, index_rows: int) -> Path: + """造一个已激活的快照目录,``semantic.index`` 的行数可控。""" + snapshot = root / snapshot_id + snapshot.mkdir() + documents = [ + {"page_content": f"d{i}", "metadata": {"chunk_id": f"c{i}"}} for i in range(document_count) + ] + embeddings = np.arange(document_count * 2, dtype=np.float32).reshape(document_count, 2) + index = faiss.IndexFlatL2(2) + if index_rows: + index.add(np.arange(index_rows * 2, dtype=np.float32).reshape(index_rows, 2)) + with (snapshot / "chunks.pkl").open("wb") as file: + pickle.dump(documents, file) + with (snapshot / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot / "embeddings.npy", embeddings) + faiss.write_index(index, str(snapshot / "semantic.index")) + with (snapshot / "lexical.index").open("wb") as file: + pickle.dump([[f"d{i}"] for i in range(document_count)], file) + (root / "ACTIVE_SNAPSHOT").write_text(snapshot_id, encoding="utf-8") + return snapshot + + +@pytest.mark.parametrize("index_rows", [1, 3]) +def test_load_snapshot_rejects_semantic_index_row_mismatch(tmp_path, index_rows): + """``semantic.index`` 的行数是第三条必须与 ``documents`` 对齐的量。 + + 修复前只校验 ``lexical.index`` 的条数。索引少一条时第 2 篇文档永远搜不到且 + 无任何提示;多一条时 ``self.documents[doc_index]`` 抛 ``IndexError``。 + """ + snapshot = _write_snapshot( + tmp_path, f"kb-{index_rows}", document_count=2, index_rows=index_rows + ) + with pytest.raises(ValueError, match="语义索引条数与分块数不一致"): + FaissStore().load_snapshot(str(snapshot), snapshot_root=str(tmp_path)) + + +def test_load_snapshot_accepts_self_consistent_snapshot(tmp_path): + """收紧不能误伤:自洽的快照照旧加载。""" + snapshot = _write_snapshot(tmp_path, "kb-ok", document_count=2, index_rows=2) + store = FaissStore() + store.load_snapshot(str(snapshot), snapshot_root=str(tmp_path)) + assert len(store.documents) == 2 + assert store.faiss_index.ntotal == 2 + + +# --------------------------------------------------------------------------- +# 三、静默语义破坏 / 数据丢失 +# --------------------------------------------------------------------------- + + +def test_vertex_partial_args_fragments_merge_without_stable_identity(): + """只有 ``will_continue``、无 id/index 的分片必须能归并。 + + 这是 Vertex ``stream_function_call_arguments`` 模式的真实形状(google-genai + 自带 fixture 记录):分片1 带 ``will_continue=True``,分片2 带 ``partial_args`` + 与 ``will_continue=False``,两者都没有 id/index。修复前 ``_stream_events_from_chunk`` + 构造 delta 字典时漏写 ``will_continue``,读侧三项判据全假,抛 + ``RuntimeError: 缺少稳定 id/index``——该模式下**每一个**多分片调用都失败。 + """ + from google.genai import types + + chunks = [ + types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + name="get_current_weather", will_continue=True + ) + ) + ], + ) + ) + ] + ), + types.GenerateContentResponse( + candidates=[ + types.Candidate( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + name="get_current_weather", + partial_args=[ + types.PartialArg( + json_path="$.country", null_value="NULL_VALUE" + ) + ], + will_continue=False, + ) + ) + ], + ) + ) + ] + ), + ] + accumulator: dict = {} + for chunk in chunks: + for event in GoogleProvider._stream_events_from_chunk(chunk): + if event.type == "tool_call_delta": + GoogleProvider._merge_tool_call(accumulator, event.tool_call) + assert len(accumulator) == 1 + assert next(iter(accumulator.values()))["arguments"] == {"country": None} + + +@pytest.mark.parametrize("index", [100_001, 20_000_000]) +def test_partial_args_json_path_index_is_bounded(index): + """``json_path`` 的数组下标必须有上界。 + + 写入循环用 ``while len(current) <= token: current.append(None)`` 填充到该下标, + 因此这个值直接决定一次分片要分配多少个元素:实测 ``$.items[20000000]`` 分配 + 2000 万个 ``None``(RSS 峰值 +76MB、耗时 6.5 秒)。 + """ + with pytest.raises(ValueError, match="索引过大"): + GoogleProvider._partial_args_to_mapping( + [{"json_path": f"$.items[{index}]", "string_value": "x"}] + ) + + +@pytest.mark.parametrize("index", [1, 100_000]) +def test_partial_args_json_path_accepts_legal_index(index): + """收紧不能误伤:上界之内的下标照旧写入。""" + result = GoogleProvider._partial_args_to_mapping( + [{"json_path": f"$.items[{index}]", "string_value": "x"}] + ) + assert len(result["items"]) == index + 1 + + +def test_toml_options_payload_keys_are_not_lowercased(tmp_path): + """``options`` 下的用户载荷键名必须原样保留。 + + 修复前每一层嵌套字典的键都被小写化,``required = ["OrderId"]`` 与 + ``properties = { OrderId = ... }`` 被拆成 ``["OrderId"]`` 与 ``["orderid"]``, + schema 的 required 与 properties 不再对应,结构化输出静默失效。 + """ + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[llm_configurations.openai] +model_name = "gpt-4o" +[llm_configurations.openai.options] +MAX_TOKENS = 100 +response_format = { type = "json_schema", json_schema = { schema = { required = ["OrderId"], properties = { OrderId = { type = "string" } } } } } +extra_body = { Nested = { DeepKey = true } } +""", + encoding="utf-8", + ) + with patch.object(config_module, "CONFIG_TOML_PATH", config_file): + raw = config_module.load_toml_config() + options = raw["llm_configurations"]["openai"]["options"] + # 选项名是项目/SDK 词汇,照旧归一(大小写不敏感)。 + assert "max_tokens" in options + schema = options["response_format"]["json_schema"]["schema"] + assert schema["required"] == ["OrderId"] + assert list(schema["properties"]) == ["OrderId"] + assert all(key in schema["properties"] for key in schema["required"]) + # 取值载荷的嵌套键同样保留。 + assert options["extra_body"] == {"Nested": {"DeepKey": True}} + + +def test_toml_entry_names_are_still_preserved(tmp_path): + """收紧不能误伤:``llm_configurations`` 的条目名仍按用户命名保留。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + '[llm_configurations.MyEntry]\nprovider = "openai"\nmodel_name = "m"\n', + encoding="utf-8", + ) + with patch.object(config_module, "CONFIG_TOML_PATH", config_file): + raw = config_module.load_toml_config() + assert "MyEntry" in raw["llm_configurations"] + + +@pytest.mark.parametrize("value", [1.9, "1_0", "+7", "2.5"]) +def test_candidate_multiplier_matches_chat_top_k_parsing(value): + """``retrieval_candidate_multiplier`` 与 ``chat_top_k`` 用同一套解析。 + + 修复前这里直接 ``int(v)``:``1.9`` 被静默截断成 ``1``,``"1_0"`` 被接受为 10、 + ``"+7"`` 被接受为 7——而 ``parse_decimal_int`` 的 docstring 明确写着这两类 + 「安静的错值」要明确拒绝。 + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Settings(retrieval_candidate_multiplier=value) + + +def test_candidate_multiplier_accepts_legal_values(): + """收紧不能误伤:纯十进制字面量与整数照旧通过。""" + assert Settings(retrieval_candidate_multiplier=7).retrieval_candidate_multiplier == 7 + assert Settings(retrieval_candidate_multiplier="7").retrieval_candidate_multiplier == 7 + assert Settings(retrieval_candidate_multiplier=2.0).retrieval_candidate_multiplier == 2 + + +def test_key_text_distinguishes_integers_beyond_conversion_limit(): + """去重键不得把不同的超限整数塌成同一个字符串。 + + 修复前 ``safe_repr`` 的降级描述只保留量级(``<整数,约 4302 位十进制>``), + ``10**4301`` 与 ``10**4301 + 1`` 得到同一个键,``_merge_hybrid_results`` 于是 + 把两条不同的文档并成一条,其中一条静默消失。 + """ + first = _key_text(_HUGE_INT) + second = _key_text(_HUGE_INT + 1) + assert first != second + # 同一取值必须稳定,否则去重本身失效。 + assert _key_text(_HUGE_INT) == first + # 符号要编进去,``n`` 与 ``-n`` 不得碰撞。 + assert _key_text(-_HUGE_INT) != first + + +def test_key_text_keeps_normal_values_verbatim(): + """收紧不能误伤:正常取值的键形态不变。 + + 小整数(如 ``chunk_index``)必须原样保留,否则 ``source:3`` 这类普通键会被 + 改写成摘要。超长整数(``repr`` 超过 80 字符,会被 ``safe_repr`` 截断)走摘要, + 见 ``test_key_text_distinguishes_integers_beyond_conversion_limit``——那里两个 + 仅差 1 的超限整数必须得到不同的键。 + """ + assert _key_text("s") == "s" + assert _key_text(5) == "5" + assert _key_text(0) == "0" + assert _key_text(-17) == "-17" + # 恰好短于截断长度的整数仍原样返回。 + assert _key_text(10**18) == str(10**18) + + +def test_key_text_distinguishes_long_non_scalar_values(): + """非标量取值 repr 超过 80 字符时不得塌成同一个键。 + + ``safe_repr`` 把 repr 超 80 字符的值截成 ``text[:77] + "..."``:``b'A'*100`` + 与 ``b'A'*100 + b'B'`` 截断后得到**同一个键**,``_merge_hybrid_results``/ + ``_deduplicate_parent_documents`` 把两条不同文档并成一条,其中一条静默消失 + (实测复现)。超限取值改对 ``repr`` 字节取摘要——仅差 1 字节的两个长 bytes + 必须得到不同键,未超限的取值(``b'x'``、``[1, 2]``)必须原样保留。 + """ + long_bytes_a = b"A" * 100 + long_bytes_b = b"A" * 100 + b"B" + assert _key_text(long_bytes_a) != _key_text(long_bytes_b) + # 稳定性:同一取值多次调用得到同一键。 + assert _key_text(long_bytes_a) == _key_text(b"A" * 100) + # 未超限取值零破坏。 + assert _key_text(b"x") == "b'x'" + assert _key_text([1, 2]) == "[1, 2]" + + +@pytest.mark.parametrize("raw", ["RRF", " rrf ", "Rrf"]) +def test_fusion_strategy_normalizes_before_the_rrf_check(raw): + """``"RRF"`` 必须与 ``"rrf"`` 得到同一结论。 + + 修复前 ``HybridReranker`` 会 ``.strip().lower()``,而 + ``_should_apply_score_threshold`` 用**原始**取值与字面量 ``"rrf"`` 比较: + ``"RRF"`` 走 RRF 融合(分数是排名倒数之和)却不被识别为 RRF,阈值过滤照常 + 套用——两种量纲混在一起,结果被静默清空。 + """ + assert _coerce_fusion_strategy(raw) == "rrf" + config = _session_config() + config.hybrid_fusion_strategy = raw + normalized = _coerce_fusion_strategy(config.hybrid_fusion_strategy) + assert _SessionConfigThresholdCheck(config, normalized) is True + + +class _SessionConfigThresholdCheck: + """调用 ``RetrievalService._should_apply_score_threshold`` 的可读包装。""" + + def __new__(cls, config, fusion_strategy): + from src.services.retrieval_service import RetrievalService + + return not RetrievalService._should_apply_score_threshold( + config, fusion_strategy=fusion_strategy + ) + + +def test_fusion_strategy_normalization_keeps_weighted(): + """收紧不能误伤:``weighted`` 仍照常套用阈值过滤。""" + from src.services.retrieval_service import RetrievalService + + assert _coerce_fusion_strategy("WEIGHTED") == "weighted" + config = _session_config() + assert ( + RetrievalService._should_apply_score_threshold(config, fusion_strategy="weighted") is True + ) + + +def test_cleanup_old_logs_tolerates_log_path_being_a_file(tmp_path): + """``log_path`` 是已存在的普通文件时不得抛 ``NotADirectoryError``。 + + 写入侧对此的处置是降级为仅控制台,而清理侧由退出钩子调用——异常会让程序在 + 退出路径上以裸 traceback 终止。 + """ + log_path = tmp_path / "not_a_dir.txt" + log_path.write_text("x", encoding="utf-8") + settings = _log_settings(str(log_path), retention_days=7) + + with patch("src.utils.log_manager.get_settings", return_value=settings): + cleanup_old_logs() # 不应抛异常 + + +def test_cleanup_old_logs_still_removes_expired_files(tmp_path): + """收紧不能误伤:过期的本应用日志照旧被删。""" + from datetime import datetime, timedelta + + old_date = (datetime.now().astimezone() - timedelta(days=30)).strftime("%Y-%m-%d") + expired = tmp_path / f"app_log_{old_date}.log" + expired.write_text("x", encoding="utf-8") + + settings = _log_settings(str(tmp_path), retention_days=7) + + with patch("src.utils.log_manager.get_settings", return_value=settings): + cleanup_old_logs() + assert not expired.exists() + + +def test_get_settings_still_returns_settings(): + """收紧不能误伤:正常配置加载路径不受影响。""" + assert get_settings() is not None + + +# --------------------------------------------------------------------------- +# 四、volcengine / anthropic 渠道的边界缺陷(第二轮审查) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "tool_type", + [ + "web_search_20250305", + "code_execution_20250522", + "text_editor_20250728", + "memory_20250818", + "web_fetch_20250910", + "browser_toolset_20260801", + "tool_search_tool_bm25", + "computer_toolset_20260801", + # 已从当前 SDK 联合类型移除、但仍在服务端生效的旧字面量 + "computer_20250124", + "bash_20250124", + "text_editor_20250124", + "server_custom", + "custom", + ], +) +def test_anthropic_server_tools_pass_through_verbatim(tool_type): + """服务端工具必须原样透传,不得被降级成空 schema 的普通函数工具。 + + 白名单原先只认 ``server_`` 前缀与三个 2025-01-24 时代的字面量,而 SDK 里 + 真实存在二十种服务端工具类型——它们被降级后 ``max_uses`` 等专属字段被丢弃, + 服务端收到自定义函数工具而非服务端检索工具,用户看到 web_search 静默失效。 + """ + from src.providers.anthropic import AnthropicProvider + + tool = {"type": tool_type, "name": "n", "max_uses": 5} + assert AnthropicProvider._convert_tools([tool])[0] == tool + + +def test_anthropic_server_tool_types_track_the_sdk(): + """推导集合必须跟随 SDK,而不是写死的白名单。 + + SDK 升级新增工具时集合自动跟随——写死白名单的失效模式正是「SDK 加了新工具、 + 本地静默降级」。 + """ + import typing + + import anthropic + + from src.providers.anthropic import _SERVER_TOOL_TYPES + + derived: set[str] = set() + for member in typing.get_args(anthropic.types.ToolUnionParam): + if member is anthropic.types.ToolParam: + continue + for literal in typing.get_args(typing.get_type_hints(member).get("type")): + if isinstance(literal, str): + derived.add(literal) + assert derived, "SDK 联合类型里应当能推导出服务端工具类型" + assert derived <= _SERVER_TOOL_TYPES + + +def test_anthropic_function_tools_still_become_schemas(): + """收紧不能误伤:普通函数工具仍走 schema 转换分支。""" + from src.providers.anthropic import AnthropicProvider + + converted = AnthropicProvider._convert_tools( + [{"function": {"name": "lookup", "parameters": {"type": "object"}}}] + ) + assert converted[0]["name"] == "lookup" + assert "input_schema" in converted[0] + + +@pytest.mark.parametrize("value", ["json_object", 42, ["a"]]) +def test_anthropic_response_format_rejects_non_mapping(value): + """``response_format`` 的两个来源必须同判据。 + + ``options.response_format`` 那一侧早有守卫,但**请求参数**来源直接进 + ``_response_format_config``,非 Mapping 时 ``.get()`` 抛裸 ``AttributeError``。 + """ + from src.providers.anthropic import AnthropicProvider + + with pytest.raises(ValueError, match="response_format 必须是对象"): + AnthropicProvider._response_format_config(value) + + +@pytest.mark.parametrize("value", ["json_object", 42, ["a"]]) +def test_ark_response_format_rejects_non_mapping(value): + """Ark Responses 同一语义的同一判据(与 google.py 对齐)。""" + from src.providers.volcengine import VolcengineProvider + + with pytest.raises(ValueError, match="response_format 必须是对象"): + VolcengineProvider._convert_responses_format(value) + + +def test_response_format_still_converts_legal_values(): + """收紧不能误伤:合法的 ``json_object``/``json_schema`` 照旧转换。""" + from src.providers.anthropic import AnthropicProvider + from src.providers.volcengine import VolcengineProvider + + assert AnthropicProvider._response_format_config({"type": "json_object"}) == { + "type": "json_schema", + "schema": {"type": "object"}, + } + assert VolcengineProvider._convert_responses_format({"type": "json_object"}) == { + "format": {"type": "json_object"} + } + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(10**400, id="beyond-double"), + pytest.param("abc", id="string"), + pytest.param([1], id="list"), + pytest.param(1.5, id="float"), + pytest.param(-5, id="negative"), + pytest.param(True, id="bool"), + pytest.param(0, id="zero"), + ], +) +def test_anthropic_request_max_tokens_is_validated(value, monkeypatch): + """请求级 ``max_tokens`` 与 ``options.max_tokens`` 必须同一判据。 + + 修复前请求级取值原样进 params,由 SDK 深处的 + ``_calculate_nonstreaming_timeout`` 执行 ``maximum_time * max_tokens / 128_000`` + 时才炸:``10**400`` 抛 ``OverflowError``(``ArithmeticError`` 子类,既不落 + ``except ValueError`` 也不落 ``except RuntimeError``),``"abc"`` 抛 + ``TypeError``,而 ``1.5``/``-5``/``True`` 静默通过、原样发给服务端。 + """ + _patch_provider_settings(monkeypatch, "src.providers.anthropic", anthropic_api_key="test-key") + from src.providers.anthropic import AnthropicProvider + + provider = AnthropicProvider("claude-sonnet-4-5") + with pytest.raises(ValueError, match="必须是正整数"): + provider._build_message_params(prompt="hi", max_tokens=value, stream=False) + + +def test_anthropic_request_max_tokens_accepts_positive_integers(monkeypatch): + """收紧不能误伤:合法取值照旧通过;未提供时用模型默认。""" + _patch_provider_settings(monkeypatch, "src.providers.anthropic", anthropic_api_key="test-key") + from src.providers.anthropic import AnthropicProvider + + provider = AnthropicProvider("claude-sonnet-4-5") + assert ( + provider._build_message_params(prompt="hi", max_tokens=4096, stream=False)["max_tokens"] + == 4096 + ) + assert provider._build_message_params(prompt="hi", stream=False)["max_tokens"] > 0 + + +@pytest.mark.parametrize( + "block", + [ + pytest.param({"type": "refusal", "refusal": "被拒绝"}, id="refusal-key"), + pytest.param({"type": "refusal", "reason": "被拒绝"}, id="reason-key"), + pytest.param({"type": "refusal", "text": "被拒绝"}, id="text-key"), + pytest.param( + {"type": "refusal", "refusal": "A", "text": "B", "reason": "C"}, id="priority" + ), + ], +) +def test_anthropic_refusal_block_matches_between_streaming_and_complete(block): + """非流式与流式对同一 refusal 块必须给出同一结果。 + + 非流式原先只读 ``reason``/``text``,漏了 SDK ``RefusalBlock`` 的正式字段名 + ``refusal``——于是 ``[{"type": "refusal", "refusal": "被拒绝"}]`` 在非流式得到 + ``refusal=None``、``text=''``、``finish_reason='end_turn'``,``invoke()`` 输出 + 空列表:一次**安全拦截**变成「空成功」。 + """ + from anthropic._models import construct_type + from anthropic.types import Message + + from src.providers.anthropic import AnthropicProvider + + response = construct_type( + type_=Message, + value={ + "id": "m", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [block], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + non_streaming = AnthropicProvider._extract_result(response).refusal + event = AnthropicProvider._stream_event( + {"type": "content_block_start", "index": 0, "content_block": block}, "m" + ) + assert non_streaming == (event.refusal if event else None) + assert non_streaming == "被拒绝" or non_streaming == "A" + + +def test_anthropic_non_refusal_block_is_unaffected(): + """收紧不能误伤:普通 text 块不产生 refusal。""" + from anthropic._models import construct_type + from anthropic.types import Message + + from src.providers.anthropic import AnthropicProvider + + response = construct_type( + type_=Message, + value={ + "id": "m", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + result = AnthropicProvider._extract_result(response) + assert result.text == "hello" + assert result.refusal is None + + +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({}, id="no-args"), + pytest.param({"store": True}, id="store-only"), + pytest.param({"text": {"format": {"type": "json_object"}}}, id="text-only"), + ], +) +def test_ark_dynamic_create_validates_without_trigger_fields(kwargs, monkeypatch): + """Ark 动态创建路径在「一个触发字段都没有」时也必须校验。 + + 修复前校验被整体跳过,请求直达 SDK 撞上 ``_utils/_utils.py`` 的 + ``assert "model" in kwargs``,抛 **``AssertionError``**——既不落 + ``except ValueError`` 也不落 ``except RuntimeError``。 + """ + _patch_provider_settings( + monkeypatch, + "src.providers.volcengine", + ark_api_key="test-key", + volc_access_key=None, + volc_secret_key=None, + volc_base_url="https://ark.invalid/v3", + ) + from src.providers.volcengine import VolcengineProvider + + provider = VolcengineProvider( + "m", options={"server_verified_protocols": ["responses"]}, protocol="responses" + ) + with pytest.raises(ValueError, match="必须显式提供 model"): + provider._require_provider_resource("responses.create", kwargs) + + +def test_ark_dynamic_non_create_calls_are_not_forced_to_supply_model(monkeypatch): + """收紧不能误伤:retrieve/delete/list 不得被要求提供 ``model``。""" + _patch_provider_settings( + monkeypatch, + "src.providers.volcengine", + ark_api_key="test-key", + volc_access_key=None, + volc_secret_key=None, + volc_base_url="https://ark.invalid/v3", + ) + from src.providers.volcengine import VolcengineProvider + + provider = VolcengineProvider( + "m", options={"server_verified_protocols": ["responses"]}, protocol="responses" + ) + for method in ("responses.retrieve", "responses.delete", "responses.list"): + provider._require_provider_resource(method, {}) + + +def test_ark_dynamic_create_with_full_args_passes(monkeypatch): + """收紧不能误伤:完整参数的创建调用照旧通过。""" + _patch_provider_settings( + monkeypatch, + "src.providers.volcengine", + ark_api_key="test-key", + volc_access_key=None, + volc_secret_key=None, + volc_base_url="https://ark.invalid/v3", + ) + from src.providers.volcengine import VolcengineProvider + + provider = VolcengineProvider( + "m", options={"server_verified_protocols": ["responses"]}, protocol="responses" + ) + provider._require_provider_resource("responses.create", {"model": "m", "input": "x"}) + + +# --------------------------------------------------------------------------- +# 五、分片重叠的比例上界(第 12 条) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("overlap", [751, 1490, 1499]) +def test_settings_rejects_overlap_beyond_half_of_size(overlap): + """overlap 超过 size 的一半时必须拒绝。 + + ``overlap < size`` 拦不住「接近但不相等」这一类,而它同样静默:实测一篇 + 15000 字符的文档在 size=1500 时,overlap=1490(99%)产出 1651 个分片 + (137.6x)、overlap=1499 产出 13751 个(1145.9x)——全部进入 embedding 与 + FAISS 写入。 + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="不能超过 kb_chunk_size 的一半"): + Settings(kb_chunk_size=1500, kb_chunk_overlap=overlap) + + +def test_settings_accepts_overlap_at_exactly_half(settings_factory=None): + """收紧不能误伤:正好一半是合法上界。""" + assert Settings(kb_chunk_size=1500, kb_chunk_overlap=750).kb_chunk_overlap == 750 + assert Settings(kb_chunk_size=100, kb_chunk_overlap=50).kb_chunk_overlap == 50 + + +def test_splitter_rejects_overlap_beyond_half_of_size(): + """分片器层同判据——它是公开类,调用方可以绕过 ``Settings`` 直接构造。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + for mode in ("standard", "hierarchical"): + splitter = RecursiveTextSplitter( + parent_chunk_size=1500, parent_chunk_overlap=1499, structure_mode=mode + ) + with pytest.raises(ValueError, match="不能超过 kb_chunk_size 的一半"): + if mode == "hierarchical": + splitter._validate_hierarchical_sizes() + else: + splitter._validate_parent_sizes() + + +def test_splitter_accepts_overlap_within_the_bound(): + """收紧不能误伤:默认比例与正好一半都照旧可用。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + for overlap in (150, 750): + splitter = RecursiveTextSplitter( + parent_chunk_size=1500, parent_chunk_overlap=overlap, structure_mode="standard" + ) + splitter._validate_parent_sizes() + assert splitter.split([Document(content="句子内容。" * 3000)]) + + +# --------------------------------------------------------------------------- +# 六、请求头取值与 embedding 配置取值的校验(第三轮审查) +# --------------------------------------------------------------------------- + + +def _capture_anthropic_headers(provider, *, beta: bool) -> dict[str, object]: + """替换 provider 的 ``count_tokens`` 客户端,捕获它实际收到的参数。 + + 客户端是懒加载的(构造期 ``_client`` 为 ``None``),因此要整体替换 + ``_get_client``/``_get_aclient`` 而不是给 ``provider._client`` 打属性—— + 后者对 ``None`` 抛 ``AttributeError: 'NoneType' object has no attribute + 'messages'``。仓库既有的协议适配测试用的是同一种注入方式。 + """ + captured: dict[str, object] = {} + + def _capture(**kwargs): + captured.update(kwargs) + return SimpleNamespace(input_tokens=3) + + async def _acapture(**kwargs): + captured.update(kwargs) + return SimpleNamespace(input_tokens=3) + + # ``create`` 与 ``count_tokens`` 共用同一个捕获函数:测试关心的是**发给 SDK + # 的参数**,入口不同不改变判据。 + client = SimpleNamespace( + messages=SimpleNamespace(count_tokens=_capture, create=_capture), + beta=SimpleNamespace(messages=SimpleNamespace(count_tokens=_capture, create=_capture)), + ) + aclient = SimpleNamespace( + messages=SimpleNamespace(count_tokens=_acapture, create=_acapture), + beta=SimpleNamespace(messages=SimpleNamespace(count_tokens=_acapture, create=_acapture)), + ) + provider._get_client = lambda: client + provider._get_aclient = lambda: aclient + return captured + + +def _anthropic_provider(monkeypatch): + """走真实构造器拿到 provider,避免手工拼内部属性时漏字段。""" + _patch_provider_settings(monkeypatch, "src.providers.anthropic", anthropic_api_key="test-key") + from src.providers.anthropic import AnthropicProvider + + return AnthropicProvider("claude-sonnet-4-5") + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(42, id="int"), + pytest.param(b"x", id="bytes"), + pytest.param(["a"], id="list"), + pytest.param({"a": 1}, id="dict"), + ], +) +def test_anthropic_rejects_non_string_header_values(monkeypatch, value): + """``extra_headers`` 的**取值**必须校验,不能只校验容器。 + + ``validate_secret_free_request_overrides`` 只确认 ``extra_headers`` 是 Mapping, + 值原样保留。非字符串值的两种收场都不在渠道的错误契约内:交给 httpx 时抛 + ``TypeError: Header value must be str or bytes``;而本模块对 ``anthropic-beta`` + 做 ``.split(",")`` 时抛 ``AttributeError: 'int' object has no attribute 'split'`` + ——两者都不落 ``except ValueError``/``except RuntimeError``。 + """ + provider = _anthropic_provider(monkeypatch) + with pytest.raises(ValueError, match="extra_headers"): + provider.count_tokens(prompt="hi", user="u1", extra_headers={"anthropic-beta": value}) + + +def test_anthropic_drops_none_header_values(monkeypatch): + """``None`` 头值等价于「该头未设置」,必须剔除而不是透传。 + + httpx 对 ``None`` 头值抛 ``TypeError``,SDK 更早一步抛 + ``AttributeError: 'NoneType' object has no attribute 'encode'``。 + """ + provider = _anthropic_provider(monkeypatch) + captured = _capture_anthropic_headers(provider, beta=False) + + provider.count_tokens( + prompt="hi", user="u1", extra_headers={"x-trace": None, "anthropic-beta": None} + ) + headers = captured["extra_headers"] + assert "x-trace" not in headers + assert "None" not in headers.get("anthropic-beta", "") + + +def test_anthropic_user_without_extra_headers_still_works(monkeypatch): + """``user`` 已设置而 ``extra_headers`` 为 ``None`` 时必须能合并出 beta 头。 + + 修复前这里对 ``None`` 做 ``headers["anthropic-beta"] = ...``,抛裸 + ``TypeError: 'NoneType' object does not support item assignment``。而 + ``extra_headers`` 不传恰恰是最常见的调用形态。 + """ + provider = _anthropic_provider(monkeypatch) + captured = _capture_anthropic_headers(provider, beta=False) + + provider.count_tokens(prompt="hi", user="u1") + assert captured["extra_headers"]["anthropic-beta"] == "user-profiles-2026-08-18" + + +@pytest.mark.parametrize("async_path", [False, True]) +def test_anthropic_beta_count_tokens_uses_validated_headers(monkeypatch, async_path): + """beta 计数入口必须用**校验后**的头,不能回退到原始的 ``request.extra_headers``。 + + 同文件 ``beta=False`` 的计数入口用的是 ``profile_headers``,而 ``beta=True`` + 的两个入口把校验结果丢进 ``_``,转头写回原始字典——被剔除的 ``None`` 值于是 + 原样发给 httpx。 + """ + import asyncio + + provider = _anthropic_provider(monkeypatch) + captured = _capture_anthropic_headers(provider, beta=True) + + call = ( + provider.async_beta_count_tokens(prompt="hi", user="u1", extra_headers={"x-trace": None}) + if async_path + else provider.beta_count_tokens(prompt="hi", user="u1", extra_headers={"x-trace": None}) + ) + if async_path: + asyncio.run(call) + + assert "x-trace" not in captured["extra_headers"] + + +def test_anthropic_header_validation_does_not_touch_legal_headers(monkeypatch): + """收紧不能误伤:合法字符串头(含既有 anthropic-beta 值)照旧保留并合并。""" + provider = _anthropic_provider(monkeypatch) + captured = _capture_anthropic_headers(provider, beta=False) + + provider.count_tokens( + prompt="hi", user="u1", extra_headers={"x-trace": "t", "anthropic-beta": "a,b"} + ) + headers = captured["extra_headers"] + assert headers["x-trace"] == "t" + assert headers["anthropic-beta"] == "a,b,user-profiles-2026-08-18" + + +# ── embedding 配置的取值校验 ── +# +# ``EmbeddingService.embedding_model_detail`` 原先只判**键**在不在,不判**值** +# 可用。``{"local-hash": None}`` 的键确实存在,于是 ``None`` 被原样返回,两个 +# 调用点紧接着读 ``.provider``/``.model_name``,在那里抛裸 ``AttributeError``。 +# 同一份配置走 ``ModelProviderFactory.get_embedding_provider`` 得到的是 +# ``ValueError``——两条路径的报错契约本该一致。 + + +def _embedding_service(configurations): + from src.services.embedding_service import EmbeddingService + + return EmbeddingService( + SimpleNamespace( + default_embedding_provider="local-hash", + embedding_configurations=configurations, + ) + ) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(None, id="none"), + pytest.param("local-hash-32", id="string"), + pytest.param(5, id="int"), + pytest.param(SimpleNamespace(provider="local-hash"), id="missing-model-name"), + pytest.param(SimpleNamespace(model_name="m"), id="missing-provider"), + ], +) +def test_embedding_model_detail_rejects_unusable_values(value): + """取值不提供 ``provider``/``model_name`` 时必须抛 ``ValueError``。 + + 判据是**属性存在性**而非 ``isinstance(..., ModelDetail)``:使用方只要求取值 + 具备这些属性,仓库测试就用 ``SimpleNamespace`` 构造等价配置。 + """ + with pytest.raises(ValueError, match="必须是提供"): + _ = _embedding_service({"local-hash": value}).embedding_model_detail + + +def test_embedding_model_detail_accepts_attribute_bearing_values(): + """收紧不能误伤:``ModelDetail`` 与具备同名属性的替身都照旧可用。""" + from src.utils.config import ModelDetail + + detail = ModelDetail(provider="local-hash", model_name="local-hash-32") + assert _embedding_service({"local-hash": detail}).embedding_model_detail is detail + + stand_in = SimpleNamespace(provider="local-hash", model_name="m") + assert _embedding_service({"local-hash": stand_in}).embedding_model_detail is stand_in + + +def test_knowledge_build_surfaces_unusable_embedding_detail_as_value_error(): + """建库主路径上同判据——错误分类必须是 ``ValueError``,不是裸 ``AttributeError``。 + + 零分块的 markdown 会跳过 embedding 调用,直接走到写清单那一步读 + ``model_name``;这是最晚才暴露、也最难从消息反推原因的路径。 + """ + from src.services.knowledge_build_service import KnowledgeBuildService + + service = KnowledgeBuildService.__new__(KnowledgeBuildService) + service.embedding_service = _embedding_service({"local-hash": None}) + + with pytest.raises(ValueError, match="必须是提供"): + _ = service.embedding_service.embedding_model_detail.model_name + + +# ── `embedding_model_detail` 的**键**形态也要收敛 ── +# +# 上面那组用例覆盖的是「键在、值不可用」;而**键本身形态非法**这一半此前漏了。 +# ``key not in configurations`` 对不可哈希的取值抛裸 ``TypeError: unhashable +# type: 'list'``——早于本方法任何拒绝路径,而本方法其余分支一律给带可选值列表的 +# ``ValueError``,调用方(``embed_query`` 的 Google 分支与 +# ``knowledge_build_service`` 的清单写入)也按 ``ValueError`` 分类。 +# +# 可达性:``default_embedding_provider`` 没有 validator(``Settings`` 里只是普通 +# ``str``),而 ``RunConfig`` 是 frozen dataclass、字段无校验器,嵌入方可以自拼 +# ``RunConfig(default_embedding_provider=["local-hash"], ...)``。 +# ``ModelProviderFactory._get_model_detail`` 对同一个 key 早已收敛(判据 +# 「必须是字符串」),两条路径必须一致。 + + +@pytest.mark.parametrize( + "key", + [ + pytest.param(["local-hash"], id="list"), + pytest.param({"local-hash": 1}, id="dict"), + pytest.param({1, 2}, id="set"), + ], +) +def test_embedding_model_detail_rejects_unhashable_provider_key(key): + """不可哈希的 provider key 必须收敛成带指引的 ``ValueError``。""" + from src.services.embedding_service import EmbeddingService + + service = EmbeddingService( + SimpleNamespace( + default_embedding_provider=key, + embedding_configurations={"local-hash": SimpleNamespace(provider="x", model_name="m")}, + ) + ) + + with pytest.raises(ValueError, match="default_embedding_provider"): + _ = service.embedding_model_detail + + +def test_embedding_model_detail_still_accepts_a_string_provider_key(): + """收紧不能误伤:字符串 key 照常取到配置。""" + from src.services.embedding_service import EmbeddingService + + detail = SimpleNamespace(provider="local-hash", model_name="m") + service = EmbeddingService( + SimpleNamespace( + default_embedding_provider="local-hash", + embedding_configurations={"local-hash": detail}, + ) + ) + + assert service.embedding_model_detail is detail + + +@pytest.mark.parametrize( + "key", + [ + pytest.param(["local-hash"], id="list"), + pytest.param({"local-hash": 1}, id="dict"), + pytest.param({1, 2}, id="set"), + ], +) +def test_vector_store_factory_rejects_unhashable_provider_key(key): + """同一条判据的第二个落点:``VectorStoreFactory._require_embedding_detail``。 + + 它与 ``EmbeddingService.embedding_model_detail`` 是同一段逻辑的两个副本 + (同一字段、同一来源、同样的「先判映射再判键」),因此判据只修一处会留下 + 另一个入口。 + """ + from src.retrieval.vdb.factory import VectorStoreFactory + + run_config = SimpleNamespace( + default_embedding_provider=key, + embedding_configurations={"local-hash": SimpleNamespace(provider="x", model_name="m")}, + ) + + with pytest.raises(ValueError, match="default_embedding_provider"): + VectorStoreFactory._require_embedding_detail(run_config) + + +def test_vector_store_factory_still_accepts_a_string_provider_key(): + """收紧不能误伤:字符串 key 照常取到配置。""" + from src.retrieval.vdb.factory import VectorStoreFactory + + detail = SimpleNamespace(provider="local-hash", model_name="m") + run_config = SimpleNamespace( + default_embedding_provider="local-hash", + embedding_configurations={"local-hash": detail}, + ) + + assert VectorStoreFactory._require_embedding_detail(run_config) is detail + + +# ── user / betas 的取值校验 ── +# +# ``CompletionRequest`` 是普通 dataclass、``__post_init__`` 不校验这两个字段 +# (声明类型分别是 ``str | None`` 与 ``Sequence[str] | None``)。它们都进 HTTP +# 请求:``user`` 转成 ``user_profile_id`` 头,``betas`` 拼成 ``anthropic-beta`` +# 头。走真实 SDK(``httpx2.MockTransport`` 拦网)实测的修复前收场: +# +# invoke(user=5) -> AttributeError: 'int' object has no attribute 'encode' +# beta_create(betas=5) -> TypeError: 'int' object is not iterable +# beta_create(betas=b"x")-> 发出 anthropic-beta: 120 +# beta_create(betas=[1, 2]) -> 发出 anthropic-beta: 1,2 +# beta_create(betas={"a": 1}) -> 发出 anthropic-beta: a(静默取键) +# +# 前者不是本渠道 ``except ValueError``/``except RuntimeError`` 分类内的类型; +# 后三者不报错,但服务端收到一个不存在的 beta 名——要么报一个与调用方写法 +# 毫无关系的错,要么静默忽略掉本想启用的特性。 + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(["a"], id="list"), + pytest.param(b"x", id="bytes"), + pytest.param({"a": 1}, id="dict"), + ], +) +def test_anthropic_rejects_non_string_user(monkeypatch, value): + """``user`` 非字符串必须抛 ``ValueError``,不是裸 ``AttributeError``。""" + provider = _anthropic_provider(monkeypatch) + _capture_anthropic_headers(provider, beta=False) + + with pytest.raises(ValueError, match="user 必须是字符串"): + provider.count_tokens(prompt="hi", user=value) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(b"x", id="bytes"), + pytest.param("s", id="bare-string"), + pytest.param({"a": 1}, id="mapping"), + ], +) +def test_anthropic_rejects_non_list_betas(monkeypatch, value): + """``betas`` 不是字符串列表时必须拒绝。 + + ``bytes``/``str``/``Mapping`` 都是**可迭代**的,``list()`` 不会报错而是 + 静默给出错误结果:``list(b"x")`` 得 ``[120]``、``list("ab")`` 把单个 beta + 名拆成两个、``list({"a": 1})`` 取键得 ``["a"]``。 + """ + provider = _anthropic_provider(monkeypatch) + _capture_anthropic_headers(provider, beta=True) + + with pytest.raises(ValueError, match="betas"): + provider.beta_create(prompt="hi", betas=value) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param([1, 2], id="ints"), + pytest.param(["a", 1], id="mixed"), + pytest.param([None], id="none-element"), + ], +) +def test_anthropic_rejects_non_string_beta_elements(monkeypatch, value): + """``betas`` 的元素非字符串必须拒绝——它们会被拼进 HTTP 头。""" + provider = _anthropic_provider(monkeypatch) + _capture_anthropic_headers(provider, beta=True) + + with pytest.raises(ValueError, match="元素必须是字符串"): + provider.beta_create(prompt="hi", betas=value) + + +def test_anthropic_betas_validated_without_user(monkeypatch): + """``betas`` 必须在 ``user`` 为 ``None`` 时也校验。 + + ``_user_profile_fields`` 只在 ``user is not None`` 时被调用,而 ``betas`` + 走 ``beta_values`` 直接进 params——把校验只放在那个函数里会漏掉这条路径, + 实测 ``beta_create(prompt="hi", betas=5)`` 仍抛裸 ``TypeError``。 + """ + provider = _anthropic_provider(monkeypatch) + _capture_anthropic_headers(provider, beta=True) + + with pytest.raises(ValueError, match="betas"): + provider.beta_create(prompt="hi", betas=5) + + +def test_anthropic_user_and_betas_still_accept_legal_values(monkeypatch): + """收紧不能误伤:合法 ``user`` 与 ``betas`` 照旧合并进 ``anthropic-beta``。""" + provider = _anthropic_provider(monkeypatch) + captured = _capture_anthropic_headers(provider, beta=True) + + provider.beta_create(prompt="hi", user="u1", betas=["a", "b"]) + assert captured["betas"] == ["a", "b", "user-profiles-2026-08-18"] + assert captured["user_profile_id"] == "u1" + + +def test_anthropic_betas_alone_reach_params_unchanged(monkeypatch): + """``user`` 为 ``None`` 时 ``betas`` 原样进 params(不注入 profile 头)。""" + provider = _anthropic_provider(monkeypatch) + captured = _capture_anthropic_headers(provider, beta=True) + + provider.beta_create(prompt="hi", betas=["a"]) + assert captured["betas"] == ["a"] + assert "user_profile_id" not in captured + + +# ── 工具定义与消息列表的容器形态 ── +# +# 四个 provider 的 ``_convert_tools`` 都写 ``for index, tool in enumerate(tools)``, +# 而同函数对**可迭代但元素非法**的形态(``"s"``/``{"a": 1}``/``b"x"``)早已给 +# 带序号的 ``ValueError``——只有「不可迭代」这一半漏了,``enumerate`` 抛裸 +# ``TypeError: 'int' object is not iterable``。同一判据的两半必须都在,否则 +# 「工具定义写错」的收场取决于它是标量还是序列。 +# +# ``extract_system_prompt`` 是更典型的一处:它在多数 provider 里**先于** +# ``normalize_messages``(后者有完整判据)被调用,因此非法取值在这里就炸。 +# 实测 ``messages=5`` 抛裸 ``TypeError``、``messages="s"`` 抛裸 +# ``AttributeError: 'str' object has no attribute 'get'``——字符串是合法的 +# ``Sequence``,只判可迭代拦不住。 + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(1.5, id="float"), + pytest.param(object(), id="opaque-object"), + ], +) +def test_google_rejects_non_sequence_tools(value): + """``tools`` 不可迭代时必须抛 ``ValueError``,不是裸 ``TypeError``。""" + from src.providers.google import GoogleProvider + + with pytest.raises(ValueError, match="工具定义必须是列表"): + GoogleProvider._convert_tools(value) + + +@pytest.mark.parametrize( + "value", + [pytest.param(5, id="int"), pytest.param(1.5, id="float")], +) +def test_anthropic_rejects_non_sequence_tools(value): + """Anthropic 同判据——``_build_message_params(prompt="hi", tools=5)`` 实测抛裸 ``TypeError``。""" + from src.providers.anthropic import AnthropicProvider + + with pytest.raises(ValueError, match="工具定义必须是列表"): + AnthropicProvider._convert_tools(value) + + +@pytest.mark.parametrize( + "value", + [pytest.param(5, id="int"), pytest.param(1.5, id="float")], +) +def test_responses_converters_reject_non_sequence_tools(value): + """Ark 与 OpenAI 兼容的 Responses 转换器同判据。""" + from src.providers.openai_compatible import OpenAICompatibleProvider + from src.providers.volcengine import VolcengineProvider + + with pytest.raises(ValueError, match="工具定义必须是列表"): + OpenAICompatibleProvider._convert_responses_tools(value) + with pytest.raises(ValueError, match="工具定义必须是列表"): + VolcengineProvider._convert_responses_tools(value) + + +def test_tool_converters_still_accept_legal_sequences(): + """收紧不能误伤:``None`` 与空列表照旧得到 ``None``,合法工具照旧转换。""" + from src.providers.anthropic import AnthropicProvider + from src.providers.google import GoogleProvider + from src.providers.openai_compatible import OpenAICompatibleProvider + from src.providers.volcengine import VolcengineProvider + + for converter in ( + GoogleProvider._convert_tools, + AnthropicProvider._convert_tools, + OpenAICompatibleProvider._convert_responses_tools, + VolcengineProvider._convert_responses_tools, + ): + assert converter(None) is None + assert converter([]) is None + + converted = GoogleProvider._convert_tools( + [{"function": {"name": "lookup", "parameters": {"type": "object"}}}] + ) + assert converted and converted[0] is not None + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("s", id="bare-string"), + pytest.param(b"x", id="bytes"), + pytest.param({"a": 1}, id="mapping"), + ], +) +def test_extract_system_prompt_rejects_non_message_containers(value): + """``messages`` 非法形态必须抛 ``ValueError``。 + + 字符串与 ``bytes`` 都是合法的 ``Sequence``,只判「可迭代」拦不住—— + 前者迭代出单字符、后者迭代出整数,随后 ``.get`` 抛裸 ``AttributeError``。 + """ + from src.providers.__base__.model_provider import extract_system_prompt + + with pytest.raises(ValueError, match="messages 必须是消息对象序列"): + extract_system_prompt(value) + + +@pytest.mark.parametrize( + "value", + [pytest.param(5, id="int"), pytest.param("s", id="bare-string")], +) +def test_extract_system_prompt_rejects_non_mapping_entries(value): + """条目非 Mapping 必须报错而不是跳过。 + + 静默跳过会让一条写错的消息**悄悄消失**,system 提示随之丢失——比报错 + 更难查。 + """ + from src.providers.__base__.model_provider import extract_system_prompt + + with pytest.raises(ValueError, match=r"messages\[0\] 必须是消息对象"): + extract_system_prompt([value]) + + +def test_extract_system_prompt_still_extracts_system_content(): + """收紧不能误伤:正常提取照旧,空列表与 ``None`` 返回 ``None``。""" + from src.providers.__base__.model_provider import extract_system_prompt + + assert extract_system_prompt(None) is None + assert extract_system_prompt([]) is None + assert extract_system_prompt([{"role": "user", "content": "hi"}]) is None + assert ( + extract_system_prompt( + [{"role": "system", "content": "A"}, {"role": "user", "content": "hi"}] + ) + == "A" + ) + + +def test_google_invoke_surfaces_bad_messages_as_value_error(monkeypatch): + """走真实入口:``messages=5`` 必须得到 ``ValueError``。 + + 必须**消费** ``invoke`` 的返回值——它是生成器,函数体在首次 ``next`` 之前 + 不执行,只调不迭代等于没验。 + """ + _patch_provider_settings(monkeypatch, "src.providers.google", google_api_key="test-key") + from src.providers.google import GoogleProvider + + provider = GoogleProvider("gemini-2.0-flash") + with pytest.raises(ValueError, match="messages 必须是消息对象序列"): + list(provider.invoke(prompt="hi", stream=False, messages=5)) + + +# ── rerank 与 embedding 入口的入参形态 ── +# +# 两个 rerank provider 的 ``rerank``/``arerank`` 都把 ``len(documents)`` 写在 +# ``_validate_inputs`` **之前**的日志行里:非序列取值在那里抛裸 +# ``TypeError: object of type 'int' has no len()``,而紧随其后的校验对同一取值 +# 给的是带指引的 ``ValueError``。同一判据的两半顺序反了。 +# +# embedding 侧更彻底:``TextEmbeddingModel`` 的六个实现没有一处判 ``texts`` +# 的形态。``texts=5`` 抛裸 ``TypeError``;``texts="abc"`` 是合法的 +# ``Sequence``,被**静默**当成三条单字符文本向量化(local_hash 实测返回 3 条)。 + + +def _rerank_providers(): + from src.providers.jina import JinaProvider + from src.providers.siliconflow_rerank import SiliconflowRerankProvider + + return [ + ("Jina", JinaProvider("m"), JinaProvider._validate_inputs), + ("SiliconFlow", SiliconflowRerankProvider("m"), SiliconflowRerankProvider._validate_inputs), + ] + + +@pytest.mark.parametrize("value", [pytest.param(5, id="int"), pytest.param(1.5, id="float")]) +def test_rerank_rejects_non_sequence_documents(value, monkeypatch): + """``documents`` 非序列必须抛 ``ValueError``,不是裸 ``TypeError``。 + + 四个入口(两个 provider × 同步/异步)的日志行都在校验之前求值 + ``len(documents)``。 + """ + monkeypatch.setattr( + "src.providers.jina.get_settings", + lambda: SimpleNamespace(jina_api_key="k"), + ) + monkeypatch.setattr( + "src.providers.siliconflow_rerank.get_settings", + lambda: SimpleNamespace(siliconflow_api_key="k", siliconflow_base_url="https://x/v1"), + ) + import asyncio + + for name, provider, _validate in _rerank_providers(): + with pytest.raises(ValueError, match="必须是非空列表"): + provider.rerank(query="q", documents=value, top_n=3) + with pytest.raises(ValueError, match="必须是非空列表"): + asyncio.run(provider.arerank(query="q", documents=value, top_n=3)) + + +def test_rerank_still_accepts_legal_documents(monkeypatch): + """收紧不能误伤:合法 ``documents`` 照旧通过校验。""" + monkeypatch.setattr( + "src.providers.jina.get_settings", lambda: SimpleNamespace(jina_api_key="k") + ) + monkeypatch.setattr( + "src.providers.siliconflow_rerank.get_settings", + lambda: SimpleNamespace(siliconflow_api_key="k", siliconflow_base_url="https://x/v1"), + ) + for name, _provider, validate in _rerank_providers(): + validate("q", ["a", "b"], 2) + + +def _embedding_providers(): + from src.providers.google import GoogleProvider + from src.providers.local_hash import LocalHashEmbeddingProvider + from src.providers.openai_compatible import OpenAICompatibleProvider + from src.providers.volcengine import VolcengineProvider + + openai_settings = SimpleNamespace( + openai_api_key="k", openai_api_base="https://api.openai.com/v1" + ) + return [ + ("LocalHash", LocalHashEmbeddingProvider("local-hash-32")), + ("Google", GoogleProvider("gemini-2.0-flash")), + ( + "OpenAI", + OpenAICompatibleProvider("text-embedding-3-large", "openai", settings=openai_settings), + ), + ("Ark", VolcengineProvider("m")), + ] + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("abc", id="bare-string"), + pytest.param([b"x"], id="bytes-element"), + pytest.param([1], id="int-element"), + ], +) +def test_embedding_rejects_non_string_list_texts(value, monkeypatch): + """``texts`` 非法形态必须抛 ``ValueError``。 + + ``"abc"`` 尤其要紧:它是合法的 ``Sequence``,此前被静默拆成三条单字符文本 + 向量化——调用方以为传了一条文本,实际入库三条垃圾向量。 + """ + _patch_provider_settings(monkeypatch, "src.providers.google", google_api_key="k") + _patch_provider_settings( + monkeypatch, + "src.providers.volcengine", + volc_access_key="a", + volc_secret_key="s", + volc_base_url="https://ark.cn-beijing.volces.com/api/v3", + ark_api_key="k", + ) + import asyncio + + for name, provider in _embedding_providers(): + with pytest.raises(ValueError, match="texts"): + provider.embed_documents(value) + with pytest.raises(ValueError, match="texts"): + asyncio.run(provider.aembed_documents(value)) + + +def test_embedding_still_accepts_legal_texts(monkeypatch): + """收紧不能误伤:空列表、列表与元组照旧可用。 + + 空列表**合法**——语义是「没有文档要向量化」,各家返回空列表,不能收紧成 + 「必须非空」(``require_text_list`` 与 rerank 的 ``_validate_inputs`` 在 + 这一点上有意不同)。 + """ + _patch_provider_settings(monkeypatch, "src.providers.google", google_api_key="k") + from src.providers.__base__.model_provider import require_text_list + from src.providers.local_hash import LocalHashEmbeddingProvider + + provider = LocalHashEmbeddingProvider("local-hash-32") + assert provider.embed_documents([]) == [] + assert len(provider.embed_documents(["a", "b"])) == 2 + assert len(provider.embed_documents(("a", "b"))) == 2 + assert require_text_list([], "X") == [] + + +def test_require_text_list_reports_the_offending_index(): + """元素非法时消息里要有下标,否则用户不知道是哪一条。""" + from src.providers.__base__.model_provider import require_text_list + + with pytest.raises(ValueError, match=r"texts\[2\]"): + require_text_list(["a", "b", 3], "X") + + +# ── ETL 三个阶段的入参形态 ── +# +# ``Pipeline.process`` → ``Cleaner.clean`` → ``Splitter.split`` 是建库主路径上 +# 相邻的三步(``KnowledgeBuildService._process_file`` 对知识库里的每个文件调 +# 一次),此前三处都不判入参形态。实测: +# +# process(5) -> AttributeError: 'int' object has no attribute 'metadata' +# clean(5) -> TypeError: 'int' object is not iterable +# clean("abc") -> AttributeError: 'str' object has no attribute 'content' +# split(5) -> TypeError: object of type 'int' has no len() +# split("abc") -> AttributeError: 'str' object has no attribute 'metadata' +# +# 三处都不在调用方按 ``ValueError`` 建立的分类内,且 ``"abc"`` 这类字符串是 +# 合法 ``Sequence``——只判可迭代拦不住。 + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("abc", id="bare-string"), + pytest.param(b"x", id="bytes"), + pytest.param(None, id="none"), + ], +) +def test_pipeline_process_rejects_non_document(value): + """``Pipeline.process`` 的入参必须是 ``Document``。""" + from src.etl.pipeline import Pipeline + + with pytest.raises(ValueError, match="必须是 Document"): + Pipeline.__new__(Pipeline).process(value) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("abc", id="bare-string"), + pytest.param(b"x", id="bytes"), + pytest.param({"a": 1}, id="mapping"), + ], +) +def test_cleaner_rejects_non_sequence_documents(value): + """``BasicCleaner.clean`` 的入参必须是文档列表。""" + from src.etl.cleaners.basic_cleaner import BasicCleaner + + with pytest.raises(ValueError, match="清洗器 documents"): + BasicCleaner().clean(value) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("abc", id="bare-string"), + pytest.param(None, id="none"), + ], +) +def test_cleaner_rejects_non_document_entries(value): + """条目非 ``Document`` 必须报错并带下标。""" + from src.etl.cleaners.basic_cleaner import BasicCleaner + + with pytest.raises(ValueError, match=r"清洗器 documents\[0\] 必须是 Document"): + BasicCleaner().clean([value]) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("abc", id="bare-string"), + pytest.param(b"x", id="bytes"), + pytest.param({"a": 1}, id="mapping"), + ], +) +def test_splitter_rejects_non_sequence_documents(value): + """``RecursiveTextSplitter.split`` 的入参必须是文档列表。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + splitter = RecursiveTextSplitter(parent_chunk_size=300, parent_chunk_overlap=50) + with pytest.raises(ValueError, match="分片器 documents"): + splitter.split(value) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param("abc", id="bare-string"), + pytest.param(None, id="none"), + ], +) +def test_splitter_rejects_non_document_entries(value): + """条目非 ``Document`` 必须报错并带下标。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + splitter = RecursiveTextSplitter(parent_chunk_size=300, parent_chunk_overlap=50) + with pytest.raises(ValueError, match=r"分片器 documents\[0\] 必须是 Document"): + splitter.split([value]) + + +def test_split_hierarchical_shares_the_same_guard(): + """``split_hierarchical`` 是公开方法,必须与 ``split`` 同判据。 + + 只守卫 ``split`` 会留下「直接调 ``split_hierarchical``」的口子——实测 + 修复前它抛 ``TypeError: 'int' object is not iterable``。 + """ + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + splitter = RecursiveTextSplitter( + parent_chunk_size=1500, + parent_chunk_overlap=150, + child_chunk_size=300, + child_chunk_overlap=50, + structure_mode="hierarchical", + ) + with pytest.raises(ValueError, match="分片器 documents"): + splitter.split_hierarchical(5) + with pytest.raises(ValueError, match=r"分片器 documents\[0\] 必须是 Document"): + splitter.split_hierarchical([5]) + + +def test_etl_stages_still_accept_legal_documents(): + """收紧不能误伤:三个阶段的合法路径照旧工作,且结果与修复前一致。""" + from src.etl.cleaners.basic_cleaner import BasicCleaner + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + cleaner = BasicCleaner() + assert cleaner.clean([]) == [] + + cleaned = cleaner.clean([Document(content="a\r\n\r\n\r\nb")]) + assert len(cleaned) == 1 and "a" in cleaned[0].content + + splitter = RecursiveTextSplitter(parent_chunk_size=300, parent_chunk_overlap=50) + assert splitter.split([]) == [] + assert len(splitter.split([Document(content="句子内容。" * 200)])) > 1 + + # ``Pipeline.process`` 需要一个完整装配的实例,这里只断言它的守卫放行 + # 合法 ``Document``(走真实的 ``isinstance`` 判据,而不是重复构造)。 + assert isinstance(Document(content="x"), Document) + + +# ── 配置写入入口的容器形态 ── +# +# ``SessionConfig.set_many`` 与 ``Chatbot.chat_config`` setter 是同一条写入路径 +# 的两端(后者优先转调前者),此前都不判入参容器形态。实测 ``5`` 与 ``None`` +# 在 ``for key in values`` 处抛裸 ``TypeError: 'int' object is not iterable``, +# 而紧随其后的 ``_require_known_field`` 对可迭代但字段名非法的形态给的是带 +# 指引的 ``ValueError``——同一个「配置写错」的收场不该取决于它是标量还是映射。 +# +# ``"abc"`` 更隐蔽:字符串是合法的 ``Sequence``,被拆成 ``a``/``b``/``c`` +# 三个字段名,报出与调用方写法毫无关系的 ``KeyError``。 + + +def _session_config_for_writes(): + from src.runtime.contracts import build_session_config + from src.utils.config import get_settings + + return build_session_config(get_settings()) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param("abc", id="bare-string"), + pytest.param(b"x", id="bytes"), + pytest.param([1], id="list"), + ], +) +def test_set_many_rejects_non_mapping(value): + """``set_many`` 的入参必须是映射。""" + with pytest.raises(ValueError, match="批量写入必须是字段映射"): + _session_config_for_writes().set_many(value) + + +def test_set_many_still_accepts_mappings(): + """收紧不能误伤:空映射与合法映射照旧工作。""" + config = _session_config_for_writes() + config.set_many({}) + config.set_many({"top_k": 3}) + assert config.top_k == 3 + + +def test_set_many_still_reports_unknown_fields(): + """字段名非法时的消息形态不变(带字段名与可选值)。""" + config = _session_config_for_writes() + with pytest.raises(ValueError, match="retrieval_method"): + config.set_many({"retrieval_method": "不存在"}) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param("abc", id="bare-string"), + pytest.param(b"x", id="bytes"), + pytest.param([1], id="list"), + ], +) +def test_chat_config_setter_rejects_non_mapping(value): + """``Chatbot.chat_config`` setter 的入参必须是 ``SessionConfig`` 或映射。""" + from src.chat.core import Chatbot + + bot = Chatbot.__new__(Chatbot) + bot.session_config = _session_config_for_writes() + with pytest.raises(ValueError, match="必须是 SessionConfig 或字段映射"): + bot.chat_config = value + + +def test_chat_config_setter_still_accepts_both_forms(): + """收紧不能误伤:``SessionConfig`` 实例与映射两条路径照旧工作。""" + from src.chat.core import Chatbot + from src.runtime.contracts import SessionConfig + + bot = Chatbot.__new__(Chatbot) + bot.session_config = _session_config_for_writes() + + replacement = _session_config_for_writes() + assert isinstance(replacement, SessionConfig) + bot.chat_config = replacement + assert bot.chat_config is replacement + + bot.chat_config = {"top_k": 7} + assert bot.session_config.top_k == 7 + + +def test_chat_config_setter_rejection_is_atomic(): + """被拒时不得留下改了一半的状态。 + + 这条守着既有的原子性契约(``deepcopy`` 副本上写入),容器守卫加在它之前 + 不能破坏它。 + """ + from src.chat.core import Chatbot + + bot = Chatbot.__new__(Chatbot) + bot.session_config = _session_config_for_writes() + before = bot.session_config.top_k + + with pytest.raises(ValueError): + bot.chat_config = {"top_k": 42, "retrieval_method": "不存在"} + assert bot.session_config.top_k == before + + +# ── retrieval_method 的取值收敛(三个取用点) ── +# +# ``retrieval_method`` 声明为 ``RetrievalMethod``,但 ``SessionConfig`` 是公开类、 +# 普通属性没有 ``__setattr__`` 守卫,嵌入方(或携带异常取值的 pickle 快照)可以 +# 直写成任何形态。修复前实测三个取用点各自的收场: +# +# * ``RetrievalService.retrieve``:``==`` 判分支,而它**没有**形态收敛——是该 +# 函数里唯一没有 ``_coerce_*`` 助手的字段(top_k/候选倍率/score_threshold/ +# rerank_enabled/hybrid_fusion_strategy 都有)。``"hybrid"``/``None``/``5`` +# 静默落进 else 分支,**退化成纯关键词检索**(实测只调了 keyword_search)。 +# 用户看到的是「配好了混合检索但只召回关键词命中」,不是一条配置错误。 +# * ``display_chat_config``:读 ``.value``,在 ``start_chat_session_async`` 里于 +# 任何提示符**之前**被调用,直写 ``5``/``None`` 抛裸 +# ``AttributeError: 'int' object has no attribute 'value'``,会话在启动横幅 +# 这一步就终止。 +# * ``config_menu.edit_retrieval_params``:读同一个 ``.value``,菜单渲染不出来。 + + +def _retrieval_service_with_recording(): + """返回 (service, calls):记录实际走到了哪条检索路径。""" + from src.retrieval.vdb.faiss_store import FaissStore + from src.runtime.contracts import build_run_config + from src.services.embedding_service import EmbeddingService + from src.services.retrieval_service import RetrievalService + from src.utils.config import get_settings + + service = RetrievalService(FaissStore(), EmbeddingService(build_run_config(get_settings()))) + calls: list[str] = [] + original_semantic = service._semantic_retrieve + original_keyword = service._keyword_retrieve + + async def recording_semantic(query, top_k): + calls.append("semantic") + return await original_semantic(query, top_k) + + async def recording_keyword(query, top_k): + calls.append("keyword") + return await original_keyword(query, top_k) + + service._semantic_retrieve = recording_semantic + service._keyword_retrieve = recording_keyword + return service, calls + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param("hybrid", id="english-alias"), + pytest.param("语义", id="abbreviated"), + pytest.param(None, id="none"), + pytest.param(5, id="int"), + pytest.param(["向量检索"], id="list"), + # id 必须显式给:pytest 生成参数 id 时会对取值调 ``str()``,位数超限的 + # 整数会让**收集阶段**自己崩掉(实测)。 + pytest.param(_HUGE_INT, id="beyond-int-max-str-digits"), + ], +) +def test_retrieve_rejects_unusable_retrieval_method(bad_value): + """无法识别的检索模式必须报错,而不是静默走错分支。 + + ``"hybrid"`` 这条尤其要紧:它是 ``"混合检索"`` 的英文写法,用户很容易写, + 而修复前它悄悄变成纯关键词检索。 + """ + import asyncio + + from src.runtime.contracts import build_session_config + from src.utils.config import get_settings + + service, calls = _retrieval_service_with_recording() + config = build_session_config(get_settings()) + config.retrieval_method = bad_value + + with pytest.raises(ValueError, match="retrieval_method 必须是"): + asyncio.run(service.retrieve("测试", config)) + + # 关键断言:被拒之前**没有**发起任何一路检索。只断言「抛了 ValueError」 + # 会让「先检索再校验」的实现也通过。 + assert calls == [] + + +@pytest.mark.parametrize( + ("value", "expected_calls"), + [ + (RetrievalMethod.HYBRID_SEARCH, ["semantic", "keyword"]), + ("混合检索", ["semantic", "keyword"]), + (RetrievalMethod.SEMANTIC_SEARCH, ["semantic"]), + ("向量检索", ["semantic"]), + (RetrievalMethod.FULL_TEXT_SEARCH, ["keyword"]), + ("全文检索", ["keyword"]), + ], +) +def test_retrieve_keeps_working_for_valid_retrieval_methods(value, expected_calls): + """收紧不能误伤:枚举成员与它的字符串取值(config.toml 的既有契约)都照旧。""" + import asyncio + + from src.runtime.contracts import build_session_config + from src.utils.config import get_settings + + service, calls = _retrieval_service_with_recording() + config = build_session_config(get_settings()) + config.retrieval_method = value + + asyncio.run(service.retrieve("测试", config)) + + assert calls == expected_calls + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param("hybrid", id="english-alias"), + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(_HUGE_INT, id="beyond-int-max-str-digits"), + ], +) +def test_display_chat_config_rejects_unusable_retrieval_method(bad_value): + """启动横幅不得因可直写的取值而抛裸 ``AttributeError``。 + + 本函数在 ``start_chat_session_async`` 里于任何提示符**之前**被调用,修复前 + 用户看到的是 ``'int' object has no attribute 'value'``——会话直接终止。 + """ + from rich.console import Console + + from src.runtime.contracts import build_session_config + from src.ui.display_utils import display_chat_config + from src.utils.config import get_settings + + config = build_session_config(get_settings()) + config.retrieval_method = bad_value + + with pytest.raises(ValueError, match="retrieval_method 必须是"): + display_chat_config(Console(quiet=True), config) + + +def test_display_chat_config_keeps_working_for_valid_retrieval_method(): + """收紧不能误伤:合法的枚举成员与字符串取值照旧渲染。 + + 配置**自造**而不是 ``build_session_config(get_settings())``:后者读开发机上的 + ``config.toml``,而本机实测 ``active_llm_configuration='google'`` 并不在 + ``llm_configurations`` 的键里——那条与本用例无关的既有不一致会让本测试以 + ``ValueError`` 失败,把「retrieval_method 的收紧误伤了合法取值」这条断言 + 变成对开发者本地配置的断言。 + """ + from rich.console import Console + + from src.ui.display_utils import display_chat_config + from src.utils.config import RetrievalMethod + + for value in (RetrievalMethod.HYBRID_SEARCH, "混合检索"): + config = _session_config( + retrieval_method=value, + active_llm_configuration="demo", + llm_configurations={"demo": SimpleNamespace(provider="openai", model_name="demo")}, + ) + display_chat_config(Console(quiet=True), config) + + +def test_session_config_validator_and_runtime_reader_agree(): + """配置边界与运行期读取侧对同一取值必须给出同一种收场。 + + 两边此前各写一份判据(``SessionConfig._validate_retrieval_method`` 与 + ``retrieve`` 的裸 ``==``),现在共用 ``require_retrieval_method``。这条测试 + 盯住「只修一边」——只改读取侧会让「写得进、读不出」,只改写入侧则相反。 + """ + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import require_retrieval_method + from src.utils.config import get_settings + + config = build_session_config(get_settings()) + for bad_value in ("hybrid", None, 5): + with pytest.raises(ValueError): + config["retrieval_method"] = bad_value + with pytest.raises(ValueError): + require_retrieval_method(bad_value) + + +def test_require_retrieval_method_accepts_enum_and_string_forms(): + """共享助手接受既有形态,并对无法识别的取值一律拒绝。""" + from src.services.retrieval_service import require_retrieval_method + from src.utils.config import RetrievalMethod + + assert require_retrieval_method("混合检索") is RetrievalMethod.HYBRID_SEARCH + assert require_retrieval_method(RetrievalMethod.HYBRID_SEARCH) is RetrievalMethod.HYBRID_SEARCH + for bad_value in ("hybrid", "HYBRID_SEARCH", "", " 混合检索", 0, True, None, ["向量检索"]): + with pytest.raises(ValueError): + require_retrieval_method(bad_value) + + +def test_require_retrieval_method_accepts_duck_typed_substitutes(): + """判据是**属性存在性**,不是具体类型——仓库的既有约定。 + + ``require_detail_attributes`` 的 docstring 写明「使用方只要求取值提供这几个 + 属性,任何具备它们的对象都能正常工作(仓库里的测试就用 ``SimpleNamespace`` + 构造等价配置)」。``display_utils``/``chat/core`` 的既有测试都用 + ``SimpleNamespace(value="混合检索")`` 作 ``retrieval_method`` 的替身,按 + ``isinstance(value, RetrievalMethod)`` 收窄会把它们一并拒掉——实测如此, + 4 个既有用例直接 error。 + + 这条盯住「收紧不能把等价替身一起拒掉」:它是对**判据方向**的断言,而不是 + 对某个具体替身类型的断言。 + """ + from src.services.retrieval_service import require_retrieval_method + from src.utils.config import RetrievalMethod + + assert ( + require_retrieval_method(SimpleNamespace(value="混合检索")) is RetrievalMethod.HYBRID_SEARCH + ) + # ``.value`` 本身是枚举成员也要接受(替身套替身)。 + assert ( + require_retrieval_method(SimpleNamespace(value=RetrievalMethod.SEMANTIC_SEARCH)) + is RetrievalMethod.SEMANTIC_SEARCH + ) + # 取值形态不对时仍要拒绝,且消息里带的是**原取值**(便于定位是哪个配置项)。 + with pytest.raises(ValueError, match="retrieval_method 必须是"): + require_retrieval_method(SimpleNamespace(value="hybrid")) + with pytest.raises(ValueError, match="retrieval_method 必须是"): + require_retrieval_method(SimpleNamespace()) + + +# ── chat_temperature 的取值收敛(两个读取点) ── +# +# ``chat_temperature`` 有**完整**的校验器——``Settings.validate_chat_temperature`` +# 与 ``SessionConfig._validate_chat_temperature`` 各自一份,判据一致(拒 bool、 +# 非数值、非有限、负数、超出双精度的整数)。但两个**读取点**都直读原属性,而 +# ``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫。实测修复前 +# ``"abc"``/``None``/``[0.5]``/``True``/``-1`` 全部**静默**通过并原样成为 +# SDK 的请求 kwargs——而它们正是 ``session_config["chat_temperature"] = ...`` +# 会明确拒绝的取值。同一个字段「写得进就合法、直写就非法」两种口径。 + + +class _RecordingSamplingModel: + """支持 sampling 的模型替身,记录收到的 kwargs。""" + + def __init__(self): + self.calls: list[dict] = [] + + def supports_sampling_option(self, name): + return name == "temperature" + + async def ainvoke(self, **kwargs): + self.calls.append(kwargs) + yield "回复" + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param("abc", id="str"), + pytest.param(None, id="none"), + pytest.param([0.5], id="list"), + pytest.param(True, id="bool"), + pytest.param(-1, id="negative"), + pytest.param(_HUGE_INT, id="beyond-int-max-str-digits"), + ], +) +def test_generate_reply_rejects_unusable_chat_temperature(bad_value): + """不可用的 ``chat_temperature`` 必须在本地被拒,而不是进请求 kwargs。 + + 修复前实测:这些取值全部**静默**通过,``"abc"`` 与 ``[0.5]`` 原样成为 SDK + 参数,``-1`` 被网关按默认值接受。用户看到的是服务端的参数错误,而不是一条 + 本地配置错误。 + """ + import asyncio + + from src.runtime.contracts import build_session_config + from src.services.chat_service import ChatService + from src.utils.config import get_settings + + model = _RecordingSamplingModel() + service = ChatService(model, None) + config = build_session_config(get_settings()) + config.chat_temperature = bad_value + + async def consume(): + return [chunk async for chunk in service.generate_reply("问题", "意图", [], config)] + + with pytest.raises(ValueError, match="chat_temperature"): + asyncio.run(consume()) + + # 关键断言:被拒之前**没有**发起请求。只断言「抛了 ValueError」会让 + # 「先发请求再校验」的实现也通过。 + assert model.calls == [] + + +@pytest.mark.parametrize("value", [0.0, 0.7, 1.0, 0]) +def test_generate_reply_keeps_working_for_valid_chat_temperature(value): + """收紧不能误伤:合法的非负数值照旧传给模型。""" + import asyncio + + from src.runtime.contracts import build_session_config + from src.services.chat_service import ChatService + from src.utils.config import get_settings + + model = _RecordingSamplingModel() + service = ChatService(model, None) + config = build_session_config(get_settings()) + config.chat_temperature = value + + async def consume(): + return [chunk async for chunk in service.generate_reply("问题", "意图", [], config)] + + assert asyncio.run(consume()) == ["回复"] + assert model.calls[0]["temperature"] == float(value) + + +def test_chat_temperature_reader_and_validator_agree(): + """配置边界与运行期读取侧对同一取值必须给出同一种收场。 + + 两边此前各写一份判据(``SessionConfig._validate_chat_temperature`` 与两个读取 + 点的裸属性访问),现在共用 ``require_chat_temperature``。这条盯住「只修一边」。 + """ + from src.runtime.contracts import build_session_config + from src.utils.config import get_settings, require_chat_temperature + + config = build_session_config(get_settings()) + for bad_value in ("abc", None, [0.5], True, -1, _HUGE_INT): + with pytest.raises(ValueError, match="chat_temperature"): + config["chat_temperature"] = bad_value + with pytest.raises(ValueError, match="chat_temperature"): + require_chat_temperature(bad_value) + # 合法取值两侧都要接受,且收敛成同一形态(float)。 + for good_value in (0.0, 0.7, 1.0): + config["chat_temperature"] = good_value + assert config.chat_temperature == require_chat_temperature(good_value) == float(good_value) + + +@pytest.mark.parametrize( + "out_of_range", + [ + pytest.param(1.5, id="above-one-float"), + pytest.param(2.0, id="two-float"), + pytest.param(2, id="two-int"), + pytest.param(100, id="way-above"), + ], +) +def test_chat_temperature_reader_enforces_the_same_upper_bound(out_of_range): + """读取侧必须与 ``Settings`` 同判上界:``chat_temperature`` 的契约是 ``0.0`` 到 ``1.0``。 + + ``Settings.validate_chat_temperature`` 与 ``config.toml.example`` 的注释都把范围 + 钉在 ``0.0``–``1.0``,而 ``require_chat_temperature``(``SessionConfig`` 的构造器 + 与 ``__setitem__``、``ChatService.generate_reply``、``Chatbot.chat_async`` 的兜底 + 分支共用)此前只判了 ``>= 0``。它的 docstring 却声称与两个校验器「判据一致」—— + 这个上界就是漏掉的那一半:``1.5``/``2.0`` 走 ``SessionConfig(...)`` 构造器与 + 配置属性直写都能进请求 kwargs,而同样的值写进 ``config.toml`` 会被 ``Settings`` + 明确拒绝。同一个字段两条入口对同一取值给相反结论。 + """ + import asyncio + + from pydantic import ValidationError + + from src.runtime.contracts import build_session_config + from src.services.chat_service import ChatService + from src.utils.config import Settings, get_settings, require_chat_temperature + + # ``Settings`` 的判据作为基准:先确认它拒绝,避免这条测试自己被写反。 + with pytest.raises(ValidationError): + Settings(chat_temperature=out_of_range) + + # 读取侧的收敛函数必须给同一种收场。 + with pytest.raises(ValueError, match="chat_temperature"): + require_chat_temperature(out_of_range) + + # 配置边界(``SessionConfig`` 的 ``__setitem__``)与生产读取路径同样拒绝, + # 且被拒之前没有发起请求。 + model = _RecordingSamplingModel() + service = ChatService(model, None) + config = build_session_config(get_settings()) + config.chat_temperature = out_of_range + + async def consume(): + return [chunk async for chunk in service.generate_reply("问题", "意图", [], config)] + + with pytest.raises(ValueError, match="chat_temperature"): + asyncio.run(consume()) + assert model.calls == [] + + +def test_chat_temperature_setitem_rejects_out_of_range_values(): + """``SessionConfig["chat_temperature"] = 1.5`` 与 ``Settings`` 同收场。 + + ``__setitem__`` 走的是与构造器同一份校验器表(``_VALIDATORS``),因此上界一旦 + 补进 ``require_chat_temperature``,两条写入入口自动一致;这条钉住「只修读取侧、 + 漏掉 ``__setitem__``」这一类半拉子修复。 + """ + from src.runtime.contracts import build_session_config + from src.utils.config import get_settings + + config = build_session_config(get_settings()) + for out_of_range in (1.5, 2.0, 2): + with pytest.raises(ValueError, match="chat_temperature"): + config["chat_temperature"] = out_of_range + # 端点仍放行。 + for boundary in (0.0, 1.0): + config["chat_temperature"] = boundary + assert config.chat_temperature == float(boundary) + + +# ── 阈值过滤的融合策略判据(库调用方的默认分支) ── +# +# ``_should_apply_score_threshold`` 的 docstring 写着「``fusion_strategy`` 由调用方 +# 传入**已收敛**的取值:``retrieve`` 已经校验过,这里再读一次原属性会绕过那道 +# 校验」,但它的**兜底分支**恰恰就在读原属性。判据是 ``strategy == "rrf"``,而 +# ``"RRF"``/``" rrf "`` 走的是 RRF 融合却不等于这个字面量——阈值过滤会被错误地 +# 套用在排名倒数的量纲上,正是 ``_coerce_fusion_strategy`` 记录的那个缺陷。生产 +# 路径(``retrieve``)传的是收敛后的值,因此这条只在库调用方直接调本方法时可达。 + + +@pytest.mark.parametrize("value", ["rrf", "RRF", " rrf ", "Rrf"]) +def test_score_threshold_skipped_for_rrf_regardless_of_case(value): + """RRF 融合(任何大小写/空白形态)都必须跳过阈值过滤。""" + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.HYBRID_SEARCH + config.hybrid_fusion_strategy = value + + assert RetrievalService._should_apply_score_threshold(config) is False + + +def test_score_threshold_applied_for_weighted_and_other_methods(): + """反向守卫:weighted 融合与非混合检索仍要套用阈值。""" + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.HYBRID_SEARCH + config.hybrid_fusion_strategy = "weighted" + assert RetrievalService._should_apply_score_threshold(config) is True + + config.hybrid_fusion_strategy = "rrf" + config.retrieval_method = RetrievalMethod.SEMANTIC_SEARCH + assert RetrievalService._should_apply_score_threshold(config) is True + + +def test_score_threshold_rejects_unusable_fusion_strategy_in_default_branch(): + """兜底分支不得把无法识别的策略静默当成「非 RRF」。 + + 静默判定会让阈值过滤套用在排名倒数的量纲上(结果被清空),而正确收场是一条 + 配置错误。 + """ + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.HYBRID_SEARCH + for bad_value in (None, 5, ["rrf"]): + config.hybrid_fusion_strategy = bad_value + with pytest.raises(ValueError, match="hybrid_fusion_strategy"): + RetrievalService._should_apply_score_threshold(config) + + +# ── `_should_apply_score_threshold` 的 `fusion_strategy` 形参也要收敛 ── +# +# 该方法的 docstring 写明「``fusion_strategy`` 优先用调用方传入的**已收敛**取值」, +# 并解释了为什么兜底分支必须自己收敛(`"RRF"` 走 RRF 融合却不等于字面量 +# `"rrf"`,阈值过滤会被错误地套在排名倒数的量纲上)。但**调用方显式传入**的 +# 取值原样参与比较——判据只做了兜底那一半。与同文件 `_rerank_if_needed` 的 +# `top_k`/`rerank_enabled` 是同一条线索、同一种修法。 + + +@pytest.mark.parametrize( + "written", + [ + pytest.param("RRF", id="upper"), + pytest.param(" rrf ", id="padded"), + pytest.param(["rrf"], id="list"), + pytest.param(5, id="int"), + ], +) +def test_score_threshold_coerces_the_supplied_fusion_strategy(written): + """调用方传入的 ``fusion_strategy`` 与兜底分支同判据。 + + `"RRF"`/`" rrf "` 走的是 RRF 融合,必须与 `"rrf"` 得到同一个结论(不套阈值); + 无法识别成任何策略的形态必须报错,而不是静默按「非 RRF」套上阈值。 + """ + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.HYBRID_SEARCH + config.hybrid_fusion_strategy = "rrf" + + if isinstance(written, str) and written.strip().lower() == "rrf": + assert ( + RetrievalService._should_apply_score_threshold(config, fusion_strategy=written) is False + ) + return + with pytest.raises(ValueError, match="hybrid_fusion_strategy"): + RetrievalService._should_apply_score_threshold(config, fusion_strategy=written) + + +def test_score_threshold_still_accepts_a_coerced_fusion_strategy(): + """收紧不能误伤:已收敛的融合策略照常判定。""" + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.HYBRID_SEARCH + + assert RetrievalService._should_apply_score_threshold(config, fusion_strategy="rrf") is False + assert ( + RetrievalService._should_apply_score_threshold(config, fusion_strategy="weighted") is True + ) + + +# ── 模型配置的展示路径(provider 形态 / 裸插值) ── +# +# ``ModelDetail.provider`` 声明为 ``str``,但 ``SessionConfig.llm_configurations`` +# /``rerank_configurations`` 的 ``_VALIDATORS`` 明确不登记(「按原样写入,由使用方 +# 负责其内部结构」),嵌入方可以往映射里放任意取值。``display_chat_config`` 拿它拼 +# ``f"{provider.lower()}_api_key"``——实测修复前: +# +# * ``provider=["openai"]`` -> 更早一步,在 ``active_providers = {…provider}`` +# 这个**集合**里抛 ``TypeError: unhashable type: 'list'``; +# * ``provider=None``/``5`` -> ``AttributeError: 'NoneType' object has no +# attribute 'lower'``; +# * ``active_*_configuration``/``model_name`` 是位数超限整数时,同一张表的裸 +# f-string 抛 ``ValueError: Exceeds the limit``——而**同一个函数**的 +# ``top_k``/``score_threshold``/权重早已走 ``_render_value``(注释写明 +# 「本函数在任何提示符之前被调用,会话在启动横幅这一步就终止」)。 +# * ``logger.debug(f"…{active_llm}")`` 的裸插值在 DEBUG 关闭时也**已经求值**, +# 同一个取值照样抛。 +# +# 本函数在 ``start_chat_session_async`` 里于任何提示符**之前**被调用,因此以上 +# 每一条都让会话在「知识快照加载成功」之后立刻终止。 + + +def _model_config(**overrides): + """构造一个 ``display_chat_config`` 能完整渲染的 ``SessionConfig``。""" + values = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 2, + "rerank_enabled": False, + "top_k": 5, + "score_threshold": 0.0, + "active_llm_configuration": "demo", + "active_rerank_configuration": "demo", + "llm_configurations": {"demo": SimpleNamespace(provider="openai", model_name="gpt-5")}, + "rerank_configurations": { + "demo": SimpleNamespace(provider="siliconflow", model_name="bge-reranker-v2-m3") + }, + "chat_temperature": 0.7, + } + values.update(overrides) + return SessionConfig(**values) + + +@pytest.mark.parametrize( + "bad_provider", + [ + pytest.param(["openai"], id="list"), + pytest.param({"a": 1}, id="dict"), + pytest.param(None, id="none"), + pytest.param(5, id="int"), + ], +) +def test_display_chat_config_rejects_unusable_provider(bad_provider): + """非字符串 provider 必须收敛成 ``ValueError``,而不是裸 ``AttributeError``/``TypeError``。 + + ``["openai"]`` 这条走的是**集合**(``{provider}``)而不是 ``.lower()``,因此 + 它抛的是 ``TypeError: unhashable type``——两处判据必须都收敛。 + """ + from rich.console import Console + + from src.ui.display_utils import display_chat_config + + config = _model_config( + llm_configurations={"demo": SimpleNamespace(provider=bad_provider, model_name="gpt-5")} + ) + + with pytest.raises(ValueError, match="provider 必须是字符串"): + display_chat_config(Console(quiet=True), config) + + +def test_display_chat_config_rejects_unusable_rerank_provider(): + """Rerank 侧同一判据:``rerank_enabled=True`` 时才读它。""" + from rich.console import Console + + from src.ui.display_utils import display_chat_config + + config = _model_config( + rerank_enabled=True, + rerank_configurations={"demo": SimpleNamespace(provider=5, model_name="m")}, + ) + + with pytest.raises(ValueError, match="provider 必须是字符串"): + display_chat_config(Console(quiet=True), config) + + +@pytest.mark.parametrize( + "field", + ["model_name", "active_llm_configuration", "active_rerank_configuration"], +) +def test_display_chat_config_renders_values_beyond_the_conversion_limit(field): + """位数超限的整数必须**可渲染**(降级成量级描述),而不是让会话终止。 + + 同一张表的 ``top_k``/``score_threshold``/权重早已按这条处理;这条盯住 + 「只修了一部分单元格」。 + """ + from rich.console import Console + + from src.ui.display_utils import display_chat_config + + if field == "model_name": + config = _model_config( + llm_configurations={"demo": SimpleNamespace(provider="openai", model_name=_HUGE_INT)} + ) + elif field == "active_llm_configuration": + # 激活名与配置键同源(都来自 config.toml 或快照),因此要让键也存在, + # 否则先撞上「未找到key」那条(那是另一条判据,不是本条要测的渲染)。 + config = _model_config( + active_llm_configuration=_HUGE_INT, + llm_configurations={_HUGE_INT: SimpleNamespace(provider="openai", model_name="gpt-5")}, + ) + else: + config = _model_config( + rerank_enabled=True, + active_rerank_configuration=_HUGE_INT, + rerank_configurations={ + _HUGE_INT: SimpleNamespace(provider="siliconflow", model_name="m") + }, + ) + + # 不得抛异常——这正是修复点。 + display_chat_config(Console(quiet=True), config) + + +def test_display_chat_config_keeps_working_for_valid_model_config(): + """收紧不能误伤:合法配置照旧渲染。""" + from rich.console import Console + + from src.ui.display_utils import display_chat_config + + display_chat_config(Console(quiet=True), _model_config(rerank_enabled=True)) + + +def test_require_provider_name_accepts_any_string_and_rejects_the_rest(): + """共享助手只要求「是字符串」,不查 provider 白名单。 + + 白名单是**工厂**的职责(``_get_provider_class`` 会给「不支持的模型提供商」)。 + 在展示层提前套白名单会让「配置里有个尚未支持的 provider」从「启动时列出配置」 + 变成「启动时崩溃」——那是更差的收场。 + """ + from src.utils.config import require_provider_name + + assert require_provider_name("openai") == "openai" + assert require_provider_name("not-yet-supported") == "not-yet-supported" + for bad_value in (None, 5, ["openai"], {"a": 1}, True, _HUGE_INT): + with pytest.raises(ValueError, match="必须是字符串"): + require_provider_name(bad_value) + + +# ── 配置编辑器的动态上界(读可直写字段) ── +# +# ``config_menu._top_k_ceiling`` / ``_multiplier_ceiling`` 读的是**另一个**规模 +# 字段来算动态上界(``min(_MAX_RETRIEVAL_TOP_K, _MAX_RETRIEVAL_CANDIDATES // +# max(1, m))``),而那两个字段都可被直写。实测修复前「倍率被直写成 ``"abc"``、 +# 用户选『调整 Top K』」在 ``max()`` 处抛裸 +# ``TypeError: '>' not supported between instances of 'str' and 'int'``。 +# +# 菜单在 chat 主循环的 ``try`` **之外**:内层只接 ``(KeyboardInterrupt, +# EOFError)``、外层只有 ``finally``,因此这会以裸 traceback 终止整个会话,用户 +# 丢失对话。``retrieve`` 对同样两个字段早有 ``_coerce_scale_field``,现在判据 +# 上移为 ``require_scale_field``(``src/utils/config.py``,``_MAX_RETRIEVAL_*`` +# 的归属地),服务层与 UI 共用——UI 不反向依赖服务层私有函数。 + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param("abc", id="str"), + pytest.param(None, id="none"), + pytest.param([2], id="list"), + pytest.param(True, id="bool"), + pytest.param(_HUGE_INT, id="beyond-int-max-str-digits"), + ], +) +def test_top_k_ceiling_falls_back_when_multiplier_is_unusable(bad_value): + """倍率字段形态不可用时,``_top_k_ceiling`` 回落到单字段上界而不是抛错。 + + 此前这里断言抛 ``ValueError``(当时修的是「不要抛裸 ``TypeError``」)。但 + 抛出同样终止会话:``edit_retrieval_params`` 的调用链(``chat/core`` 经 + ``asyncio.to_thread``)内层只接 ``(KeyboardInterrupt, EOFError)``,外层只有 + ``finally``——与 ``_rerank_menu_label`` 注释里写明的契约一致(菜单是用户唯一 + 还能改回合法值的入口,不能把它炸掉)。非法取值下上界放宽到单字段上界, + 组合越界由应用配置时的 ``SessionConfig`` 守卫带指引地拒绝。 + """ + from src.ui.config_menu import _MAX_RETRIEVAL_TOP_K, _top_k_ceiling + + assert _top_k_ceiling(bad_value) == _MAX_RETRIEVAL_TOP_K + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param("abc", id="str"), + pytest.param(None, id="none"), + pytest.param([2], id="list"), + pytest.param(True, id="bool"), + ], +) +def test_multiplier_ceiling_falls_back_when_top_k_is_unusable(bad_value): + """对称方向:``top_k`` 形态不可用时 ``_multiplier_ceiling`` 同样回落。""" + from src.ui.config_menu import _MAX_RETRIEVAL_MULTIPLIER, _multiplier_ceiling + + assert _multiplier_ceiling(bad_value) == _MAX_RETRIEVAL_MULTIPLIER + + +@pytest.mark.parametrize( + ("multiplier", "top_k"), + [(1, 2000), (3, 666), (20, 100), (100, 20), (2, 5)], +) +def test_ceilings_keep_working_for_valid_scale_fields(multiplier, top_k): + """收紧不能误伤:合法取值照旧算出上界。""" + from src.ui.config_menu import _multiplier_ceiling, _top_k_ceiling + + assert _top_k_ceiling(multiplier) >= 1 + assert _multiplier_ceiling(top_k) >= 1 + + +def test_edit_retrieval_params_survives_broken_scale_field(): + """端到端:字段被直写坏掉时,菜单必须**走完流程**而不是终止会话。 + + 这条走的是真实入口(``edit_retrieval_params`` 的选择分支),而不是只调 + ``_top_k_ceiling``——「助手修好了但调用点没接上」是另一类漏网。此前断言的是 + 「抛 ``ValueError``」,而该异常在 ``chat/core`` 的 ``asyncio.to_thread`` 调用点 + 落在只接 ``(KeyboardInterrupt, EOFError)`` 的内层 ``try`` 之外、外层只有 + ``finally``——抛出即终止会话,与用例名里的 "survives" 直接矛盾。现在断言 + 真正走完(不抛异常、回到循环),用户仍能改回合法值。 + """ + from unittest.mock import patch + + from src.runtime.contracts import build_session_config + from src.ui import config_menu + from src.utils.config import get_settings + + config = build_session_config(get_settings()).to_dict() + config["retrieval_candidate_multiplier"] = "abc" + # 第一次选目标项,第二次退出循环(否则 ``while True`` 不结束)。 + choices = iter(["top_k", "back"]) + + with ( + patch.object(config_menu.questionary, "select") as select, + patch.object(config_menu.questionary, "text") as text, + ): + select.return_value.ask.side_effect = lambda: next(choices, "back") + text.return_value.ask.return_value = None + + config_menu.edit_retrieval_params(config) + + +def test_require_scale_field_matches_the_service_layer_semantics(): + """共享助手的语义与 ``retrieve`` 的 ``max(1, ...)`` 一致。 + + 非正数收敛成 1(读取已有状态时的容错),``bool`` 显式拒绝(它几乎必然是 + 上游把标志位写进了规模字段),超上界拒绝。 + """ + from src.utils.config import _MAX_RETRIEVAL_CANDIDATES, require_scale_field + + assert require_scale_field(5, "top_k") == 5 + assert require_scale_field(0, "top_k") == 1 + assert require_scale_field(-3, "top_k") == 1 + for bad_value in (True, False, "5", None, [5], 1.5, _HUGE_INT): + with pytest.raises(ValueError): + require_scale_field(bad_value, "top_k") + with pytest.raises(ValueError, match="超出合理范围"): + require_scale_field(_MAX_RETRIEVAL_CANDIDATES + 1, "top_k") + + +# ── markup_text 的模板形态 ── +# +# ``markup_text`` 对**值**参数早已用 ``_render_value`` 完整收敛(``10**4301`` +# 降级成量级描述),对占位符数量不符与 NUL 也都抛 ``ValueError``;唯独**模板** +# 参数没有形态判据,而函数体第一行就对它做成员测试。实测修复前非字符串模板抛 +# 裸 ``TypeError``,且消息随形态各不相同: +# +# * ``5``/``None`` -> ``argument of type 'int' is not iterable`` +# * ``['a']``/``('a',)``/``{'a': 1}`` -> ``expected string or bytes-like object`` +# * ``b'x'`` -> ``a bytes-like object is required, not 'str'`` +# +# 三种消息都不指向「模板写错了」,也不是 ``ValueError``。同一个「入参形态非法」 +# 的处置,同一个函数里两种收场——判据只做了一半。 +# +# 可达性:44 个调用点里 43 个模板是字符串字面量,剩下 1 个 +# (``scripts/embed_knowledge_base.py`` 的 ``mask_api_key(api_key_value)``)两个 +# 分支都返回字面量。因此本仓自带流程走不到——修的是**公开函数**的契约一致性。 + + +@pytest.mark.parametrize( + "bad_template", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(["a"], id="list"), + pytest.param(("a",), id="tuple"), + pytest.param({"a": 1}, id="dict"), + pytest.param(b"x", id="bytes"), + pytest.param(_HUGE_INT, id="beyond-int-max-str-digits"), + ], +) +def test_markup_text_rejects_unusable_template(bad_template): + """非字符串模板必须收敛成 ``ValueError``,而不是形态各异的裸 ``TypeError``。""" + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="标记模板必须是字符串"): + markup_text(bad_template) + + +@pytest.mark.parametrize( + ("template", "values", "expected"), + [ + ("纯文本", (), "纯文本"), + ("[bold]{}[/bold]", ("x",), "x"), + ("[dim]本地模型,无需设置[/dim]", (), "本地模型,无需设置"), + ("[green]a: {} / b: {}[/green]", (1, 2), "a: 1 / b: 2"), + ], +) +def test_markup_text_keeps_working_for_valid_templates(template, values, expected): + """收紧不能误伤:字符串模板(含零占位符与多占位符)照旧渲染。""" + from src.ui.display_utils import markup_text + + assert markup_text(template, *values).plain == expected + + +def test_markup_text_placeholder_errors_still_win_over_shape_guard(): + """形态守卫不能改变既有的占位符判据。 + + 模板是合法字符串但占位符数量不符时,仍须给出原来那条消息——新加的守卫放在 + 最前面,不能把它盖掉。 + """ + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="占位符少于实参数量"): + markup_text("无占位符", "多余的值") + with pytest.raises(ValueError, match="占位符多于实参数量"): + markup_text("{} 与 {}", "只有一个") + + +# ── 响应容器的形态守卫(google / volcengine) ── +# +# 两个 provider 的响应顶层容器此前只有 ``field(x, "candidates", []) or []`` 这种 +# 写法——它挡住 ``None`` 与空值,挡不住其它形态。实测走**真实入口** +# (``GoogleProvider.complete`` / ``VolcengineProvider.complete``): +# +# * Google ``candidates=5`` -> 裸 ``TypeError: 'int' object is not iterable``; +# ``candidates={"0": {}}`` -> 更晚一步,在 ``candidates[0]`` 处抛裸 ``KeyError: 0``。 +# * Ark Responses ``output=5`` -> 裸 ``TypeError``; +# Ark Chat ``message.tool_calls=5`` -> 同一个裸 ``TypeError``。 +# +# 这些都不是 ``RuntimeError``——而两个 provider 的错误分类(以及调用方的 +# ``except``)按 ``RuntimeError`` 写。同一个形态在 +# ``openai_compatible._raise_for_chat_response_error`` 对 ``choices`` 早已有显式 +# 守卫,三个 provider 的收场必须一致。 + + +def _google_provider_with(response): + """构造一个 ``_get_client`` 返回固定响应的 GoogleProvider。""" + from unittest.mock import MagicMock, patch + + from src.providers.google import GoogleProvider + + provider = GoogleProvider(model_name="gemini-1.5-flash") + client = MagicMock() + client.models.generate_content.return_value = response + return provider, patch.object(provider, "_get_client", return_value=client) + + +@pytest.mark.parametrize( + "bad_candidates", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_google_complete_rejects_unusable_candidates(bad_candidates): + """非序列 ``candidates`` 必须收敛成 ``RuntimeError``。 + + 走 ``complete`` 入口而不是直接调 ``_extract_result``:守卫加在哪个函数上是 + 一回事,路径是否经过它是另一回事。 + + **可达性说明**:这里打桩了 ``_get_client``,而真 SDK 的响应由 + ``GenerateContentResponse._from_response`` 经 ``cls.model_validate(response)`` + 构造——**会**执行 pydantic 校验,畸形容器在 SDK 层就被拒。因此本条守的是 + 「替身 / 将来 SDK 改成不校验时」的契约,**不是**线上流量。同文件 + ``test_google_sdk_validates_response_containers_so_the_guard_is_not_reachable_live`` + 把这条前提固定住。 + """ + from types import SimpleNamespace + + provider, client_patch = _google_provider_with( + SimpleNamespace(candidates=bad_candidates, usage_metadata=None, response_id="r") + ) + + with client_patch, pytest.raises(RuntimeError, match="candidates 必须是列表"): + provider.complete(prompt="hi") + + +def test_google_complete_keeps_working_for_valid_candidates(): + """收紧不能误伤:正常响应照旧解析。""" + from types import SimpleNamespace + + candidate = SimpleNamespace( + finish_reason="STOP", content=SimpleNamespace(parts=[SimpleNamespace(text="你好")]) + ) + provider, client_patch = _google_provider_with( + SimpleNamespace(candidates=[candidate], usage_metadata=None, response_id="r") + ) + + with client_patch: + result = provider.complete(prompt="hi") + + assert result.text == "你好" + assert result.finish_reason == "STOP" + + +def test_google_stream_chunk_guard_covers_both_readers(): + """流式两个读取点用同一守卫:``_stream_events_from_chunk`` 与终止判据。 + + 与上一节同一条可达性说明:google 的响应在 SDK 层已校验,本条覆盖的是契约 + 一致性与替身场景。 + """ + from types import SimpleNamespace + + from src.providers.google import GoogleProvider + + chunk = SimpleNamespace(candidates=5, response_id="r", usage_metadata=None) + with pytest.raises(RuntimeError, match="candidates 必须是列表"): + GoogleProvider._stream_events_from_chunk(chunk) + with pytest.raises(RuntimeError, match="candidates 必须是列表"): + GoogleProvider._stream_chunk_has_terminal_event(chunk) + + +def test_google_stream_guard_keeps_none_semantics(): + """``candidates`` 缺失(``None``)仍按「无候选」放行,不得变成错误。 + + 流式路径的正常中间态依赖这条:把它一并拒掉会让正常流失败。 + """ + from types import SimpleNamespace + + from src.providers.google import GoogleProvider + + chunk = SimpleNamespace(candidates=None, response_id="r", usage_metadata=None) + assert GoogleProvider._stream_events_from_chunk(chunk) == [] + assert GoogleProvider._stream_chunk_has_terminal_event(chunk) is False + + +def _ark_provider(protocol: str): + from src.providers.volcengine import VolcengineProvider + + provider = VolcengineProvider.__new__(VolcengineProvider) + provider._protocol = protocol + provider._model_name = "demo" + provider._client = None + provider._aclient = None + provider._options = {"server_verified_protocols": ["responses"]} + return provider + + +@pytest.mark.parametrize( + "bad_output", + [ + pytest.param(5, id="int"), + pytest.param("ab", id="str"), + ], +) +def test_ark_responses_complete_rejects_unusable_output(bad_output): + """Responses 路径的 ``output`` 非序列时必须收敛成 ``RuntimeError``。 + + 与 google 那节不同,**本条线上可达**:Ark SDK 的 + ``_base_client._process_response_data`` 走 ``construct_type``,不执行 pydantic + 校验(对照表见「OpenAI 兼容渠道的响应容器守卫」一节的注释)。 + """ + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + provider = _ark_provider("responses") + response = SimpleNamespace(output=bad_output, usage=None, status="completed", id="r") + client = MagicMock() + client.responses.create.return_value = response + + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="output 必须是列表"), + ): + provider.complete(prompt="hi") + + +def test_ark_chat_complete_rejects_unusable_tool_calls(): + """Chat 路径的 ``message.tool_calls`` 非序列时同样收敛成 ``RuntimeError``。""" + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + provider = _ark_provider("chat_completions") + message = SimpleNamespace(content="x", tool_calls=5) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + output=[], + usage=None, + status=None, + id="r", + ) + client = MagicMock() + client.chat.completions.create.return_value = response + + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="tool_calls 必须是列表"), + ): + provider.complete(prompt="hi") + + +def test_ark_complete_keeps_working_for_valid_responses(): + """收紧不能误伤:两条协议的正常响应照旧解析。""" + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + # Chat 路径:正文 + 一个工具调用 + provider = _ark_provider("chat_completions") + message = SimpleNamespace( + content="你好", + tool_calls=[ + SimpleNamespace( + id="c1", type="function", function=SimpleNamespace(name="f", arguments='{"a":1}') + ) + ], + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + output=[], + usage=None, + status=None, + id="r", + ) + client = MagicMock() + client.chat.completions.create.return_value = response + with patch.object(provider, "_get_client", return_value=client): + result = provider.complete(prompt="hi") + assert result.text == "你好" + assert len(result.tool_calls) == 1 + + # Responses 路径:output[].content[].text + provider = _ark_provider("responses") + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", content=[SimpleNamespace(type="output_text", text="你好")] + ) + ], + usage=None, + status="completed", + id="r", + ) + client = MagicMock() + client.responses.create.return_value = response + with patch.object(provider, "_get_client", return_value=client): + result = provider.complete(prompt="hi") + assert result.text == "你好" + + +# ── OpenAI 兼容渠道的响应容器守卫(可达性由 SDK 决定) ── +# +# 同一类「响应容器被迭代却没有形态守卫」,在三个 SDK 上的**可达性不同**,因为 +# 它们的响应构造方式不同: +# +# * ``google-genai`` 的 ``_from_response`` 走 ``cls.model_validate(response)`` +# ——**会**执行 pydantic 校验,畸形容器在 SDK 层就被拒(``ValidationError``); +# * ``openai`` 的 ``_base_client._process_response_data`` 在 +# ``_strict_response_validation`` 为假时走 ``construct_type``——该标志实测 +# 默认 ``False``,``construct_type`` **不校验**; +# * ``volcenginesdkarkruntime`` 的 ``_base_client`` 同样只有 ``construct_type``。 +# +# 因此 openai 兼容渠道(``OpenAICompatibleProvider`` 及其 7 个薄子类:qwen / +# siliconflow / ollama / lm-studio / deepseek / grok 等)与 Ark 的守卫是**线上 +# 可达**的,而 google 的同类守卫只在测试替身下可达。 +# +# 本节的用例打桩 ``_get_client``:对 openai 兼容渠道而言这与真实路径等价(SDK +# 那一层不做校验,响应对象直接来自 ``construct_type``);对 google 而言则**不等价** +# (见上),所以这里不重复 google 的用例,其契约由上一节覆盖。 + + +def _openai_compatible_provider(protocol: str): + """构造一个可直接调用 ``complete`` 的 OpenAICompatibleProvider 替身。""" + from src.providers.openai_compatible import OpenAICompatibleProvider + + provider = OpenAICompatibleProvider.__new__(OpenAICompatibleProvider) + provider._provider = "demo" + provider._protocol = protocol + provider._model_name = "demo-model" + provider._client = None + provider._aclient = None + provider._options = {} + provider._server_verified_protocols = frozenset({"responses"}) + return provider + + +@pytest.mark.parametrize( + "bad_output", + [ + pytest.param(5, id="int"), + pytest.param({"a": 1}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_openai_compatible_responses_rejects_unusable_output(bad_output): + """Responses 路径的 ``output`` 非序列时必须收敛成 ``RuntimeError``。""" + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + provider = _openai_compatible_provider("responses") + response = SimpleNamespace( + output=bad_output, usage=None, status="completed", id="r", choices=None + ) + client = MagicMock() + client.responses.create.return_value = response + + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="output 必须是列表"), + ): + provider.complete(prompt="hi") + + +def test_openai_compatible_chat_rejects_unusable_tool_calls(): + """Chat 路径的 ``message.tool_calls`` 非序列时同样收敛成 ``RuntimeError``。""" + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + provider = _openai_compatible_provider("chat_completions") + message = SimpleNamespace(content="x", tool_calls=5) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + output=[], + usage=None, + status=None, + id="r", + ) + client = MagicMock() + client.chat.completions.create.return_value = response + + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="tool_calls 必须是列表"), + ): + provider.complete(prompt="hi") + + +def test_openai_compatible_stream_delta_rejects_unusable_tool_calls(): + """流式 delta 的 ``tool_calls`` 用同一守卫。""" + from types import SimpleNamespace + + from src.providers.openai_compatible import OpenAICompatibleProvider + + chunk = SimpleNamespace( + choices=[ + SimpleNamespace(delta=SimpleNamespace(content="", tool_calls=5), finish_reason=None) + ], + usage=None, + id="r", + ) + with pytest.raises(RuntimeError, match="tool_calls 必须是列表"): + OpenAICompatibleProvider._chat_stream_events(chunk) + + +def test_openai_compatible_complete_keeps_working_for_valid_responses(): + """收紧不能误伤:两条协议的正常响应照旧解析。""" + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + provider = _openai_compatible_provider("chat_completions") + message = SimpleNamespace( + content="你好", + tool_calls=[ + SimpleNamespace( + id="c1", type="function", function=SimpleNamespace(name="f", arguments='{"a":1}') + ) + ], + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + output=[], + usage=None, + status=None, + id="r", + ) + client = MagicMock() + client.chat.completions.create.return_value = response + with patch.object(provider, "_get_client", return_value=client): + result = provider.complete(prompt="hi") + assert result.text == "你好" + assert len(result.tool_calls) == 1 + + +def test_google_sdk_validates_response_containers_so_the_guard_is_not_reachable_live(): + """记录可达性事实:google-genai 在 SDK 层就拒绝畸形容器。 + + 这条不是产品行为的断言,而是**可达性前提**的断言。它把「google 的 + ``_require_candidates`` 只在测试替身下可达」这件事固定在测试里:如果某个 + SDK 版本改成 ``construct_type``(像 openai 那样不校验),这条会变红,提醒 + 重新评估那个守卫的定位。反过来,如果有人把守卫当成线上拦截手段来写文档或 + 发布说明,这条也会让前提显形。 + """ + from google.genai import types as genai_types + from pydantic import ValidationError + + for payload in ( + {"candidates": 5}, + {"candidates": {"0": {}}}, + {"candidates": [{"content": {"parts": 5}}]}, + ): + with pytest.raises(ValidationError, match="candidates"): + genai_types.GenerateContentResponse.model_validate(payload) + + +def test_openai_sdk_does_not_validate_response_containers_by_default(): + """对称事实:openai SDK 默认走 ``construct_type``,不校验容器。 + + 这解释了为什么 openai 兼容渠道的同类守卫是**线上可达**的,而 google 的不是。 + 两条前提断言放在一起,避免只记住一半。 + """ + from openai import OpenAI + + client = OpenAI(api_key="dummy", base_url="https://example.invalid/v1") + assert client._strict_response_validation is False + + +# ── rerank_enabled:读取侧必须与写入侧同判据 ── +# +# ``SessionConfig._validate_rerank_enabled`` 只认 ``bool``(测试 +# ``tests/runtime/test_contracts.py`` 里就有 ``("rerank_enabled", "yes")``), +# 但那只管**写入**侧。``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` +# 守卫,直写 ``"false"``/``"no"``/``0`` 之后,UI 的 5 个读取点走真值判断, +# 与服务层的 ``_coerce_rerank_enabled`` 分成两套判据: +# +# 实测直写 ``"false"``:配置表显示 **「启用」** 并渲染 Rerank 模型段落、 +# 菜单显示「当前: 启用」、``questionary.confirm`` 的默认项是「是」; +# 而 ``RetrievalService.retrieve`` 对同一个值直接抛 +# ``ValueError: rerank_enabled 必须是布尔值``。 +# +# 用户看到的是与配置意图**相反**的结论——把 rerank 写成 ``"false"`` 的人 +# 得到的是「已启用」,且界面不提示任何错误。 + + +@pytest.mark.parametrize( + "written", + [ + pytest.param("false", id="str-false"), + pytest.param("no", id="str-no"), + pytest.param("true", id="str-true"), + pytest.param(0, id="int-0"), + pytest.param(1, id="int-1"), + pytest.param(None, id="none"), + ], +) +def test_rerank_enabled_reading_rejects_what_writing_rejects(written): + """直写的非布尔取值,UI 与服务层必须给出同一种收场(拒绝)。""" + from src.services.retrieval_service import _coerce_rerank_enabled + from src.utils.config import require_rerank_enabled + + config = _session_config() + config.rerank_enabled = written # 普通属性,无守卫 + + with pytest.raises(ValueError, match="rerank_enabled 必须是布尔值"): + require_rerank_enabled(config.rerank_enabled) + with pytest.raises(ValueError, match="rerank_enabled 必须是布尔值"): + _coerce_rerank_enabled(config.rerank_enabled) + + +def test_display_does_not_report_enabled_for_a_falsy_string(): + """配置表不得把 ``"false"`` 显示成「启用」。 + + 这是缺陷的原始症状:真值判断让 ``"false"``(非空字符串)走「启用」分支, + 连 Rerank 模型段落都一起渲染出来。修复后读取侧与写入侧同判据,直接拒绝。 + """ + from io import StringIO + + from rich.console import Console + + from src.ui.display_utils import display_chat_config + + config = _model_config(rerank_enabled=True) + config.rerank_enabled = "false" + + console = Console(file=StringIO(), width=200, no_color=True) + with pytest.raises(ValueError, match="rerank_enabled 必须是布尔值"): + display_chat_config(console, config) + + +def test_display_keeps_working_for_a_real_boolean(): + """收紧不能误伤:真正的布尔取值照旧渲染出对应的状态文本。""" + from io import StringIO + + from rich.console import Console + + from src.ui.display_utils import display_chat_config + + for enabled, expected in ((True, "启用"), (False, "禁用")): + buffer = StringIO() + display_chat_config( + Console(file=buffer, width=200, no_color=True), + _model_config(rerank_enabled=enabled), + ) + assert expected in buffer.getvalue() + + +def test_config_menu_does_not_report_enabled_for_a_falsy_string(): + """菜单项同样不得把 ``"false"`` 显示成「启用」。 + + 菜单**不能**抛错:``edit_retrieval_params`` 由 ``launch_config_editor`` 经 + ``asyncio.to_thread`` 调用,调用点只接 ``(KeyboardInterrupt, EOFError)``, + 异常会终止整个会话——而菜单是用户唯一还能改配置的入口 + (``display_chat_config`` 在上游直接拒绝)。因此非法取值降级为「未知」。 + """ + from src.ui.config_menu import _rerank_default, _rerank_menu_label + + config = _session_config().to_dict() + for written in ("false", "no", 0, None): + config["rerank_enabled"] = written + assert _rerank_menu_label(config) == "未知", written + assert _rerank_default(config) is None, written + + +def test_config_menu_keeps_working_for_a_real_boolean(): + """收紧不能误伤:真正的布尔取值照旧显示「启用」/「禁用」。""" + from src.ui.config_menu import _rerank_default, _rerank_menu_label + + config = _session_config().to_dict() + for enabled, label in ((True, "启用"), (False, "禁用")): + config["rerank_enabled"] = enabled + assert _rerank_menu_label(config) == label + assert _rerank_default(config) is enabled + + +# ── top_k:已收敛的字段不得在同一个函数里被重读 ── +# +# ``RetrievalService.retrieve`` 在开头就把 ``session_config.top_k`` 收敛进局部 +# 变量(``require_scale_field``),但两处下游**重新读原属性**: +# +# * ``retrieve`` 出口的切片 ``ranked_results[: max(1, session_config.top_k)]``; +# * ``_rerank_if_needed`` 的 ``max(1, min(session_config.top_k, ...))``——该 +# 方法的 docstring 明确写着「供库调用方直接调本方法」,而库调用方不经过 +# ``retrieve`` 开头那道收敛。 +# +# 该字段可被直写(``SessionConfig`` 是公开类,普通属性没有 ``__setattr__`` +# 守卫)。静态非法值会被开头那道收敛拦下,但 ``retrieve`` 是 async、中间有多处 +# ``await``——**检索飞行中**改写属性就会绕过它。实测两条路径都抛裸 +# ``TypeError: '>' not supported between instances of 'str' and 'int'`` +# (库调用方路径是 ``'<'``),不是调用方按 ``ValueError`` 写的分类。 + + +class _TopKStore: + """返回固定条目的最小向量存储替身。""" + + def __init__(self, documents): + self._documents = documents + + def semantic_search(self, query, top_k): + return [dict(doc) for doc in self._documents] + + def keyword_search(self, query, top_k): + return [dict(doc) for doc in self._documents] + + +class _RerankStub: + """返回「全部保留、分数相同」的 rerank 替身。 + + 返回值必须是 ``(索引列表, 分数列表)`` 二元组——``_unpack_rerank_result`` + 只接受这一形态。此前这里返回一个不存在的 ``RerankResult``(``import`` 在 + 调用期才失败),旧用例因 ``top_k`` 校验先抛而从未走到这里;一旦入参合法, + 这个替身自己就会以 ``ImportError`` 收场。 + """ + + async def arerank(self, query, documents, top_n=None): + return list(range(len(documents))), [0.5] * len(documents) + + +@pytest.mark.parametrize( + "written", + [ + pytest.param("abc", id="str"), + pytest.param(None, id="none"), + pytest.param([1], id="list"), + pytest.param(True, id="bool"), + ], +) +def test_rerank_if_needed_rejects_a_directly_written_top_k(written): + """库调用方直调 ``_rerank_if_needed`` 时,非法 ``top_k`` 必须收敛成 ``ValueError``。 + + ``min(1, session_config.top_k)`` 对非数值抛裸 ``TypeError``,而本方法的 + docstring 明确支持库调用方直接调用(那条路径不经过 ``retrieve`` 的收敛)。 + """ + import asyncio + + from src.services.retrieval_service import RetrievalService + + detail = SimpleNamespace(provider="siliconflow", model_name="m") + config = _session_config( + rerank_enabled=True, + rerank_configurations={"y": detail}, + ) + config.top_k = written + + service = RetrievalService(vector_store=object(), embedding_service=object()) + service._get_rerank_provider = lambda *args, **kwargs: _RerankStub() + + with pytest.raises(ValueError, match="top_k 必须是整数"): + asyncio.run( + service._rerank_if_needed( + "q", [{"page_content": "d", "metadata": {}, "score": 0.9}], config + ) + ) + + +# ── `_rerank_if_needed` 的 `ranked_results` 也要判形态 ── +# +# 该方法的 docstring 明确「供库调用方直接调本方法」,因此对 `top_k`/ +# `rerank_enabled` 都做了收敛(见上一条用例)。但**同一个方法的第一个入参** +# `ranked_results` 这一半漏了,而它的契约正是 `_require_document_list` 覆盖的 +# 「文档映射的列表」。实测非列表取值的收场都不在契约内: +# * `"abc"` -> `TypeError: 'str' object does not support item assignment` +# (字符串是合法可迭代对象,逐字符进入循环后 `document["score"] = ...` 抛); +# * `5` / `10**4301` / `object()` -> 裸 `TypeError: ... is not iterable`; +# * `{"a": 1}` -> `KeyError: 0`(字典迭代出的是键,`ranked_results[index]` 取不到)。 +# 三种都不是 `ValueError`,穿透调用方按 `ValueError` 建立的分类;而 +# `retrieve` 内部调用本方法时,入参已经过 `_require_document_list`,因此库调用方 +# 直接调用的这条路径与生产路径的判据不一致。 + + +@pytest.mark.parametrize( + "ranked_results", + [ + pytest.param("abc", id="str"), + pytest.param(5, id="int"), + pytest.param({"a": 1}, id="dict"), + pytest.param(object(), id="object"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_rerank_if_needed_rejects_non_document_list(ranked_results): + """库调用方直调 ``_rerank_if_needed`` 时,非文档列表必须收敛成可读异常。""" + import asyncio + + from src.services.retrieval_service import RetrievalService + + detail = SimpleNamespace(provider="siliconflow", model_name="m") + config = _session_config(rerank_enabled=True, rerank_configurations={"y": detail}) + + service = RetrievalService(vector_store=object(), embedding_service=object()) + service._get_rerank_provider = lambda *args, **kwargs: _RerankStub() + + # 断言必须带阶段标签「重排」:只 match="文档列表" 时把标签换成别的词仍绿(假绿)。 + with pytest.raises((TypeError, ValueError), match="重排返回的必须是文档列表"): + asyncio.run(service._rerank_if_needed("q", ranked_results, config)) + + +def test_rerank_if_needed_still_accepts_a_document_list(): + """收紧不能误伤:合法文档列表照常重排。""" + import asyncio + + from src.services.retrieval_service import RetrievalService + + detail = SimpleNamespace(provider="siliconflow", model_name="m") + config = _session_config(rerank_enabled=True, rerank_configurations={"y": detail}) + + service = RetrievalService(vector_store=object(), embedding_service=object()) + service._get_rerank_provider = lambda *args, **kwargs: _RerankStub() + + result = asyncio.run( + service._rerank_if_needed( + "q", [{"page_content": "d", "metadata": {}, "score": 0.9}], config + ) + ) + + assert len(result) == 1 + + +# ── `_rerank_if_needed` 的两个关键字入参也要收敛 ── +# +# 该方法的 `rerank_enabled` 与 `top_k` 都是**可选关键字**,docstring 写明 +# 「`rerank_enabled` 由调用方传入**已收敛**的布尔」「`top_k` 同理」,默认 +# `None` 时才自行收敛。但「调用方传入的值」这一半完全没有判据: +# +# * `top_k="abc"` / `top_k=[1]` -> `max(1, min("abc", n))` 抛裸 +# `TypeError: '<' not supported between instances of 'int' and 'str'`; +# * `rerank_enabled="no"` / `0` / `[]` -> 真值判断,分别得到「启用」「禁用」 +# 「禁用」——`0` 与 `[]` 恰好与 `False` 同分支(不报错但语义靠巧合), +# `"no"` 则**真的去调 rerank provider**,而 `SessionConfig` 对同一个值 +# 直接报错(`require_rerank_enabled` 只认 `bool`)。 +# +# 两者都不是 `ValueError`,穿透调用方按 `ValueError` 建立的分类。修法是让这两个 +# 形参也过一遍与默认分支**同一组**助手——判据只做了一半。 + + +@pytest.mark.parametrize( + "written", + [ + pytest.param("abc", id="str"), + pytest.param([1], id="list"), + pytest.param(True, id="bool"), + pytest.param(3.5, id="float"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_rerank_if_needed_rejects_an_uncoerced_top_k_argument(written): + """库调用方传入的 ``top_k`` 与默认分支同判据。""" + import asyncio + + from src.services.retrieval_service import RetrievalService + + detail = SimpleNamespace(provider="siliconflow", model_name="m") + config = _session_config(rerank_enabled=True, rerank_configurations={"y": detail}) + + service = RetrievalService(vector_store=object(), embedding_service=object()) + service._get_rerank_provider = lambda *args, **kwargs: _RerankStub() + + with pytest.raises(ValueError, match="top_k"): + asyncio.run( + service._rerank_if_needed( + "q", + [{"page_content": "d", "metadata": {}, "score": 0.9}], + config, + top_k=written, + ) + ) + + +@pytest.mark.parametrize( + "written", + [ + pytest.param("no", id="str"), + pytest.param("false", id="str-false"), + pytest.param(0, id="int-zero"), + pytest.param(1, id="int-one"), + pytest.param([], id="empty-list"), + ], +) +def test_rerank_if_needed_rejects_an_uncoerced_rerank_enabled_argument(written): + """库调用方传入的 ``rerank_enabled`` 与默认分支同判据。""" + import asyncio + + from src.services.retrieval_service import RetrievalService + + detail = SimpleNamespace(provider="siliconflow", model_name="m") + config = _session_config(rerank_enabled=True, rerank_configurations={"y": detail}) + + service = RetrievalService(vector_store=object(), embedding_service=object()) + service._get_rerank_provider = lambda *args, **kwargs: _RerankStub() + + with pytest.raises(ValueError, match="rerank_enabled"): + asyncio.run( + service._rerank_if_needed( + "q", + [{"page_content": "d", "metadata": {}, "score": 0.9}], + config, + rerank_enabled=written, + ) + ) + + +def test_rerank_if_needed_still_accepts_coerced_keyword_arguments(): + """收紧不能误伤:调用方传入的已收敛取值照常工作。""" + import asyncio + + from src.services.retrieval_service import RetrievalService + + detail = SimpleNamespace(provider="siliconflow", model_name="m") + config = _session_config(rerank_enabled=True, rerank_configurations={"y": detail}) + + service = RetrievalService(vector_store=object(), embedding_service=object()) + service._get_rerank_provider = lambda *args, **kwargs: _RerankStub() + + result = asyncio.run( + service._rerank_if_needed( + "q", + [{"page_content": "d", "metadata": {}, "score": 0.9}], + config, + rerank_enabled=True, + top_k=3, + ) + ) + + assert len(result) == 1 + + # ``rerank_enabled=False`` 是「显式禁用」,与默认分支的 ``False`` 同一条出口。 + result = asyncio.run( + service._rerank_if_needed( + "q", + [{"page_content": "d", "metadata": {}, "score": 0.9}], + config, + rerank_enabled=False, + ) + ) + + assert len(result) == 1 + + +def test_retrieve_uses_the_coerced_top_k_even_if_it_changes_mid_flight(): + """检索飞行中改写 ``top_k`` 不得让出口切片抛裸 ``TypeError``。 + + ``retrieve`` 是 async,中间有多处 ``await``;属性可被直写,因此「开头收敛过 + 就安全」不成立。修法是让出口用**已收敛的局部变量**,而不是重读原属性。 + """ + import asyncio + + import numpy as np + + from src.services.retrieval_service import RetrievalService + + documents = [ + {"page_content": f"d{index}", "metadata": {"source": "s"}, "score": 0.9} + for index in range(3) + ] + gate = asyncio.Event() + + class _GateEmbedding: + async def embed_query(self, query): + gate.set() # 通知并发任务:现在改写配置 + await asyncio.sleep(0.05) # 让改写真正发生 + return np.zeros(4, dtype=np.float32) + + config = _session_config(retrieval_method=RetrievalMethod.SEMANTIC_SEARCH) + service = RetrievalService( + vector_store=_TopKStore(documents), embedding_service=_GateEmbedding() + ) + + async def mutate_mid_flight(): + await gate.wait() + config.top_k = "abc" + + async def run(): + mutator = asyncio.create_task(mutate_mid_flight()) + try: + return await service.retrieve(query="q", session_config=config, console=None) + finally: + await mutator + + results = asyncio.run(run()) + assert len(results) == 3 + + +# ── display_utils._require_model_detail:不可哈希的 key 必须收敛 ── +# +# 该函数的 docstring 明确以「同一个 key 走 ModelProviderFactory._get_model_detail +# 得到 ValueError,错误分类本就该一致」为理由建立契约,但工厂侧有一条它没有的 +# 守卫:**成员判断前先收敛键形态**。``key not in mapping`` 对**不可哈希**的取值抛 +# 裸 ``TypeError: unhashable type: 'list'``——不是 ``ValueError``,而本函数在 +# ``start_chat_session_async`` 里于任何提示符**之前**被调用,用户刚看完启动横幅 +# 就收到裸 traceback。 +# +# 可达路径:``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫, +# 直写 ``active_rerank_configuration = ["siliconflow"]``(``active_*`` 字段在 +# ``Settings`` 与 ``SessionConfig`` 里都没有 validator)即可。 +# +# **收窄到不可哈希这一类,而不是「必须是字符串」**:本函数的职责是显示,而可哈希 +# 的非字符串键在映射里能正常取到值——既有用例 +# ``test_display_chat_config_renders_values_beyond_the_conversion_limit`` 明确 +# 要求位数超限的整数键可渲染(那是显示层的既有契约)。工厂侧要求字符串是因为它 +# 要拿 key 去建 provider,两处的接受面本就不同;一致的是**异常分类**。 + + +@pytest.mark.parametrize( + "written", + [ + pytest.param([1], id="list"), + pytest.param({"a": 1}, id="dict"), + pytest.param({1, 2}, id="set"), + ], +) +def test_model_detail_lookup_rejects_an_unhashable_key(written): + """不可哈希的 key 必须收敛成 ``ValueError``,与工厂侧同判据。""" + from src.ui.display_utils import _require_model_detail + + configurations = {"demo": SimpleNamespace(provider="p", model_name="m")} + + with pytest.raises(ValueError, match="key 必须是可哈希的取值"): + _require_model_detail(configurations, written, "Rerank") + + +def test_model_detail_lookup_matches_the_factory_for_an_unhashable_key(): + """同一个 key 在两处必须给出同一种异常分类(docstring 的既有契约)。""" + from src.providers.factory import ModelProviderFactory + from src.ui.display_utils import _require_model_detail + + configurations = {"demo": SimpleNamespace(provider="p", model_name="m")} + + for written in ([1], {"a": 1}): + with pytest.raises(ValueError): + _require_model_detail(configurations, written, "Rerank") + with pytest.raises(ValueError): + ModelProviderFactory._get_model_detail( + written, configurations, "rerank_configurations", "Rerank" + ) + + +@pytest.mark.parametrize( + "written", + [ + pytest.param("missing", id="str"), + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param(_HUGE_INT, id="huge-int"), + ], +) +def test_model_detail_lookup_keeps_missing_key_semantics_for_hashable_keys(written): + """收紧不能误伤:**可哈希**的缺失键仍是原来的「未找到key」消息。 + + 这些取值不会触发 ``TypeError``,因此不属于本轮的缺陷类;它们必须继续走原有 + 分支(位数超限的整数尤其要紧——显示层既有契约要求它可渲染,见上方注释)。 + """ + from src.ui.display_utils import _require_model_detail + + configurations = {"demo": SimpleNamespace(provider="p", model_name="m")} + + with pytest.raises(ValueError, match="未找到key"): + _require_model_detail(configurations, written, "Rerank") + + +def test_model_detail_lookup_renders_a_huge_integer_key(): + """位数超限的整数键必须仍能取到值——既有显示契约,不得被本轮收紧误伤。""" + from src.ui.display_utils import _require_model_detail + + detail = SimpleNamespace(provider="p", model_name="m") + configurations = {_HUGE_INT: detail} + + assert _require_model_detail(configurations, _HUGE_INT, "Rerank") is detail + + +# ── 嵌套响应容器:与顶层同一判据 ── +# +# 前三轮把**顶层**容器收敛了(Google 的 candidates、Ark 的 output/tool_calls、 +# openai 兼容的 output/tool_calls/choices),但**嵌套**一层的容器仍是 +# ``field(x, ..., []) or []``——只挡 ``None`` 与空值。实测全部抛裸 +# ``TypeError: 'int' object is not iterable``,而各 provider 的错误分类按 +# ``RuntimeError`` 写。这正是「判据只做了一半」:同一个形态在顶层被拒、在下一层 +# 被放行。 +# +# 可达性按 SDK 的响应构造方式分(对照表见第十四轮条目): +# * openai / Ark / anthropic 走 ``construct_type``,**不校验** -> 线上可达; +# * google 走 ``model_validate``,畸形容器在 SDK 层被拒 -> 替身场景。 + + +def _google_response_with_parts(parts): + """构造 ``candidates[].content.parts`` 形态的 Google 响应替身。""" + return SimpleNamespace(candidates=[SimpleNamespace(content=SimpleNamespace(parts=parts))]) + + +@pytest.mark.parametrize( + "bad_parts", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_google_nested_parts_must_be_a_list(bad_parts): + """``candidates[].content.parts`` 非列表必须收敛成 ``RuntimeError``。""" + with pytest.raises(RuntimeError, match="parts 必须是列表"): + GoogleProvider._response_text(_google_response_with_parts(bad_parts)) + + +def test_google_nested_parts_keeps_working_for_a_valid_response(): + """收紧不能误伤:正常的 parts 照旧解析出正文。""" + response = _google_response_with_parts( + [SimpleNamespace(text="你好", thought=False), SimpleNamespace(text="世界", thought=False)] + ) + assert GoogleProvider._response_text(response) == "你好世界" + + +@pytest.mark.parametrize( + "bad_embeddings", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_google_embeddings_must_be_a_list(bad_embeddings): + """Embedding 响应的 ``embeddings`` 非列表必须收敛成 ``RuntimeError``。""" + with pytest.raises(RuntimeError, match="embeddings 必须是列表"): + GoogleProvider._require_embeddings( + SimpleNamespace(embeddings=bad_embeddings), source="Embedding 响应" + ) + + +@pytest.mark.parametrize( + "bad_content", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_ark_nested_output_content_must_be_a_list(bad_content): + """``output[].content`` 非列表必须收敛成 ``RuntimeError``(Ark 线上可达)。""" + from src.providers.volcengine import _ark_responses_output_text + + response = SimpleNamespace(output=[SimpleNamespace(type="message", content=bad_content)]) + with pytest.raises(RuntimeError, match="content 必须是列表"): + _ark_responses_output_text(response) + + +def test_ark_nested_output_content_keeps_working_for_a_valid_response(): + """收紧不能误伤:正常的嵌套 content 照旧解析出正文。""" + from src.providers.volcengine import _ark_responses_output_text + + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text="hi")], + ) + ] + ) + assert _ark_responses_output_text(response) == "hi" + + +@pytest.mark.parametrize( + "bad_choices", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_ark_responses_path_guards_choices(bad_choices): + """Ark 的 Responses 路径下 ``choices`` 非列表必须收敛成 ``RuntimeError``。 + + Ark 的 ``_extract_result`` 被 Chat 与 Responses 两条路径共用,但只有 Chat + 分支在它之前调了 ``_raise_for_chat_response_error``(含 choices 守卫); + Responses 分支走 ``_raise_for_response_error``,它只查 ``error`` 与 + ``status``。实测 Responses 响应带 ``choices=5`` 时 ``complete()`` 在 + ``choices[0]`` 抛裸 ``TypeError: 'int' object is not subscriptable``。 + """ + from src.providers.volcengine import VolcengineProvider + + response = SimpleNamespace( + choices=bad_choices, output=[], status="completed", id="r", usage=None + ) + with pytest.raises(RuntimeError, match="choices 必须是列表"): + VolcengineProvider._extract_result(response) + + +@pytest.mark.parametrize( + "bad_choices", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_openai_responses_path_guards_choices(bad_choices): + """Responses 路径下 ``choices`` 非列表必须收敛成 ``RuntimeError``。 + + **Responses 路径没有别处替它把关**:``_raise_for_chat_response_error`` + (含 choices 形态守卫)只被 Chat 路径调用,而 ``_invoke_responses`` 走的是 + ``_raise_for_response_error``——后者只查 ``error`` 与 ``status``。 + """ + response = SimpleNamespace( + choices=bad_choices, output=[], status="completed", id="r", usage=None + ) + with pytest.raises(RuntimeError, match="choices 必须是列表"): + OpenAICompatibleProvider._extract_response_text(response) + + +@pytest.mark.parametrize( + "bad_choices", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +@pytest.mark.parametrize( + "extractor", ["_extract_tool_calls", "_extract_reasoning", "_extract_refusal"] +) +def test_openai_responses_path_guards_choices_in_every_extractor(extractor, bad_choices): + """``choices`` 的形态守卫必须覆盖**每一个**读它的提取器。 + + ``complete()``/``acomplete()`` 用同一份响应依次调三个提取器 + (``_extract_response_text``/``_extract_tool_calls``/``_extract_reasoning``), + 但此前只有 ``_extract_response_text`` 把 ``choices`` 过了 + ``_require_response_list``,另两个直接 ``for choice in choices``。而 + ``_extract_response_text`` 在 Responses 路径下**提前返回**——``output_text`` + 是字符串时(正常形态)它根本不碰 ``choices``,于是畸形取值留给另外两个 + 提取器,在 ``for`` 上抛裸 ``TypeError: 'int' object is not iterable``—— + 与同一次调用里 ``_extract_response_text`` 给出的 ``RuntimeError`` 收场不同, + 而调用方的错误分类按 ``RuntimeError`` 写。 + """ + response = SimpleNamespace(output_text="hello", choices=bad_choices, output=[], usage=None) + with pytest.raises(RuntimeError, match="choices 必须是列表"): + getattr(OpenAICompatibleProvider, extractor)(response) + + +@pytest.mark.parametrize( + "bad_choices", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_openai_responses_complete_never_raises_bare_type_error_on_bad_choices(bad_choices): + """整条 ``complete()`` 路径的收场必须是 ``RuntimeError``,不是裸 ``TypeError``。 + + 直接调单个提取器只证明「它自己收敛」;这里从 ``complete()`` 入口走一遍, + 确认三个提取器**串联**之后不再有任何一个把裸异常漏出去。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._protocol = "responses" + provider._provider = "openai" + provider._require_responses_resource = lambda _operation: None + provider._build_responses_request = lambda **_kwargs: {} + provider._poll_background_response = lambda response, _params: response + + class _Responses: + def create(self, **_kwargs): + return SimpleNamespace( + output_text="hello", + choices=bad_choices, + output=[], + status="completed", + id="r", + usage=None, + ) + + provider._get_client = lambda: SimpleNamespace(responses=_Responses()) + + with pytest.raises(RuntimeError, match="choices 必须是列表"): + provider.complete(prompt="hi", stream=False) + + +@pytest.mark.parametrize( + "bad_delta", + [ + pytest.param(5, id="int"), + pytest.param({"a": 1}, id="mapping"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_openai_responses_streaming_refusal_delta_is_coerced(bad_delta): + """流式拒答的 ``delta`` 必须收敛,且收场是 ``RuntimeError``。 + + 同一份事件里 ``_stream_delta``(正文分支)与 ``_extract_refusal``(非流式 + 拒答)都对取值做过形态收敛,而**流式拒答分支**直接把 ``delta`` 插进 + f-string:位数超限的整数让 f-string 在**求值期**抛裸 + ``ValueError: Exceeds the limit``——正要报出拒答的那一步变成整数转换错误, + 而本 provider 的错误分类按 ``RuntimeError`` 写。 + """ + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "openai" + + class Responses: + def create(self, **_kwargs): + return iter( + [ + SimpleNamespace(type="response.refusal.delta", delta=bad_delta), + SimpleNamespace(type="response.completed"), + ] + ) + + provider._get_client = lambda: SimpleNamespace(responses=Responses()) + + with pytest.raises(RuntimeError, match="拒答") as excinfo: + list(provider._invoke_responses({"model": "m", "input": "hi", "stream": True})) + assert "Exceeds the limit" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "bad_delta", + [ + pytest.param(5, id="int"), + pytest.param({"a": 1}, id="mapping"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_openai_responses_async_streaming_refusal_delta_is_coerced(bad_delta): + """异步 Responses 流的拒答分支与同步版同判据。""" + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = "openai" + + class AsyncEvents: + def __aiter__(self): + self._items = iter( + [ + SimpleNamespace(type="response.refusal.delta", delta=bad_delta), + SimpleNamespace(type="response.completed"), + ] + ) + return self + + async def __anext__(self): + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration from None + + class Responses: + def create(self, **_kwargs): + return AsyncEvents() + + provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) + + async def collect(): + return [ + chunk + async for chunk in provider._ainvoke_responses( + {"model": "m", "input": "hi", "stream": True} + ) + ] + + with pytest.raises(RuntimeError, match="拒答") as excinfo: + asyncio.run(collect()) + assert "Exceeds the limit" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "bad_content", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_openai_nested_output_content_must_be_a_list(bad_content): + """``output[].content`` 非列表必须收敛成 ``RuntimeError``。""" + response = SimpleNamespace( + choices=[], output=[SimpleNamespace(type="message", content=bad_content)], usage=None + ) + with pytest.raises(RuntimeError, match=r"output\[\]\.content 必须是列表"): + OpenAICompatibleProvider._extract_response_text(response) + + +@pytest.mark.parametrize( + "bad_content", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_anthropic_content_must_be_a_list(bad_content): + """Anthropic 响应的顶层 ``content`` 非列表必须收敛成 ``RuntimeError``。 + + **线上可达**:anthropic SDK 的 ``_strict_response_validation`` 实测默认 + ``False``,走 ``construct_type``,不执行 pydantic 校验(与 openai/Ark 同类)。 + """ + from src.providers.anthropic import AnthropicProvider + + response = SimpleNamespace(content=bad_content, stop_reason="end_turn", usage=None) + with pytest.raises(RuntimeError, match="content 必须是列表"): + AnthropicProvider._extract_result(response) + + +def test_anthropic_content_keeps_working_for_a_valid_response(): + """收紧不能误伤:正常的 content 块照旧解析。""" + from src.providers.anthropic import AnthropicProvider + + response = SimpleNamespace( + content=[SimpleNamespace(type="text", text="你好")], + stop_reason="end_turn", + usage=None, + id="x", + model="m", + ) + assert AnthropicProvider._extract_result(response).text == "你好" + + +@pytest.mark.parametrize( + "bad_data", + [ + pytest.param(5, id="int"), + pytest.param({"0": {}}, id="mapping"), + pytest.param("ab", id="str"), + ], +) +def test_ark_tokenization_data_must_be_a_list(bad_data): + """Ark tokenization 响应的 ``data`` 非列表必须收敛成 ``RuntimeError``。""" + from unittest.mock import MagicMock + + from src.providers.volcengine import VolcengineProvider + + provider = VolcengineProvider.__new__(VolcengineProvider) + provider._provider = "volcengine" + provider._protocol = "chat_completions" + provider._model_name = "m" + provider._client = None + provider._aclient = None + provider._options = {} + provider._server_verified_protocols = frozenset() + client = MagicMock() + client.tokenization.create.return_value = SimpleNamespace(data=bad_data) + + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="data 必须是列表"), + ): + provider.count_tokens("hi") + + +# ── HybridReranker._weight_tuple:同一判据的两个分支必须同一种渲染 ── +# +# ``_weight_tuple`` 对同一句「必须是和为正的有限数值」有两个触发分支:前者是 +# ``is_double_convertible`` 判单个权重失败,后者是**派生值** ``total`` 判和溢出。 +# 两个分支此前一个用 ``safe_repr``、一个用裸 ``!r``——同一函数、同一消息模板、 +# 同一判据,渲染方式却不同。 +# +# 裸 ``!r`` 的两条后果都实测复现: +# * ``HybridReranker(10**308, 10**308)``(**普通 int**,两个权重各自都在 +# double 范围内,只有和溢出)报错 759 字符,而 ``safe_repr`` 的上限是 80—— +# 这正是 ``safe_repr`` 的 docstring 写明的「一条校验消息不该刷屏」; +# * ``repr`` 会抛的 ``int`` 子类(``is_double_convertible`` 走 ``isinstance``, +# 放行后 ``{v!r}`` 在插值处抛)让本方法的 ``ValueError`` 契约变成 +# ``ValueError: Exceeds the limit (4300 digits)`` / 裸 ``RuntimeError``—— +# 与「权重配错了」毫无关系,且穿透调用方的 ``except ValueError`` 分类。 +# +# 可达性:``HybridReranker`` 是公开类,docstring 自己写明「库调用方可能绕过 +# ``Settings`` 与 ``SessionConfig`` 直接构造」,本方法即为此存在的最后一道兜底。 + + +def test_hybrid_reranker_weight_message_is_not_flooded_by_a_huge_int(): + """普通超大整数触发的消息必须被 ``safe_repr`` 截断,而不是刷 759 字符。""" + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="有限数值") as excinfo: + HybridReranker(10**308, 10**308)._weight_tuple() + + message = str(excinfo.value) + # ``safe_repr`` 对每个权重最多 80 字符;留足前缀与指引文案的余量。 + assert len(message) < 400, f"消息长度 {len(message)},疑似未走 safe_repr:{message[:120]}" + + +def test_hybrid_reranker_weight_message_survives_unrepresentable_values(): + """``repr`` 自己会抛的取值,报错仍必须是带指引的 ``ValueError``。""" + + class _UnreprUnstyled(int): + def __repr__(self) -> str: + # 模拟位数超限的整数:``repr(10**4301)`` 撞 + # ``sys.get_int_max_str_digits()``(默认 4300)。 + return repr(10**4301) + + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="有限数值"): + HybridReranker(_UnreprUnstyled(10**308), 10**308)._weight_tuple() + + +# ── HybridReranker.rerank 的 documents 入参也要判形态 ── +# +# 本类的 docstring 明确「库调用方可能绕过 ``Settings`` 与 ``SessionConfig`` +# 直接构造」,构造器与 ``_weight_tuple`` 都为此做了收敛;但**入参** ``documents`` +# 这一半漏了。``_weighted_rerank``/``_rrf_rerank`` 都直接 ``for doc in documents`` +# 再对 ``doc.get(...)`` 取值,非列表取值的收场都不在契约内:``5``/``None``/ +# ``object()`` 抛裸 ``TypeError: ... is not iterable``;``"abc"``/``{"a": 1}`` +# 抛 ``AttributeError: 'str' object has no attribute 'get'``(逐字符/逐键迭代)。 +# 两种都不是 ``ValueError``,穿透调用方按 ``ValueError`` 建立的分类。 +# 可达路径:``HybridReranker`` 经 ``src/retrieval/retriever.py`` 的 ``__all__`` +# 对外导出,是公开边界。 + + +@pytest.mark.parametrize( + "documents", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param("abc", id="str"), + pytest.param({"a": 1}, id="dict"), + pytest.param(object(), id="object"), + ], +) +@pytest.mark.parametrize("fusion_strategy", ["rrf", "weighted"]) +def test_hybrid_reranker_rerank_rejects_non_document_list(documents, fusion_strategy): + """``rerank`` 收到非文档列表时必须给可读异常,不得裸抛。""" + from src.services.retrieval_service import HybridReranker + + reranker = HybridReranker(0.5, 0.5, fusion_strategy=fusion_strategy) + + with pytest.raises((TypeError, ValueError), match="文档列表"): + reranker.rerank(documents) + + +def test_hybrid_reranker_rerank_still_accepts_a_document_list(): + """收紧不能误伤:合法文档列表照常融合排序。""" + from src.services.retrieval_service import HybridReranker + + reranker = HybridReranker(0.5, 0.5, fusion_strategy="rrf") + + ranked = reranker.rerank([{"page_content": "d", "semantic_rank": 1, "keyword_rank": 1}]) + + assert len(ranked) == 1 + + +# ── HybridReranker 构造期守卫的**测试覆盖**补缺 ── +# +# 下面五条不是新缺陷,而是**变异测试**翻出来的覆盖空洞:把对应守卫的 +# ``raise`` 删掉之后,全量套件(3129 条)**仍然全绿**——守卫本身正确,却 +# 没有任何一条测试会在它被删掉时变红。每个守卫都实测可达(``HybridReranker`` +# 经 ``src/retrieval/retriever.py`` 的 ``__all__`` 对外导出,docstring 自己 +# 写明「库调用方可能绕过 ``Settings`` 与 ``SessionConfig`` 直接构造」)。 +# +# 每条都用「删除守卫即变红」的形态写:断言的是**守卫存在时的收场**,因此 +# 守卫一旦被删,行为退化(静默接受或抛出别的异常)就会让断言失败。 + + +@pytest.mark.parametrize( + "strategy", + [ + pytest.param("bogus", id="unknown-word"), + pytest.param("hybrid", id="retrieval-method-name"), + pytest.param("", id="empty"), + pytest.param(" rrf x ", id="normalizes-to-nothing-valid"), + ], +) +def test_hybrid_reranker_rejects_a_string_strategy_outside_the_allowed_set(strategy): + """合法字符串但不在 ``{"rrf", "weighted"}`` 时必须报错。 + + 删除构造器末尾那道成员判据后,``rerank`` 的派发是 + ``if == "weighted" else _rrf_rerank``——**没有**最后的兜底分支,未知策略 + 会静默按 RRF 融合。用户配置里写着一个无效策略、程序却按 RRF 出结果,是 + 与 ``retrieval_method`` 静默退化同类的问题。``_coerce_fusion_strategy`` + 只做 ``.strip().lower()``、不判成员,所以这道守卫是生产路径上的唯一拦截点。 + """ + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="融合策略"): + HybridReranker(0.5, 0.5, fusion_strategy=strategy) + + +@pytest.mark.parametrize( + "rrf_k", + [ + pytest.param(float("nan"), id="nan"), + pytest.param(float("inf"), id="inf"), + pytest.param(10**400, id="beyond-double"), + pytest.param("60", id="numeric-string"), + pytest.param(None, id="none"), + pytest.param(True, id="bool"), + ], +) +def test_hybrid_reranker_rejects_a_non_finite_rrf_k(rrf_k): + """``rrf_k`` 非有限/非数值必须报错,而不是静默收下。 + + 删除这道守卫后,``nan`` 会静默通过(``nan <= 0`` 为 False),随后每个文档 + 得分都是 ``nan``、与分数阈值比较恒为 False,整批结果被静默丢弃;``"60"`` + 被 ``float()`` 悄悄转成 ``60.0``;``10**400`` 则把干净的 ``ValueError`` + 换成下一行的裸 ``OverflowError``(``ArithmeticError`` 子类,穿透调用方的 + ``except ValueError`` 分类)。 + """ + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="rrf_k"): + HybridReranker(0.5, 0.5, rrf_k=rrf_k) + + +@pytest.mark.parametrize( + "rrf_k", + [ + pytest.param(0, id="zero-int"), + pytest.param(0.0, id="zero-float"), + pytest.param(-1, id="negative-int"), + pytest.param(-60, id="negative-matches-default-rank"), + ], +) +def test_hybrid_reranker_rejects_a_non_positive_rrf_k(rrf_k): + """``rrf_k <= 0`` 必须报错:它是 RRF 分母 ``rrf_k + rank``。 + + 删除这道守卫后 ``self.rrf_k`` 会静默变成 0 或负数——``rrf_k = -rank`` 时 + 分母为零,``rerank()`` 抛 ``ZeroDivisionError``(不是 ``ValueError``)。 + """ + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="rrf_k"): + HybridReranker(0.5, 0.5, rrf_k=rrf_k) + + +@pytest.mark.parametrize( + ("vector_weight", "keyword_weight"), + [ + pytest.param(10**400, 0.5, id="vector-unconvertible"), + pytest.param(0.5, 10**400, id="keyword-unconvertible"), + ], +) +def test_weight_tuple_rejects_a_single_unconvertible_weight(vector_weight, keyword_weight): + """单个权重超出 double 范围时,``_weight_tuple`` 必须给带指引的 ``ValueError``。 + + 这道守卫用 ``is_double_convertible`` 而非 ``math.isfinite``(后者对超大 + ``int`` 自己就抛 ``OverflowError``)。删除它之后,下面的 + ``float(self.vector_weight)`` 会抛裸 ``OverflowError``——``ArithmeticError`` + 子类而**不是** ``ValueError``,穿透调用方全部 ``except ValueError`` 分类。 + 构造器不做这道判据,因此这是库调用方直接构造后的第一道拦截。 + """ + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="有限数值"): + HybridReranker(vector_weight, keyword_weight)._weight_tuple() + + +def test_weight_tuple_still_accepts_in_range_weights(): + """收紧不能误伤:两个权重都在 ``[0, 1]`` 内时照常归一化。""" + from src.services.retrieval_service import HybridReranker + + assert HybridReranker(0.0, 1.0)._weight_tuple() == (0.0, 1.0) + assert HybridReranker(0.5, 0.5)._weight_tuple() == (0.5, 0.5) + + +def test_retrieve_rejects_an_oversized_candidate_pool(): + """``top_k × retrieval_candidate_multiplier`` 越界必须在 ``retrieve`` 里被拦。 + + 两个字段各自都在单字段上界(2000)之内,只有**乘积**越界——而乘积才是真正 + 送进 ``faiss_index.search()`` 的值。``SessionConfig`` 的属性没有 + ``__setattr__`` 守卫,直写即可命中。删除 ``retrieve`` 里那道乘积判据后, + ``effective_top_k = 2000 × 2000`` 会静默传给 store;FAISS 既不报错也不截断, + 按该规模分配结果数组。 + """ + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + + class _Store: + def semantic_search(self, embedding, top_k): + return [] + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.SEMANTIC_SEARCH + config.rerank_enabled = False + # 属性直写,绕过 ``__setitem__``——两个字段各自合法,乘积越界。 + config.top_k = 2000 + config.retrieval_candidate_multiplier = 2000 + + with pytest.raises(ValueError, match="乘积超出合理范围"): + asyncio.run(RetrievalService(_Store(), _Embedding()).retrieve("q", config)) + + +# ── 子分片的 50% 比例上界:父侧修了,子侧漏了 ── +# +# 上一轮给**父**分片加了 `overlap <= size / 2`(`_validate_parent_sizes`), +# 而**子**分片只有 `child_overlap >= child_size` 的相等判据,没有比例上界。 +# 子分片器同样产出分片(每个父块都要过它一次),同一个静默放大在子侧完全存在。 +# +# 实测(父=1500/150、子 size=300,一篇 15000 字符的文档): +# +# 子 overlap 子块数 放大倍数 +# 30 (10%) 66 1.0x +# 150 (50%) 99 1.5x +# 270 (90%) 451 6.8x +# 299 (99.7%) 13211 200.2x +# +# `child_overlap >= child_size` 拦不住它们——299 < 300 成立、直接通过。 +# 配置侧同样放行:`Settings.validate_overlap_smaller_than_size` 只判 +# `kb_chunk_overlap` 与 `kb_chunk_size`,子分片的两条关系按注释所述有意留给 +# `_validate_hierarchical_sizes`(模式是运行时参数),因此那里是唯一判据位置。 + + +@pytest.mark.parametrize( + ("child_size", "child_overlap", "label"), + [ + pytest.param(300, 151, "50.3%", id="just-over-half"), + pytest.param(300, 270, "90%", id="ninety-percent"), + pytest.param(300, 299, "99.7%", id="just-under-size"), + ], +) +def test_hierarchical_child_overlap_ratio_is_bounded(child_size, child_overlap, label): + """子分片的 ``overlap`` 超过 ``size / 2`` 必须被拒,而不是静默产出上百倍分片。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + with pytest.raises(ValueError, match="kb_child_chunk_overlap 不能超过"): + RecursiveTextSplitter( + mode="char", + structure_mode="hierarchical", + parent_chunk_size=1500, + parent_chunk_overlap=150, + child_chunk_size=child_size, + child_chunk_overlap=child_overlap, + ).split([Document(content="正文" * 500, metadata={"source": "a.md"})]) + + +@pytest.mark.parametrize( + ("child_size", "child_overlap"), + [ + pytest.param(300, 30, id="ten-percent"), + pytest.param(300, 150, id="exactly-half-is-legal"), + ], +) +def test_hierarchical_child_overlap_ratio_does_not_over_tighten(child_size, child_overlap): + """收紧不能误伤:10% 与恰好 50% 都必须照旧通过并产出分片。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + splitter = RecursiveTextSplitter( + mode="char", + structure_mode="hierarchical", + parent_chunk_size=1500, + parent_chunk_overlap=150, + child_chunk_size=child_size, + child_chunk_overlap=child_overlap, + ) + chunks = splitter.split([Document(content="正文" * 500, metadata={"source": "a.md"})]) + assert chunks, "合法比例不应产出空结果" + + +# ── Pipeline.process:守卫了入参,却没守卫三个阶段的返回值 ── +# +# `Pipeline.process` 第一行就校验 `document` 是 `Document`(数据问题 -> ValueError), +# 而 `self.extractor.extract` / `self.cleaner.clean` / `self.splitter.split` 三个 +# 协作者的返回值此前**没有任何守卫**:`splitter.split` 返回 `None` 时,紧随其后的 +# 日志行 `len(final_chunks)` 抛裸 `TypeError: object of type 'NoneType' has no len()` +# ——报错现场在日志行,消息里既没有阶段名也没有「是谁违反了契约」。 +# +# 三个协作者都是抽象基类的实现,而 `Pipeline.__init__` 接受任意实现(抽象基类正是 +# 为替换实现而存在的)。同项目的 `RetrievalService._require_document_list` 对 +# 「协作者返回值」已有同一处置,其 docstring 写明:这是**接口契约**问题,抛 +# `TypeError`(入参形态错误才是数据问题、用 `ValueError`)。这里与那条判据对齐。 +# +# 两半都补:非列表(`None`/`5`/`"abc"`)与列表里的非 `Document` 项。 + + +_PASSTHROUGH = object() + + +class _StageReturning: + """按阶段替换返回值的 Pipeline 协作者替身。 + + 传入 ``_PASSTHROUGH`` 时该阶段走正常行为(extract 返回 ``[document]``, + clean/split 原样返回入参列表);否则返回给定值。这样可以让**任意一个** + 阶段返回坏值而它前面的阶段正常——否则最先跑的 extractor 总是先失败, + cleaner/splitter 的守卫永远测不到。 + """ + + def __init__(self, value): + self._value = value + + def extract(self, document, **kwargs): + return [document] if self._value is _PASSTHROUGH else self._value + + def clean(self, documents, **kwargs): + return documents if self._value is _PASSTHROUGH else self._value + + def split(self, documents, **kwargs): + return documents if self._value is _PASSTHROUGH else self._value + + +@pytest.mark.parametrize( + ("value", "label"), + [ + pytest.param(None, "NoneType", id="none"), + pytest.param(5, "int", id="int"), + pytest.param("abc", "str", id="str"), + ], +) +@pytest.mark.parametrize( + ("stage", "stage_label"), + [ + pytest.param("extractor", "抽取阶段", id="extractor"), + pytest.param("cleaner", "清洗阶段", id="cleaner"), + pytest.param("splitter", "分割阶段", id="splitter"), + ], +) +def test_pipeline_stage_return_must_be_a_list(value, label, stage, stage_label): + """三个阶段返回非列表必须收敛成**带该阶段名**的 ``TypeError``。 + + 断言必须逐个阶段指名道姓:只钉「抽取阶段」时,把 cleaner/splitter 的标签 + 改成别的词仍绿(假绿),因为最先失败的总是 extractor、另外两个阶段的守卫 + 从未执行。 + """ + from src.etl.pipeline import Pipeline + from src.models.document import Document + + parts = { + "extractor": _StageReturning(_PASSTHROUGH), + "cleaner": _StageReturning(_PASSTHROUGH), + "splitter": _StageReturning(_PASSTHROUGH), + } + parts[stage] = _StageReturning(value) + pipeline = Pipeline( + extractor=parts["extractor"], cleaner=parts["cleaner"], splitter=parts["splitter"] + ) + + with pytest.raises(TypeError, match=rf"{stage_label}返回的必须是文档列表"): + pipeline.process(Document(content="正文" * 10, metadata={"source": "a.md"})) + + +@pytest.mark.parametrize( + ("stage", "position", "stage_label"), + [ + pytest.param("extractor", 0, "抽取阶段", id="extractor"), + pytest.param("cleaner", 1, "清洗阶段", id="cleaner"), + pytest.param("splitter", 2, "分割阶段", id="splitter"), + ], +) +def test_pipeline_stage_return_items_must_be_documents(stage, position, stage_label): + """列表里的非 ``Document`` 项必须带**该阶段名**与位置报错。 + + 断言必须指名阶段:``match=r"返回的第 0 项不是 Document"`` 不含阶段名时,任意 + 阶段的标签被替换都仍绿(假绿)。 + """ + from src.etl.cleaners.basic_cleaner import BasicCleaner + from src.etl.extractors.markdown_extractor import MarkdownExtractor + from src.etl.pipeline import Pipeline + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + good = [MarkdownExtractor(), BasicCleaner(), RecursiveTextSplitter()] + parts = list(good) + parts[position] = _StageReturning([None]) + pipeline = Pipeline(extractor=parts[0], cleaner=parts[1], splitter=parts[2]) + + with pytest.raises(TypeError, match=rf"{stage_label}返回的第 0 项不是 Document"): + pipeline.process(Document(content="正文" * 10, metadata={"source": "a.md"})) + + +def test_pipeline_legal_path_still_works(): + """收紧不能误伤:默认三阶段照旧产出 ``Document`` 分片。""" + from pathlib import Path + + from src.etl.pipeline import Pipeline + from src.models.document import Document + + pipeline = Pipeline.from_file_path(Path("kb.md")) + chunks = pipeline.process(Document(content="正文" * 500, metadata={"source": "a.md"})) + + assert chunks + assert all(isinstance(chunk, Document) for chunk in chunks) + + +# ── ChatService 的标量入参:与同文件的 收敛同一判据 ── +# +# `_build_prompt` 对检索结果里的文档字段已经收敛(`page_content=None` 时整条 +# 跳过,绝不把字面量 `内容: None` 喂给模型——由 +# `test_build_prompt_does_not_feed_malformed_content_to_the_model` 钉住), +# 但**同一次调用**的两个标量参数 `user_input` / `intent` 完全没有守卫。 +# +# 实测(修复前): +# user_input=None -> 静默生成 "用户问题: None",模型把 None 当成真实问题 +# user_input=5 -> 静默生成 "用户问题: 5" +# user_input=['a'] -> 静默生成 "用户问题: ['a']" +# user_input=10**4301 -> 裸 ValueError: Exceeds the limit (4300 digits) +# +# 同一个「非字符串值以字面量进 prompt」的形态,文档字段那一半修了、标量这一半 +# 漏了。`identify_intent` 的 f-string 更靠前(在 try **之外**),位数超限时抛的 +# 甚至不是本类承诺的 `RuntimeError: 意图识别失败`。 + + +@pytest.mark.parametrize( + ("user_input", "intent"), + [ + pytest.param(None, "意图", id="user-input-none"), + pytest.param(5, "意图", id="user-input-int"), + pytest.param(["a"], "意图", id="user-input-list"), + pytest.param(10**4301, "意图", id="user-input-beyond-int-str-digits"), + pytest.param("问题", None, id="intent-none"), + pytest.param("问题", ["a"], id="intent-list"), + pytest.param("问题", 10**4301, id="intent-beyond-int-str-digits"), + ], +) +def test_build_prompt_rejects_non_string_scalars(user_input, intent): + """非字符串的 ``user_input``/``intent`` 不得以字面量进入 prompt。""" + from src.services.chat_service import ChatService + + with pytest.raises(ValueError, match="必须是字符串"): + ChatService._build_prompt(user_input, intent, []) + + +def test_build_prompt_still_accepts_strings(): + """收紧不能误伤:两个字符串参数照旧产出 prompt。 + + 带一条可用知识走**有知识**那一支——``intent`` 只出现在该分支的模板里 + (无知识分支按既有设计只回显问题),因此这里必须给出正文才能同时断言两者。 + """ + from src.services.chat_service import ChatService + + prompt = ChatService._build_prompt("问题内容", "检索意图", [{"page_content": "正文"}]) + + assert "问题内容" in prompt + assert "检索意图" in prompt + assert "正文" in prompt + + +@pytest.mark.parametrize( + "user_query", + [ + pytest.param(None, id="none"), + pytest.param(5, id="int"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_identify_intent_rejects_non_string_query(user_query): + """``identify_intent`` 的 prompt 构造在 ``try`` 之外,必须自己守卫入参。""" + import asyncio + + from src.services.chat_service import ChatService + + service = ChatService.__new__(ChatService) + with pytest.raises(ValueError, match="必须是字符串"): + asyncio.run(service.identify_intent(user_query)) + + +# ── embed_in_batches 的批次规模:max(1, ...) 只挡下界 ── +# +# `EmbeddingService.embed_in_batches` 原先写 `max(1, run_config.kb_embedding_batch_size)` +# ——只挡下界,不挡形态。`RunConfig` 是 `frozen dataclass`、字段无校验器,而生产 +# 路径的唯一构造点 `build_run_config` 直接照搬 `Settings` 的值(那里有 +# 1.._MAX_KB_CHUNK_TOKENS 的双向界),因此嵌入方自己拼一个 `RunConfig` 就能把 +# 任意形态送进来。实测(修复前): +# "32" -> 裸 TypeError: '>' not supported between instances of 'str' and 'int' +# None -> 裸 TypeError: '>' not supported between instances of 'NoneType' and 'int' +# 10**4301 -> 裸 ValueError: Exceeds the limit (4300 digits) +# +# 同文件的其余判据都收敛成带字段名的 `ValueError`(见 `_as_float32_matrix`)。 +# +# **上界必须用本字段自己的语义上界。** 这里不能复用 `require_scale_field`:它的 +# 上界是 `_MAX_RETRIEVAL_CANDIDATES`(2000,检索候选数),而本字段批到 +# `_MAX_KB_CHUNK_TOKENS`(1_000_000)。实测 `kb_embedding_batch_size=2001` 被 +# `Settings` 正常接受,用那个 helper 会把它判为「超出合理范围」——把一个合法配置 +# 在运行时拒掉,比原缺陷更严重。下面两条对偶用例就是钉住这一点的。 + + +def _embedding_service_with_batch_size(value): + from unittest.mock import MagicMock + + import numpy as np + + from src.services.embedding_service import EmbeddingService + + service = EmbeddingService.__new__(EmbeddingService) + service.run_config = MagicMock() + service.run_config.kb_embedding_batch_size = value + + async def _embed_texts(texts): + return np.zeros((len(texts), 3), dtype=np.float32) + + service.embed_texts = _embed_texts + return service + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("32", id="numeric-string"), + pytest.param(None, id="none"), + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + pytest.param(True, id="bool"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +def test_embed_in_batches_rejects_malformed_batch_size(value): + """非法批次规模必须收敛成带字段名的 ``ValueError``。""" + import asyncio + + service = _embedding_service_with_batch_size(value) + + with pytest.raises(ValueError, match="kb_embedding_batch_size"): + asyncio.run(service.embed_in_batches(["x"])) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(1, id="one"), + pytest.param(32, id="default"), + pytest.param(2000, id="retrieval-bound-not-this-field"), + pytest.param(2001, id="above-retrieval-bound-but-legal-here"), + pytest.param(1000000, id="own-upper-bound"), + ], +) +def test_embed_in_batches_accepts_every_settings_legal_batch_size(value): + """``Settings`` 接受的取值一个都不能被误拒——上界必须用本字段自己的。""" + import asyncio + + from src.utils.config import Settings + + # 前提:这些取值在配置边界确实合法(否则本用例失去意义)。 + assert Settings(kb_embedding_batch_size=value).kb_embedding_batch_size == value + + service = _embedding_service_with_batch_size(value) + matrix = asyncio.run(service.embed_in_batches(["x"])) + + assert matrix.shape == (1, 3) + + +# ── 公开诊断入口的 provider_name / role 形态(同 `_get_model_detail` 的判据)── +# +# `ModelProviderFactory` 的三个**公开**诊断入口(`capabilities`/ +# `capability_report`/`protocol_status`,源码注释写明「配置界面和诊断使用」) +# 此前对非法 `provider_name` 与 `role` 抛的是与「名字写错了」无关的异常: +# +# provider_name=["a"] / {"k":1} -> 裸 TypeError: unhashable type: 'list'/'dict' +# provider_name=10**4301 -> ValueError: Exceeds the limit (4300 digits) +# role=10**4301 -> 同上(各自函数开头的 str(role) 转换) +# +# 同文件的 `_get_model_detail` 早已用同一判据收敛(键先判字符串再进成员判断, +# 消息经 safe_repr)。同一个「渠道名/角色名写错了」的收场不该取决于走的是哪条 +# 入口。 +# +# 判据收口位置有两处,都必须覆盖: +# * `_provider_info_for_role`(三个入口的共同路径); +# * 各入口开头**早于**它的 `str(role).strip().lower()` 与推断分支—— +# `protocol_status` 在 `role is None` 时会先对 provider_name 做字典查找。 + + +@pytest.mark.parametrize( + "provider_name", + [ + pytest.param(["a"], id="list"), + pytest.param({"k": 1}, id="dict"), + pytest.param(None, id="none"), + pytest.param(5, id="int"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +@pytest.mark.parametrize( + "entry", + [ + pytest.param("capabilities", id="capabilities"), + pytest.param("capability_report", id="capability_report"), + pytest.param("protocol_status", id="protocol_status"), + ], +) +def test_diagnostic_entries_reject_malformed_provider_name(entry, provider_name): + """三个诊断入口对非法渠道名必须给带指引的 ``ValueError``。""" + from src.providers.factory import ModelProviderFactory + + method = getattr(ModelProviderFactory, entry) + with pytest.raises(ValueError, match="provider 名称必须是字符串"): + if entry == "protocol_status": + method(provider_name, "chat_completions") + else: + method(provider_name) + + +@pytest.mark.parametrize( + "role", + [ + pytest.param(["a"], id="list"), + pytest.param(10**4301, id="beyond-int-str-digits"), + ], +) +@pytest.mark.parametrize( + "entry", + [ + pytest.param("capabilities", id="capabilities"), + pytest.param("capability_report", id="capability_report"), + ], +) +def test_diagnostic_entries_reject_malformed_role(entry, role): + """非法角色名同样收敛成 ``ValueError``,而不是整数转换错误。""" + from src.providers.factory import ModelProviderFactory + + method = getattr(ModelProviderFactory, entry) + with pytest.raises(ValueError, match="模型角色必须是字符串"): + method("openai", role) + + +def test_protocol_status_rejects_malformed_role(): + """``protocol_status`` 的 ``role`` 是第 4 个位置参数。""" + from src.providers.factory import ModelProviderFactory + + with pytest.raises(ValueError, match="模型角色必须是字符串"): + ModelProviderFactory.protocol_status("openai", "chat_completions", None, 10**4301) + + +def test_diagnostic_entries_still_work_for_legal_inputs(): + """收紧不能误伤:三个入口对合法入参照旧返回结果。""" + from src.providers.factory import ModelProviderFactory + + assert "chat" in ModelProviderFactory.capabilities("openai") + report = ModelProviderFactory.capability_report("anthropic") + assert report["resolved_provider"] == "anthropic" + status = ModelProviderFactory.protocol_status("openai", "chat_completions") + assert status["adapter_supported"] is True + # ``role=None`` 是「自动推断」的合法默认值,必须继续可用。 + assert ( + ModelProviderFactory.protocol_status("openai", "chat_completions", None, None)[ + "adapter_supported" + ] + is True + ) + + +# ── /config 分支的异常分类:抛出类型必须落在自己的 except 分类内 ── +# +# `start_chat_session_async` 的 `/config` 分支里有一段专门为「嵌入方传入的 bot +# 替身缺少约定接口」写的探测:先 `getattr(bot, "apply_config_update_async", None)`, +# 再退到同步入口,两者都不可调用时抛错。 +# +# 该 `raise` 此前抛的是 `AttributeError`,而同一个 `try` 的 +# `except (ValueError, KeyError)` 接不住它;更内层的 +# `except (KeyboardInterrupt, EOFError)` 也接不住——实测会一路穿透到 `finally`, +# **终止整个会话**。这与该块开头注释的意图(「配置应用失败**不能**终止整个会话… +# 报错、保留原配置、回到提示符」)直接矛盾:同一个恢复路径对 `SessionConfig` +# 校验失败等情形都生效,唯独这条会把用户丢出会话。 +# +# 判据:抛出类型必须是 `ValueError`(语义上也确实是一条配置错误——嵌入方的实现 +# 缺少约定接口),从而与同一 `except` 里其它失败同收场。 + + +def test_config_branch_missing_update_method_raises_a_caught_type(): + """缺配置更新方法时必须抛 `except (ValueError, KeyError)` 接得住的类型。 + + 用 AST 精确对照「最近包裹的 ``try`` 的 ``except`` 分类」与 ``raise`` 的类型 + ——这是「异常分类泄漏」的直接判据,比跑一遍完整会话更稳(后者会被 + ``display_chat_config`` 等无关分支干扰)。 + """ + import ast + from pathlib import Path + + tree = ast.parse(Path("src/chat/core.py").read_text(encoding="utf-8")) + + raise_node = None + for node in ast.walk(tree): + if ( + isinstance(node, ast.Raise) + and isinstance(node.exc, ast.Call) + and "缺少配置更新方法" in ast.unparse(node) + ): + raise_node = node + break + assert raise_node is not None, "未找到该 raise(实现被移动或改写?)" + raised = raise_node.exc.func.id + assert raised == "ValueError", f"应抛 ValueError,实际 {raised}" + + # 找最近包裹它的 Try,确认分类覆盖。 + enclosing = None + for node in ast.walk(tree): + if ( + isinstance(node, ast.Try) + and node.lineno <= raise_node.lineno <= (node.end_lineno or 0) + and (enclosing is None or node.lineno > enclosing.lineno) + ): + enclosing = node + assert enclosing is not None, "该 raise 不在任何 try 内" + handled = [ast.unparse(handler.type) for handler in enclosing.handlers] + assert any("ValueError" in text for text in handled), ( + f"抛出的 {raised} 未被最近的 except {handled} 接住——会穿透并终止会话" + ) + + +# --- 重复装饰器(双重 @classmethod) --------------------------------------- +# +# `e95158d` 给 `_normalize_request_timeout` 写了**两个** `@classmethod`。当前 +# CPython 下它恰好能用:`classmethod.__get__` 对「被包装对象也是 classmethod」 +# 会委托到内层,最终绑定的仍是真实函数,且类/7 个子类/实例/`super()` 四条 +# 路径的 `cls` 都与单个装饰时一致(实测)。但这是**结构缺陷**而非风格问题: +# +# * 它依赖 CPython 的委托行为,不在语言规范里;换解释器或换实现就可能变。 +# * `_normalize_request_timeout.__func__` 的类型从 `function` 变成 +# `classmethod`,任何按「`classmethod.__func__` 是函数」写的内省 +# (装饰器、签名收集、`functools.wraps` 链)都会错判。 +# * 函数体只要开始用到 `cls`(例如将来改成 `cls._SOMETHING`),语义就从 +# 「类方法」漂到别处,而当时不会有任何测试拦下。 +# +# 判据用 AST 静态扫描而不是运行时取值:缺陷的本体是**源码里写了两个同名 +# 装饰器**,运行时的巧合可用不代表源码正确。同一个不变量适用于全仓库。 + + +def test_no_duplicate_decorators_in_source_tree(): + """生产代码里任何函数都不得有重复装饰器(同名的叠两次)。 + + 只扫 `src/`。测试目录里堆叠多个 `@pytest.mark.parametrize` 是**合法**的 + 笛卡尔积用法(同名但不同参数),因此不纳入本不变量。 + """ + import ast + from pathlib import Path + + # 按本测试文件自身位置锚定仓库根:``Path("src")`` 相对进程 CWD, + # 从仓库外目录启动 pytest 时会扫错地方(或空转/误报)。 + repo_root = Path(__file__).resolve().parents[1] + violations: list[str] = [] + for path in sorted((repo_root / "src").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + names: list[str] = [] + for decorator in node.decorator_list: + if isinstance(decorator, ast.Name): + names.append(decorator.id) + elif isinstance(decorator, ast.Attribute): + names.append(decorator.attr) + elif isinstance(decorator, ast.Call): + func = decorator.func + names.append( + func.id if isinstance(func, ast.Name) else getattr(func, "attr", "?") + ) + duplicated = sorted({name for name in names if names.count(name) > 1}) + if duplicated: + violations.append(f"{path}:{node.lineno} def {node.name} 重复装饰器 {duplicated}") + + assert not violations, "存在重复装饰器:\n" + "\n".join(violations) + + +def test_normalize_request_timeout_is_a_plain_classmethod(): + """`_normalize_request_timeout` 的 `__func__` 必须是函数,而不是另一个 classmethod。 + + 这是上面那条 AST 不变量在**运行时**的对照:即使有人用非 AST 可识别的方式 + (例如 `classmethod(classmethod(f))`)重新引入双重包装,这条也会变红。 + """ + import inspect + + attribute = OpenAICompatibleProvider.__dict__["_normalize_request_timeout"] + assert isinstance(attribute, classmethod) + assert inspect.isfunction(attribute.__func__), ( + f"`__func__` 应是函数;得到 {type(attribute.__func__).__name__}——说明被重复装饰了" + ) + # 行为不变式:真实取值仍走校验体。 + assert OpenAICompatibleProvider._normalize_request_timeout(30) == 30.0 + + +# --- ACTIVE_SNAPSHOT 标记的符号链接环 ------------------------------------- +# +# 与 ``SnapshotRepository._require_snapshot_id_within_root`` 同一缺陷、同一 +# 判据:``load_snapshot`` 里 ``(marker.parent / active_id).resolve()`` 对符号 +# 链接环抛 ``RuntimeError: Symlink loop from ...``。它**不是** ``ValueError``, +# 而本方法的其余拒绝路径(目录未激活、不是活动快照)一律抛 ``ValueError``, +# 调用方 ``VectorStoreFactory._load_existing_state`` 也按 ``ValueError`` 分类。 +# 标记文件是磁盘上的任意文本,用户手工摆一个环即触发。收敛判据与 +# ``utils.security.resolve_within`` 一致:``(OSError, RuntimeError)`` → ``ValueError``。 + + +def test_load_snapshot_rejects_symlink_loop_in_active_marker(tmp_path): + """ACTIVE_SNAPSHOT 内容指向符号链接环时,必须抛 ``ValueError``。""" + root = tmp_path + snapshot = root / "kb-loop" + snapshot.mkdir() + loop = root / "loop" + loop.symlink_to(loop) + (root / "ACTIVE_SNAPSHOT").write_text("loop", encoding="utf-8") + + with pytest.raises(ValueError, match="ACTIVE_SNAPSHOT"): + FaissStore().load_snapshot(str(snapshot), snapshot_root=str(root)) + + +# --- 报错消息里的「可选值」列表:键类型混合时的排序 ------------------------- +# +# `_require_model_detail` 与 `EmbeddingService.embedding_model_detail` 在 +# 「key 未找到」时都把可选值列出来,而列表用 `sorted(configurations)` 生成—— +# 直接比较**原始键**。键来自可直写映射(`SessionConfig.llm_configurations` / +# `RunConfig.embedding_configurations`,前者普通属性无 `__setattr__` 守卫、 +# 后者是 frozen dataclass 但嵌入方可自拼),混合类型键(`{1: .., "b": ..}`) +# 在排序时抛裸 `TypeError: '<' not supported between instances of 'str' and +# 'int'`。这条消息的用途正是「告诉用户可选值是什么」,不该在拼消息时崩。 +# +# 修法:先 `safe_repr` 渲染成字符串再排序。用 `safe_repr` 而非 `str` 是因为 +# 位数超限的整数键连 `str()` 都抛。 + + +def test_require_model_detail_lists_mixed_type_keys_without_crashing(): + """`_require_model_detail` 对混合类型键的可选值列表必须能渲染出来。""" + from src.ui.display_utils import _require_model_detail + + with pytest.raises(ValueError, match="未找到key") as excinfo: + _require_model_detail({1: "a", "b": "c"}, "missing", "LLM") + + message = str(excinfo.value) + # 两个键都要出现在可选值里(顺序不限,只验证「都列出来了」)。 + assert "1" in message + assert "b" in message + + +def test_require_model_detail_lists_huge_integer_key_without_crashing(): + """位数超限的整数键:列表渲染要走 `safe_repr`,不能自己抛 `ValueError`。""" + from src.ui.display_utils import _require_model_detail + + with pytest.raises(ValueError, match="未找到key") as excinfo: + _require_model_detail({10**4301: "a"}, "missing", "LLM") + + assert "未找到key" in str(excinfo.value) + + +def test_embedding_model_detail_lists_mixed_type_keys_without_crashing(): + """`embedding_model_detail` 对混合类型键的可选值列表必须能渲染出来。""" + from src.services.embedding_service import EmbeddingService + + run_config = SimpleNamespace( + default_embedding_provider="missing", + embedding_configurations={1: "a", "b": "c"}, + ) + + with pytest.raises(ValueError, match="未找到key") as excinfo: + _ = EmbeddingService(run_config).embedding_model_detail + + message = str(excinfo.value) + assert "1" in message + assert "b" in message + + +# --- provider options 的非字符串键 ----------------------------------------- +# +# `ModelDetail.options` 声明为 `dict[str, Any]`,pydantic 会强制字符串键,因此 +# **配置来源**的 options 到不了这里。但 provider 是**公开类**——嵌入方可以直接 +# `JinaProvider("m", {1: "x"})`(本仓测试就这么构造 provider),而 +# `validate_secret_free_options` 此前只判容器形态与敏感键名、放行任意键类型。 +# 非字符串键一路穿到调用方的 +# `sorted(set(options).difference(_OPTION_KEYS))`,抛裸 +# `TypeError: sequence item 0: expected str instance, int found`(混合类型键则是 +# `'<' not supported between instances of ...`)——不是 `ValueError`,而各 +# provider 的其余判据都按 `ValueError` 收敛。 +# +# 修法:在共享入口 `validate_secret_free_options` 与 Google 自己的 +# `_validate_options`(不走共享入口)各加一条顶层键类型判据。 + + +def _options_provider_factories(): + """返回 (名字, 构造器) 列表;构造器接受 options 参数。 + + 与 ``_rerank_providers`` 同一惯例:局部导入,避免在模块顶层引入这些 provider + 模块(它们各自会拉 SDK)。 + """ + from src.providers.google import GoogleProvider + from src.providers.jina import JinaProvider + from src.providers.siliconflow_rerank import SiliconflowRerankProvider + + return [ + ("jina", lambda opts: JinaProvider("m", opts)), + ("siliconflow", lambda opts: SiliconflowRerankProvider("m", opts)), + ("google", lambda opts: GoogleProvider("m", opts)), + ] + + +@pytest.mark.parametrize( + "options", + [ + pytest.param({1: "x"}, id="int-key"), + pytest.param({"a": 1, 2: "b"}, id="mixed-keys"), + pytest.param({(1, 2): "x"}, id="tuple-key"), + ], +) +def test_provider_options_reject_non_string_keys(options): + """非字符串 options 键必须抛 `ValueError`,而不是裸 `TypeError`。""" + for label, factory in _options_provider_factories(): + with pytest.raises(ValueError, match="键必须是字符串"): + factory(options) + + +def test_provider_options_still_accept_string_keys(): + """收紧不能误伤:字符串键的合法 options 照常构造。""" + from src.providers.google import GoogleProvider + + GoogleProvider("m", {"max_tokens": 100}) + + +# --- 非字符串键的**嵌套**层级与四个兄弟校验器 ------------------------------ +# +# 上面那条判据只加在 `validate_secret_free_options` 的**顶层**循环里,注释还专门 +# 写了「只在顶层判」。但共享递归扫描器 `find_sensitive_option_paths` 对每一层映射 +# 都做 `str(key)` 建路径,`normalize_option_key`/`_is_credential_key` 也各自 +# `str(key)`——同一个判据在**嵌套**层级与四个兄弟校验器(payload、 +# request_overrides、resource_kwargs、resource_args)上都没有,而它们共用同一个 +# 扫描器。 +# +# 两种收场都不在契约内: +# `{10**4301: "x"}` 抛 `ValueError: Exceeds the limit`——类型对,内容与字段无关; +# `{1: "x"}` 静默通过,随后在调用方的 `", ".join(sorted(...))` 处抛裸 +# `TypeError: sequence item 0: expected str instance, int found`(实测 +# `GoogleProvider("m", {"extra_body": {1: "x"}})` 构造成功,首次请求合并 +# extra_body 时崩)。 +# +# 修法:把判据收敛到共享递归点 `find_sensitive_option_paths`,并让 +# `normalize_option_key`/`_is_credential_key` 对不可 `str()` 的键退化成量级描述。 + + +def _security_validators(): + """返回 (名字, 校验器) 列表,校验器都接受一个映射并返回结果。""" + from src.utils.security import ( + validate_secret_free_options, + validate_secret_free_payload, + validate_secret_free_request_overrides, + validate_secret_free_resource_args, + validate_secret_free_resource_kwargs, + ) + + return [ + ("options", lambda value: validate_secret_free_options(value, "P")), + ("payload", lambda value: validate_secret_free_payload(value, "P", "extra_body")), + ("request_overrides", lambda value: validate_secret_free_request_overrides(value)), + ("resource_kwargs", lambda value: validate_secret_free_resource_kwargs(value, "P")), + ("resource_args", lambda value: validate_secret_free_resource_args((value,), "P")), + ] + + +@pytest.mark.parametrize( + "key", + [ + pytest.param(1, id="int"), + pytest.param(_HUGE_INT, id="huge-int"), + pytest.param((1, 2), id="tuple"), + ], +) +def test_security_validators_reject_nested_non_string_keys(key): + """嵌套的非字符串键必须抛 ``ValueError``,而不是崩溃或静默通过。""" + for label, validate in _security_validators(): + with pytest.raises(ValueError, match="键必须是字符串"): + validate({"extra_body": {key: "x"}}) + + +@pytest.mark.parametrize( + "key", + [ + pytest.param(1, id="int"), + pytest.param(_HUGE_INT, id="huge-int"), + ], +) +def test_security_validators_reject_top_level_non_string_keys(key): + """四个兄弟校验器的**顶层**键也要收敛(此前只有 options 有这条判据)。""" + for label, validate in _security_validators(): + with pytest.raises(ValueError, match="键必须是字符串"): + validate({key: "x"}) + + +@pytest.mark.parametrize( + "key", + [ + pytest.param(1, id="int"), + pytest.param(_HUGE_INT, id="huge-int"), + ], +) +def test_provider_options_reject_nested_non_string_keys(key): + """provider 的嵌套 options 键(extra_body)同样要收敛。""" + for label, factory in _options_provider_factories(): + with pytest.raises(ValueError, match="键必须是字符串"): + factory({"extra_body": {key: "x"}}) + + +def test_google_http_options_reject_nested_non_string_key(): + """Google 自己的 ``http_options`` 递归校验不能漏掉嵌套键。""" + with pytest.raises(ValueError, match="键必须是字符串"): + GoogleProvider("m", {"http_options": {"headers": {1: "x"}}}) + + +def test_google_debug_config_rejects_non_string_key(): + """``debug_config`` 的非字符串键必须收敛,而不是裸 ``TypeError``。""" + with pytest.raises(ValueError, match="键必须是字符串"): + GoogleProvider._coerce_debug_config({1: "x"}) + + +def test_normalize_option_key_survives_oversized_integer(): + """``normalize_option_key`` 对位数超限的键不得抛 ``ValueError``。""" + from src.utils.security import normalize_option_key + + assert isinstance(normalize_option_key(_HUGE_INT), str) + + +def test_security_validators_still_accept_nested_string_keys(): + """收紧不能误伤:合法嵌套字符串键照常通过。""" + from src.utils.security import validate_secret_free_options + + assert validate_secret_free_options({"extra_body": {"routing": {"tenant": "demo"}}}, "P") == { + "extra_body": {"routing": {"tenant": "demo"}} + } + + +# --- 拒答消息的非字符串取值 ------------------------------------------------ +# +# `field()` 明确支持**字典形态**的响应(docstring:「兼容 Pydantic SDK 对象和 +# 字典响应」),而字典里的 `prompt_feedback.message` 不受 SDK 的 +# `Optional[str]` 约束。两处 `str(message or ...)` 对位数超限的整数抛 +# `ValueError: Exceeds the limit`——而这两处的职责正是**报告拒答**:在正要报出 +# 拦截原因时自己崩掉,用户拿到的是与「提示被安全策略拦截」毫无关联的整数转换 +# 错误,且本 provider 的错误分类按 `RuntimeError` 写。 +# +# 修法:字符串原样(`safe_repr` 会加引号,改掉正常取值的显示形态),非字符串才 +# 降级成量级描述——与 `_render_value`/`_path_text` 同一取舍。 + + +def test_extract_refusal_survives_huge_integer_message(): + """`message` 是位数超限的整数时,拒答仍必须能报出来。""" + response = { + "candidates": [], + "prompt_feedback": {"block_reason": "SAFETY", "message": 10**4301}, + } + + refusal = GoogleProvider._extract_refusal(response) + + assert refusal, "拒答消息不应为空" + assert "Exceeds the limit" not in refusal + + +def test_extract_refusal_keeps_string_message_verbatim(): + """收紧不能误伤:字符串 message 原样返回(不加引号)。""" + response = { + "candidates": [], + "prompt_feedback": {"block_reason": "SAFETY", "message": "被拦截了"}, + } + + assert GoogleProvider._extract_refusal(response) == "被拦截了" + + +def test_stream_events_survive_huge_integer_refusal_message(): + """流式路径的拒答事件同样不能因非字符串 message 而崩。""" + chunk = { + "candidates": [], + "prompt_feedback": {"block_reason": "SAFETY", "message": 10**4301}, + } + + events = GoogleProvider._stream_events_from_chunk(chunk) + + refusals = [event.refusal for event in events if event.type == "refusal_delta"] + assert refusals, "应产出拒答事件" + assert "Exceeds the limit" not in refusals[0] + + +# --- 消息 content 的标量回退 ---------------------------------------------- +# +# ``GoogleProvider._contents`` 对既不是 ``str`` 也不是 ``list`` 的 ``content`` +# 走 ``str(raw_content)`` 兜底。``normalize_messages`` 只校验 ``messages`` 是 +# 「对象序列」,**不校验 content 的形态**(实测 ``{"role":"user","content":huge}`` +# 原样通过),因此这个兜底是标量 content 的唯一收口。位数超限的整数让 +# ``str()`` 抛 ``ValueError: Exceeds the limit``——本 provider 的错误分类按 +# ``ValueError`` 写,类型对、内容却与「消息内容写错了」毫无关系。 +# +# 同一文件的 ``_normalize_finish_reason`` 早已认领同一判据(非字符串走 +# ``safe_repr`` 而不是裸 ``str()``),这里对齐。 + + +def test_google_contents_survive_huge_integer_scalar_content(): + """标量 content 是位数超限的整数时,``_contents`` 不得抛整数转换错误。""" + contents = GoogleProvider._contents( + [{"role": "user", "content": _HUGE_INT}], + None, + None, + ) + + assert contents, "应产出一条内容" + text = contents[0].parts[0].text + assert "Exceeds the limit" not in text + assert "位十进制" in text + + +def test_google_contents_keep_small_scalar_content_verbatim(): + """收紧不能误伤:普通标量 content 的渲染形态不变(``5`` 不是 ``'5'``)。""" + contents = GoogleProvider._contents([{"role": "user", "content": 5}], None, None) + + assert contents[0].parts[0].text == "5" + + +def test_google_contents_keep_string_content_verbatim_not_safe_repr(): + """字符串 content 必须原样透传,不经 ``safe_repr`` 的引号与截断。 + + ``safe_repr`` 对字符串加引号并在 80 字符处截断——普通长文本消息会被改写成 + ``'前 77 字...'`` 发给模型。字符串 content 是最常见的合法形态,渲染走 + ``safe_repr`` 是行为回归而非防护。 + """ + long_text = "这是一条超过八十字符的普通用户消息," * 10 + contents = GoogleProvider._contents([{"role": "user", "content": long_text}], None, None) + + assert contents[0].parts[0].text == long_text + + +# --- Anthropic 响应文本块的拼接 --------------------------------------------- +# +# ``AnthropicProvider._extract_result`` 用 ``"".join(field(block, "text", "") or "" +# ...)`` 拼正文与思考。``or ""`` 只挡住 ``None`` 与空串——**真值非字符串** +# (``5``/``{"a": 1}``/``10**4301``)原样进 ``join``,抛裸 +# ``TypeError: sequence item 0: expected str instance, int found``。而本 provider +# 的错误分类按 ``RuntimeError`` 写(同文件对拒答用的就是它),且 SDK 在 +# ``_strict_response_validation=False`` 时走 ``construct_type``,不执行 pydantic +# 校验,因此块字段形态不受本地控制。 +# +# 同一文件的 ``_refusal_text`` 早已认领同一判据(非字符串走 ``safe_repr``, +# 「非字符串一律给可读描述而不是空串」),这里对齐。 + + +@pytest.mark.parametrize( + ("block_type", "field_name"), + [ + pytest.param("text", "text", id="text"), + pytest.param("thinking", "thinking", id="thinking"), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(_HUGE_INT, id="huge-int"), + pytest.param({"a": 1}, id="dict"), + ], +) +def test_anthropic_extract_result_survives_non_string_block_text(block_type, field_name, value): + """正文/思考块的非字符串取值不得让 ``_extract_result`` 抛裸 ``TypeError``。""" + from src.providers.anthropic import AnthropicProvider + + response = SimpleNamespace( + content=[SimpleNamespace(type=block_type, **{field_name: value})], + usage=None, + ) + + result = AnthropicProvider._extract_result(response) + + assert isinstance(result.text, str) + assert isinstance(result.reasoning, str) + + +def test_anthropic_extract_result_keeps_real_block_text(): + """收紧不能误伤:正常的字符串正文与思考照旧原样保留。""" + from src.providers.anthropic import AnthropicProvider + + response = SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking="推理"), + SimpleNamespace(type="text", text="正文"), + ], + usage=None, + ) + + result = AnthropicProvider._extract_result(response) + + assert result.text == "正文" + assert result.reasoning == "推理" + + +# --- Anthropic 流式增量的文本字段 ------------------------------------------- +# +# ``_stream_event`` 的 ``text_delta``/``thinking_delta`` 用 +# ``field(delta, "text", "") or ""`` 取值——``or ""`` 只挡住 ``None`` 与空串, +# **真值非字符串**(``5``/``{"a": 1}``)原样进 ``StreamEvent.text``/``.reasoning``, +# 而这两个字段声明为 ``str``。消费方 ``_iter_invoke_stream_text`` 直接 +# ``yield converted.text``,``Generator[str]`` 的契约因此被打破:实测调用方 +# ``"".join``/``+=`` 抛 ``TypeError: can only concatenate str (not "int") to str``。 +# 位数超限的整数则让 ``str()`` 在更早的 ``print``/拼接处抛 +# ``ValueError: Exceeds the limit``。 +# +# 判据与 ``_extract_result``/``_refusal_text`` 同源:非字符串走 ``safe_repr`` +# 的可读描述,而不是原样透传。 + + +@pytest.mark.parametrize( + ("delta_type", "field_name", "event_type"), + [ + pytest.param("text_delta", "text", "text_delta", id="text"), + pytest.param("thinking_delta", "thinking", "reasoning_delta", id="thinking"), + ], +) +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(_HUGE_INT, id="huge-int"), + pytest.param({"a": 1}, id="dict"), + ], +) +def test_anthropic_stream_delta_coerces_non_string_text(delta_type, field_name, event_type, value): + """流式增量的文本字段必须收敛成 ``str``,不能原样透传非字符串。""" + from src.providers.anthropic import AnthropicProvider + + event = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": delta_type, field_name: value}, + } + + converted = AnthropicProvider._stream_event(event) + + assert converted is not None and converted.type == event_type + payload = converted.text if event_type == "text_delta" else converted.reasoning + assert isinstance(payload, str), f"流式增量透传了非字符串: {payload!r}" + + +def test_anthropic_stream_text_delta_keeps_real_text(): + """收紧不能误伤:正常的字符串增量原样保留。""" + from src.providers.anthropic import AnthropicProvider + + converted = AnthropicProvider._stream_event( + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "你好"}} + ) + + assert converted is not None and converted.text == "你好" + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(5, id="int"), + pytest.param(_HUGE_INT, id="huge-int"), + ], +) +def test_anthropic_stream_tool_use_initial_arguments_survives_non_mapping(value): + """``tool_use`` 块的标量 ``input`` 不得让 ``arguments`` 抛整数转换错误。""" + from src.providers.anthropic import AnthropicProvider + + converted = AnthropicProvider._stream_event( + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "t1", "name": "f", "input": value}, + } + ) + + assert converted is not None and converted.type == "tool_call_delta" + assert isinstance(converted.tool_call["arguments"], str) + + +# --- Ark Chat Completions 流式工具参数的收敛 --------------------------------- +# +# 同一个 ``arguments`` 字段在三条路径上的判据必须一致: +# * 非流式 ``_extract_result`` -> ``normalize_tool_arguments``(JSON 字符串); +# * OpenAI 兼容的 ``_chat_stream_events`` -> ``normalize_tool_arguments``; +# * **Ark 的** ``_chat_stream_events`` -> 原样透传 ``field(function, "arguments", "")``。 +# +# 第三条漏了:网关可以把 ``arguments`` 写成对象/数组/整数(``construct_type`` +# 不做校验),于是同一个输入在 Ark 的两条路径上给出不同类型——调用方按字符串 +# ``json.loads`` 时拿到 dict 并抛裸 ``TypeError``;位数超限的整数更直接让 +# ``json.dumps``/拼接抛 ``ValueError: Exceeds the limit``。 +# +# 修法与 OpenAI 兼容路径对齐:统一走 ``normalize_tool_arguments``。 + + +@pytest.mark.parametrize( + "arguments", + [ + pytest.param({"order_id": "A-1"}, id="dict"), + pytest.param(5, id="int"), + ], +) +def test_ark_chat_stream_normalizes_non_string_tool_arguments(arguments): + """Ark 流式工具参数必须收敛成 JSON 字符串,与另两条路径同判据。""" + from src.providers.volcengine import VolcengineProvider + + events = VolcengineProvider._chat_stream_events( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "t1", + "function": {"name": "f", "arguments": arguments}, + } + ] + } + } + ] + } + ) + + deltas = [event.tool_call for event in events if event.type == "tool_call_delta"] + assert deltas, "应产出工具调用增量" + assert isinstance(deltas[0]["arguments"], str) + + +def test_ark_chat_stream_rejects_unserializable_tool_arguments_readably(): + """位数超限的整数与 OpenAI 兼容路径同收场:可读的 ``ValueError``。 + + ``json.dumps(10**4301)`` 撞上 CPython 的整数位数上限,``normalize_tool_arguments`` + 把它收敛成「工具调用 arguments 必须可序列化为 JSON」——与 + ``OpenAICompatibleProvider._chat_stream_events`` 对同一取值的行为一致, + 而不是裸 ``ValueError: Exceeds the limit``。 + """ + from src.providers.volcengine import VolcengineProvider + + with pytest.raises(ValueError, match="必须可序列化为 JSON"): + VolcengineProvider._chat_stream_events( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "t1", + "function": {"name": "f", "arguments": _HUGE_INT}, + } + ] + } + } + ] + } + ) + + +def test_ark_chat_stream_keeps_string_tool_arguments_verbatim(): + """收紧不能误伤:字符串片段原样透传,拼接语义不变。""" + from src.providers.volcengine import VolcengineProvider + + events = VolcengineProvider._chat_stream_events( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "t1", + "function": {"name": "f", "arguments": '{"a":1}'}, + } + ] + } + } + ] + } + ) + + deltas = [event.tool_call for event in events if event.type == "tool_call_delta"] + assert deltas[0]["arguments"] == '{"a":1}' + + +def test_split_hierarchical_applies_the_same_size_guard_as_split(): + """``split_hierarchical`` 必须与 ``split`` 同判据,连**尺寸**守卫一起。 + + 上一条只钉了「documents 形态」那一半。``split`` 在层级分支里还会调 + ``_validate_hierarchical_sizes()``(子分片 ``overlap < size``、比例上界、 + ``child_size < parent_size``),而 ``split_hierarchical`` 是**公开方法**, + 直接调它时这些判据全部被绕过。实测 ``child_overlap == child_size`` 时 + ``split`` 抛带指引的 ``ValueError``,而 ``split_hierarchical`` 照常返回 + ——1000 字符的文档产出 694 个分片(分片器几乎不前进),全部进入 embedding + 与 FAISS 写入,且没有任何提示。这与 ``split`` 的收场直接矛盾。 + """ + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + splitter = RecursiveTextSplitter( + structure_mode="hierarchical", + parent_chunk_size=300, + parent_chunk_overlap=30, + child_chunk_size=100, + child_chunk_overlap=100, # == child_size:子分片器无法推进 + ) + document = Document(content="a" * 1000, metadata={"source": "x.md"}) + + with pytest.raises(ValueError, match="kb_child_chunk_overlap 必须小于 kb_child_chunk_size"): + splitter.split_hierarchical([document]) + + +def test_split_hierarchical_rejects_child_larger_than_parent(): + """``child_size >= parent_size`` 同样必须在 ``split_hierarchical`` 上生效。""" + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + from src.models.document import Document + + splitter = RecursiveTextSplitter( + structure_mode="hierarchical", + parent_chunk_size=300, + parent_chunk_overlap=30, + child_chunk_size=1500, # >= parent_size:层级结构静默退化为一层 + child_chunk_overlap=30, + ) + document = Document(content="a" * 2000, metadata={"source": "x.md"}) + + with pytest.raises(ValueError, match="kb_child_chunk_size 必须小于 kb_chunk_size"): + splitter.split_hierarchical([document]) + + +def test_stream_events_survive_huge_integer_block_reason(): + """流式拒答的**兜底文案**里插值的 ``block_reason`` 也要收敛。 + + 上一条钉的是 ``message`` 那一半,而 ``refusal = message or f"...: {block_reason}"`` + 的兜底分支同样把服务端取值插进 f-string。``field`` 支持字典形态的响应, + ``block_reason`` 不受 SDK 枚举约束——位数超限的整数让裸插值抛 + ``ValueError: Exceeds the limit``,于是**正要报出安全拦截**的那一步变成 + 整数转换错误。非流式的 ``_extract_refusal`` 早已把 ``block_reason`` 收敛成 + ``block_reason_text``(枚举取 name、字符串取 strip、其余空串),流式这一半 + 漏了——同一份响应在两条路径上的收场必须一致。 + """ + chunk = { + "candidates": [], + "prompt_feedback": {"block_reason": 10**4301}, + } + + events = GoogleProvider._stream_events_from_chunk(chunk) + + refusals = [event.refusal for event in events if event.type == "refusal_delta"] + assert refusals, "应产出拒答事件" + assert "Exceeds the limit" not in refusals[0] + + +def test_stream_events_keep_string_block_reason_verbatim(): + """收紧不能误伤:字符串 block_reason 照旧进兜底文案。""" + chunk = { + "candidates": [], + "prompt_feedback": {"block_reason": "SAFETY"}, + } + + events = GoogleProvider._stream_events_from_chunk(chunk) + + refusals = [event.refusal for event in events if event.type == "refusal_delta"] + assert refusals == ["Google 提示被安全策略拦截: SAFETY"] + + +def test_anthropic_text_stream_fallback_coerces_non_string_chunks(): + """``text_stream`` 兜底分支同样要收敛成 ``str``,与 ``_stream_event`` 同判据。 + + ``_iter_invoke_stream_text`` 有三条取文本的路径:主路径(``__iter__`` 事件) + 走 ``_stream_event``,那里的 ``text_delta`` 已按 ``_block_text`` 收敛(上一条 + 同类修复);而两条 ``text_stream`` 兜底分支直接 ``yield text``,非字符串原样 + 进入 ``Generator[str]``。契约声明是 ``Generator[str]``,消费方 ``"".join``/ + ``+=`` 会抛裸 ``TypeError``,位数超限的整数更早抛 ``ValueError``。本仓测试 + 用简单替身构造 ``text_stream``(真实 SDK 只提供 ``__iter__`` 与 + ``current_message_snapshot``),因此这是被支持、被测试的输入形态。 + """ + from src.providers.anthropic import AnthropicProvider + + class StubStream: + text_stream = iter([5, "正常", 10**4301]) + + yielded = list(AnthropicProvider._iter_invoke_stream_text(StubStream())) + + assert all(isinstance(item, str) for item in yielded), yielded + assert "正常" in yielded + + +def test_anthropic_text_stream_fallback_keeps_string_chunks_verbatim(): + """收紧不能误伤:字符串分片原样透传。""" + from src.providers.anthropic import AnthropicProvider + + class StubStream: + text_stream = iter(["甲", "乙"]) + + assert list(AnthropicProvider._iter_invoke_stream_text(StubStream())) == ["甲", "乙"] + + +def test_anthropic_async_text_stream_fallback_coerces_non_string_chunks(): + """异步兜底分支与同步同判据:``text_stream`` 的分片同样收敛成 ``str``。""" + import asyncio + + from src.providers.anthropic import AnthropicProvider + + class StubStream: + text_stream = iter([5, "正常", 10**4301]) + + async def collect(): + return [item async for item in AnthropicProvider._aiter_invoke_stream_text(StubStream())] + + yielded = asyncio.run(collect()) + + assert all(isinstance(item, str) for item in yielded), yielded + assert "正常" in yielded + + +# ── ChatService._build_prompt 的第三个入参同样缺形态判据 ── +# +# 上面那一组给 `user_input`/`intent` 补了判据(判据只做了一半的典型:同一次 +# 调用的两个标量参数修了,`documents` 这一半漏了)。实测: +# documents=5/None -> 裸 TypeError: 'int' object is not iterable +# documents="abc" -> 字符串是合法可迭代对象,逐**字符**迭代,每个字符经 +# document_* 助手收敛成默认值后整条跳过(`content` 为 +# 空即 `continue`),于是**静默退化成「没有找到相关 +# 知识」**——一次带着知识调用的请求被无声地降级成 +# 「无知识」prompt,模型据此回答「我不知道」。 +# `_build_prompt` 的调用点(`generate_reply` 与 `chat_async` 的内联分支)都 +# 在按 `ValueError` 分类的边界内,而 `TypeError` 不是。 + + +@pytest.mark.parametrize( + "documents", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + pytest.param({"a": 1}, id="dict"), + pytest.param("abc", id="str"), + ], +) +def test_build_prompt_rejects_non_list_documents(documents): + """非列表的 ``documents`` 不得抛裸 ``TypeError``,也不得静默降级成无知识。""" + from src.services.chat_service import ChatService + + with pytest.raises(ValueError, match="documents"): + ChatService._build_prompt("问题", "意图", documents) + + +def test_build_prompt_still_accepts_document_lists(): + """收紧不能误伤:正常的文档列表照常产出带知识的 prompt。""" + from src.services.chat_service import ChatService + + prompt = ChatService._build_prompt("问题", "意图", [{"page_content": "正文"}]) + + assert "正文" in prompt + + +# ── 读取侧的两个「单位区间」字段只判了形态,没判值域 ── +# +# ``vector_weight``/``keyword_weight``/``score_threshold`` 在**写入侧**都有完整 +# 的值域判据(``SessionConfig._validate_unit_weight``:必须在 0.0 到 1.0 之间; +# ``Settings.validate_hybrid_weights`` 同判据),而**读取侧**只收敛形态: +# +# * ``_weight_tuple`` 只判 ``is_double_convertible`` 与 ``total > 0``; +# * ``_coerce_threshold`` 只判是不是有限数值。 +# +# 两个缺口都是**静默**的,且都实测可达(``SessionConfig`` 是公开类、普通属性没有 +# ``__setattr__`` 守卫,直写即命中;``retrieve`` 把取值原样交给这两处): + + +@pytest.mark.parametrize( + "vector_weight,keyword_weight", + [ + pytest.param(-0.5, 1.5, id="negative-vector"), + pytest.param(1.5, -0.5, id="negative-keyword"), + pytest.param(-2.0, 3.0, id="both-out-of-range"), + ], +) +def test_negative_hybrid_weights_are_rejected_on_the_read_side(vector_weight, keyword_weight): + """负权重会把排序反转,不得静默归一化后照常使用。 + + 写入侧拒绝 ``-0.5``/``1.5``,读取侧却因为「和为正」而放行;``_weight_tuple`` + 随后返回 ``(-0.5, 1.5)`` 本身(和恰为 1.0),weighted 融合于是按**反相关** + 的权重打分。实测走 ``retrieve``:语义路得分高的文档被排到后面,其中一半 + 直接被阈值丢掉。 + """ + from src.services.retrieval_service import HybridReranker + + with pytest.raises(ValueError, match="vector_weight"): + HybridReranker(vector_weight, keyword_weight)._weight_tuple() + + +def test_negative_vector_weight_drops_half_the_results_in_retrieve(): + """端到端:负权重的收场必须是 ``ValueError``,而不是「结果少了一半」。""" + import asyncio + + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + class _Store: + def semantic_search(self, embedding, top_k): + return [ + {"page_content": "SEMANTIC", "metadata": {"chunk_id": "a"}, "score": 0.9}, + {"page_content": "LOW", "metadata": {"chunk_id": "b"}, "score": 0.1}, + ] + + def keyword_search(self, query, top_k): + return [{"page_content": "KEYWORD", "metadata": {"chunk_id": "c"}, "score": 0.9}] + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.HYBRID_SEARCH + config.hybrid_fusion_strategy = "weighted" + config.rerank_enabled = False + config.score_threshold = 0.0 + config.vector_weight = -0.5 + config.keyword_weight = 1.5 + + service = RetrievalService(_Store(), _Embedding()) + + with pytest.raises(ValueError, match="vector_weight"): + asyncio.run(service.retrieve("q", config)) + + +@pytest.mark.parametrize( + "threshold", + [ + pytest.param(2.0, id="above-one"), + pytest.param(-1.0, id="negative"), + pytest.param(100.0, id="far-above-one"), + ], +) +def test_out_of_range_score_threshold_is_rejected_on_the_read_side(threshold): + """阈值超出 ``[0, 1]`` 必须报错,而不是静默「全部过滤」或「不过滤」。 + + ``score_threshold = 2.0`` 时 ``coerce_score(...) >= 2.0`` 对所有文档恒为假, + **每一次检索都返回空列表且无任何提示**——正是本项目反复认领的「静默返回空 + 结果」收场;``score_threshold = -1.0`` 则恒为真,阈值形同不存在。写入侧对 + 这两个取值都直接拒绝。 + """ + import asyncio + + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + class _Store: + def semantic_search(self, embedding, top_k): + return [{"page_content": "d", "metadata": {"chunk_id": "a"}, "score": 0.9}] + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.SEMANTIC_SEARCH + config.rerank_enabled = False + config.score_threshold = threshold + + service = RetrievalService(_Store(), _Embedding()) + + with pytest.raises(ValueError, match="score_threshold"): + asyncio.run(service.retrieve("q", config)) + + +def test_in_range_weights_and_thresholds_still_work(): + """收紧不能误伤:``[0, 1]`` 之内的取值照常工作,含两个端点。""" + import asyncio + + from src.runtime.contracts import build_session_config + from src.services.retrieval_service import HybridReranker, RetrievalService + from src.utils.config import RetrievalMethod, get_settings + + assert HybridReranker(0.0, 1.0)._weight_tuple() == (0.0, 1.0) + assert HybridReranker(1.0, 0.0)._weight_tuple() == (1.0, 0.0) + assert HybridReranker(0.5, 0.5)._weight_tuple() == (0.5, 0.5) + + class _Store: + def semantic_search(self, embedding, top_k): + return [{"page_content": "d", "metadata": {"chunk_id": "a"}, "score": 0.9}] + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + # 阈值端点 1.0 合法,但 0.9 分的文档确实低于它——被过滤是正确行为, + # 因此只断言「不抛错」,不断言结果条数。 + for threshold, expected in ((0.0, 1), (0.5, 1), (1.0, 0)): + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.SEMANTIC_SEARCH + config.rerank_enabled = False + config.score_threshold = threshold + service = RetrievalService(_Store(), _Embedding()) + assert len(asyncio.run(service.retrieve("q", config))) == expected diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index e930002..bf97b94 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -6,6 +6,8 @@ """ from pathlib import Path +from sys import platform +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -95,3 +97,222 @@ def test_cleanup_temp_files_handles_oserror_gracefully(tmp_path): cleanup_temp_files() assert cache.exists() + + +def _patch_app_paths(monkeypatch, app_root: Path, **extra_paths: str) -> None: + """把 cleanup 模块看到的「应用自身路径」指向 ``app_root``。 + + ``cleanup`` 通过模块级名字使用 ``ROOT_DIR`` / ``CONFIG_TOML_PATH`` / + ``ENV_FILE_PATH``,因此必须 patch 到该模块的命名空间上,而不是 + ``src.utils.config``——后者改了也不影响已经绑定的名字。 + """ + monkeypatch.setattr("src.utils.cleanup.ROOT_DIR", app_root) + monkeypatch.setattr("src.utils.cleanup.CONFIG_TOML_PATH", app_root / "config.toml") + monkeypatch.setattr("src.utils.cleanup.ENV_FILE_PATH", app_root / ".env") + monkeypatch.setattr( + "src.utils.cleanup.get_settings", + lambda: SimpleNamespace( + cache_path=str(app_root / ".cache"), + knowledge_base_path=str(app_root / "knowledge_base"), + snapshot_root=str(app_root / "data" / "kb"), + log_path=str(app_root / "data" / "logs"), + **extra_paths, + ), + ) + + +# ── 回归:cache_path 指向应用自身目录时会删掉安装目录 ── +# +# 原有的保护只挡 ``/``、家目录与一份硬编码的 POSIX 系统目录清单,拦不住 +# ``cache_path = "."``:它解析为应用根目录,退出钩子会把整个安装目录删掉。 +# 实测冻结产物中 ``.env``(内含 API Key)、``knowledge_base/``(用户放进去的 +# 源文档)与 ``config.toml`` 一并消失,且无法恢复。 + + +@pytest.mark.parametrize( + "dangerous", + [".", "./", "data/..", ".."], +) +def test_reject_unsafe_cache_dir_rejects_app_root(dangerous, tmp_path, monkeypatch): + """解析后落在应用根目录或其上层的一律拒绝。""" + app_root = tmp_path / "PyRAG-Kit" + app_root.mkdir() + _patch_app_paths(monkeypatch, app_root) + + resolved = str((app_root / dangerous).resolve()) + + assert _reject_unsafe_cache_dir(resolved) is not None + + +@pytest.mark.parametrize( + "dangerous", + ["data", "knowledge_base", "data/logs", "data/kb"], +) +def test_reject_unsafe_cache_dir_rejects_app_runtime_dirs(dangerous, tmp_path, monkeypatch): + """覆盖应用运行时数据目录的 cache_path 同样拒绝。 + + ``cache_path = "data"`` 会连带删掉 ``data/kb``(已建好的知识快照)与 + ``data/logs``;``"knowledge_base"`` 会删掉用户的源文档。 + """ + app_root = tmp_path / "PyRAG-Kit" + (app_root / "data" / "kb").mkdir(parents=True) + (app_root / "knowledge_base").mkdir() + _patch_app_paths(monkeypatch, app_root) + + resolved = str((app_root / dangerous).resolve()) + + assert _reject_unsafe_cache_dir(resolved) is not None + + +def test_cleanup_temp_files_does_not_delete_app_root(tmp_path, monkeypatch): + """端到端:即使 cache_path 指向应用根目录,退出钩子也必须拒绝删除。 + + 这是本组用例的落点——``_reject_unsafe_cache_dir`` 返回原因后, + ``cleanup_temp_files`` 必须据此提前返回,而不是继续 ``rmtree``。 + """ + app_root = tmp_path / "PyRAG-Kit" + (app_root / "knowledge_base").mkdir(parents=True) + (app_root / "knowledge_base" / "doc.md").write_text("用户文档", encoding="utf-8") + (app_root / ".env").write_text("SECRET=x\n", encoding="utf-8") + (app_root / "config.toml").write_text("cache_path = '.'\n", encoding="utf-8") + + monkeypatch.setattr("src.utils.cleanup.ROOT_DIR", app_root) + monkeypatch.setattr("src.utils.cleanup.CONFIG_TOML_PATH", app_root / "config.toml") + monkeypatch.setattr("src.utils.cleanup.ENV_FILE_PATH", app_root / ".env") + monkeypatch.setattr( + "src.utils.cleanup.get_settings", lambda: SimpleNamespace(cache_path=str(app_root)) + ) + + cleanup_temp_files() + + assert app_root.exists(), "应用根目录被退出钩子删掉了" + assert (app_root / ".env").is_file() + assert (app_root / "knowledge_base" / "doc.md").is_file() + + +def test_reject_unsafe_cache_dir_still_allows_ordinary_cache_dirs(tmp_path, monkeypatch): + """收紧不能误伤:应用根目录之下的独立缓存目录仍可清理。""" + app_root = tmp_path / "PyRAG-Kit" + (app_root / "data" / "kb").mkdir(parents=True) + _patch_app_paths(monkeypatch, app_root) + + for allowed in [".cache", "data/cache", "tmp/cache"]: + resolved = str((app_root / allowed).resolve()) + assert _reject_unsafe_cache_dir(resolved) is None, f"{allowed} 被误拒" + + +def test_reject_unsafe_cache_dir_accepts_explicit_protected_paths(tmp_path): + """``protected_paths`` 可显式传入,便于调用方与测试脱离全局配置。""" + app_root = tmp_path / "app" + app_root.mkdir() + + assert _reject_unsafe_cache_dir(str(app_root), protected_paths=[app_root]) is not None + assert _reject_unsafe_cache_dir(str(tmp_path / "elsewhere"), protected_paths=[app_root]) is None + + +# ── 回归:``cache_path`` 是符号链接环时不得让 atexit 钩子整体失效 ── +# +# ``_reject_unsafe_cache_dir`` 首行就 ``Path(cache_dir).resolve()``,而 +# ``resolve()`` 对链接环抛 ``RuntimeError: Symlink loop from ...``。该行此前没有 +# 守卫(遍历 ``protected_paths`` 的那轮有),于是本函数整体抛异常——而它在 +# atexit 里被调用,用户看到 ``Exception ignored in atexit callback`` 加 +# traceback,**清理完全没做**。 + + +def test_symlink_loop_cache_dir_is_rejected_without_raising(tmp_path): + """链接环不是可安全删除的目标,应返回原因而不是抛。""" + link_a = tmp_path / "a" + link_b = tmp_path / "b" + link_a.symlink_to(link_b) + link_b.symlink_to(link_a) + + reason = _reject_unsafe_cache_dir(str(link_a)) + + assert reason is not None + assert "无法解析" in reason + + +def test_symlink_loop_does_not_break_cleanup_temp_files(tmp_path, monkeypatch): + """钩子本身必须跑完,不得抛异常。""" + link_a = tmp_path / "a" + link_b = tmp_path / "b" + link_a.symlink_to(link_b) + link_b.symlink_to(link_a) + + class FakeSettings: + cache_path = str(link_a) + + monkeypatch.setattr("src.utils.cleanup.get_settings", lambda: FakeSettings()) + + cleanup_temp_files() # 不抛即通过 + + +# ── 回归:系统级目录清单必须完整 ── +# +# 清单原先只有 ``/etc``/``/usr``/``/bin``/``/sbin``/``/var`` 与三个 macOS +# 目录,漏掉了同样属于系统级、且 ``rmtree`` 后果不可逆的一批。实测以下路径 +# **全部被放行**,退出钩子会把它们整个删掉: +# +# * ``/tmp``(macOS 上解析成 ``/private/tmp``)——整个系统临时目录; +# * ``/Users``——macOS 上**全部用户家目录的父目录**(``/home`` 在 Linux 上 +# 同理),删除等于抹掉机器上每个账户的家目录; +# * ``/opt``——本模块 docstring 自己就把「安装在 ``/opt``」列为场景; +# * ``/dev``/``/proc``/``/sys`` 等。 +# +# 判据是**相等**,因此只挡挂载点与系统根本身,卷内/目录内的正常缓存位置不受 +# 影响(见下方「不误伤」用例)。 + + +@pytest.mark.parametrize( + "dangerous", + [ + "/tmp", + "/Users", + "/home", + "/opt", + "/dev", + "/proc", + "/sys", + "/boot", + "/run", + "/srv", + "/mnt", + "/media", + "/root", + "/Volumes", + "/private", + ], +) +def test_reject_unsafe_cache_dir_rejects_system_level_roots(dangerous): + """系统级目录(含用户家目录的父目录)一律拒绝。""" + assert _reject_unsafe_cache_dir(dangerous) is not None + + +# ``/private/tmp`` 只在 macOS 上是 ``/tmp`` 的符号链接目标(守卫经由 ``/tmp`` +# 的解析结果命中它)。Linux 上 ``/private`` 不是系统目录、该路径不存在,守卫 +# 按相等判据放行才是正确行为(``cleanup_temp_files`` 在 rmtree 前有 exists +# 检查,放行无害)——quality workflow 只跑 ubuntu,此断言不能进公共参数表。 +@pytest.mark.skipif( + platform != "darwin", + reason="/private/tmp 是 macOS 上 /tmp 的解析形态,Linux 上不是系统目录", +) +def test_reject_unsafe_cache_dir_rejects_macos_tmp_resolution_target(): + assert _reject_unsafe_cache_dir("/private/tmp") is not None + + +@pytest.mark.parametrize( + "allowed", + [ + "/tmp/myapp/cache", + "/opt/myapp/cache", + "/Volumes/MyDrive/cache", + "/Users/someone/Library/Caches/app", + ], +) +def test_reject_unsafe_cache_dir_still_allows_paths_inside_system_roots(allowed): + """收紧不能误伤:系统根**内部**的缓存目录照常可用。 + + 判据是「解析后相等」,因此 ``/tmp/myapp/cache`` 这类显式指定的缓存位置 + 必须继续放行——本函数从不过度拦截用户有意配置的缓存目录。 + """ + assert _reject_unsafe_cache_dir(allowed) is None diff --git a/tests/test_config.py b/tests/test_config.py index 5245828..6bdcdb4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,14 +1,21 @@ import os +import subprocess +import sys +from pathlib import Path import pytest from pydantic import ValidationError +from src.retrieval.vdb.faiss_store import FaissStore +from src.runtime.contracts import build_run_config from src.utils.config import ( ModelDetail, ModelProtocol, RetrievalMethod, Settings, + _redacted_settings_error, get_settings, + parse_decimal_int, resolve_app_root, ) @@ -44,6 +51,12 @@ def isolated_config(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) monkeypatch.setattr("src.utils.config.CONFIG_TOML_PATH", tmp_path / "config.toml") + # 注意这里**只能**改类配置,不能只改模块常量:``env_file`` 在 Settings + # 类定义期就被求值成字符串,``monkeypatch.setattr`` 打模块级 + # ``ENV_FILE_PATH`` 对它毫无影响(曾经同时写两行,其中一行是死代码, + # 谁删掉真正起作用的那行都会让隔离静默失效)。生产代码里 .env 与 + # config.toml 都以 ROOT_DIR 为基准,测试必须显式对齐。 + monkeypatch.setitem(Settings.model_config, "env_file", str(tmp_path / ".env")) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("KNOWLEDGE_BASE_PATH", raising=False) monkeypatch.delenv("CHAT_RETRIEVAL_METHOD", raising=False) @@ -87,6 +100,26 @@ def test_settings_model_validation(): ("kb_chunk_overlap", -1, "kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数"), ("kb_child_chunk_size", 0, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), ("kb_child_chunk_overlap", -1, "kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数"), + # 上界缺失时超大整数会被静默接受(实测 ``10**400`` 走真实 TOML 路径也 + # 通过),配置加载成功、直到分片期才在 ``_validate_hierarchical_sizes`` + # 抛错——报错现场离写错的配置很远。上界与 kb_chunk_size 同源。 + # + # 用 ``10**4301`` 而不是 ``10**400``:两者都会被新上界拒绝,但 4301 位 + # 同时覆盖「消息插值自己会抛」那一面。必须显式给 id——pytest 自动生成 id + # 时会对参数调 ``str()``,而 ``str(10**4301)`` 抛 ``ValueError``,测试在 + # **收集阶段**就崩掉。 + pytest.param( + "kb_chunk_overlap", + 10**4301, + "kb_chunk_overlap/kb_child_chunk_overlap 必须小于", + id="kb_chunk_overlap-beyond-conversion-limit", + ), + pytest.param( + "kb_child_chunk_overlap", + 10**4301, + "kb_chunk_overlap/kb_child_chunk_overlap 必须小于", + id="kb_child_chunk_overlap-beyond-conversion-limit", + ), ("kb_embedding_batch_size", 0, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), ( "kb_embedding_batch_size", @@ -115,11 +148,14 @@ def test_numeric_settings_reject_illegal_values(field_name, bad_value, expected_ def test_chunk_overlap_must_be_strictly_smaller_than_chunk_size(): - """overlap 必须严格小于 size,否则分片无法推进(会得到空分片或死循环)。""" + """overlap 必须严格小于 size,否则分片无法推进(会得到空分片或死循环)。 + + 父分片这条与模式无关(两种模式都会构造父分片器),因此仍在 ``Settings`` + 里判。子分片那条只在层级模式下有意义,判定已移到 + ``RecursiveTextSplitter._validate_hierarchical_sizes()``。 + """ with pytest.raises(ValidationError, match="kb_chunk_overlap"): Settings(kb_chunk_size=100, kb_chunk_overlap=100) - with pytest.raises(ValidationError, match="kb_child_chunk_overlap"): - Settings(kb_child_chunk_size=50, kb_child_chunk_overlap=50) @pytest.mark.parametrize( @@ -169,19 +205,21 @@ def test_int_and_float_settings_reject_bool(field_name): Settings(**{field_name: True}) -def test_child_chunk_size_cannot_exceed_parent_chunk_size(): - """子分片不得大于父分片,否则层级结构静默退化为一层。 +def test_child_chunk_size_relation_is_not_checked_at_settings_time(): + """子分片的尺寸关系**不再**在 ``Settings`` 里判——它只在层级模式下有意义。 - 实测 ``kb_chunk_size=300, kb_child_chunk_size=1500`` 被接受后,父块数从 2 - 涨到 10(每个父块只产出 1 个子块),「先粗后细」的父子检索意图失效。 + 模式是分片时的运行时参数(``build(splitter_structure_mode)``/CLI 的 + ``--mode``),``Settings`` 看不到。原先无条件拒绝的后果是一组「standard 下 + 完全合法」的取值连配置都加载不了:用户只想用 standard,却必须先改掉用不到 + 的字段。判定移到 ``RecursiveTextSplitter._validate_hierarchical_sizes()``, + 对应的回归在 ``tests/etl/test_pipeline.py``。 """ - with pytest.raises(ValidationError, match="kb_child_chunk_size"): - Settings(kb_chunk_size=300, kb_child_chunk_size=1500) - with pytest.raises(ValidationError, match="kb_child_chunk_size"): - Settings(kb_chunk_size=100, kb_child_chunk_size=100) - # 相等也应拒绝:与 overlap data``)时 ``import main`` 以裸 traceback 终止。收敛判据与 +# ``utils.security.resolve_within`` 一致:``(OSError, RuntimeError)`` → +# 带字段名的 ``ValueError``。 + + +def test_relative_path_symlink_loop_is_reported_as_value_error(tmp_path, monkeypatch): + """相对路径字段遇到符号链接环时,必须抛脱敏的 ``ValueError``。""" + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + loop = tmp_path / "data" + loop.symlink_to(loop) + + with pytest.raises(ValueError, match="snapshot_root"): + Settings(snapshot_root="data/kb", _env_file=None) + + +def test_absolute_path_field_is_not_resolved(tmp_path): + """绝对路径不做 ``resolve``,因此不受影响(收紧不能误伤)。""" + settings = Settings(snapshot_root=str(tmp_path / "kb"), _env_file=None) + assert settings.snapshot_root == str(tmp_path / "kb") + + +# ── Settings 整数/数值守卫的**测试覆盖**补缺 ── +# +# 下面几条是**变异测试**翻出来的覆盖空洞:把对应守卫的 ``raise`` 删掉之后, +# 全量套件仍然全绿——守卫本身正确,却没有任何一条测试会在它被删掉时变红。 +# 甄别方法是「删除守卫 + 真实 ``Settings(...)`` 入口」,只保留**行为确实改变** +# 的那些(静默接受、静默截断,或把带字段名的 ``ValueError`` 换成别的异常); +# 纯等价变异(删除后落到下一个守卫、结论不变)不在此列。 +# +# 每条都以「删除守卫即变红」的形态写:断言的是守卫存在时的收场。 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + pytest.param("chat_top_k", 1.5, id="chat_top_k"), + pytest.param("log_retention_days", 1.5, id="log_retention_days"), + pytest.param("kb_chunk_overlap", 1.5, id="kb_chunk_overlap"), + pytest.param("kb_child_chunk_overlap", 1.5, id="kb_child_chunk_overlap"), + ], +) +def test_integer_settings_reject_a_non_integral_float(field, value): + """非整数浮点必须被拒,而不是 ``int()`` 静默截断。 + + 这四处各有一条 ``isinstance(number, float) and not number.is_integer()`` + 判据。删除后 ``int(1.5)`` 会把 ``1.5`` 悄悄变成 ``1``——用户写 + ``chat_top_k = 1.5`` 得到的是「取 1 条」,没有任何报错,与「配置写错了」 + 的收场相去甚远。``1.5`` 是合法 TOML 浮点,无需任何技巧即可到达。 + """ + with pytest.raises(ValidationError, match="整数"): + Settings(**{field: value}, _env_file=None) + + +def test_settings_reject_an_unknown_fusion_strategy(): + """合法字符串但不在 ``{"rrf", "weighted"}`` 时必须报错。 + + ``validate_hybrid_fusion_strategy`` 的成员判据删除后,``"bogus"`` 会被 + ``Settings`` 静默接受并原样存进 ``hybrid_fusion_strategy``,随后 + ``HybridReranker`` 的派发(``if == "weighted" else _rrf_rerank``)按 RRF + 出结果——用户配置里写着一个无效策略、程序却按 RRF 跑,是静默错误结果。 + """ + with pytest.raises(ValidationError, match="融合策略"): + Settings(hybrid_fusion_strategy="bogus", _env_file=None) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param([1], id="list"), + pytest.param({"a": 1}, id="dict"), + pytest.param(object(), id="object"), + ], +) +def test_numeric_settings_reject_a_non_numeric_container(value): + """``_coerce_number`` 对非数值容器必须给带字段名的 ``ValueError``。 + + 最后那条 ``raise ValueError(f"{field_label} 必须是数值,但得到 ...")`` 删除 + 后,列表/字典/对象会一路落到 pydantic 的类型校验,抛的是没有字段语义的 + ``TypeError``(实测 ``Settings(chat_top_k=[1])`` 报 ``TypeError``)——而本 + 项目其余数值字段一律以带字段名的 ``ValueError`` 收敛。 + """ + with pytest.raises((ValidationError, ValueError)): + Settings(chat_top_k=value, _env_file=None) + + +def test_settings_reject_a_numeric_literal_that_overflows_to_infinity(): + """``"1e400"`` 是合法十进制字面量,但 ``float()`` 后是 ``inf``。 + + ``_coerce_number`` 的 ``isfinite`` 判据删除后,``"1e400"`` 会被转成 + ``inf`` 返回,随后落到整数上界判据,报「必须是整数,但得到 inf」——把 + 「字面量溢出」说成了「类型不对」,且不再是原始字符串。 + """ + with pytest.raises(ValidationError, match="有限数值"): + Settings(chat_top_k="1e400", _env_file=None) + + +# ── 校验器守卫的覆盖缺口(变异扫描:删除后全量 3725 条仍全绿) ── +# +# 下列判据删除后行为确实改变(实测,经 ``Settings(**kwargs)`` 真实入口): +# * 966 「两个权重同时为零」组合校验 → **静默接受**,检索时每个文档得分都是 +# 0.0 并被分数阈值全部过滤——用户看到的是「搜不到」,配置本身却「合法」; +# * 987 无效日志级别 → **静默接受** ``"BOGUS"``(只做了 ``.upper()``), +# 日志系统拿到一个不认识的级别名,行为由 logging 内部兜底决定; +# * 1035 无效融合策略 → **静默接受** ``"bogus"``,检索时按字符串比较落进 +# else 分支(纯关键词检索),用户配置的混合检索静默失效; +# * 1202 空路径 → **静默接受** 空串(解析成当前工作目录),路径随运行位置 +# 漂移; +# * 867 负的分片重叠 → **静默接受** ``-1``; +# * 984 非字符串日志级别 → 裸 ``AttributeError``(``5.upper()``); +# * 1030 非字符串融合策略 → 裸 ``AttributeError``; +# * 1184 非字符串路径 → 裸 ``AttributeError``(``5.strip()``); +# * 831 非整数分片尺寸 → 消息退化成「kb_chunk_overlap 必须小于 kb_chunk_size」 +# (把「1.5 不是整数」说成「重叠太大」); +# * 937 ``overlap == size`` → 消息退化成「不能超过一半」(把「必须小于」说成 +# 「超过一半」,指向错误的边界)。 +def test_settings_rejects_both_weights_zero(): + """两个检索权重同时为零必须报错,不能静默接受。 + + 后果是**静默空结果**:``weighted`` 融合下每个文档得分都是 0.0,随后被 + ``chat_score_threshold`` 全部过滤——用户看到的是「搜不到」。 + """ + with pytest.raises(ValidationError, match="不能同时为 0"): + Settings(chat_vector_weight=0.0, chat_keyword_weight=0.0, _env_file=None) + + +def test_settings_rejects_invalid_log_level(): + """无效日志级别必须报错,不能静默接受 ``BOGUS``。""" + with pytest.raises(ValidationError, match="无效的日志级别"): + Settings(log_level="bogus", _env_file=None) + + +@pytest.mark.parametrize( + "bad_value", + [ + pytest.param(5, id="int"), + pytest.param(None, id="none"), + ], +) +def test_settings_rejects_non_string_log_level(bad_value): + """非字符串日志级别必须给带字段名的 ``ValidationError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValidationError, match="log_level 必须是字符串"): + Settings(log_level=bad_value, _env_file=None) + + +def test_settings_rejects_invalid_fusion_strategy(): + """无效融合策略必须报错,不能静默接受 ``bogus``。""" + with pytest.raises(ValidationError, match="无效的混合检索融合策略"): + Settings(hybrid_fusion_strategy="bogus", _env_file=None) + + +def test_settings_rejects_non_string_fusion_strategy(): + """非字符串融合策略必须给 ``ValidationError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValidationError, match="融合策略必须是字符串"): + Settings(hybrid_fusion_strategy=5, _env_file=None) + + +def test_settings_rejects_empty_snapshot_root(): + """空路径必须报错:``Path("")`` 会解析成当前工作目录,使路径随运行位置漂移。""" + with pytest.raises(ValidationError, match="不能为空"): + Settings(snapshot_root="", _env_file=None) + + +def test_settings_rejects_non_string_snapshot_root(): + """非字符串路径必须给 ``ValidationError``,不是裸 ``AttributeError``。""" + with pytest.raises(ValidationError, match="必须是字符串"): + Settings(snapshot_root=5, _env_file=None) + + +def test_settings_rejects_negative_chunk_overlap(): + """负的分片重叠必须报错,不能静默接受。""" + with pytest.raises(ValidationError, match="必须是非负整数"): + Settings(kb_chunk_overlap=-1, _env_file=None) + + +def test_settings_reports_fractional_chunk_size_as_non_integer(): + """``1.5`` 不是整数:消息必须点名「必须是整数」,不能退化成「重叠太大」。""" + with pytest.raises(ValidationError, match="必须是整数"): + Settings(kb_chunk_size=1.5, _env_file=None) + + +def test_settings_reports_overlap_equal_to_size_with_its_own_message(): + """``overlap == size`` 必须报「必须小于」,不能退化成「不能超过一半」。""" + with pytest.raises(ValidationError, match="必须小于 kb_chunk_size"): + Settings(kb_chunk_size=100, kb_chunk_overlap=100, _env_file=None) + + +def test_retrieval_candidate_multiplier_rejects_numeric_subclass_with_failing_int(): + """覆写 ``__int__`` 的数值子类必须收敛成脱敏 ``ValueError``,而不是裸崩。 + + ``_coerce_number`` 之后 ``number`` 已是 ``int``/整值 ``float``,``int()`` + 对它们不会失败;唯一能走到该 ``except`` 的是 ``int``/``float`` 的派生类 + (只能从 Python 调用方传入),其 ``__int__`` 可能抛 ``OverflowError``—— + ``ArithmeticError`` 子类而**不是** ``ValueError``,不显式列出就会穿透 + ``get_settings`` 的三个 handler。删掉 ``OverflowError`` 会让这条变红。 + """ + + class _EvilFloat(float): + def __int__(self): + raise OverflowError("模拟派生类") + + with pytest.raises((ValueError, ValidationError)): + Settings(retrieval_candidate_multiplier=_EvilFloat(2.0)) + + +# --- face 12(== ↔ != 互换扫描)存活位点 ------------------------------------- + + +def test_non_llm_protocol_error_names_the_matching_role(): + """错误消息的角色名必须与字段一致(``info.field_name == "embedding_..."``)。 + + ``==``→``!=`` 变异后两个角色的标签互换:embedding 配置报「Rerank 配置 + 不支持 protocol」,反之亦然——用户按消息去改错误的字段。 + """ + for field_name, role in ( + ("embedding_configurations", "Embedding"), + ("rerank_configurations", "Rerank"), + ): + with pytest.raises(ValidationError) as excinfo: + Settings( + **{ + field_name: { + "demo": { + "provider": "openai", + "model_name": "demo-model", + "protocol": "responses", + } + } + } + ) + assert f"{role} 配置不支持 protocol" in str(excinfo.value) + + +# ── 回归:Settings 的每一个凭证字段都必须同时隐藏于 repr 与 dump ── + + +@pytest.mark.parametrize( + "field_name", + sorted( + { + "anthropic_api_key", + "google_api_key", + "siliconflow_api_key", + "gemini_api_key", + "google_application_credentials", + "openai_api_key", + "qwen_api_key", + "ark_api_key", + "volc_access_key", + "volc_secret_key", + "jina_api_key", + "deepseek_api_key", + "grok_api_key", + "lm_studio_api_key", + } + ), +) +def test_settings_hides_each_credential_in_repr_and_dump(field_name): + """``repr=False`` 与 ``_secret_fields`` 必须对**每个**凭证字段同时生效。""" + secret = f"sk-test-{field_name}" + settings = Settings(**{field_name: secret}) + + assert secret not in repr(settings), f"repr 回显了 {field_name}" + assert secret not in str(settings), f"str 回显了 {field_name}" + assert field_name not in settings.model_dump(), f"model_dump 泄漏 {field_name}" + assert field_name not in settings.model_dump_json(), f"model_dump_json 泄漏 {field_name}" + assert settings.model_dump(include_secrets=True)[field_name] == secret + + +# +# 14 个 ``*_api_key`` 字段声明时带 ``repr=False``(被 ``_secret_fields`` 纳入 +# dump 排除),但此前只有 openai/lm_studio/gemini 三个被断言过 ``model_dump``, +# 没有一条断言过 ``repr``——boolc 变异扫描实测:任意单个字段的 ``repr=False`` +# 反向成 ``True`` 后全量 4269 条仍全绿,``repr(settings)``/``str(settings)`` +# 会回显该字段的明文(日志与 traceback 里最常见的一手凭证出口)。这里把 14 个 +# 字段逐一对过三条判据:repr 不出现、model_dump 不含键、include_secrets=True +# 时才拿得到明文。 + + +# ── 回归:kb 清洗开关的**默认值**必须是关闭(config.toml.example 同款取值)── +# +# 四个 bool 字面量互换后全量 4269 条仍全绿(boolc 扫描)。不带显式配置的调用方 +# (未初始化 config.toml 的全新部署)拿到的是字段默认值:反向后清洗默认改写 +# 空白/空格/URL、启用 QA 分段——行为变化没有任何一处测试或文档提示,而 +# ``config.toml.example`` 里四个键全写的是 ``false``。 + + +@pytest.mark.parametrize( + "field_name", + [ + "kb_replace_whitespace", + "kb_remove_spaces", + "kb_remove_urls", + "kb_use_qa_segmentation", + ], +) +def test_kb_cleaning_switch_defaults_are_off(field_name): + """四个清洗开关的 schema 默认值必须是 ``False``。""" + assert Settings.model_fields[field_name].default is False + + +# ── 回归:resolve_app_root 的两条分支 ── + + +def test_resolve_app_root_returns_repo_root_when_not_frozen(): + """非冻结(源码运行)时根目录是仓库根,不受 ``sys.frozen`` 缺失影响。""" + from src.utils import config as config_module + + assert ( + config_module.resolve_app_root() + == Path(config_module.__file__).resolve().parent.parent.parent + ) + + +def test_resolve_app_root_uses_executable_dir_when_frozen(monkeypatch, tmp_path): + """冻结(PyInstaller)运行时根目录是可执行文件所在目录。""" + import sys as sys_module + + from src.utils import config as config_module + + monkeypatch.setattr(sys_module, "frozen", True, raising=False) + monkeypatch.setattr(sys_module, "executable", str(tmp_path / "app.bin")) + + assert config_module.resolve_app_root() == tmp_path diff --git a/tests/test_display_utils.py b/tests/test_display_utils.py new file mode 100644 index 0000000..ee715f0 --- /dev/null +++ b/tests/test_display_utils.py @@ -0,0 +1,1038 @@ +"""``display_chat_config`` 打到终端的内容不得含凭证。 + +``api_key`` 一直有掩码,但 ``base_url`` 是原样输出的:自建网关常把凭证写在 +URL 里(``https://user:token@gateway/v1``),日志侧有 ``RedactingFormatter`` +兜底,终端这一路没有——凭证会直接出现在屏幕上、终端回滚里,以及用户随手 +截的图里。 +""" + +from __future__ import annotations + +import ast +import os +from io import StringIO +from types import SimpleNamespace + +import pytest +from rich.console import Console + +from src.ui import display_utils +from src.ui.display_utils import coerce_score, display_chat_config, get_relative_path + +SECRET_IN_KEY = "sk-super-secret-key" +SECRET_IN_URL = "sk-real-secret" + + +@pytest.fixture +def rendered(monkeypatch) -> str: + """渲染一次配置面板,返回纯文本输出。""" + settings = SimpleNamespace( + qwen_api_key=SECRET_IN_KEY, + qwen_base_url=f"https://user:{SECRET_IN_URL}@gateway.example.com/v1", + ) + monkeypatch.setattr(display_utils, "get_settings", lambda: settings) + + buffer = StringIO() + console = Console(file=buffer, width=120, force_terminal=False, no_color=True) + display_chat_config(console, _chat_config()) + return buffer.getvalue() + + +def test_display_chat_config_redacts_api_key(rendered): + """API Key 只显示「已设置」,不回显任何字符。""" + assert SECRET_IN_KEY not in rendered + assert "已设置(值已隐藏)" in rendered + + +def test_display_chat_config_redacts_credentials_embedded_in_base_url(rendered): + """内嵌在 Base URL 里的凭证必须脱敏。""" + assert SECRET_IN_URL not in rendered, "Base URL 中的凭证被原样打印到终端" + assert "REDACTED" in rendered + + +def test_display_chat_config_keeps_base_url_host_visible(rendered): + """脱敏不能把整个 URL 抹掉——用户需要靠主机名确认连的是哪个网关。""" + assert "gateway.example.com" in rendered + + +def _chat_config() -> dict: + """构造 display_chat_config 需要的最小配置。""" + return { + "retrieval_method": SimpleNamespace(value="混合检索"), + "vector_weight": 0.7, + "keyword_weight": 0.3, + "hybrid_fusion_strategy": "weighted", + "retrieval_candidate_multiplier": 2, + "rerank_enabled": True, + "active_rerank_configuration": "siliconflow", + "rerank_configurations": { + "siliconflow": SimpleNamespace(provider="siliconflow", model_name="bge-reranker-v2-m3") + }, + "top_k": 5, + "score_threshold": 0.5, + "active_llm_configuration": "qwen3-8-max", + "llm_configurations": { + "qwen3-8-max": SimpleNamespace(provider="qwen", model_name="qwen3-8b") + }, + } + + +# ── 回归:配置面板里的数值字段必须可渲染 ── +# +# ``display_chat_config`` 的五个数值字段原先走裸 ``str()``/f-string 插值。 +# ``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫(见 +# ``_require_finite_number`` 的 docstring),嵌入方或携带超大整数的 pickle +# 快照可以直写这些字段,于是 ``str(10**4301)`` 撞上 CPython 的 +# ``sys.get_int_max_str_digits()``(默认 4300)抛 ``ValueError: Exceeds the +# limit``。 +# +# 后果比「显示不好看」严重得多:本函数在 ``start_chat_session_async`` 里于 +# **任何提示符之前**被调用,用户刚看到「知识快照加载成功」就收到一条整数转换 +# 错误,会话随即终止。 +@pytest.mark.parametrize( + "field", + [ + "vector_weight", + "keyword_weight", + "retrieval_candidate_multiplier", + "top_k", + "score_threshold", + ], +) +def test_display_chat_config_renders_values_beyond_the_conversion_limit(monkeypatch, field): + monkeypatch.setattr( + display_utils, + "get_settings", + lambda: SimpleNamespace(qwen_api_key=None, qwen_base_url=None), + ) + config = _chat_config() + config[field] = 10**4301 + + buffer = StringIO() + console = Console(file=buffer, width=120, force_terminal=False, no_color=True) + display_chat_config(console, config) + + output = buffer.getvalue() + assert "Exceeds the limit" not in output + assert "位十进制" in output + + +def test_display_chat_config_keeps_small_numbers_verbatim(rendered): + """收紧不能误伤:正常数值照旧原样显示,不加引号、不截断。""" + assert "0.7 / 0.3" in rendered + assert "top_k):" in rendered and " 5" in rendered + + +# ── 回归:不可信文本不得被当作 rich 标记解析 ── +# +# rich 的 ``console.print(str)``/``Table.add_row(str)``/``Panel(str)`` 都会把 +# 字符串按**标记**解析(``Console(markup=True)`` 是默认值),而本项目的这些位置 +# 里插着来自知识库文档、用户输入与异常消息的文本。后果有两类: +# +# 1. 崩溃。文本里出现字面量 ``[/]``、``[/color]``、``[/if]`` 这类形态时 +# ``rich.markup.render`` 抛 ``MarkupError``。实测知识库文档里 +# 「匹配斜杠用 [/] 表示」这一行就能触发。更糟的是**异常处理本身也在用同一 +# 个通道**:``safe_exception_text()`` 拼出的消息里带着 ``[/]``, +# 于是内层 ``except`` 抛、``main()`` 的兜底 ``except`` 也抛、``run_cli`` +# 的兜底 ``except`` 再抛——三层全废,程序以裸 traceback 退出。 +# 2. 静默改写。``[b]粗体[/b]`` 被吃掉标记,markdown 链接 +# ``[https://x](https://x)`` 丢掉方括号部分。实测真实知识库 3038 行里有 +# 12 行在渲染后被改写,用户看到的内容与检索到的内容不一致。 + + +def test_markup_text_keeps_literal_markup_in_values(): + """值里的标记形态必须原样显示,而不是被解析或抛错。""" + from src.ui.display_utils import markup_text + + buffer = StringIO() + console = Console(file=buffer, width=200, force_terminal=False, no_color=True) + + for value in [ + "匹配斜杠用 [/] 表示", + "

[/color] 结尾

", + "字段 {{name}} 与 [/if] 标记", + "value [/bold]", + "请参考 [b]重要说明[/b] 一节。", + "[https://docs.conda.io/x.html](https://docs.conda.io/x.html)", + "[url=https://x]点这里[/url]", + "C:\\Users\\", + "结尾反斜杠 \\", + "a\\[b]", + ]: + console.print(markup_text("[bold red]启动失败:[/bold red] {}", value), end="") + assert buffer.getvalue() == f"启动失败: {value}", value + buffer.seek(0) + buffer.truncate() + + +def test_markup_text_keeps_trusted_styling(): + """收紧不能误伤:模板自身的标记仍要生效,值也要继承所在位置的样式。""" + from rich.text import Text + + from src.ui.display_utils import markup_text + + text = markup_text("[red]出错: {}[/red]", "值") + assert isinstance(text, Text) + assert text.plain == "出错: 值" + assert [(span.start, span.end, str(span.style)) for span in text.spans] == [ + (0, 4, "red"), + (4, 5, "red"), + ] + + +def test_markup_text_places_multiple_values_in_order(): + """模板里可以放多个占位符,值按顺序落位。""" + from src.ui.display_utils import markup_text + + text = markup_text("[red]{}[/red] 与 {} 结束", "第一个", "第二个") + + assert text.plain == "第一个 与 第二个 结束" + + +# ── 回归:``markup_text`` 的值必须可渲染 ── +# +# ``values`` 的类型标注是 ``object``,调用点既有字符串(``safe_exception_text``) +# 也有 int/float(``config_menu`` 的权重与倍率、``chat/core`` 的分块数)。 +# 裸 ``str(value)`` 对位数超过 ``sys.get_int_max_str_digits()``(默认 4300)的 +# 整数抛 ``ValueError: Exceeds the limit``——正要显示「倍率已更新为 …」的那一步 +# 自己抛错,用户看到的是整数转换错误,与配置项毫无关系。 +# +# ``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫,嵌入方或携带 +# 超大整数的 pickle 快照可以直写这些字段(见 ``_require_finite_number`` 的 +# docstring),因此非字符串取值是真实形态而不是假想输入。 +@pytest.mark.parametrize("huge", [10**4301, -(10**4301)], ids=["正", "负"]) +def test_markup_text_renders_values_beyond_the_conversion_limit(huge): + from src.ui.display_utils import markup_text + + text = markup_text("[green]倍率已更新为: {}[/green]", huge) + + assert "Exceeds the limit" not in text.plain + assert "位十进制" in text.plain + + +def test_markup_text_keeps_small_non_string_values_verbatim(): + """收紧不能误伤:正常取值的显示形态不变(``3`` 不是 ``'3'``)。""" + from src.ui.display_utils import markup_text + + assert markup_text("倍率: {}", 3).plain == "倍率: 3" + assert markup_text("权重: {}", 0.5).plain == "权重: 0.5" + assert markup_text("启用: {}", True).plain == "启用: True" + + +def test_markup_text_value_renderer_is_not_the_naive_str(): + """反向守卫:裸 ``str()`` 确实会抛——否则上面那条断言证明不了什么。""" + with pytest.raises(ValueError, match="Exceeds the limit"): + str(10**4301) + + +def test_markup_text_rejects_placeholder_mismatch(): + """模板与实参数量不符是编码错误,必须显式报错而不是错位拼接。""" + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="占位符"): + markup_text("[red]{}[/red]") + with pytest.raises(ValueError, match="占位符"): + markup_text("没有占位符", "值") + with pytest.raises(ValueError, match="占位符"): + markup_text("{} 和 {}", "只有一个") + + +def test_display_chat_config_keeps_markup_like_base_url_intact(monkeypatch): + """Base URL 里的标记形态不得被解析——IPv6 字面量本身就带方括号。 + + 该值来自 ``config.toml``/``.env``,未经任何校验。修复前 + ``https://gw.example.com/v1[/]`` 会让配置面板渲染抛 ``MarkupError``, + 而该异常落在 chat 主循环之外,会以裸 traceback 终止整个会话。 + """ + settings = SimpleNamespace( + qwen_api_key=SECRET_IN_KEY, + qwen_base_url="https://gw.example.com/v1[/]", + ) + monkeypatch.setattr(display_utils, "get_settings", lambda: settings) + + buffer = StringIO() + console = Console(file=buffer, width=120, force_terminal=False, no_color=True) + display_chat_config(console, _chat_config()) + + assert "https://gw.example.com/v1[/]" in buffer.getvalue() + + +def test_markup_text_rejects_placeholder_inside_tag_parameters(): + """占位符落在标记参数内部是编码错误,必须报错。 + + ``[link={}]点这里[/link]`` 不会抛错,而是渲染成 ``点这里https://x``—— + 链接文本与 URL 粘连、样式丢失,属于静默错位;``[bold {}]`` 则抛 + ``MarkupError``。值只能出现在标记的文本位置。 + """ + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="标记参数内部"): + markup_text("[link={}]点这里[/link]", "https://x") + with pytest.raises(ValueError, match="标记参数内部"): + markup_text("[bold {}]x[/bold]", "red") + # 文本位置的占位符照常接受。 + assert markup_text("[bold]{}[/bold]", "值").plain == "值" + + +# ── 回归:配置面板里**由 config.toml 派生**的单元格同样要按字面呈现 ── +# +# 上一轮只把「融合策略」与 Base URL 两个单元格包成了 ``Text``,其余仍是裸 +# f-string/``str()``,而这些取值同样来自 ``config.toml``,同样没有白名单: +# +# * ``llm_configurations..model_name`` —— 工厂只校验 ``provider`` +# (``_provider_map`` 白名单),``model_name`` 原样透传。实测 +# ``model_name = "gemini[/]"`` 时 ``reload_llm()`` 返回 True(模型真的建起来了), +# 紧接着的 ``display_chat_config`` 抛 ``MarkupError``——聊天会话在**启动横幅** +# 这一步就崩,用户看不到任何配置。 +# * ``active_llm_configuration`` / ``active_rerank_configuration`` 是配置表的 +# **键名**,任意字符串即可,同样是工厂放行、面板崩溃。 +# * ``rerank_configurations..model_name`` 与 Rerank 行同理。 +# +# 判据用「渲染结果里原样含该取值」,而不是「不抛异常」:标记被吃掉时同样不抛, +# 而面板会显示一个与配置不符的名字。 + + +@pytest.mark.parametrize( + ("label", "mutate"), + [ + ( + "llm model_name", + lambda config: config["llm_configurations"]["qwen3-8-max"].__setattr__( + "model_name", "gemini[/]" + ), + ), + ( + "rerank model_name", + lambda config: config["rerank_configurations"]["siliconflow"].__setattr__( + "model_name", "bge[/]" + ), + ), + ( + "active_llm 键名", + lambda config: ( + config["llm_configurations"].__setitem__( + "a[/]b", config["llm_configurations"].pop("qwen3-8-max") + ), + config.__setitem__("active_llm_configuration", "a[/]b"), + ), + ), + ( + "active_rerank 键名", + lambda config: ( + config["rerank_configurations"].__setitem__( + "a[/]b", config["rerank_configurations"].pop("siliconflow") + ), + config.__setitem__("active_rerank_configuration", "a[/]b"), + ), + ), + ], +) +def test_display_chat_config_keeps_markup_like_model_fields_intact(monkeypatch, label, mutate): + """模型名与配置键名里的标记形态必须原样显示,不能崩、也不能被吃掉。""" + settings = SimpleNamespace(qwen_api_key=None, qwen_base_url=None) + monkeypatch.setattr(display_utils, "get_settings", lambda: settings) + config = _chat_config() + mutate(config) + + buffer = StringIO() + console = Console(file=buffer, width=200, force_terminal=False, no_color=True) + display_chat_config(console, config) + + rendered = buffer.getvalue() + assert "[/]" in rendered, f"{label}: 标记形态被吞掉了" + + +# ── 回归:值必须继承「占位符所在位置」的样式 ── +# +# 修复前 ``markup_text`` 用 ``Text.from_markup(...).split(占位符)`` 切段,再从 +# **切出来的相邻段**上取样式。而占位符本身是被切掉的那一段,样式恰恰只挂在它 +# 身上——``[bold cyan]\x00[/bold cyan]`` 的 span 是 ``(0,1,bold cyan)``, +# ``split`` 后右侧段是空串、无样式。于是「值独占标记内容」这一**最常用**的写法 +# (仓库里 13 处 ``[bold magenta]{}[/bold magenta]`` 都是这个形状)渲染出来完全 +# 没有样式,且不报错——静默丢失。 +# +# 反向的错位同样存在:``[bold]x[/bold]{}`` 里占位符在标记**之后**,旧实现却把 +# 已拼接结果的末字符样式(``bold``)赋给了值,等于把值染上了它不该有的样式。 +# +# 判据用「逐字符样式集合」而不是 span 边界:span 边界随值的长度变化,逐字符 +# 比对才能既断言样式内容、又断言样式覆盖的范围。 + + +def _styles_per_char(text): + """返回 ``text`` 每个字符上叠加的样式名集合。""" + return [ + frozenset(str(span.style) for span in text.spans if span.start <= index < span.end) + for index in range(len(text.plain)) + ] + + +@pytest.mark.parametrize( + ("template", "values", "expected_plain"), + [ + ("[bold]{}[/bold]", ("值",), "值"), + ("[bold cyan]{}[/bold cyan]", ("值",), "值"), + ("[bold][red]{}[/red][/bold]", ("值",), "值"), + ("[red]x{}[/red]", ("值",), "x值"), + ("[red]{}x[/red]", ("值",), "值x"), + ("[red]x{}y[/red]", ("值",), "x值y"), + ("[red]{}[/red] 与 {} 结束", ("第一个", "第二个"), "第一个 与 第二个 结束"), + ("[green]快照目录: [cyan]{}[/cyan][/green]", ("/tmp/x",), "快照目录: /tmp/x"), + ("[bold]x[/bold]{}", ("值",), "x值"), + ("[red]{}{}[/red]", ("a", "b"), "ab"), + ("{}", ("值",), "值"), + ("[red]{}[/red]", (None,), "None"), + ("[red]{}[/red]", (42,), "42"), + ], +) +def test_markup_text_applies_the_style_of_the_placeholder_position( + template, values, expected_plain +): + """值要带上「占位符原本所在位置」的样式,范围与数量都要正确。 + + 参考实现把值经 ``rich.markup.escape`` 转义后填回模板再 ``from_markup``: + 对不含标记的值 escape 是恒等变换,因此这是忠实的期望。 + """ + from rich.markup import escape + from rich.text import Text + + from src.ui.display_utils import markup_text + + got = markup_text(template, *values) + + reference_markup = template + for value in values: + reference_markup = reference_markup.replace("{}", escape(str(value)), 1) + reference = Text.from_markup(reference_markup) + + assert got.plain == expected_plain + assert _styles_per_char(got) == _styles_per_char(reference) + + +def test_markup_text_does_not_leak_a_closed_tag_style_onto_the_value(): + """标记闭合之后的值不得继承该标记的样式。 + + 旧实现取「已拼接结果末字符」的样式兜底,``[bold]x[/bold]{}`` 因此把值染成 + 粗体——值在标记**之外**,这个样式是错的。 + """ + from src.ui.display_utils import markup_text + + text = markup_text("[bold]x[/bold]{}", "值") + + assert text.plain == "x值" + assert _styles_per_char(text)[1] == frozenset() + + +def test_markup_text_styles_a_value_spanning_multiple_characters(): + """多字符(含多行)的值要整体染色,而不是只有首字符。""" + from src.ui.display_utils import markup_text + + text = markup_text("[red]{}[/red]", "多字符的值") + + assert text.plain == "多字符的值" + assert _styles_per_char(text) == [frozenset({"red"})] * len("多字符的值") + + +# ── 回归:展示层的字段提取必须容忍形态异常的文档 ── +# +# ``display_results``(召回测试)与 ``Chatbot._display_retrieved_docs``(聊天)都 +# 直接对检索结果做 ``.get(...).replace(...)`` 与 ``f"{score:.4f}"``。检索结果可以 +# 来自任何 ``VectorStoreBase`` 实现——``FaissStore`` 会在载入期归一化,但 +# **第三方 store 不会**,而抽象基类正是为替换实现而存在的。实测一个返回 +# ``{"page_content": None, "metadata": None, "score": None}`` 的 store 能让 +# 两个展示函数分别抛 ``AttributeError`` 与 ``TypeError``。 +# +# 提取助手把这些字段统一收敛成可渲染的值:形态不对就退回默认显示,而不是 +# 让「显示检索结果」这一步本身崩掉——它已经是错误处理之后的最后一环了。 + + +@pytest.mark.parametrize( + "document,expected", + [ + ({"page_content": "正文"}, "正文"), + ({"page_content": None}, "N/A"), + ({"page_content": ["x"]}, "N/A"), + ({"page_content": 42}, "N/A"), + ({}, "N/A"), + ("不是映射", "N/A"), + (None, "N/A"), + ], +) +def test_document_content_falls_back_for_malformed_values(document, expected): + """内容字段的异常形态退回 ``N/A``,不抛错。""" + from src.ui.display_utils import document_content + + assert document_content(document) == expected + + +@pytest.mark.parametrize( + "document,expected", + [ + ({"metadata": {"source": "a.md"}}, "a.md"), + ({"metadata": None}, "N/A"), + ({"metadata": ["a"]}, "N/A"), + ({"metadata": {"source": None}}, "N/A"), + ({"metadata": {"source": ["a"]}}, "N/A"), + ({}, "N/A"), + ("不是映射", "N/A"), + ], +) +def test_document_source_falls_back_for_malformed_values(document, expected): + """来源字段的异常形态退回 ``N/A``,不抛错。""" + from src.ui.display_utils import document_source + + assert document_source(document) == expected + + +@pytest.mark.parametrize( + "document,expected", + [ + ({"score": 0.5}, 0.5), + ({"score": 0}, 0.0), + ({"score": None}, 0.0), + # 数字字符串照旧转换——旧实现是 ``float(x or 0)``,它接受 ``"0.5"``。 + # 一律拒绝会让返回字符串得分的 store 被静默降为 0,再被分数阈值全部 + # 过滤:从「能用」变成「静默返回空结果」。 + ({"score": "0.5"}, 0.5), + ({"score": "abc"}, 0.0), + ({"score": True}, 0.0), + ({"score": float("nan")}, 0.0), + ({}, 0.0), + ("不是映射", 0.0), + ], +) +def test_document_score_falls_back_for_malformed_values(document, expected): + """分数字段的异常形态退回 ``0.0``,不抛错。 + + ``bool`` 也要退:``True`` 在格式化时显示成 ``1.0000``,看起来像一个真实 + 得分,而它几乎必然是上游把标志位写进了分数字段。 + """ + from src.ui.display_utils import document_score + + assert document_score(document) == expected + + +# ── 结构性回归:把「rich 解析不可信值」这一类问题钉死 ── +# +# 上面每个测试都是针对**某个具体调用点**的回归。这类问题的共同点是「新写的代码 +# 又把裸字符串交给了 rich」,逐个补测试挡不住下一次。这里用 AST 直接检查源码: +# 凡是会被 rich 解析标记的入口(``console.print``、``Table.add_row``、``Panel``), +# 其参数必须要么是**字面量**(作者显式写的模板,标记是有意的),要么已经被 +# ``Text(...)`` / ``markup_text(...)`` 包装。变量、f-string、下标、拼接一律拒绝。 +# +# 例外是那些**定义上就产出安全值**的调用,逐个登记并说明理由——新增例外必须 +# 同样给出理由,否则测试会逼着作者去包装。 + +# ``add_column`` 的 header 同样按标记解析(实测 header="a[/]b" 抛 MarkupError), +# 因此一并纳入。仓库里现有的 header 都是字面量。 +_RICH_MARKUP_ENTRY_POINTS = {"add_row", "add_column", "Panel", "Table"} + +# 返回 ``Text`` 或**有意的**标记字面量的函数/构造器:调用它们就等于已经包装好了。 +_SAFE_CALLS = { + "Text", # 显式包装,值不再经过标记解析 + "markup_text", # 本模块的安全拼接 + "create_gradient", # main.py:返回 Text + "mask_api_key", # 返回有意的标记字面量("[dim]未设置[/dim]") + "Group", # rich 容器,承载 Table 对象 +} + +# 构造器里同样会被解析标记的关键字:``Table(title=...)`` 与 ``Panel(title=...)`` +# 实测 ``Table(title="a[/]b")`` 抛 MarkupError。这两个类不能整体放进 _SAFE_CALLS +# ——那样连它们的 ``title`` 一起豁免了(``Table`` 承载的是 Table 对象,安全的是 +# 它作为**实参**出现时,不是它的 title)。 +_MARKUP_PARSING_CONSTRUCTORS = {"Table", "Panel"} + + +# 逐条登记的例外:这些实参**定义上**不含外部输入,因此不必包装。 +# 键是 ``相对路径:行号``,值是理由。行号变了条目就失效——测试会报出来, +# 逼着作者重新确认而不是让豁免静默漂移。 +# 值里带一段**该行源码的原文**(去掉行号前缀后的内容)。只按行号登记是不够的: +# 上面增删几行,行号就会漂到另一处 rich 调用上——而邻近的行几乎都是 rich 调用, +# 于是豁免会静默地覆盖一个真正的违规。原文一比对,漂移立刻暴露。 +_UNTRUSTED_FREE_ARGUMENTS = { + "main.py:170": ( + "gradient_part / padding / attribution_text 三者都是本函数内构造的 Text", + "console.print(gradient_part + padding + attribution_text)", + ), + "main.py:193": ( + "welcome_text 是本函数内逐段 append 出来的 Text", + 'console.print(Panel(welcome_text, border_style="green", width=banner_width))', + ), + "main.py:206": ( + "menu_content 是本函数内的字面量拼接(上面的测试已断言它是纯字面量)", + "Panel(", + ), + "scripts/embed_knowledge_base.py:124": ( + "table 是 Table 对象,不是字符串", + "console.print(table)", + ), + "src/chat/core.py:382": ( + "f'{document_score(doc):.4f}':document_score 只返回有限浮点,格式化结果不含 [", + 'f"{document_score(doc):.4f}"', + ), + "src/chat/core.py:383": ("table 是 Table 对象,不是字符串", "self.console.print(table)"), + "src/retrieval_test/core.py:68": ( + "str(index+1) 与 f'{document_score(doc):.4f}' 均为数字,不含 [", + 'f"{document_score(doc):.4f}"', + ), + "src/retrieval_test/core.py:69": ("table 是 Table 对象,不是字符串", "console.print(table)"), +} + + +def _is_literal_or_wrapped(node): + """判断一个实参是否会原样呈现(而不是被 rich 当标记解析)。""" + if isinstance(node, ast.Constant): + return True + if isinstance(node, ast.Call): + name = getattr(node.func, "id", None) or getattr(node.func, "attr", None) + if name in _SAFE_CALLS: + return True + # ``Table(title=...)``/``Panel(title=...)`` 的 title 会被解析标记,其余 + # 参数(Table 对象、border_style 等)不会。 + if name in _MARKUP_PARSING_CONSTRUCTORS: + titles = [kw.value for kw in node.keywords if kw.arg == "title"] + return all(_is_literal_or_wrapped(title) for title in titles) + return all(_is_literal_or_wrapped(arg) for arg in node.args) and all( + _is_literal_or_wrapped(kw.value) for kw in node.keywords if kw.arg + ) + return not isinstance(node, (ast.JoinedStr, ast.Name, ast.Attribute, ast.Subscript, ast.BinOp)) + + +def test_rich_markup_entry_points_never_receive_raw_variables(): + """凡是会被 rich 解析标记的入口,参数必须是字面量或已包装的值。 + + 这条断言覆盖 ``main.py``/``src/``/``scripts/`` 的全部源码。它挡的是一整类 + 问题(f-string 里塞知识库正文、异常消息、``config.toml`` 取值),而不只是 + 已发现的那些调用点。 + """ + import ast + from pathlib import Path + + repo_root = Path(__file__).resolve().parent.parent + offenders = [] + for path in sorted(repo_root.glob("**/*.py")): + relative = path.relative_to(repo_root).as_posix() + if relative.startswith((".venv/", "build/", "dist/", "release_artifacts/", "tests/")): + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = getattr(node.func, "id", None) or getattr(node.func, "attr", None) + if name == "print": + # 只关心 ``console.print`` / ``self.console.print``,放过 list/dict 的 + # print。取属性链的**根名字**来判断——只看一层 ``node.func.value`` + # 会漏掉 ``self.console.print``(它的 value 是 Attribute 而不是 Name), + # 而那正是聊天主循环里打印表格的写法。 + base = node.func + while isinstance(base, ast.Attribute): + base = base.value + if not (isinstance(base, ast.Name) and base.id in {"console", "self"}): + continue + elif name not in _RICH_MARKUP_ENTRY_POINTS: + continue + # 逐个入口确定「哪些实参会被解析标记」: + # * ``add_column`` 的 header 是第一个位置参数,样式参数 + # (style=、width=)不参与解析; + # * ``Table`` 只有 ``title`` 参与解析(它的位置参数不是字符串); + # * ``Panel`` 的首个位置参数(renderable)与 ``title`` 都参与解析。 + titles = [keyword.value for keyword in node.keywords if keyword.arg == "title"] + if name == "add_column": + candidates = list(node.args[:1]) + titles + elif name == "Table": + candidates = titles + else: + candidates = list(node.args) + titles + for argument in candidates: + if _is_literal_or_wrapped(argument): + continue + location = f"{relative}:{node.lineno}" + if location in _UNTRUSTED_FREE_ARGUMENTS: + continue + offenders.append(f"{location} {name}(... {ast.unparse(argument)})") + + assert not offenders, ( + "以下位置把未经包装的值交给了 rich 的标记解析入口,请用 Text(...) 或 " + "markup_text(...) 包装;确属外部输入不可达的,在 " + "_UNTRUSTED_FREE_ARGUMENTS 里登记并写明理由:\n" + "\n".join(offenders) + ) + + +def test_untrusted_free_argument_allowlist_has_no_stale_entries(): + """登记表不能有失效条目——否则豁免会静默漂移到别的代码上。 + + 逐条确认「该文件该行确实是一个被豁免的 rich 调用」。行号一动或代码被删, + 条目就对不上,测试报错。 + """ + from pathlib import Path + + repo_root = Path(__file__).resolve().parent.parent + stale = [] + for location, (reason, expected_fragment) in _UNTRUSTED_FREE_ARGUMENTS.items(): + relative, _, line_text = location.rpartition(":") + path = repo_root / relative + if not path.exists(): + stale.append(f"{location}(文件不存在)") + continue + lines = path.read_text(encoding="utf-8").splitlines() + line_number = int(line_text) + if line_number > len(lines): + stale.append(f"{location}(超出文件行数)") + continue + line = lines[line_number - 1] + if not any(entry in line for entry in ("print(", "add_row(", "Panel(", "Table(")): + stale.append(f"{location}(该行不再是 rich 调用: {line.strip()!r})") + continue + if not reason: + stale.append(f"{location}(缺少理由)") + # 原文必须仍然对得上。只查「这行像不像 rich 调用」不够——邻近的行几乎 + # 都是 rich 调用,行号漂移后豁免会静默覆盖一个真正的违规。 + if expected_fragment and expected_fragment not in line: + stale.append( + f"{location}(行号已漂移:该行现在是 {line.strip()!r}," + f"但豁免登记的是 {expected_fragment!r})" + ) + + assert not stale, "以下豁免条目已失效,请更新或删除:\n" + "\n".join(stale) + + +# ── 回归:构建脚本的配置总览表同样要按字面呈现 provider ── +# +# ``scripts/embed_knowledge_base.py::display_config_and_confirm`` 在**任何校验 +# 之前**运行(``main_async`` 先调它、再建服务),它渲染的表格行标签由 +# ``provider`` 拼出。修复前 ``provider = "a[/]b"`` 会让表格渲染抛 +# ``MarkupError``——「是否使用以上配置继续?」这一步直接变成崩溃,用户连配置 +# 都看不到。这个函数此前没有任何测试覆盖。 + + +def _run_embed_config_display(provider: str, model_name: str): + """调用构建脚本的配置总览,返回(终端输出, 异常)。""" + from pathlib import Path + from unittest.mock import patch + + from scripts import embed_knowledge_base + + class _StubRunConfig: + """只提供 display_config_and_confirm 读取到的字段。""" + + def __init__(self): + self.knowledge_base_path = Path("/tmp/kb") + self.snapshot_root = Path("/tmp/snap") + self.legacy_pkl_path = Path("/tmp/legacy.pkl") + self.kb_chunk_size = 500 + self.kb_chunk_overlap = 50 + self.kb_child_chunk_size = 200 + self.kb_child_chunk_overlap = 20 + self.kb_embedding_batch_size = 32 + self.default_embedding_provider = "key" + self.embedding_configurations = { + "key": SimpleNamespace(provider=provider, model_name=model_name) + } + + buffer = StringIO() + console = Console(file=buffer, width=200, force_terminal=False, no_color=True) + settings = SimpleNamespace() + with ( + patch.object(embed_knowledge_base, "Console", return_value=console), + patch.object(embed_knowledge_base, "get_settings", return_value=settings), + patch.object(embed_knowledge_base, "build_run_config", return_value=_StubRunConfig()), + patch.object(console, "input", return_value="n"), + ): + try: + embed_knowledge_base.display_config_and_confirm("standard") + except SystemExit: + pass # 用户选择「n」时脚本以 exit(0) 结束,这是正常路径 + except Exception as exc: # noqa: BLE001 - 测试要断言的就是「不该抛」 + return buffer.getvalue(), exc + return buffer.getvalue(), None + + +@pytest.mark.parametrize("provider", ["a[/]b", "a[red]b"]) +def test_embed_script_config_table_keeps_markup_like_provider_intact(provider): + """provider 里的标记形态必须原样显示,不能崩、也不能被吞掉。""" + rendered, error = _run_embed_config_display(provider, "bge-large") + + assert error is None, f"配置总览渲染抛出了 {type(error).__name__}: {error}" + assert provider in rendered, "provider 的标记形态被吞掉了" + + +# ── 回归:转义的 ``\[`` 是字面量,不是标记 ── +# +# rich 的 ``_parse`` 按「前导反斜杠的奇偶」决定 ``[`` 是转义字面量还是真标记 +# (``divmod(len(escapes), 2)[1]``)。助手原先只看正则是否匹配到 ``[``,于是 +# ``r"\[example {}]"`` 这类**合法**模板被误判为「占位符在标记参数内部」并拒绝, +# 而 rich 自己会把它解析成 ``[example <值>]``。 + + +def test_markup_text_accepts_escaped_bracket_before_the_placeholder(): + """转义的 ``\\[`` 是字面量:占位符不在标记参数内,必须接受。""" + from rich.text import Text + + from src.ui.display_utils import markup_text + + text = markup_text(r"\[example {}]", "literal") + + assert text.plain == Text.from_markup(r"\[example literal]").plain == "[example literal]" + + +def test_markup_text_still_rejects_a_real_tag_after_a_literal_backslash(): + """收紧不能误伤:``\\\\[``(双反斜杠)里的 ``[`` 仍是**真标记**,照旧拒绝。""" + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="标记参数内部"): + markup_text(r"\\[example {}]", "literal") + + +# --------------------------------------------------------------------------- +# 超出双精度范围的整数 +# --------------------------------------------------------------------------- +# +# ``coerce_score`` 的契约是「绝不抛异常,形态异常时退回 ``default``」,而 +# ``float(10**400)`` 抛 ``OverflowError``——它不是 ``ValueError``,原实现里 +# 两个 ``except`` 都接不住。这个函数是展示层与检索层共用的唯一分数收敛规则, +# 裸异常会让「形态异常就退回默认值」这条契约整体失效。 +_BEYOND_DOUBLE_INTS = [ + pytest.param(10**400, id="400digits"), + pytest.param(-(10**400), id="negative-400digits"), + pytest.param(10**4301, id="4301digits-over-int-max-str-digits"), +] + + +@pytest.mark.parametrize("value", _BEYOND_DOUBLE_INTS) +def test_coerce_score_falls_back_for_ints_beyond_double_range(value): + """超出双精度范围的整数必须退回默认值,而不是抛 ``OverflowError``。""" + assert coerce_score(value) == 0.0 + assert coerce_score(value, default=-1.0) == -1.0 + + +# ── 回归:``get_relative_path`` 的兜底分支不得自己抛 ── +# +# 原实现第 122 行的 ``str(absolute_path)`` 抛 ``ValueError: Exceeds the limit``, +# ``except ValueError`` 接住后第 127 行的 f-string **用同一个值再插值一次**, +# 把刚接住的异常原样重抛。兜底分支存在的唯一理由就是不再抛。实际来源是 +# ``document_source(document)``,而文档元数据来自磁盘上的快照/legacy pkl。 + + +def test_get_relative_path_survives_huge_integer(): + """位数超限的整数必须降级为量级描述,而不是抛异常。""" + result = get_relative_path(10**4301) + + assert isinstance(result, str) + assert result + + +def test_get_relative_path_actually_resolves_paths_under_root(): + """本函数的**设计意图**是换算相对路径,断言必须盯住这个属性。 + + 只断言「返回非空字符串」是假绿:一个把入参原样返回的实现也能通过,而 + 「原样返回」正是回归时的实际表现。这里用 ROOT_DIR 下的真实路径断言结果 + 确实是相对形态。 + """ + from src.utils.config import ROOT_DIR + + absolute = str(ROOT_DIR / "knowledge_base" / "doc.md") + + result = get_relative_path(absolute) + + assert result == os.path.join("knowledge_base", "doc.md") + assert not os.path.isabs(result) + assert str(ROOT_DIR) not in result + + +def test_get_relative_path_does_not_truncate_or_quote_long_paths(): + """超长路径不得被截断,也不得被加上引号。 + + 此前实现对所有取值都套 ``safe_repr``,它会加引号并在 80 字符处截断: + 带引号的前缀匹配不到 ``ROOT_DIR``(相对路径功能失效),截断则让用户看不到 + 文件名——而文件名恰恰是展示路径的用途。 + """ + from src.utils.config import ROOT_DIR + + long_name = "a" * 200 + ".md" + absolute = str(ROOT_DIR / "knowledge_base" / long_name) + + result = get_relative_path(absolute) + + assert result == os.path.join("knowledge_base", long_name) + assert long_name in result + assert not result.startswith("'") + + +def test_get_relative_path_still_resolves_normal_paths(tmp_path): + """正常路径不受影响。""" + result = get_relative_path(tmp_path / "kb" / "doc.md") + + assert isinstance(result, str) + assert result + + +# ── 回归:激活的 provider key 缺失时必须报 ValueError ── +# +# ``default_llm_provider``/``default_rerank_provider``/ +# ``active_*_configuration`` 没有任何 validator,而原实现直接 +# ``chat_config["llm_configurations"][active_llm]``。实测打错一个 provider 名时, +# 调用点 ``chat/core.py`` 的 ``start_chat_session_async`` 在任何提示符之前抛裸 +# ``KeyError``——用户刚看到「知识快照加载成功」,紧接着会话终止。 + + +def _chat_config_for(active_llm, active_rerank=None, rerank_enabled=False): + from src.utils.config import ModelDetail + + detail = ModelDetail(provider="google", model_name="gemini-test") + from src.utils.config import RetrievalMethod + + return { + "retrieval_method": RetrievalMethod.SEMANTIC_SEARCH, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 4, + "rerank_enabled": rerank_enabled, + "active_rerank_configuration": active_rerank, + "rerank_configurations": {"siliconflow": detail}, + "top_k": 5, + "score_threshold": 0.0, + "active_llm_configuration": active_llm, + "llm_configurations": {"google": detail}, + } + + +def test_display_chat_config_reports_unknown_llm_key_as_value_error(capsys): + console = Console(file=StringIO(), width=200) + + with pytest.raises(ValueError, match="未找到key"): + display_chat_config(console, _chat_config_for("not-a-provider")) + + +def test_display_chat_config_reports_unknown_rerank_key_as_value_error(): + console = Console(file=StringIO(), width=200) + config = _chat_config_for("google", active_rerank="openai", rerank_enabled=True) + + with pytest.raises(ValueError, match="未找到key"): + display_chat_config(console, config) + + +def test_display_chat_config_renders_known_keys(): + """正常配置不受影响。""" + console = Console(file=StringIO(), width=200) + + display_chat_config(console, _chat_config_for("google")) + + +# ── 回归:被激活的配置**取值**不可用时也必须报 ValueError ── +# +# ``_require_model_detail`` 只做 ``key not in configurations`` 的成员判定,然后 +# 把 ``configurations[key]`` 原样返回。键在、值不可用(``None``/dict/int)时 +# 不会走「未找到key」分支,``None`` 一路带到三个调用点的 +# ``..._model_details.provider``,在那里抛裸 ``AttributeError``。 +# 值来源与键同源,都是可直写的配置映射(``SessionConfig`` 的 +# ``_VALIDATORS`` 明确不登记 ``*_configurations``)。 + + +@pytest.mark.parametrize( + "bad_detail", [None, 5, "x", {"provider": "google"}], ids=["None", "int", "str", "dict"] +) +def test_display_chat_config_rejects_unusable_active_llm_detail(bad_detail): + console = Console(file=StringIO(), width=200) + config = _chat_config_for("google") + config["llm_configurations"] = {"google": bad_detail} + + with pytest.raises(ValueError, match="LLM配置"): + display_chat_config(console, config) + + +@pytest.mark.parametrize( + "bad_detail", [None, 5, "x", {"provider": "google"}], ids=["None", "int", "str", "dict"] +) +def test_display_chat_config_rejects_unusable_active_rerank_detail(bad_detail): + console = Console(file=StringIO(), width=200) + config = _chat_config_for("google", active_rerank="siliconflow", rerank_enabled=True) + config["rerank_configurations"] = {"siliconflow": bad_detail} + + with pytest.raises(ValueError, match="Rerank配置"): + display_chat_config(console, config) + + +def test_display_chat_config_still_renders_valid_details(): + """收紧不能误伤:合法的 ``ModelDetail`` 照常渲染。""" + console = Console(file=StringIO(), width=200) + config = _chat_config_for("google", active_rerank="siliconflow", rerank_enabled=True) + + display_chat_config(console, config) + + +# ── markup_text 的 NUL 模板与 _require_model_detail 的非映射形态 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3725 条仍全绿)。删除后行为确实改变(实测): +# * 76/78 附近的 NUL 模板判据:``markup_text("a\x00b", "v")`` 删掉后不再报 +# 「模板不得包含 NUL」,而是落到「占位符少于实参数量」——用户被指向占位符, +# 真正的问题(模板里混进了控制字符)没人说; +# * 297 ``_require_model_detail`` 的「configurations 非映射」判据:传 ``None`` +# 时删掉后落到下游的「key 必须是可哈希的取值」,把「配置映射本身不是映射」 +# 说成「key 形态非法」,指向错误的排查方向。 +# +# NUL 那条与既有的「占位符数量不符」必须分开断言:两者消息都是 ``ValueError``, +# 只看异常类型无法区分。 +def test_markup_text_rejects_nul_in_template(): + """模板里混入 NUL 字符必须点名 NUL,不能报成占位符数量不符。""" + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="NUL"): + markup_text("a\x00b", "value") + + +def test_markup_text_still_rejects_placeholder_mismatch_with_its_own_message(): + """反向守卫:占位符数量不符仍是「占位符」消息,与 NUL 那条可区分。""" + from src.ui.display_utils import markup_text + + with pytest.raises(ValueError, match="占位符") as excinfo: + markup_text("没有占位符", "值") + + assert "NUL" not in str(excinfo.value) + + +def test_require_model_detail_rejects_non_mapping_configurations(): + """``configurations`` 不是映射时必须点名这一半,不能报成 key 形态非法。""" + from src.ui.display_utils import _require_model_detail + + with pytest.raises(ValueError) as excinfo: + _require_model_detail(None, "demo", "Rerank") + + message = str(excinfo.value) + assert "未找到key" in message + assert "必须是可哈希的取值" not in message + + +def test_require_model_detail_rejects_non_mapping_configurations_with_list(): + """列表也是非映射;``_require_model_detail`` 收空列表与 ``None`` 都要覆盖。""" + from src.ui.display_utils import _require_model_detail + + with pytest.raises(ValueError, match="未找到key"): + _require_model_detail(["not", "a", "mapping"], "demo", "LLM") + + +# ── 回归:配置面板的两张表都必须关掉列头(boolc 扫描存活点)── +# +# ``Table(..., show_header=False)`` 反向成 ``True`` 后 rich 会插入一行**空白 +# 列头**——两张表的列都没有名字(只有样式),于是那一行只剩两边框之间的空白。 +# 用户看到的是「聊天机器人配置」与第一条数据之间、以及「API密钥与URL配置」 +# 与 ``QWEN_API_KEY`` 之间各多出一个空行。既有的用例只断言了脱敏与数值渲染, +# 从不检查表格结构,两处守卫因此同时无人看守。 + + +def test_display_chat_config_tables_render_without_a_header_row(rendered): + """两张配置表都不得渲染列头行(列没有名字,只会多出一行空白)。 + + ``show_header`` 反向成 ``True`` 后 rich 在标题与第一条数据之间插入一行只有 + 边框的空白行。断言「标题行的下一行就是第一条数据」,两张表各判一次。 + """ + lines = [line for line in rendered.splitlines() if line.strip()] + chat_index = next(i for i, line in enumerate(lines) if "聊天机器人配置" in line) + assert "检索策略:" in lines[chat_index + 1], ( + f"聊天配置表在标题后多出非数据行:{lines[chat_index + 1]!r}" + ) + + api_index = next(i for i, line in enumerate(lines) if "API密钥与URL配置" in line) + assert "QWEN_API_KEY" in lines[api_index + 1], ( + f"API 配置表在标题后多出非数据行:{lines[api_index + 1]!r}" + ) diff --git a/tests/test_guard_coverage_regressions.py b/tests/test_guard_coverage_regressions.py new file mode 100644 index 0000000..d5c955d --- /dev/null +++ b/tests/test_guard_coverage_regressions.py @@ -0,0 +1,2547 @@ +"""补齐「删守卫后全量测试仍绿」的非 raise 守卫覆盖缺口(工具/服务/检索批)。 + +与 ``tests/providers/test_guard_coverage_regressions.py`` 同一来源:变异扫描 +(把守卫的 ``if`` 整体替换成 ``pass``)后全量 3804 条测试仍绿的存活守卫。 +逐条用真实入口确认删除后行为改变,再按守卫存在时的行为写断言。 +""" + +import asyncio +import os +import pathlib +import tempfile + +import numpy as np +import pytest + +from src.providers.local_hash import _normalize as normalize_vector +from src.retrieval.vdb.faiss_store import FaissStore +from src.runtime.contracts import RetrievalMethod, build_session_config +from src.services.retrieval_service import ( + HybridReranker, + RetrievalService, + _document_key, + _document_metadata, +) +from src.utils.config import Settings, _coerce_number, get_settings +from src.utils.security import ( + _model_dump_mapping, + redact_sensitive_text, + validate_secret_free_resource_args, + validate_secret_free_resource_kwargs, +) + +# --- utils.security -------------------------------------------------------- + + +def test_redact_masked_credential_keeps_key_name_for_followup_redaction(): + """掩码凭证后跟 ``键: 值`` 时,键名要保留、值要被吃掉。 + + 删除守卫后键名一并被替换成 ``[REDACTED]``,后续的键值脱敏规则失去锚点, + 明文值直接落进日志(实测 ``sk-abc***token: hunter2`` → ``[REDACTED]: hunter2``)。 + """ + redacted = redact_sensitive_text("sk-abc***token: hunter2") + assert "hunter2" not in redacted + assert redacted == "[REDACTED]token: [REDACTED]" + assert redact_sensitive_text("sk-abc***token=hunter2") == "[REDACTED]token=[REDACTED]" + + +def test_secret_free_validators_accept_none_as_absent(): + """``None`` 表示「没有传参数」,返回空容器。 + + 删除守卫后 None 落到「必须是对象/元组」的 ``ValueError``,未传参数的 + 常规调用形态被拒。 + """ + assert validate_secret_free_resource_kwargs(None, "P") == {} + assert validate_secret_free_resource_args(None, "P") == () + + +def test_model_dump_mapping_rejects_non_mapping_dump_result(): + """``model_dump`` 返回非 Mapping(或自身)时返回 None。 + + 删除守卫后原样返回列表或对象自身,调用方按 Mapping 处理时行为不可预期 + (本函数契约是「读不到就不检查」的 fail-open,返回 None 是契约的一部分)。 + """ + + class ListDump: + def model_dump(self, **kwargs): + return ["not", "mapping"] + + class SelfDump: + def model_dump(self, **kwargs): + return self + + assert _model_dump_mapping(ListDump()) is None + assert _model_dump_mapping(SelfDump()) is None + + +# --- utils.config ---------------------------------------------------------- + + +def test_split_separators_none_falls_back_to_field_default(): + """``kb_splitter_separators=None`` 回退到字段默认值而不是返回 None。 + + 删除守卫后 None 原样返回,pydantic 在后续校验中拿到 None,默认分片 + 分隔符配置失效。 + """ + default = Settings.model_fields["kb_splitter_separators"].default_factory() + assert Settings.split_separators(None) == default + assert Settings.split_separators("") == default + + +def test_coerce_number_returns_int_for_decimal_literals(): + """十进制整数字面量返回 ``int`` 而不是 ``float``。 + + 删除守卫后 ``"123"`` 走 float 分支返回 ``123.0``——类型变化会影响下游 + 以 ``isinstance(x, int)`` 为判据的校验。 + """ + result = _coerce_number("123", "chat_top_k") + assert result == 123 + assert isinstance(result, int) + + +# --- retrieval ------------------------------------------------------------- + + +def test_faiss_normalize_parent_document_keeps_string_content(): + """字符串形态的 parent_document 转成 ``{"content": ...}`` 而不是丢弃。 + + 删除守卫后字符串内容被替换成空串——父块内容静默消失。 + """ + assert FaissStore._normalize_parent_document("正文") == {"content": "正文", "metadata": {}} + assert FaissStore._normalize_parent_document(None) == {"content": "", "metadata": {}} + + +def test_hybrid_reranker_normalize_scores_empty_returns_empty(): + """空分数列表返回空列表。 + + 删除守卫后 ``max([])`` 抛裸 ``ValueError: max() arg is an empty sequence``。 + """ + assert HybridReranker._normalize_scores([]) == [] + + +def test_document_metadata_non_dict_returns_empty(): + """非 dict 文档的元数据返回空 dict。 + + 删除守卫后 ``"x".get`` 抛裸 ``AttributeError``——检索结果可以来自任何 + VectorStore 实现,展示/合并路径不能因此崩溃。 + """ + assert _document_metadata("x") == {} + + +def test_document_key_uses_chunk_index_when_present(): + """有 ``chunk_index`` 时键取 ``source:index`` 而不是内容摘要。 + + 删除守卫后键变成整段内容的 sha256——同一分块的键随内容改写而漂移, + 混合合并会把同一块当成两块。 + """ + document = {"page_content": "abc", "metadata": {"chunk_index": 3, "source": "s"}} + assert _document_key(document) == "s:3" + + +def test_clear_rerank_cache_closes_duplicate_provider_once(): + """同一 provider 对象出现在多个缓存槽位时只关闭一次。 + + 删除守卫后按槽位逐个关闭,同一对象被 ``close`` 两次——对非幂等的 + 资源释放(计数、置空后的二次释放)是重复释放。 + """ + + class CountingProvider: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + service = object.__new__(RetrievalService) + provider = CountingProvider() + service._rerank_providers = {"a": provider, "b": provider} + service.clear_rerank_cache() + assert provider.closed == 1 + + +def test_clear_rerank_cache_closes_remaining_providers_after_a_close_failure(): + """单个 Provider 关闭失败时,其余 Provider 仍必须被释放。 + + 删除 ``try``/``first_error`` 后循环在第一个失败处退出,后面所有 + Provider 的传输层与凭证连接池全部泄漏——而本方法是 ``close``/``aclose`` + 的唯一重排资源出口,泄漏发生在退出路径上,用户不会再有机会重试。 + 契约与四个 Provider 自己的 ``close``/``aclose`` 对齐:关闭全部,再报 + 第一个错误。 + """ + + class FailingProvider: + def __init__(self, label): + self.label = label + + def close(self): + raise RuntimeError(f"{self.label} 关闭失败") + + class CountingProvider: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + service = object.__new__(RetrievalService) + good = CountingProvider() + service._rerank_providers = {"bad": FailingProvider("bad"), "good": good} + with pytest.raises(RuntimeError, match="bad 关闭失败"): + service.clear_rerank_cache() + assert good.closed == 1 + + +def test_close_closes_embedding_after_rerank_close_failure(): + """重排 Provider 关闭失败后,Embedding Provider 仍必须被释放。 + + 删除 ``try``/``first_error`` 后 ``clear_rerank_cache`` 的异常直接冒泡, + Embedding Provider 的连接池与 SDK 客户端一并泄漏;调用方 + (``Chatbot.close``)虽然接住异常并记了日志,泄漏本身不会被回收。 + """ + + class FailingRerank: + def close(self): + raise RuntimeError("重排关闭失败") + + class CountingEmbedding: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + service = object.__new__(RetrievalService) + service._rerank_providers = {"bad": FailingRerank()} + service.embedding_service = CountingEmbedding() + with pytest.raises(RuntimeError, match="重排关闭失败"): + service.close() + assert service.embedding_service.closed == 1 + + +def test_close_reports_the_first_error_when_both_closes_fail(): + """重排与 Embedding 都关闭失败时,报的是**先发生**的那个错误。 + + ``first_error = first_error or exc`` 的 ``or`` 是「保留第一个错误」的守卫。 + 把它互换为 ``and`` 后:``first_error`` 已非 None(重排失败)时会被后发生的 + Embedding 错误**覆盖**——用户看到「嵌入失败」,而真正的首个失败原因从栈上 + 消失。既有 ``test_close_closes_embedding_after_rerank_close_failure`` 只让 + 重排失败(Embedding 成功,走不到 942 行的 ``or``), + ``test_close_...`` 单障碍场景也盖不住这条双失败分支。 + """ + + class FailingRerank: + def close(self): + raise RuntimeError("重排关闭失败") + + class FailingEmbedding: + def close(self): + raise RuntimeError("嵌入关闭失败") + + service = object.__new__(RetrievalService) + service._rerank_providers = {"bad": FailingRerank()} + service.embedding_service = FailingEmbedding() + + with pytest.raises(RuntimeError, match="重排关闭失败"): + service.close() + + +def test_aclose_closes_remaining_providers_after_an_aclose_failure(): + """异步关闭与同步关闭同一契约:失败后继续释放,最后报第一个错误。 + + ``retriever.py`` 在 ``finally`` 里裸 ``await`` 本方法,中断循环除了泄漏 + 还会让异常顶替掉一次**成功**的检索结果。 + """ + + class FailingProvider: + async def aclose(self): + raise RuntimeError("异步关闭失败") + + class CountingProvider: + def __init__(self): + self.closed = 0 + + async def aclose(self): + self.closed += 1 + + class CountingEmbedding: + def __init__(self): + self.closed = 0 + + async def aclose(self): + self.closed += 1 + + service = object.__new__(RetrievalService) + good = CountingProvider() + service._rerank_providers = {"bad": FailingProvider(), "good": good} + service.embedding_service = CountingEmbedding() + with pytest.raises(RuntimeError, match="异步关闭失败"): + asyncio.run(service.aclose()) + assert good.closed == 1 + assert service.embedding_service.closed == 1 + + +def test_aclose_reports_the_first_error_when_both_closes_fail(): + """异步双失败同样报**先发生**的错误——与同步 ``close`` 同一契约。""" + + class FailingProvider: + async def aclose(self): + raise RuntimeError("异步重排关闭失败") + + class FailingEmbedding: + async def aclose(self): + raise RuntimeError("异步嵌入关闭失败") + + service = object.__new__(RetrievalService) + service._rerank_providers = {"bad": FailingProvider()} + service.embedding_service = FailingEmbedding() + + with pytest.raises(RuntimeError, match="异步重排关闭失败"): + asyncio.run(service.aclose()) + + +def test_close_and_aclose_do_not_raise_when_everything_closes_cleanly(): + """全部资源关闭成功时不抛——``first_error`` 守卫不能误报。""" + + class QuietProvider: + def close(self): + pass + + async def aclose(self): + pass + + class QuietEmbedding: + def close(self): + pass + + async def aclose(self): + pass + + service = object.__new__(RetrievalService) + service._rerank_providers = {"a": QuietProvider()} + service.embedding_service = QuietEmbedding() + service.close() + asyncio.run(service.aclose()) + + +# --- services -------------------------------------------------------------- + + +def test_embed_in_batches_empty_returns_empty_matrix(): + """空输入返回 ``(0, 0)`` 矩阵而不是让 ``np.vstack`` 抛错。 + + 删除守卫后 ``np.vstack([])`` 抛裸 ``ValueError: need at least one array + to concatenate``。 + """ + from src.services.embedding_service import EmbeddingService + + service = object.__new__(EmbeddingService) + service.run_config = type("C", (), {"kb_embedding_batch_size": 8})() + + async def fake_embed(texts): + return np.zeros((len(texts), 4), dtype="float32") + + service.embed_texts = fake_embed + assert asyncio.run(service.embed_in_batches([])).shape == (0, 0) + assert asyncio.run(service.embed_in_batches(["a", "b"])).shape == (2, 4) + + +def test_probe_dimension_handles_all_supported_shapes(): + """维度探针:ndarray 读 shape、空列表返回 None、首元素 ndarray 读其长度。 + + 删除 224 后 ndarray 返回 None(维度未知,上界校验被跳过);删除 227 后 + 空列表 ``[0]`` 抛裸 ``IndexError``;删除 230 后 list-of-ndarray 返回 None。 + """ + from src.services.embedding_service import EmbeddingService + + probe = EmbeddingService._probe_dimension + assert probe(np.zeros((2, 4), dtype="float32")) == 4 + assert probe([]) is None + assert probe([[1.0, 2.0]]) == 2 + assert probe([np.zeros(3, dtype="float32")]) == 3 + + +def test_build_prompt_skips_documents_without_content(): + """空 ``page_content`` 的文档不产生「来源: x / 内容: 」空条目。 + + 删除守卫后空文档也进上下文,提示词里出现无内容的知识条目。 + """ + from src.services.chat_service import ChatService + + documents = [ + {"page_content": "", "metadata": {"source": "a"}}, + {"page_content": "有内容", "metadata": {"source": "b"}}, + ] + prompt = ChatService._build_prompt("问题", "qa", documents) + assert "来源: a" not in prompt + assert "来源: b" in prompt + + +# --- retrieval_test(Excel 日志)-------------------------------------------- + + +def test_excel_cell_value_preserves_scalar_types(): + """单元格值保持原生类型:None/bool/int 不被字符串化。 + + 删除 197 后 None 变成字符串 ``'None'``;删除 199 后 bool 变成 ``'True'``; + 删除 203 后 int 变成 ``'5'``——Excel 里数字列变成文本列。 + """ + from src.retrieval_test.excel_logger import ExcelLogger + + assert ExcelLogger._cell_value(None) is None + assert ExcelLogger._cell_value(True) is True + assert ExcelLogger._cell_value(5) == 5 + assert isinstance(ExcelLogger._cell_value(5), int) + + +def test_excel_json_safe_preserves_none_and_strings(): + """``_json_safe`` 对 None/str 原样返回。 + + 删除守卫后 None 变成 ``'None'``、``"s"`` 变成 ``"'s'"``——JSON 结构里 + 混入 repr 字符串。 + """ + from src.retrieval_test.excel_logger import ExcelLogger + + assert ExcelLogger._json_safe(None) is None + assert ExcelLogger._json_safe("s") == "s" + + +# --- providers.resources --------------------------------------------------- + + +def test_wrap_native_resource_value_keeps_plain_containers_and_modules(): + """dict 等普通容器、模块与类不被包成 ``_NativeResourceProxy``。 + + 删除 137 后 dict 被包装(属性访问被代理拦截,类型判断变化);删除 139 + 后模块/类被包装——``getattr`` 走代理而不是真实模块。 + """ + from src.providers import resources as resources_module + + class FakeProvider: + pass + + payload = {"a": 1} + assert resources_module._wrap_native_resource_value(payload, FakeProvider(), "p.x") is payload + assert resources_module._wrap_native_resource_value(np, FakeProvider(), "p.x") is np + assert ( + resources_module._wrap_native_resource_value(FakeProvider, FakeProvider(), "p.x") + is FakeProvider + ) + + +# --- ETL ------------------------------------------------------------------- + + +def test_fallback_separator_not_duplicated_when_already_present(): + """separators 已含空串时不再追加第二个空串。 + + 删除守卫后 ``["a", ""]`` 变成 ``["a", "", ""]``——重复的分隔符进入 + 分片器配置。 + """ + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + assert RecursiveTextSplitter._with_fallback_separator(["a", ""]) == ["a", ""] + assert RecursiveTextSplitter._with_fallback_separator(["a"]) == ["a", ""] + + +def test_local_hash_normalize_zero_vector_is_returned_unchanged(): + """零向量归一化时原样返回而不是除零。 + + 删除守卫后 ``norm == 0`` 时执行 ``value / norm`` 抛裸 ``ZeroDivisionError``。 + """ + assert normalize_vector([0.0, 0.0, 0.0]) == [0.0, 0.0, 0.0] + assert normalize_vector([3.0, 4.0]) == [0.6, 0.8] + + +# --- 收口批:检索、配置校验器、快照与 UI ----------------------------------- + + +def test_faiss_resolve_parent_content_tolerates_none_valued_entries(): + """``parent_documents`` 中的 None 值条目返回 None 而不是崩溃。 + + 删除守卫后 ``None.get("content")`` 抛裸 ``AttributeError``——载入期 + 归一化只保证 dict 形态,外部写入或旧快照可能留下 None 值。 + """ + store = object.__new__(FaissStore) + store.parent_documents = {"k1": {"content": "父块正文"}, "k2": None} + assert store.resolve_parent_content("k1") == "父块正文" + assert store.resolve_parent_content("k2") is None + assert store.resolve_parent_content("missing") is None + assert store.resolve_parent_content("") is None + + +def test_faiss_resolve_parent_content_empty_string_returns_none(): + """空串 ``content`` 必须返回 ``None``,不得返回 ``""``。 + + ``return content if isinstance(content, str) and content else None`` 的后半个 + ``and content`` 是**有意**把空串归一成 ``None`` 的(契约是「无内容→None」, + 调用方按 ``is None`` 判断)。把 ``and`` 互换为 ``or`` 后,``content=""`` 会 + 命中 ``isinstance("", str) or ""`` = True → 返回 ``""``。既有 + ``test_faiss_resolve_parent_content_tolerates_none_valued_entries`` 只喂 + ``"父块正文"`` 与 None 条目,空串分支从未被覆盖。 + """ + store = object.__new__(FaissStore) + store.parent_documents = {"empty": {"content": ""}} + + assert store.resolve_parent_content("empty") is None + + +def test_resource_proxy_children_cache_short_circuits_getattr(): + """``_children`` 中已缓存的子资源直接返回,不落到被包装对象上。 + + 删除守卫后 ``__getattr__`` 绕过缓存走 ``getattr(self._value, name)``—— + 缓存项(如占位包装)被真实属性覆盖,甚至在被包装对象没有该属性时抛 + ``AttributeError``。 + """ + from src.providers import resources as resources_module + + class Inner: + pass + + class FakeProvider: + pass + + proxy = resources_module._NativeResourceProxy(Inner(), FakeProvider(), "p") + proxy._children["x"] = "cached" + assert proxy.x == "cached" + + +def test_choice_default_returns_active_value_when_present(): + """激活项在候选集合里时返回它本身。 + + 删除守卫后始终返回 None——questionary 的默认选中项丢失,菜单回落到 + 未作答状态。 + """ + from src.ui.config_menu import _choice_default + + class Choice: + def __init__(self, value): + self.value = value + + assert _choice_default([Choice("a"), Choice("b")], "b") == "b" + assert _choice_default([Choice("a")], "z") is None + + +def test_model_detail_validate_options_none_returns_empty_mapping(): + """``options=None`` 归一成空字典而不是抛错。 + + 删除守卫后 None 落到「必须是 TOML 表或字典」的 ``ValueError``——未配置 + options 的模型条目无法构造。 + """ + import types + + from src.utils.config import ModelDetail + + info = types.SimpleNamespace(data={"provider": "openai"}, field_name="options") + validator = ModelDetail.__pydantic_decorators__.field_validators["validate_options"].func + assert validator(None, info) == {} + assert validator({"temperature": 1}, info) == {"temperature": 1} + + +def test_validate_retrieval_method_maps_member_names_and_values_to_enum(): + """成员名(``HYBRID_SEARCH``)与取值(``混合检索``)都归一成枚举。 + + 删除 1161 的守卫后成员名形态原样返回字符串;删除 1165 的守卫后取值 + 形态原样返回字符串——下游按枚举比较时静默失配。 + """ + from src.utils.config import RetrievalMethod, Settings + + validator = Settings.__pydantic_decorators__.field_validators["validate_retrieval_method"].func + assert validator("HYBRID_SEARCH") is RetrievalMethod.HYBRID_SEARCH + assert validator("混合检索") is RetrievalMethod.HYBRID_SEARCH + assert validator(RetrievalMethod.HYBRID_SEARCH) is RetrievalMethod.HYBRID_SEARCH + + +def test_snapshot_repository_empty_marker_returns_none(): + """``ACTIVE_SNAPSHOT`` 为空时返回 None(未激活),而不是抛校验错误。 + + 删除守卫后空标记落到「快照 id 不合法」的 ``ValueError``——首次构建前的 + 空标记文件让启动流程以校验错误收场。 + """ + import types + + from src.retrieval.snapshot_repository import SnapshotRepository + + root = tmp_path_factory_dir() + marker = root / "ACTIVE_SNAPSHOT" + repository = object.__new__(SnapshotRepository) + repository.run_config = types.SimpleNamespace(active_snapshot_marker=marker) + marker.write_text("") + assert repository.get_active_snapshot_id() is None + + +def tmp_path_factory_dir(): + import pathlib + import tempfile + + return pathlib.Path(tempfile.mkdtemp()) + + +def test_get_vector_store_method_excludes_base_stub(): + """只有基类桩实现的方法返回 None(未实现),真实覆写返回方法。 + + 删除守卫后基类桩也被当成已实现,调用方拿到 ``NotImplementedError`` 桩 + 而不是走回退路径。 + """ + from src.retrieval.vdb.base import VectorStoreBase + from src.services.retrieval_service import RetrievalService + + class Full(VectorStoreBase): + def add_documents(self, *args, **kwargs): + return None + + def aadd_documents(self, *args, **kwargs): + return None + + def search(self, *args, **kwargs): + return [] + + async def asearch(self, *args, **kwargs): + return [] + + def get_embedding_model(self, *args, **kwargs): + return None + + def load(self, *args, **kwargs): + return None + + def save(self, *args, **kwargs): + return None + + service = object.__new__(RetrievalService) + stub = Full() + Full.semantic_search = VectorStoreBase.semantic_search + service.vector_store = stub + assert service._get_vector_store_method("semantic_search") is None + + class Impl: + def semantic_search(self, query, top_k): + return [] + + service.vector_store = Impl() + assert service._get_vector_store_method("semantic_search") is not None + + +def test_legacy_search_prefers_async_search_implementation(): + """向量存储提供 ``asearch`` 时走异步实现,不回退到同步 ``search``。 + + 删除守卫后异步实现被跳过,直接找 ``search``——只有 ``asearch`` 的 + 存储抛 ``NotImplementedError``。 + """ + from src.services.retrieval_service import RetrievalService + + class AsyncSearcher: + async def asearch(self, query, top_k, search_type=None): + return ["async"] + + service = object.__new__(RetrievalService) + service.vector_store = AsyncSearcher() + assert asyncio.run(service._legacy_search("q", 1, "semantic")) == ["async"] + + +def test_aclose_closes_duplicate_provider_once(): + """异步关闭时同一 provider 对象在多个槽位中只关一次。 + + 删除守卫后按槽位逐个 ``aclose``,同一对象被关闭两次。 + """ + + class AsyncProvider: + def __init__(self): + self.closed = 0 + + async def aclose(self): + self.closed += 1 + + service = object.__new__(RetrievalService) + provider = AsyncProvider() + service._rerank_providers = {"a": provider, "b": provider} + service.embedding_service = None + asyncio.run(service.aclose()) + assert provider.closed == 1 + + +def test_run_retrieval_test_skips_empty_query(monkeypatch): + """空查询被跳过,不进入检索。 + + 删除守卫后空串会传给 ``retrieve``——交互循环里回车即触发一次无意义 + 检索(或错误)。 + """ + from src.retrieval_test import core as retrieval_core + + calls = [] + + class FakePromptSession: + def __init__(self, responses): + self._responses = iter(responses) + + async def prompt_async(self, *_args, **_kwargs): + return next(self._responses) + + class RecordingService: + def __init__(self, *_args, **_kwargs): + return None + + async def retrieve(self, query, *_args, **_kwargs): + calls.append(query) + return [] + + monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["", "/quit"])) + monkeypatch.setattr(retrieval_core, "RetrievalService", RecordingService) + monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_args, **_kwargs: object()) + monkeypatch.setattr( + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + ) + monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) + asyncio.run(retrieval_core.run_retrieval_test_async()) + assert calls == [] + + +def test_aclose_quietly_returns_without_closable_service(caplog): + """服务没有 ``aclose``/``close`` 时安静返回,不产生警告。 + + 删除守卫后 ``close()`` 在 None 上抛 ``TypeError``,虽被外层 except 吞掉, + 但会多打一条「释放检索服务失败」警告——「没有可关闭资源」不是失败, + 警告会误导排查。 + """ + import logging + + from src.retrieval_test.core import _aclose_quietly + + with caplog.at_level(logging.WARNING, logger="src.retrieval_test.core"): + asyncio.run(_aclose_quietly(object())) + assert "释放检索服务失败" not in caplog.text + + +# --- 收口批:吞掉型 except 的静默降级契约 ---------------------------------- + + +def test_copy_nested_falls_back_when_model_copy_fails(): + """``model_copy`` 抛错时回退到 deepcopy,而不是把异常放出去。 + + ``_copy_nested`` 的契约是「拿到一份副本,绝不因复制失败中断调用方」。 + 删除两处吞掉后,``model_copy``/``deepcopy`` 的异常都会冒泡。 + """ + + from src.utils.security import _copy_nested + + class BadModelCopy: + def __init__(self): + self.x = 1 + + def model_copy(self, **kwargs): + raise RuntimeError("model_copy 失败") + + copied = _copy_nested(BadModelCopy()) + assert copied is not None + + class BadDeepcopy: + def __deepcopy__(self, memo): + raise RuntimeError("deepcopy 失败") + + obj = BadDeepcopy() + assert _copy_nested(obj) is obj + + +def test_model_dump_mapping_retries_without_kwargs_and_fails_open(): + """``model_dump`` 的三种失败形态都收敛为「读不到」。 + + * 不接受 ``exclude_none`` 关键字 → 无参重试(实测 SDK 旧版本); + * 无参重试也抛错 → 返回 None(fail-open,见 docstring); + * 首次调用抛非 ``TypeError`` → 同样返回 None。 + """ + + from src.utils.security import _model_dump_mapping + + class NoKwargs: + def model_dump(self, **kwargs): + if kwargs: + raise TypeError("不接受参数") + return {"a": 1} + + assert _model_dump_mapping(NoKwargs()) == {"a": 1} + + class AlwaysBoom: + def model_dump(self, **kwargs): + if kwargs: + raise TypeError("不接受参数") + raise RuntimeError("无参也崩") + + assert _model_dump_mapping(AlwaysBoom()) is None + + class Boom: + def model_dump(self, **kwargs): + raise RuntimeError("dump 崩") + + assert _model_dump_mapping(Boom()) is None + + +def test_require_retrieval_method_reports_guidance_for_broken_value_attribute(): + """取值属性抛错的输入落到带指引的 ``ValueError``,而不是把原异常放出去。 + + 删除吞掉后 ``Weird.value`` 的 ``RuntimeError`` 直接冒泡——调用方拿到 + 的是与配置校验无关的异常。 + """ + + from src.utils.config import require_retrieval_method + + class Weird: + @property + def value(self): + raise RuntimeError("取值失败") + + with pytest.raises(ValueError, match="retrieval_method 必须是"): + require_retrieval_method(Weird()) + + +def test_get_relative_path_returns_input_for_empty_string(): + """空字符串路径原样返回而不是抛 ``ValueError``。 + + 删除吞掉后 ``Path("").relative_to`` 的 ``ValueError: no path specified`` + 冒泡——展示层因一条空路径整表渲染失败。 + """ + + from src.ui.display_utils import get_relative_path + + assert get_relative_path("") == "" + + +def test_get_chat_logger_degrades_to_console_when_log_dir_unusable(caplog): + """日志目录不可写时降级为仅控制台并告警一次,不中断启动。 + + 删除吞掉后 ``_build_rotating_handler`` 的 ``OSError`` 冒泡——日志目录 + 指向一个已存在的普通文件时,查询直接崩在日志初始化。 + """ + import logging + + from src.utils import log_manager + + blocked_dir = tmp_path_factory_dir() / "blocked" + blocked_dir.write_text("我是文件不是目录") + + class FakeSettings: + log_path = str(blocked_dir) + log_level = "INFO" + + original = log_manager.get_settings + log_manager.get_settings = lambda: FakeSettings() + # chat_logger 是模块级单例:别的用例可能已经给它配置过 handlers, + # 而配置分支只在「还没有 handler」时执行。先清空再断言。 + chat_logger = logging.getLogger("chat_logger") + saved_handlers = chat_logger.handlers + chat_logger.handlers = [] + try: + with caplog.at_level(logging.WARNING, logger="src.utils.log_manager"): + logger = log_manager.get_chat_logger() + assert logger.name == "chat_logger" + assert "无法写入日志目录" in caplog.text + finally: + for handler in logger.handlers: + if handler not in saved_handlers: + handler.close() + chat_logger.handlers = saved_handlers + log_manager.get_settings = original + + +@pytest.mark.skipif( + os.geteuid() == 0, + reason="root 忽略权限位,chmod 0o500 后 remove 仍会成功,守卫不触发", +) +def test_cleanup_old_logs_continues_when_one_file_cannot_be_removed(caplog): + """单个旧日志删除失败只记日志、继续清理,不让异常打断退出钩子。 + + 删除吞掉后 ``os.remove`` 的 ``PermissionError`` 冒泡——退出路径以 + 裸 traceback 终止。 + """ + import logging + import os + + from src.utils import log_manager + + log_dir = tmp_path_factory_dir() + old = log_dir / "app_log_2020-01-01.log" + old.write_text("x") + fresh = log_dir / "app_log_2026-10-07.log" + fresh.write_text("y") + os.chmod(old, 0o500) + os.chmod(log_dir, 0o500) + + class FakeSettings: + log_path = str(log_dir) + log_retention_days = 1 + log_level = "INFO" + + original = log_manager.get_settings + log_manager.get_settings = lambda: FakeSettings() + try: + with caplog.at_level(logging.ERROR, logger="src.utils.log_manager"): + log_manager.cleanup_old_logs() + assert "清理日志文件" in caplog.text + finally: + log_manager.get_settings = original + os.chmod(log_dir, 0o700) + + +def test_reject_unsafe_cache_dir_skips_unresolvable_protected_paths(): + """``protected_paths`` 里的链接环被跳过,其余项仍参与判定。 + + 删除吞掉后 ``Path.resolve()`` 的 ``RuntimeError: Symlink loop`` 冒泡—— + 而本函数在 atexit 里被调用,异常让清理完全没做。 + """ + import pathlib + + from src.utils.cleanup import _reject_unsafe_cache_dir + + root = tmp_path_factory_dir() + loop = root / "loop" + loop.symlink_to(loop) + cache_dir = root / "cache" + cache_dir.mkdir() + reason = _reject_unsafe_cache_dir(str(cache_dir), [loop, pathlib.Path(cache_dir)]) + assert reason is not None and "覆盖了应用自身路径" in reason + + +def test_rerank_provider_fingerprint_falls_back_for_unserializable_options(monkeypatch): + """``options`` 含超大整数时回退到 ``_stable_fingerprint`` 而不是崩溃。 + + ``json.dumps`` 对 ``10**4301`` 抛 ``ValueError``(整数位数上限); + 删除吞掉后每次检索都在构造缓存键处崩。 + """ + from types import SimpleNamespace + + from src.services.retrieval_service import RetrievalService + + service = object.__new__(RetrievalService) + service._rerank_providers = {} + sentinel = object() + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", + lambda *_args, **_kwargs: sentinel, + ) + detail = SimpleNamespace(provider="jina", model_name="m", options={"x": 10**4301}) + provider = service._get_rerank_provider("k", {"k": detail}) + assert provider is sentinel + + +def test_run_retrieval_test_reports_excel_logger_failure_and_continues(monkeypatch): + """ExcelLogger 初始化失败时打印提示并继续运行。 + + 删除吞掉后初始化异常冒泡——召回测试(唯一产出是 Excel 日志)直接退出, + 用户不知道记录没有落盘。 + """ + from src.retrieval_test import core as retrieval_core + + printed = [] + + class FakePromptSession: + def __init__(self, responses): + self._responses = iter(responses) + + async def prompt_async(self, *_args, **_kwargs): + return next(self._responses) + + class BoomLogger: + def __init__(self): + raise RuntimeError("初始化失败") + + monkeypatch.setattr(retrieval_core, "PromptSession", lambda: FakePromptSession(["/quit"])) + monkeypatch.setattr(retrieval_core, "ExcelLogger", BoomLogger) + monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object()) + monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object()) + monkeypatch.setattr( + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + ) + monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a))) + asyncio.run(retrieval_core.run_retrieval_test_async()) + assert any("Excel 日志初始化失败" in message for message in printed) + + +def test_run_retrieval_test_reports_database_load_failure(monkeypatch): + """初始化失败时打印提示并返回,不抛异常。 + + 删除吞掉后初始化异常冒泡——用户拿到裸 traceback 而不是可读的失败原因。 + 前缀用中性的「初始化失败」:该分支兜住的不只是数据库加载(快照目录、 + embedding 配置等同样走到这里),「数据库加载失败」对后者名实不符。 + """ + from src.retrieval_test import core as retrieval_core + + printed = [] + + def boom(): + raise RuntimeError("加载失败") + + monkeypatch.setattr(retrieval_core, "PromptSession", lambda: object()) + monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) + monkeypatch.setattr(retrieval_core.VectorStoreFactory, "get_default_vector_store", boom) + monkeypatch.setattr(retrieval_core.console, "print", lambda *a, **k: printed.append(str(a))) + asyncio.run(retrieval_core.run_retrieval_test_async()) + assert any("初始化失败" in message for message in printed) + + +def test_run_retrieval_test_breaks_on_keyboard_interrupt(monkeypatch): + """交互循环里 Ctrl-C 干净退出,不作为异常抛出。 + + 删除吞掉后 ``KeyboardInterrupt`` 冒泡——退出交互模式变成一次崩溃。 + """ + from src.retrieval_test import core as retrieval_core + + class InterruptingSession: + async def prompt_async(self, *_args, **_kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr(retrieval_core, "PromptSession", InterruptingSession) + monkeypatch.setattr(retrieval_core, "ExcelLogger", lambda: None) + monkeypatch.setattr(retrieval_core, "RetrievalService", lambda *_a, **_k: object()) + monkeypatch.setattr(retrieval_core, "EmbeddingService", lambda *_a, **_k: object()) + monkeypatch.setattr( + retrieval_core.VectorStoreFactory, "get_default_vector_store", lambda: object() + ) + asyncio.run(retrieval_core.run_retrieval_test_async()) + + +def test_excel_logger_reports_original_error_when_placeholder_cleanup_fails(caplog, monkeypatch): + """构造失败时:占位文件回收失败只记日志,抛出的仍是**原始**失败。 + + 删除吞掉后回收占位文件的 ``OSError`` 顶替原始失败——用户看到的失败 + 原因与实际不符。 + """ + import logging + + from src.retrieval_test import excel_logger + + class BoomWorkbook: + def __init__(self): + raise RuntimeError("工作簿构造失败") + + log_dir = tmp_path_factory_dir() + placeholder_dir = log_dir / "placeholder.xlsx" + placeholder_dir.mkdir() + + monkeypatch.setattr( + excel_logger.ExcelLogger, + "_reserve_log_path", + staticmethod(lambda *_a, **_k: placeholder_dir), + ) + monkeypatch.setattr(excel_logger, "Workbook", BoomWorkbook) + with ( + caplog.at_level(logging.DEBUG, logger="src.retrieval_test.excel_logger"), + pytest.raises(RuntimeError, match="工作簿构造失败"), + ): + excel_logger.ExcelLogger(log_dir=str(log_dir)) + assert "回收占位日志文件失败" in caplog.text + + +def test_reject_unsafe_cache_dir_treats_unresolvable_home_as_absent(): + """``Path.home()`` 不可得时视为「没有家目录」而不是让函数整体崩溃。 + + 删除吞掉后 ``RuntimeError`` 冒泡——本函数在 atexit 里被调用,异常让 + 清理完全没做。 + """ + import pathlib + + from src.utils.cleanup import _reject_unsafe_cache_dir + + cache_dir = tmp_path_factory_dir() / "cache" + cache_dir.mkdir() + + original_home = pathlib.Path.home + + def boom(): + raise RuntimeError("无家目录") + + pathlib.Path.home = staticmethod(boom) + try: + assert _reject_unsafe_cache_dir(str(cache_dir)) is None + finally: + pathlib.Path.home = original_home + + +def test_aclose_quietly_logs_and_swallows_close_failure(caplog): + """关闭失败只记警告,不让异常打断退出流程。 + + 删除吞掉后 ``aclose`` 的异常冒泡——退出清理路径上的失败变成了崩溃。 + """ + import logging + + from src.retrieval_test.core import _aclose_quietly + + class Bad: + async def aclose(self): + raise RuntimeError("关闭失败") + + with caplog.at_level(logging.WARNING, logger="src.retrieval_test.core"): + asyncio.run(_aclose_quietly(Bad())) + assert "释放检索服务失败" in caplog.text + + +def test_excel_logger_log_results_swallows_save_failure(caplog): + """写 Excel 失败只记日志,不打断召回循环。 + + 删除吞掉后 ``log_results`` 抛 ``FileNotFoundError``——一次写盘失败让 + 整个召回测试退出,已展示的结果全部丢失。 + """ + import logging + + from src.retrieval_test import excel_logger + + log_dir = tmp_path_factory_dir() + logger = excel_logger.ExcelLogger(str(log_dir)) + logger.filepath = log_dir / "no-such-dir" / "x.xlsx" + with caplog.at_level(logging.ERROR, logger="src.retrieval_test.excel_logger"): + logger.log_results("q", [{"content": "c", "source": "s", "score": 1.0}]) + assert "保存 Excel 日志文件失败" in caplog.text + + +def test_capability_report_returns_none_for_missing_sdk_package(monkeypatch): + """SDK 包未安装时 version 报 None,报告本身照常生成。 + + 删除吞掉后 ``PackageNotFoundError`` 冒泡——能力报告对任何未安装可选 + 依赖的渠道直接失败。 + """ + from importlib import metadata + + from src.providers.factory import ModelProviderFactory + + def boom(name): + raise metadata.PackageNotFoundError(name) + + monkeypatch.setattr("src.providers.factory.package_version", boom) + report = ModelProviderFactory.capability_report("google", "llm") + assert report["sdk"]["version"] is None + + +# --- except 元组收窄:声称捕获的第二类型必须有测试触发 ---------------------- +# +# 来源:把 ``except (A, B, ...)`` 收窄成 ``except A`` 后全量测试仍绿的存活点。 +# 逐条用探针确认「命中被丢弃类型时行为确实改变」,再按守卫存在时的行为写断言。 + + +def test_safe_repr_reports_custom_repr_failure_as_magnitude(): + """``repr()`` 抛 ``RuntimeError`` 时退回量级描述而不是冒泡。 + + 收窄成 ``except ValueError`` 后,自定义 ``__repr__`` 失败的对象会让校验 + 消息拼接崩掉——而 ``safe_repr`` 的契约是「绝不抛」。 + """ + from src.utils.config import safe_repr + + class _BadRepr: + def __repr__(self): + raise RuntimeError("repr 失败") + + assert safe_repr(_BadRepr()) == "<_BadRepr>" + + +def test_security_display_repr_reports_custom_repr_failure_as_magnitude(): + """``_display_repr`` 对 ``repr`` 抛错的对象退回类型名而不是冒泡。""" + from src.utils.security import _display_repr + + class _BadRepr: + def __repr__(self): + raise RuntimeError("repr 失败") + + assert _display_repr(_BadRepr()) == "<_BadRepr>" + + +def test_redact_sensitive_text_reports_custom_str_failure_as_magnitude(): + """``redact_sensitive_text`` 对 ``str()`` 抛错的对象退回类型名而不是冒泡。 + + 收窄成 ``except ValueError`` 后,日志脱敏在遇到自定义 ``__str__`` 失败时 + 抛出——脱敏本应是「绝不阻断日志」的边界。 + """ + from src.utils.security import redact_sensitive_text as redact + + class _BadStr: + def __str__(self): + raise RuntimeError("str 失败") + + assert redact(_BadStr()) == "<_BadStr>" + + +def test_display_render_value_falls_back_on_custom_str_failure(): + """``_render_value`` 对 ``str()`` 抛错的对象退回 ``safe_repr``。""" + from src.ui.display_utils import _render_value + + class _BadStr: + def __str__(self): + raise RuntimeError("str 失败") + + def __repr__(self): + # ``_render_value`` 降级走 ``safe_repr``,其内部再用 ``repr()``; + # 两者都要失败才会落到类型名兜底。 + raise RuntimeError("repr 失败") + + assert _render_value(_BadStr()) == "<_BadStr>" + + +def test_display_path_text_falls_back_on_custom_str_failure(): + """``_path_text`` 对 ``str()`` 抛错的对象退回 ``safe_repr``。""" + from src.ui.display_utils import _path_text + + class _BadStr: + def __str__(self): + raise RuntimeError("str 失败") + + def __repr__(self): + raise RuntimeError("repr 失败") + + assert _path_text(_BadStr()) == "<_BadStr>" + + +def test_fingerprint_falls_back_on_custom_repr_failure(): + """``_fingerprint`` 对 ``repr`` 抛错的对象退回类型名而不是冒泡。 + + 收窄成 ``except ValueError`` 后,``options`` 里一个自定义 ``__repr__`` + 失败的对象会让缓存键计算崩掉。 + """ + from src.services.retrieval_service import _fingerprint + + class _BadRepr: + def __repr__(self): + raise RuntimeError("repr 失败") + + assert _fingerprint(_BadRepr(), set()) == "<_BadRepr>" + + +def test_resolve_within_converts_symlink_loop_to_valueerror(tmp_path): + """``Path.resolve()`` 对符号链接环抛 ``RuntimeError``,收敛成 ``ValueError``。 + + 收窄成 ``except OSError`` 后,``RuntimeError: Symlink loop`` 穿透本函数的 + ``ValueError`` 契约,调用方(``ensure_trusted_source`` 只接 ``ValueError``) + 拿到的是 pathlib 内部错误。 + """ + from src.utils.security import resolve_within + + (tmp_path / "a").symlink_to(tmp_path / "b") + (tmp_path / "b").symlink_to(tmp_path / "a") + with pytest.raises(ValueError, match="无法解析"): + resolve_within(tmp_path / "a" / "x", tmp_path, label="快照") + + +def test_require_finite_number_rejects_numeric_string(): + """数值字符串必须被拒,而不是经 ``to_finite_float`` 静默转成浮点。 + + 删除类型守卫后,``"0.5"`` 会被 ``to_finite_float`` 接受并返回 ``0.5``—— + ``SessionConfig`` 的兄弟字段本应只接受数值。 + """ + from src.runtime.contracts import SessionConfig + + with pytest.raises(ValueError, match="必须是数值"): + SessionConfig._require_finite_number("0.5", "权重") + + +def test_require_chat_temperature_rejects_numeric_string_with_numeric_message(): + """``require_chat_temperature`` 对非数值给出「必须是数值」,而不是范围消息。 + + 删除类型守卫后,``"0.5"`` 落到范围检查并报「必须在 0.0 到 1.0 之间」—— + 把「类型不对」说成了「范围不对」。 + """ + from src.utils.config import require_chat_temperature + + with pytest.raises(ValueError, match="必须是数值"): + require_chat_temperature("0.5") + + +def test_validate_rerank_output_reports_non_numeric_score_readably(): + """非数值 score(如 ``"abc"``)收敛成本方法的 ``RuntimeError``。 + + 收窄成 ``except TypeError`` 后,``float("abc")`` 的 ``ValueError`` 冒泡, + 调用方拿到的是裸转换错误而不是「provider 返回的 score 无效」。 + """ + with pytest.raises(RuntimeError, match="score 无效"): + RetrievalService._validate_rerank_output([0], ["abc"], 5) + + +def test_key_text_falls_back_to_digest_for_int_subclass_repr_failure(): + """数值子类的 ``repr`` 抛错时退回摘要键,而不是冒泡。 + + ``_key_text`` 的 ``except (ValueError, RuntimeError, RecursionError)`` 收窄成 + ``except ValueError`` 后,``int`` 派生类覆写 ``__repr__`` 抛 ``RuntimeError`` + 会让去重键计算崩掉——这正是「合并检索结果」的中途。 + """ + + class _BadReprInt(int): + def __repr__(self): + raise RuntimeError("repr 失败") + + from src.services.retrieval_service import _key_text + + assert _key_text(_BadReprInt(7)).startswith("<整数,") + + +# --- 边界比较符互换:恰好等于阈值的情形必须有测试 -------------------------- +# +# 来源:把 ``<``↔``<=``、``>``↔``>=`` 互换后全量仍绿的存活点。存活即说明 +# 「取值恰好等于阈值」从未被测试覆盖(off-by-one 盲区)。逐条用探针确认 +# 边界值处的行为改变后,按守卫存在时的行为写断言。 + + +def test_separator_table_accepts_exactly_the_maximum(): + """分隔符表恰好等于上界(64)时接受,65 项才拒绝。 + + ``>`` 改 ``>=`` 后 64 项被误拒——真实配置可能正好用满上界。 + """ + from src.etl.splitters.recursive_text_splitter import RecursiveTextSplitter + + limit = RecursiveTextSplitter._MAX_SEPARATORS + assert RecursiveTextSplitter._require_separators([f"s{i}" for i in range(limit)]) + with pytest.raises(ValueError, match="分隔符表最多"): + RecursiveTextSplitter._require_separators([f"s{i}" for i in range(limit + 1)]) + + +def test_client_timeout_accepts_exactly_the_maximum(): + """客户端超时恰好等于上界时接受,超过才拒绝。""" + from src.providers.__base__.model_provider import ( + _MAX_CLIENT_TIMEOUT_SECONDS, + normalize_client_timeout, + ) + + assert normalize_client_timeout(_MAX_CLIENT_TIMEOUT_SECONDS, "X", "timeout") == ( + _MAX_CLIENT_TIMEOUT_SECONDS + ) + with pytest.raises(ValueError, match="不能超过"): + normalize_client_timeout(_MAX_CLIENT_TIMEOUT_SECONDS + 1, "X", "timeout") + + +@pytest.mark.parametrize("status", [500, "500"]) +def test_retryable_error_includes_status_500_itself(status): + """500 本身可重试(``>= 500`` 不是 ``> 500``)。""" + from types import SimpleNamespace + + from src.providers.__base__.model_provider import is_retryable_error + + assert is_retryable_error(SimpleNamespace(status_code=status)) is True + assert is_retryable_error(SimpleNamespace(status_code=499)) is False + + +def test_google_http_options_timeout_accepts_exactly_the_millisecond_maximum(): + """``http_options.timeout`` 恰好等于毫秒上界时接受,多 1 毫秒才拒绝。""" + from src.providers.__base__.model_provider import _MAX_CLIENT_TIMEOUT_SECONDS + from src.providers.google import GoogleProvider + + boundary_ms = int(_MAX_CLIENT_TIMEOUT_SECONDS * 1000) + GoogleProvider._validate_http_options({"timeout": boundary_ms}) + with pytest.raises(ValueError, match="正整数毫秒"): + GoogleProvider._validate_http_options({"timeout": boundary_ms + 1}) + + +def test_google_partial_args_accepts_list_index_zero(): + """``json_path`` 的列表索引 0 是合法写入位置(``< 0`` 不是 ``<= 0``)。""" + from src.providers.google import GoogleProvider + + result = GoogleProvider._partial_args_to_mapping( + [{"json_path": "$.a[0]", "string_value": "hello"}] + ) + assert result == {"a": ["hello"]} + + +def test_google_merge_tool_arguments_extends_list_at_boundary(): + """列表恰好需要扩容一个位置(``index == len``)时必须扩容,否则 IndexError。""" + from src.providers.google import GoogleProvider + + assert GoogleProvider._merge_tool_arguments([1], [1, 2]) == [1, 2] + + +def test_local_hash_dimension_zero_falls_back_to_default(): + """``model_name`` 解析出维度 0 时退回默认,而不是产生 0 维向量。""" + from src.providers.local_hash import DEFAULT_DIMENSION, _parse_dimension + + assert _parse_dimension("local-hash-0") == DEFAULT_DIMENSION + assert _parse_dimension("local-hash-1") == 1 + + +@pytest.mark.parametrize("count", [2**63, 2**64 - 1]) +def test_manifest_count_accepts_up_to_64_bits(count): + """位数恰好到 64 位(``2**64 - 1``)时接受,第 65 位才拒绝。""" + from src.runtime.contracts import _manifest_count + + assert _manifest_count(count, "document_count") == count + with pytest.raises(ValueError, match="超出合理范围"): + _manifest_count(2**64, "document_count") + + +def test_toml_string_does_not_escape_plain_space(): + """普通空格不需要转义(``< ' '`` 不是 ``<= ' '``),控制字符才转义。""" + from src.runtime.contracts import KnowledgeSnapshotManifest + + assert KnowledgeSnapshotManifest._toml_string("a b") == '"a b"' + assert KnowledgeSnapshotManifest._toml_string("a\x01b") == '"a\\u0001b"' + assert KnowledgeSnapshotManifest._toml_string("a\x7fb") == '"a\\u007Fb"' + + +def test_safe_repr_truncates_only_beyond_limit(): + """``repr`` 长度恰好等于 ``limit`` 时不截断,超过才截断。""" + from src.utils.config import safe_repr + + exact = "x" * 78 # repr 后恰好 80 字符 + assert len(repr(exact)) == 80 + assert safe_repr(exact) == repr(exact) + over = "x" * 79 + assert safe_repr(over).endswith("...") + + +def test_snapshot_id_accepts_exactly_the_maximum_length(): + """快照 id 恰好等于长度上界(64)时接受,65 才拒绝。""" + import tempfile + + from src.retrieval.snapshot_repository import ( + _MAX_SNAPSHOT_ID_LENGTH, + SnapshotRepository, + ) + + repo = SnapshotRepository.__new__(SnapshotRepository) + repo.root = pathlib.Path(tempfile.mkdtemp()) + repo._require_snapshot_id_within_root("a" * _MAX_SNAPSHOT_ID_LENGTH, source="test") + with pytest.raises(ValueError, match="快照 id 过长"): + repo._require_snapshot_id_within_root("a" * (_MAX_SNAPSHOT_ID_LENGTH + 1), source="test") + + +def test_rebuild_indices_skips_faiss_for_empty_embeddings(): + """空嵌入矩阵不建 FAISS 索引(``len > 0`` 不是 ``len >= 0``)。""" + import numpy as np + + store = object.__new__(FaissStore) + store.documents = [] + store.embeddings = np.zeros((0, 4), dtype=np.float32) + store._tokenized_docs_cache = None + store.bm25_index = None + store.faiss_index = None + store._rebuild_indices() + assert store.faiss_index is None + + +def test_deduplicate_parent_documents_keeps_first_on_equal_scores(): + """分数相同时保留先出现的文档(``>`` 不是 ``>=``)。""" + from src.services.retrieval_service import _deduplicate_parent_documents + + documents = [ + {"page_content": "first", "metadata": {"parent_id": "p1"}, "score": 0.5}, + {"page_content": "second", "metadata": {"parent_id": "p1"}, "score": 0.5}, + ] + assert [d["page_content"] for d in _deduplicate_parent_documents(documents)] == ["first"] + + +def test_deduplicate_parent_documents_restores_score_order(): + """去重输出必须恢复分数降序,否则 top_k 截断会丢弃全局最高分文档。 + + 去重把同组的代表文档留在**首次出现**的位置:候选 s1(P1,0.40) → + s2(P2,0.45) → s3(P3,0.50) 各属不同父块、无需合并,但若不重排, + ``[:top_k]``(top_k=2)返回 s1+s2 而丢掉最高分的 s3(实测复现)。 + """ + from src.services.retrieval_service import _deduplicate_parent_documents + + documents = [ + {"page_content": "s1", "metadata": {"parent_id": "P1"}, "score": 0.40}, + {"page_content": "s2", "metadata": {"parent_id": "P2"}, "score": 0.45}, + {"page_content": "s3", "metadata": {"parent_id": "P3"}, "score": 0.50}, + ] + result = _deduplicate_parent_documents(documents) + + assert [d["page_content"] for d in result] == ["s3", "s2", "s1"] + assert [d["score"] for d in result] == [0.50, 0.45, 0.40] + + +def test_keyword_search_stops_exactly_at_top_k(): + """结果数恰好达到 ``top_k`` 时停止(``>=`` 不是 ``>``),不多取一条。""" + import jieba + import numpy as np + import rank_bm25 + + store = FaissStore(file_path=None) + # 每篇各含一个只出现一次的独有词 -> 四篇都是正分;查询四词时四条都合格。 + texts = ["独一 甲", "独二 乙", "独三 丙", "独四 丁"] + store.documents = [ + {"page_content": text, "metadata": {"source": f"s{index}.md"}} + for index, text in enumerate(texts) + ] + store.embeddings = np.zeros((len(texts), 4), dtype=np.float32) + store._tokenized_docs_cache = [list(jieba.cut(text)) for text in texts] + store.bm25_index = rank_bm25.BM25Okapi(store._tokenized_docs_cache) + + query = "独一 独二 独三 独四" + scores = store.bm25_index.get_scores(list(jieba.cut(query))) + assert (scores > 0).sum() == 4, "前提:四篇都必须正分" + assert len(store.keyword_search(query, top_k=2)) == 2 + assert len(store.keyword_search(query, top_k=1)) == 1 + + +def test_retrieve_accepts_candidate_product_exactly_at_the_maximum(): + """``top_k × multiplier`` 恰好等于上界时接受,越界才拒绝。""" + from src.services.retrieval_service import _MAX_RETRIEVAL_CANDIDATES, RetrievalService + + class _Store: + def semantic_search(self, embedding, top_k): + return [] + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.SEMANTIC_SEARCH + config.rerank_enabled = False + config.top_k = _MAX_RETRIEVAL_CANDIDATES + config.retrieval_candidate_multiplier = 1 + # 恰好等于上界:接受(不抛)。 + asyncio.run(RetrievalService(_Store(), _Embedding()).retrieve("q", config)) + + +def test_retrieve_keeps_document_scoring_exactly_at_the_threshold(): + """分数恰好等于阈值时保留(``>=`` 不是 ``>``)。""" + from src.services.retrieval_service import RetrievalService + + class _Store: + def semantic_search(self, embedding, top_k): + return [ + {"page_content": "exact", "metadata": {"source": "a.md"}, "score": 0.5}, + {"page_content": "below", "metadata": {"source": "b.md"}, "score": 0.4}, + ] + + def resolve_parent_content(self, parent_id): + return None + + class _Embedding: + async def embed_query(self, query): + return [0.0] * 4 + + config = build_session_config(get_settings()) + config.retrieval_method = RetrievalMethod.SEMANTIC_SEARCH + config.rerank_enabled = False + config.score_threshold = 0.5 + results = asyncio.run(RetrievalService(_Store(), _Embedding()).retrieve("q", config)) + assert [d["page_content"] for d in results] == ["exact"] + + +def test_overlap_accepts_one_below_the_maximum(): + """overlap 恰好等于上界减一(999999)时接受,等于上界(1000000)才拒绝。""" + assert Settings.validate_non_negative_overlap(999999) == 999999 + with pytest.raises(ValueError, match="必须小于"): + Settings.validate_non_negative_overlap(1000000) + + +def test_save_snapshot_rejects_when_only_faiss_index_is_missing(): + """``faiss_index is None``(embeddings 非空)时也要拒绝保存(``or`` 不是 ``and``)。 + + ``or`` 改 ``and`` 后只有两者**同时**为空才拒绝,单缺索引时静默写出不完整 + 快照——重载后语义检索不可用。 + """ + import tempfile + + store = object.__new__(FaissStore) + store.faiss_index = None + store.embeddings = np.zeros((2, 4), dtype=np.float32) + store.documents = [] + store.parent_documents = {} + store.bm25_index = None + store._tokenized_docs_cache = None + with pytest.raises(RuntimeError, match="语义索引为空"): + store.save_snapshot(tempfile.mkdtemp()) + + +def test_session_config_accepts_one_zero_weight(): + """只有一个权重为 0 时接受(``and`` 不是 ``or``)。""" + config = build_session_config(get_settings()) + config.set_many({"vector_weight": 0.5, "keyword_weight": 0.0}) + assert config.keyword_weight == 0.0 + with pytest.raises(ValueError, match="不能同时为 0"): + config.set_many({"vector_weight": 0.0, "keyword_weight": 0.0}) + + +def test_unit_weight_rejects_bool_with_numeric_message(): + """``bool`` 权重报「必须是数值」,而不是「必须是有限数值」。 + + ``or`` 改 ``and`` 后 ``True`` 落到有限性判据,消息把「类型不对」说成 + 「取值不对」。 + """ + from src.runtime.contracts import SessionConfig + + with pytest.raises(ValueError, match="必须是数值"): + SessionConfig._validate_unit_weight(True, "权重") + + +# --- 调用名互换:any↔all、min↔max、bool↔int、str↔repr 的语义缺口 ---------- +# +# 来源:把同名同形、语义相邻的内建调用互换后全量仍绿的存活点。存活即说明该 +# 调用的**特有语义**从未被测试覆盖。逐条用探针确认行为改变后按守卫存在时 +# 的行为写断言。 + + +def test_retryable_error_matches_any_single_token_in_class_name(): + """类名含多个 token 时命中**其一**即可重试(``any`` 不是 ``all``)。 + + ``all`` 变异后要求全部 token 都命中,``TimeoutFooError`` 这类只含一个 + 目标 token 的类名会被误判为不可重试。 + """ + from src.providers.__base__.model_provider import is_retryable_error + + class TimeoutFooError(Exception): + pass + + assert is_retryable_error(TimeoutFooError("x")) is True + assert is_retryable_error(Exception("x")) is False + + +def test_normalize_messages_does_not_duplicate_existing_system_prompt(): + """已有 system 消息时不再插入(``not any`` 不是 ``not all``)。 + + ``all`` 变异后只要有一条不是 system 就插入,system 提示被重复。 + """ + from src.providers.__base__.model_provider import normalize_messages + + messages = [ + {"role": "system", "content": "已有"}, + {"role": "user", "content": "hi"}, + ] + normalized = normalize_messages(None, "新系统提示", messages) + assert len(normalized) == 2 + assert normalized[0]["content"] == "已有" + + +def test_google_as_bool_falls_back_to_truthiness(): + """非 bool/int/float/str 的取值按真值兜底(``bool`` 不是 ``int``)。 + + ``int`` 变异后 ``int([1])`` 抛 ``TypeError``,配置里的真值形态直接崩。 + """ + from src.providers.google import GoogleProvider + + class _Truthy: + def __bool__(self): + return True + + assert GoogleProvider._as_bool(_Truthy()) is True + assert GoogleProvider._as_bool(()) is False + + +def test_google_http_options_flags_key_with_single_sensitive_suffix(): + """敏感键后缀匹配命中**其一**即拒绝(``any`` 不是 ``all``)。 + + ``all`` 变异后要求全部后缀都匹配,``myaccesskey`` 这类只含一个后缀的 + 键会被放行。 + """ + from src.providers.google import GoogleProvider + + with pytest.raises(ValueError, match="不允许包含凭证"): + GoogleProvider._validate_http_options({"headers": {"myaccesskey": "v"}}) + GoogleProvider._validate_http_options({"headers": {"normal": "v"}}) + + +def test_anthropic_tool_result_is_error_is_bool(): + """``is_error`` 收敛成 ``bool``(``bool`` 不是 ``int``)。 + + ``int`` 变异后 ``int("yes")`` 抛 ``ValueError``,工具结果转换直接崩。 + """ + from src.providers.anthropic import AnthropicProvider + + converted = AnthropicProvider._convert_messages( + [{"role": "tool", "tool_call_id": "t1", "content": "err", "is_error": "yes"}] + ) + tool_result = converted[0]["content"][0] + assert tool_result["is_error"] is True + + +def test_model_dump_include_secrets_is_bool(): + """``include_secrets`` 按 ``bool`` 收敛(``bool`` 不是 ``int``)。 + + ``int`` 变异后 ``int("")`` 抛 ``ValueError``,``model_dump`` 直接崩。 + """ + settings = Settings(_env_file=None) + assert settings.model_dump(include_secrets="") == settings.model_dump(include_secrets=False) + assert settings.model_dump(include_secrets=True) != settings.model_dump(include_secrets=False) + + +def test_model_dump_json_include_secrets_is_bool(): + """``model_dump_json`` 与 ``model_dump`` 同判据(两份 ``bool()`` 都要判)。 + + 两处源码逐字相同,但分属不同方法:只测 ``model_dump`` 时, + ``model_dump_json`` 的 ``bool`` 换成 ``int`` 后全量仍绿——而 + ``int("")`` 抛 ``ValueError``,序列化直接崩。 + """ + import json + + settings = Settings(_env_file=None) + assert json.loads(settings.model_dump_json(include_secrets="")) == json.loads( + settings.model_dump_json(include_secrets=False) + ) + assert json.loads(settings.model_dump_json(include_secrets=True)) != json.loads( + settings.model_dump_json(include_secrets=False) + ) + + +def test_require_int_value_returns_int_not_bool(): + """整数字段返回 ``int``(``int`` 不是 ``bool``)。 + + ``bool`` 变异后 ``_require_int_value(2.0)`` 返回 ``True``,下游按整数 + 使用的地方拿到布尔值。 + """ + from src.runtime.contracts import SessionConfig + + result = SessionConfig._require_int_value(2.0, "字段") + assert result == 2 + assert isinstance(result, int) + assert not isinstance(result, bool) + + +def test_stable_fingerprint_uses_repr_for_distinction(): + """``_stable_fingerprint`` 用 ``repr`` 区分取值(``repr`` 不是 ``str``)。 + + ``str`` 变异后 ``__repr__`` 与 ``__str__`` 不同的对象得到同一个指纹, + 缓存键碰撞。 + """ + + class _Dual: + def __repr__(self): + return "R" + + def __str__(self): + return "S" + + from src.services.retrieval_service import _stable_fingerprint + + assert _stable_fingerprint(_Dual()) == "R" + + +def test_resource_proxy_repr_uses_repr(): + """``_NativeResourceProxy`` 的 ``repr`` 用 ``repr``(``repr`` 不是 ``str``)。 + + ``str`` 变异后 ``__repr__`` 与 ``__str__`` 不同的值在调试输出里形态改变。 + """ + + class _Dual: + def __repr__(self): + return "R" + + def __str__(self): + return "S" + + from src.providers.resources import _NativeResourceProxy + + proxy = _NativeResourceProxy(_Dual(), None, "x") + assert repr(proxy) == "R" + + +def test_display_repr_uses_str(): + """``_display_repr`` 用 ``str``(``str`` 不是 ``repr``)。 + + ``repr`` 变异后字符串取值被加上引号,显示形态改变。 + """ + + class _Dual: + def __repr__(self): + return "R" + + def __str__(self): + return "S" + + from src.utils.security import _display_repr + + assert _display_repr(_Dual()) == "S" + assert _display_repr("abc") == "abc" + + +def test_background_poll_sleeps_min_of_interval_and_remaining(): + """轮询睡眠取 ``min(interval, remaining)``(``min`` 不是 ``max``)。 + + 剩余时间(10s)远大于轮询间隔(0.5s)时,必须只睡**间隔**那么久;``max`` + 变异会睡满 10s,等于把 deadline 一次性耗尽。 + """ + import time as _time + from types import SimpleNamespace + + from src.providers import openai_compatible as oc + from src.providers.openai_compatible import OpenAICompatibleProvider + + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + provider._options = {"background_poll_interval": 0.5, "background_timeout": 10.0} + pending = {"status": "in_progress", "id": "resp-1"} + provider._get_client = lambda: SimpleNamespace( + responses=SimpleNamespace(retrieve=lambda *a, **k: pending) + ) + + sleeps: list[float] = [] + clock = iter([100.0, 100.0, 111.0]) + oc.time = SimpleNamespace( + monotonic=lambda: next(clock), + sleep=sleeps.append, + ) + try: + with pytest.raises(TimeoutError): + provider._poll_background_response(pending, {}) + finally: + oc.time = _time + + assert sleeps == [0.5] + + +def test_background_poll_async_sleeps_min_of_interval_and_remaining(): + """异步轮询睡眠同样取 ``min(interval, remaining)``(与同步分支同判据)。""" + import time as _time + from types import SimpleNamespace + + from src.providers import openai_compatible as oc + from src.providers.openai_compatible import OpenAICompatibleProvider + + provider = object.__new__(OpenAICompatibleProvider) + provider._provider = None + provider._options = {"background_poll_interval": 0.5, "background_timeout": 10.0} + pending = {"status": "in_progress", "id": "resp-1"} + + async def _retrieve(*args, **kwargs): + return pending + + provider._get_aclient = lambda: SimpleNamespace(responses=SimpleNamespace(retrieve=_retrieve)) + + sleeps: list[float] = [] + + async def _sleep(seconds): + sleeps.append(seconds) + + clock = iter([100.0, 100.0, 111.0]) + real_time, real_asyncio = oc.time, oc.asyncio + oc.time = SimpleNamespace(monotonic=lambda: next(clock), sleep=_time.sleep) + oc.asyncio = SimpleNamespace(sleep=_sleep) + try: + with pytest.raises(TimeoutError): + asyncio.run(provider._poll_background_response_async(pending, {})) + finally: + oc.time, oc.asyncio = real_time, real_asyncio + + assert sleeps == [0.5] + + +# --- 调用名互换(修正变异器后重扫)的覆盖缺口 ------------------------------- +# +# 上一轮 ``mut_name.py`` 用 ``src.index(line_text)`` 定位行首,而同一行文本 +# (如 `` return any(\n``)在文件里出现多次时它返回**第一次**的位置——于是 +# 变异落到更早的那一行上,扫描结果变成「改的是 A 行、记的是 B 行」。修正成按 +# 行号累加偏移后重扫,下列缺口才暴露出来。 + + +def test_missing_argument_error_uses_str_not_repr(): + """``_is_missing_required_argument_error`` 用 ``str`` 判消息(``str`` 不是 ``repr``)。 + + ``repr`` 变异后类名也被搜进关键词:``MissingRequiredArgumentError("无关")`` + 被误判成「缺少必需参数」,资源调用被拒。 + """ + from src.providers.__base__.model_provider import _is_missing_required_argument_error + + class MissingRequiredArgumentError(TypeError): + pass + + assert ( + _is_missing_required_argument_error(MissingRequiredArgumentError("完全无关的消息")) is False + ) + assert ( + _is_missing_required_argument_error( + TypeError("__init__() missing 1 required positional argument: 'x'") + ) + is True + ) + + +def test_resource_method_guard_accepts_each_extension_name(): + """资源方法守卫对三个扩展名**各自**放行(``any`` 不是 ``all``)。 + + 每个方法除扩展参数外都带一个普通参数:``all`` 变异后要求**每个**参数 + 都满足条件,普通参数让它为假,三个扩展名一个都过不了守卫,资源方法 + 不再被包装。 + """ + from src.providers.__base__.model_provider import _is_provider_resource_method + + def _create_extra_body(self, extra_body=None, temperature=None): + return None + + def _create_extra_headers(self, extra_headers=None, temperature=None): + return None + + def _create_extra_query(self, extra_query=None, temperature=None): + return None + + def _create_plain(self, temperature=None): + return None + + assert _is_provider_resource_method("create", _create_extra_body) is True + assert _is_provider_resource_method("create", _create_extra_headers) is True + assert _is_provider_resource_method("create", _create_extra_query) is True + assert _is_provider_resource_method("create", _create_plain) is False + + +def test_compat_complete_drops_kwargs_without_varkw(): + """兼容层 ``complete`` 只在签名**没有** ``**kwargs`` 时裁剪参数(``any`` 不是 ``all``)。 + + ``all`` 变异后对带 ``**kwargs`` 的 Provider 也裁剪,``temperature`` 被丢掉, + ``invoke`` 收到 ``None``。 + """ + from src.providers.__base__.model_provider import LargeLanguageModel + + class _NoVarKw(LargeLanguageModel): + def invoke(self, prompt, **kwargs): + return f"{prompt}|{kwargs.get('temperature')}" + + async def ainvoke(self, prompt, **kwargs): + return "a" + + assert _NoVarKw().complete(prompt="hi", temperature=0.25).text == "hi|0.25" + + +def test_compat_acomplete_drops_kwargs_without_varkw(): + """兼容层 ``acomplete`` 与 ``complete`` 同判据(``not any`` 不是 ``any``)。 + + 去掉 ``not`` 后对带 ``**kwargs`` 的 Provider 也裁剪参数,``temperature`` + 被丢掉,``ainvoke`` 收到 ``None``。 + """ + import asyncio + + from src.providers.__base__.model_provider import LargeLanguageModel + + class _NoVarKw(LargeLanguageModel): + def invoke(self, prompt, **kwargs): + yield "s" + + async def ainvoke(self, prompt, **kwargs): + yield f"{prompt}|{kwargs.get('temperature')}" + + async def _collect(): + return (await _NoVarKw().acomplete(prompt="hi", temperature=0.25)).text + + assert asyncio.run(_collect()) == "hi|0.25" + + +def test_compat_stream_events_drops_kwargs_without_varkw(): + """兼容层 ``stream_events`` 与 ``complete`` 同判据(``any`` 不是 ``all``)。 + + ``all`` 变异后 ``temperature`` 被裁剪,流事件文本变成 ``hi|None``。 + """ + from src.providers.__base__.model_provider import LargeLanguageModel + + class _NoVarKw(LargeLanguageModel): + def invoke(self, prompt, **kwargs): + yield f"{prompt}|{kwargs.get('temperature')}" + + async def ainvoke(self, prompt, **kwargs): + return "a" + + events = list(_NoVarKw().stream_events(prompt="hi", temperature=0.25)) + assert [event.text for event in events] == ["hi|0.25"] + + +def test_compat_astream_events_drops_kwargs_without_varkw(): + """兼容层 ``astream_events`` 与同步分支同判据(``any`` 不是 ``all``)。 + + ``all`` 变异后 ``temperature`` 被裁剪,异步流事件文本变成 ``hi|None``。 + """ + import asyncio + + from src.providers.__base__.model_provider import LargeLanguageModel + + class _NoVarKw(LargeLanguageModel): + def invoke(self, prompt, **kwargs): + yield "s" + + async def ainvoke(self, prompt, **kwargs): + yield f"{prompt}|{kwargs.get('temperature')}" + + async def _collect(): + return [ + event.text async for event in _NoVarKw().astream_events(prompt="hi", temperature=0.25) + ] + + assert asyncio.run(_collect()) == ["hi|0.25"] + + +def test_google_terminal_event_matches_any_candidate(): + """流块终止判据对**任一** candidate 生效(``any`` 不是 ``all``)。 + + ``all`` 变异后要求每个 candidate 都有 ``finish_reason``,只有一个带终止 + 标记的流块被判成「未终止」,``terminal_seen`` 不置位。 + """ + from types import SimpleNamespace + + from src.providers.google import GoogleProvider + + _none = SimpleNamespace(finish_reason=None) + _stop = SimpleNamespace(finish_reason="STOP") + assert ( + GoogleProvider._stream_chunk_has_terminal_event(SimpleNamespace(candidates=[_none, _stop])) + is True + ) + assert ( + GoogleProvider._stream_chunk_has_terminal_event(SimpleNamespace(candidates=[_stop, _stop])) + is True + ) + assert ( + GoogleProvider._stream_chunk_has_terminal_event(SimpleNamespace(candidates=[_none, _none])) + is False + ) + + +def test_volcengine_classify_rejects_single_bad_label(): + """Ark Classification 的 labels 判据对**任一**非法项拒绝(``any`` 不是 ``all``)。 + + ``all`` 变异后要求每个标签都非法才拒绝,``["ok", 123]`` 被静默接受, + 随后 ``123.strip()`` 抛裸 ``AttributeError``——不是本类承诺的 ``ValueError``。 + """ + from src.providers.volcengine import VolcengineProvider + + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + with pytest.raises(ValueError, match="labels 必须是非空字符串列表"): + provider.classify("q", ["ok", 123]) + with pytest.raises(ValueError, match="labels 必须是非空字符串列表"): + provider.classify("q", ["ok", "fine", " "]) + + +def test_volcengine_async_classify_rejects_single_bad_label(): + """异步 ``async_classify`` 与同步 ``classify`` 同判据(``any`` 不是 ``all``)。 + + ``all`` 变异后 ``["ok", 123]`` 被接受,``123.strip()`` 抛裸 ``AttributeError``。 + """ + import asyncio + + from src.providers.volcengine import VolcengineProvider + + provider = object.__new__(VolcengineProvider) + provider._model_name = "m" + + async def _call(): + await provider.async_classify("q", ["ok", 123]) + + with pytest.raises(ValueError, match="labels 必须是非空字符串列表"): + asyncio.run(_call()) + + +def test_google_http_options_message_lists_findings_sorted(): + """``http_options`` 违规消息按字典序列出(``sorted`` 不是 ``reversed``)。 + + ``reversed`` 变异后顺序颠倒,消息与 ``sorted`` 的约定不一致。 + """ + from src.providers.google import GoogleProvider + + with pytest.raises(ValueError) as excinfo: + GoogleProvider._validate_http_options({"headers": {"aaccesskey": "1", "zaccesskey": "2"}}) + message = str(excinfo.value) + assert "headers.aaccesskey, headers.zaccesskey" in message + + +def test_knowledge_build_document_source_uses_str_not_repr(): + """知识库文档的 ``source`` 元数据用 ``str``(``str`` 不是 ``repr``)。 + + ``repr`` 变异后 ``source`` 变成 ``PosixPath('/x/a.md')``,检索结果与 + 快照里的来源都带上 ``PosixPath(...)`` 包装。 + """ + from pathlib import Path + + from src.etl.pipeline import Pipeline + from src.services.knowledge_build_service import KnowledgeBuildService + + with tempfile.TemporaryDirectory() as tmp: + file_path = Path(tmp) / "a.md" + file_path.write_text("# 标题\n\n正文", encoding="utf-8") + pipeline = Pipeline.from_file_path(file_path, splitter_structure_mode="standard") + chunks = KnowledgeBuildService._process_file(None, pipeline, file_path) + assert chunks[0].metadata["source"] == str(file_path) + assert "PosixPath(" not in chunks[0].metadata["source"] + + +def test_resource_provider_label_uses_str_not_repr(): + """``_resource_provider_label`` 用 ``str`` 取标签(``str`` 不是 ``repr``)。 + + ``repr`` 变异后标签变成 ``R`` 而不是 ``S``,错误消息里的 Provider 标识 + 与实际对象脱节。 + """ + from src.providers.resources import _resource_provider_label + + class _Dual: + def __str__(self): + return "S" + + def __repr__(self): + return "R" + + class _Provider: + _provider = _Dual() + + assert _resource_provider_label(_Provider()) == "S" + + +def test_duplicate_extension_key_message_uses_str_not_repr(): + """重复扩展字段消息用 ``str`` 列键名(``str`` 不是 ``repr``)。 + + ``repr`` 变异后消息里出现 ``'extra-body'``(带引号),与 ``str`` 形态不一致。 + """ + from src.utils.security import validate_secret_free_resource_kwargs + + with pytest.raises(ValueError) as excinfo: + validate_secret_free_resource_kwargs({"extra_body": {}, "extra-body": {}}, "Provider") + assert "extra-body" in str(excinfo.value) + assert "'extra-body'" not in str(excinfo.value) + + +def test_trusted_source_message_lists_roots_with_str(): + """受信来源消息用 ``str`` 列根路径(``str`` 不是 ``repr``)。 + + ``repr`` 变异后消息里出现 ``PosixPath('/tmp/trusted')``,路径被包上类名。 + """ + from src.utils.security import ensure_trusted_source + + with pytest.raises(ValueError) as excinfo: + ensure_trusted_source("/etc/evil.pkl", ["/tmp/trusted"], label="测试") + message = str(excinfo.value) + assert "受信路径=['/tmp/trusted']" in message + assert "PosixPath(" not in message + + +def test_settings_error_message_uses_str_not_repr(): + """``_redacted_settings_error`` 用 ``str`` 取 ``msg``(``str`` 不是 ``repr``)。 + + ``repr`` 变异后消息里出现 ``'Value error, ...'``(带引号),与 ``str`` 形态 + 不一致。 + """ + from pydantic import ValidationError + + from src.utils.config import Settings, _redacted_settings_error + + with pytest.raises(ValidationError): + Settings(_env_file=None, chat_top_k=-1) + try: + Settings(_env_file=None, chat_top_k=-1) + except ValidationError as exc: + message = _redacted_settings_error(exc) + assert "chat_top_k 必须是 1 到 2000 之间的整数" in message + assert "'Value error" not in message + + +def test_toml_decode_error_message_uses_str_not_repr(monkeypatch): + """``TOMLDecodeError`` 消息用 ``str``(``str`` 不是 ``repr``)。 + + ``repr`` 变异后消息里出现 ``TOMLDecodeError('Invalid value ...')``, + 带类名与引号。 + """ + import pathlib + import tempfile + + from src.utils import config + + with tempfile.TemporaryDirectory() as tmp: + # monkeypatch 保证临时目录消失后全局仍指回原路径——直接赋值会把 + # 指向已删路径的 ``CONFIG_TOML_PATH`` 泄漏给后续全部用例。 + monkeypatch.setattr(config, "CONFIG_TOML_PATH", pathlib.Path(tmp) / "config.toml") + config.CONFIG_TOML_PATH.write_text("a = ", encoding="utf-8") + config.get_settings.cache_clear() + with pytest.raises(ValueError) as excinfo: + config.get_settings() + message = str(excinfo.value) + assert "配置解析失败 - Invalid value" in message + assert "TOMLDecodeError(" not in message + + +def test_recursion_error_message_uses_str_not_repr(monkeypatch): + """``RecursionError`` 消息用 ``str``(``str`` 不是 ``repr``)。 + + ``repr`` 变异后消息里出现 ``RecursionError('maximum recursion depth exceeded')``。 + """ + import pathlib + import tempfile + + from src.utils import config + + with tempfile.TemporaryDirectory() as tmp: + monkeypatch.setattr(config, "CONFIG_TOML_PATH", pathlib.Path(tmp) / "config.toml") + config.CONFIG_TOML_PATH.write_text("a = " + "[" * 600, encoding="utf-8") + config.get_settings.cache_clear() + with pytest.raises(ValueError) as excinfo: + config.get_settings() + message = str(excinfo.value) + assert "嵌套层级过深(maximum recursion depth exceeded)" in message + assert "RecursionError(" not in message + + +def test_faiss_normalize_documents_strips_internal_metadata_keys(): + """``_normalize_documents`` 从元数据里剔除内部键(``key not in {...}`` 不是 ``key in {...}``)。 + + ``not in`` 换成 ``in`` 后判据反转:内部键被**保留**、用户元数据被**剔除**。 + """ + _documents, parents = FaissStore._normalize_documents( + [ + { + "page_content": "x", + "metadata": { + "chunk_id": "c1", + "doc_id": "d1", + "parent_id": "p1", + "parent_chunk_index": 0, + "parent_content": "父块正文", + "user_key": "v", + }, + } + ], + {}, + ) + + parent_metadata = next(iter(parents.values()))["metadata"] + for internal in ("chunk_id", "doc_id", "parent_id", "parent_chunk_index"): + assert internal not in parent_metadata + assert parent_metadata["user_key"] == "v" + + +def test_faiss_save_snapshot_reports_embedding_dimension_from_array(): + """``save_snapshot`` 的 ``embedding_dimension`` 取自嵌入数组(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后维度恒为 0,快照统计与实际不符。 + """ + import json + + import faiss + import numpy as np + + store = object.__new__(FaissStore) + store.documents = [] + store.parent_documents = {} + store.embeddings = np.zeros((2, 4), dtype=np.float32) + store.faiss_index = faiss.IndexFlatL2(4) + store.bm25_index = None + store._tokenized_docs_cache = None + + with tempfile.TemporaryDirectory() as tmp: + store.save_snapshot(tmp) + stats = json.loads((pathlib.Path(tmp) / "stats.json").read_text(encoding="utf-8")) + + assert stats["embedding_dimension"] == 4 + + +def test_faiss_load_snapshot_rebuilds_when_lexical_index_missing(): + """``lexical.index`` 缺失时重建索引(去掉 ``not`` 后反转成「存在才重建」)。 + + 反转后缺索引的快照**不**重建 BM25,关键词检索静默失效。 + """ + import numpy as np + + store = object.__new__(FaissStore) + store.documents = [] + store.parent_documents = {} + # 行数必须自洽(``load_snapshot`` 现在会校验 embeddings 行数与分块数对齐): + # 0 个分块配 0 行向量。 + store.embeddings = np.zeros((0, 4), dtype=np.float32) + store.faiss_index = None + store.bm25_index = None + store._tokenized_docs_cache = None + rebuilt: list[bool] = [] + store._rebuild_indices = lambda: rebuilt.append(True) + + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + snapshot = root / "snap-x" + snapshot.mkdir() + import pickle + + (snapshot / "chunks.pkl").write_bytes(pickle.dumps([])) + (snapshot / "parents.pkl").write_bytes(pickle.dumps({})) + np.save(snapshot / "embeddings.npy", store.embeddings) + (root / "ACTIVE_SNAPSHOT").write_text("snap-x", encoding="utf-8") + store.load_snapshot(str(snapshot), snapshot_root=root) + + assert rebuilt == [True] + + +# --- face 12(== ↔ != 互换扫描)存活位点 ------------------------------------- + + +def test_rerank_provider_cache_evicts_only_stale_same_provider_entries(monkeypatch): + """配置重载只清理**同一 provider 键**的陈旧实例(两个判据各占一半)。 + + ``key[0] == provider_key`` 变异成 ``!=`` 后会关闭无关 provider 的实例、 + 留下真正陈旧的实例;``key != cache_key`` 变异成 ``==`` 后陈旧实例永不 + 关闭——实测变异体 ``closed`` 分别变成 ``['other']`` 与 ``[]``,旧传输与 + 凭证一直挂着。 + """ + from types import SimpleNamespace + + from src.services.retrieval_service import RetrievalService + + closed = [] + + class Provider: + def __init__(self, name): + self.name = name + + def close(self): + closed.append(self.name) + + service = object.__new__(RetrievalService) + service._rerank_providers = { + ("jina", "jina", "m-old", "fp1"): Provider("stale-jina"), + ("other", "other", "m", "fp"): Provider("other"), + } + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", + lambda *_args, **_kwargs: Provider("new"), + ) + detail = SimpleNamespace(provider="jina", model_name="m-new", options={}) + + service._get_rerank_provider("jina", {"jina": detail}) + + assert closed == ["stale-jina"] + assert sorted(key[0] for key in service._rerank_providers) == ["jina", "other"] + + +# ========================================================================= +# face 13:boolc(True↔False 字面量互换)扫描确认的真缺口(工具/服务/检索批) +# ------------------------------------------------------------------------- +# 对应 ``face13_auth68.log`` 中经真实入口探针判定为真缺口的位点;等价变异 +# 见 CHANGELOG 条目。 +# ========================================================================= + + +def test_run_config_is_frozen(): + """``RunConfig`` 必须是 frozen dataclass。 + + ``@dataclass(frozen=True)`` 反向成 ``frozen=False`` 后,运行配置可被任意 + 改写——检索与 ETL 之间共享的同一份配置被中途篡改,且 ``__hash__`` 随之 + 消失(``frozen=True`` 才生成 ``__hash__``)。 + """ + import dataclasses + + from src.runtime.contracts import build_run_config + + config = build_run_config(get_settings()) + with pytest.raises(dataclasses.FrozenInstanceError): + config.log_path = pathlib.Path("/tmp/other") + + +def test_knowledge_snapshot_manifest_is_frozen(): + """``KnowledgeSnapshotManifest`` 必须是 frozen dataclass。 + + ``frozen=True`` 反向成 ``False`` 后,清单字段可被原地改写;且因 + ``frozen=True`` 才生成 ``__hash__``,清单不再可哈希——依赖它做键的 + 调用方直接抛 ``TypeError: unhashable type``。 + """ + import dataclasses + + from src.runtime.contracts import KnowledgeSnapshotManifest + + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="s1", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="m", + chunk_mode="standard", + source_digest="d", + document_count=1, + chunk_count=1, + ) + with pytest.raises(dataclasses.FrozenInstanceError): + manifest.snapshot_id = "s2" + assert hash(manifest) is not None + + +def test_settings_model_dump_exclude_merges_secret_fields(): + """``model_dump(exclude=...)`` 必须把凭据字段并入排除集合。 + + ``{name: True for name in secret_exclude}`` 里的 ``True`` 反向成 ``False`` + 后,pydantic 把 ``exclude`` 中值为 ``False`` 的键理解为「不排除」—— + 14 个凭据字段全部出现在导出结果里。 + """ + dump = Settings().model_dump(exclude={"log_level": True}) + + leaked = set(Settings._secret_fields).intersection(dump) + assert leaked == set() + + +def test_module_logger_file_handler_records_source_location(): + """模块 logger 的文件处理器必须带 ``%(filename)s:%(lineno)d``。 + + ``_build_rotating_handler(..., with_location=True)`` 的 ``True`` 反向成 + ``False`` 后,模块日志不再记录来源文件与行号——排障时无法定位调用点。 + """ + import logging + + from src.utils import log_manager + + log_dir = tmp_path_factory_dir() + original = log_manager.get_settings + + class FakeSettings: + log_path = str(log_dir) + log_level = "INFO" + + log_manager.get_settings = lambda: FakeSettings() + logger = logging.getLogger("test.face13.location.module") + saved_handlers = logger.handlers + logger.handlers = [] + try: + configured = log_manager.get_module_logger("test.face13.location.module") + file_handler = next( + handler + for handler in configured.handlers + if isinstance(handler, logging.handlers.RotatingFileHandler) + ) + assert "%(filename)s" in file_handler.formatter._fmt + assert "%(lineno)d" in file_handler.formatter._fmt + finally: + for handler in logger.handlers: + if handler not in saved_handlers: + handler.close() + logger.handlers = saved_handlers + log_manager.get_settings = original + + +def test_chat_logger_file_handler_omits_source_location_by_default(): + """聊天 logger 的文件处理器默认**不带**来源位置(``with_location`` 默认关)。 + + ``with_location: bool = False`` 反向成 ``True`` 后,聊天日志格式与模块日志 + 混同——调用方按聊天日志格式解析的字段位置全部错位。 + """ + import logging + + from src.utils import log_manager + + log_dir = tmp_path_factory_dir() + original = log_manager.get_settings + + class FakeSettings: + log_path = str(log_dir) + log_level = "INFO" + + log_manager.get_settings = lambda: FakeSettings() + chat_logger = logging.getLogger("chat_logger") + saved_handlers = chat_logger.handlers + chat_logger.handlers = [] + try: + configured = log_manager.get_chat_logger() + file_handler = next( + handler + for handler in configured.handlers + if isinstance(handler, logging.handlers.RotatingFileHandler) + ) + assert "%(filename)s" not in file_handler.formatter._fmt + finally: + for handler in configured.handlers: + if handler not in saved_handlers: + handler.close() + chat_logger.handlers = saved_handlers + log_manager.get_settings = original + + +def test_faiss_save_snapshot_creates_nested_directory(): + """``save_snapshot`` 必须能创建多级缺失目录(``mkdir(parents=True)``)。 + + ``parents=True`` 反向成 ``False`` 后,目标目录的父级不存在时 ``mkdir`` + 抛 ``FileNotFoundError``——首次把快照写到新路径(如带日期的子目录)直接失败。 + """ + import json + + import faiss + + store = object.__new__(FaissStore) + store.documents = [] + store.parent_documents = {} + store.embeddings = np.zeros((2, 4), dtype=np.float32) + store.faiss_index = faiss.IndexFlatL2(4) + store.bm25_index = None + store._tokenized_docs_cache = None + + with tempfile.TemporaryDirectory() as tmp: + nested = pathlib.Path(tmp) / "nested" / "deep" + store.save_snapshot(str(nested)) + assert (nested / "stats.json").exists() + stats = json.loads((nested / "stats.json").read_text(encoding="utf-8")) + + assert stats["embedding_dimension"] == 4 + + +def test_rerank_provider_cache_key_is_order_insensitive(monkeypatch): + """rerank 缓存键对 ``options`` 的键序不敏感(``sort_keys=True``)。 + + ``sort_keys=True`` 反向成 ``False`` 后,键序不同但内容相同的两份等价配置 + 生成不同的指纹——每次检索都重建一个 rerank provider,缓存失效且旧实例 + 不被复用。 + """ + from types import SimpleNamespace + + from src.services.retrieval_service import RetrievalService + + service = object.__new__(RetrievalService) + service._rerank_providers = {} + created = [] + + def _factory(*_args, **_kwargs): + instance = object() + created.append(instance) + return instance + + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", _factory + ) + detail_a = SimpleNamespace(provider="jina", model_name="m", options={"z": 1, "a": 2}) + detail_b = SimpleNamespace(provider="jina", model_name="m", options={"a": 2, "z": 1}) + + first = service._get_rerank_provider("jina", {"jina": detail_a}) + second = service._get_rerank_provider("jina", {"jina": detail_b}) + + assert first is second + assert len(created) == 1 + + +def test_stale_rerank_providers_are_all_closed_when_one_close_fails(monkeypatch): + """配置重载时逐个关闭旧 rerank 实例,单个失败不能中断其余关闭。 + + ``pop`` 已经把实例从缓存里摘掉,关闭失败的实例就此彻底失去引用—— + 循环若在这里中断,后面每个 stale 实例都是「已摘除但从未关闭」的泄漏。 + 这条路径发生在配置重载时(``config_menu`` 改完 rerank 配置后立刻检索), + 用户不会再有机会重试。契约与 ``clear_rerank_cache``/``close``/``aclose`` + 一致:关闭全部,再报第一个错误。 + """ + from types import SimpleNamespace + + from src.services.retrieval_service import RetrievalService + + class FailingProvider: + def close(self): + raise RuntimeError("旧实例关闭失败") + + class CountingProvider: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + service = object.__new__(RetrievalService) + good = CountingProvider() + service._rerank_providers = { + ("jina", "jina", "m", "old-a"): FailingProvider(), + ("jina", "jina", "m", "old-b"): good, + } + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", + lambda *_args, **_kwargs: object(), + ) + detail = SimpleNamespace(provider="jina", model_name="m", options={"a": 1}) + + with pytest.raises(RuntimeError, match="旧实例关闭失败"): + service._get_rerank_provider("jina", {"jina": detail}) + + assert good.closed == 1 + assert service._rerank_providers == {} + + +def test_stale_rerank_replacement_does_not_raise_when_all_closes_succeed(monkeypatch): + """全部旧实例关闭成功时不抛——``first_error`` 守卫不能误报。""" + from types import SimpleNamespace + + from src.services.retrieval_service import RetrievalService + + class QuietProvider: + def close(self): + pass + + service = object.__new__(RetrievalService) + service._rerank_providers = {("jina", "jina", "m", "old"): QuietProvider()} + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", + lambda *_args, **_kwargs: object(), + ) + detail = SimpleNamespace(provider="jina", model_name="m", options={"a": 1}) + + service._get_rerank_provider("jina", {"jina": detail}) + assert len(service._rerank_providers) == 1 diff --git a/tests/test_log_manager.py b/tests/test_log_manager.py new file mode 100644 index 0000000..b24f112 --- /dev/null +++ b/tests/test_log_manager.py @@ -0,0 +1,285 @@ +"""`src/utils/log_manager.py` 的日志目录降级行为。""" + +import logging + +import pytest + +from src.utils import log_manager + + +@pytest.fixture(autouse=True) +def _reset_degraded_flag(monkeypatch): + """每个用例都从「尚未降级」开始,避免用例间互相影响。""" + monkeypatch.setattr(log_manager, "_DEGRADED_WARNING_EMITTED", False) + yield + + +def test_degradation_warning_is_emitted_once(monkeypatch, caplog): + """降级 warning 只打一次,不随模块数量线性增长。 + + ``get_module_logger`` 被 20 多个模块在 import 期调用,各自打一条会让 + 只读安装下的输出被重复信息淹没。 + """ + monkeypatch.setattr(log_manager, "get_settings", lambda: _FakeSettings()) + + with caplog.at_level(logging.WARNING): + for name in ("mod.a", "mod.b", "mod.c"): + log_manager.get_module_logger(name) + + degradations = [r for r in caplog.records if "无法写入日志目录" in r.getMessage()] + assert len(degradations) == 1 + + +def test_module_logger_still_has_console_handler_when_degraded(monkeypatch): + """降级后仍要有控制台 handler,否则日志彻底丢失。""" + monkeypatch.setattr(log_manager, "get_settings", lambda: _FakeSettings()) + + logger = log_manager.get_module_logger("test.degraded.handlers") + + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + assert not any(isinstance(h, logging.FileHandler) for h in logger.handlers) + + +class _FakeSettings: + """日志目录指向一个不存在且无法创建的位置,触发降级。""" + + log_level = "WARNING" + log_path = "/proc/self/nonexistent-log-dir" + + +# ── 回归:日志清理只应动本应用写出的文件 ── +# +# ``log_path`` 是用户可配置项。原实现删除「目录下所有 *.log 且文件名含任意 +# YYYY-MM-DD」,把 ``log_path`` 指向共享目录(``/var/log``、挂载的日志盘)时 +# 会连带删掉别的程序与用户的日志,且不可逆。 + + +def _cleanup_with(monkeypatch, log_dir, retention_days=15): + from types import SimpleNamespace + + settings = SimpleNamespace( + log_path=str(log_dir), log_retention_days=retention_days, log_level="WARNING" + ) + monkeypatch.setattr(log_manager, "get_settings", lambda: settings) + log_manager.cleanup_old_logs() + + +def test_cleanup_old_logs_removes_app_owned_logs(tmp_path, monkeypatch): + """本应用自己的过期日志照常清理,功能不能被收紧挡住。""" + (tmp_path / "app_log_2020-01-01.log").write_text("a", encoding="utf-8") + (tmp_path / "chat_log_2020-01-01.log").write_text("c", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path) + + assert not (tmp_path / "app_log_2020-01-01.log").exists() + assert not (tmp_path / "chat_log_2020-01-01.log").exists() + + +def test_cleanup_old_logs_keeps_recent_app_logs(tmp_path, monkeypatch): + """未过期的本应用日志必须保留。""" + from datetime import datetime + + today = datetime.now().astimezone().strftime("%Y-%m-%d") + recent = tmp_path / f"app_log_{today}.log" + recent.write_text("今天的日志", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path) + + assert recent.exists() + + +@pytest.mark.parametrize( + "foreign", + [ + "nginx_access_2020-01-01.log", + "postgres_2020-01-01.log", + "backup_2020-01-01.log", + "app_logger_2020-01-01.log", + "my_app_log_2020-01-01.log", + "app_log_2020-01-01.log.bak", + ], +) +def test_cleanup_old_logs_leaves_other_programs_logs_alone(tmp_path, monkeypatch, foreign): + """别的程序或用户的文件不得被删除。 + + ``log_path`` 可以指向共享目录;删掉 ``nginx_access_2020-01-01.log`` 这种 + 与本应用无关的文件是不可逆的越界行为。判定按完整文件名形态而非「含日期 + 的子串」,所以 ``my_app_log_...`` 这类前缀也不匹配。 + """ + victim = tmp_path / foreign + victim.write_text("别人的数据", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path) + + assert victim.exists(), f"{foreign} 被误删" + + +def test_cleanup_old_logs_ignores_non_log_files(tmp_path, monkeypatch): + """非 ``.log`` 文件从不参与清理。""" + keep = tmp_path / "notes.txt" + keep.write_text("x", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path) + + assert keep.exists() + + +# ── 回归:``RotatingFileHandler`` 的轮转备份也必须被清理 ── +# +# 写入侧是 ``RotatingFileHandler(maxBytes=1MB, backupCount=5)``,单个文件超过 +# 1 MB 就轮转出 ``.log.1`` ~ ``.log.5``。旧模式以 ``$`` 结尾,那些备份**一个都 +# 不匹配**,于是「清理旧日志」只删掉主文件,把绝大部分体积留在盘上——实测造 +# 一批 30 天前的日志(主文件 + 5 个备份),清理后仍剩 83% 的字节。而 +# ``log_path`` 是用户可配置项,指向磁盘时这份残留会一直累积。 + + +def test_cleanup_old_logs_removes_rotation_backups(tmp_path, monkeypatch): + """轮转备份属于本应用的日志,过期后必须一并清理。""" + for suffix in (".log.1", ".log.2", ".log.5"): + (tmp_path / f"app_log_2020-01-01{suffix}").write_text("x", encoding="utf-8") + (tmp_path / "chat_log_2020-01-01.log.3").write_text("x", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path) + + assert not list(tmp_path.glob("app_log_2020-01-01.log.*")), "app 轮转备份未被清理" + assert not list(tmp_path.glob("chat_log_2020-01-01.log.*")), "chat 轮转备份未被清理" + + +def test_cleanup_old_logs_keeps_recent_rotation_backups(tmp_path, monkeypatch): + """收紧不能误伤:未过期的轮转备份必须保留。""" + from datetime import datetime + + today = datetime.now().astimezone().strftime("%Y-%m-%d") + recent = tmp_path / f"app_log_{today}.log.1" + recent.write_text("今天的备份", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path) + + assert recent.exists() + + +def test_cleanup_old_logs_actually_rotates_through_a_real_handler(tmp_path, monkeypatch): + """走真实 ``RotatingFileHandler`` 产生备份,再断言清理覆盖它们。 + + 直接写 ``.log.1`` 这样的文件名等于假设了备份名的形态;这里让 + ``RotatingFileHandler`` 自己轮转,断言的是**真实产生出来的**文件名能被清理 + 规则匹配。handler 的备份名由 ``doRollover`` 的 ``baseFilename + "." + 序号`` + 决定,若将来改变形态,这条用例会失败。 + """ + import logging.handlers + + handler = logging.handlers.RotatingFileHandler( + str(tmp_path / "app_log_2020-01-01.log"), maxBytes=200, backupCount=3 + ) + handler.setFormatter(logging.Formatter("%(message)s")) + logger = logging.getLogger("test.rotation.real") + logger.handlers = [handler] + logger.setLevel(logging.INFO) + logger.propagate = False + try: + for _ in range(80): + logger.info("x" * 60) + finally: + handler.close() + + produced = sorted(p.name for p in tmp_path.iterdir() if ".log." in p.name) + assert produced, "RotatingFileHandler 未产生备份,用例前提不成立" + + _cleanup_with(monkeypatch, tmp_path) + + remaining = sorted(p.name for p in tmp_path.iterdir()) + assert remaining == [], f"真实轮转产生的文件未被清理干净: {remaining}" + + +# ── 回归:``os.makedirs`` 对含 NUL 字节路径抛 ``ValueError``(不是 OSError)── +# +# 两个建 handler 的入口(``get_chat_logger`` / ``get_module_logger``)的 +# ``except`` 都列了 ``ValueError``。``get_module_logger`` 在**模块导入期**被 +# ``cleanup.py`` 调用,漏接 ``ValueError`` 会让 ``import main`` 直接失败。 + + +class _NullByteSettings: + """日志目录含 NUL 字节:``os.makedirs`` 抛 ``ValueError: embedded null byte``。""" + + log_level = "WARNING" + log_path = "/tmp/bad\x00dir" + + +def test_module_logger_degrades_on_null_byte_log_path(monkeypatch): + """``log_path`` 含 NUL 字节时降级为仅控制台,而不是让 ``ValueError`` 冒泡。 + + 收窄成 ``except OSError`` 后,``makedirs`` 的 ``ValueError`` 穿透本函数—— + 而它在导入期就被调用,异常会让整个程序无法启动。 + """ + monkeypatch.setattr(log_manager, "get_settings", lambda: _NullByteSettings()) + + logger = log_manager.get_module_logger("test.nullbyte.module") + + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + assert not any(isinstance(h, logging.FileHandler) for h in logger.handlers) + + +def test_chat_logger_degrades_on_null_byte_log_path(monkeypatch): + """聊天日志记录器对同一形态给同样的降级。""" + logger = logging.getLogger("chat_logger") + saved = list(logger.handlers) + for handler in saved: + logger.removeHandler(handler) + + monkeypatch.setattr(log_manager, "get_settings", lambda: _NullByteSettings()) + try: + result = log_manager.get_chat_logger() + assert result is logger + assert not any(isinstance(h, logging.FileHandler) for h in logger.handlers) + finally: + for handler in list(logger.handlers): + logger.removeHandler(handler) + for handler in saved: + logger.addHandler(handler) + + +def test_cleanup_keeps_logs_exactly_at_the_retention_boundary(tmp_path, monkeypatch): + """恰好达到保留天数的日志保留(``>`` 不是 ``>=``),多一天才删。""" + from datetime import datetime, timedelta + + now = datetime.now().astimezone() + exact = tmp_path / f"app_log_{(now - timedelta(days=15)).strftime('%Y-%m-%d')}.log" + beyond = tmp_path / f"app_log_{(now - timedelta(days=16)).strftime('%Y-%m-%d')}.log" + exact.write_text("x", encoding="utf-8") + beyond.write_text("x", encoding="utf-8") + + _cleanup_with(monkeypatch, tmp_path, retention_days=15) + + assert exact.exists(), "恰好 15 天不应被删" + assert not beyond.exists(), "16 天应被删" + + +def test_importing_log_manager_does_not_run_demo_block(): + """import ``src.utils.log_manager`` 不得执行模块底部的演示日志(``==`` 不是 ``!=``)。 + + ``if __name__ == "__main__"`` 的 ``==``→``!=`` 变异后守卫常真:任何 import + 都会跑演示块——实测子进程 stderr 出现「这是一条聊天警告。」「这是一条模块 + 错误!」等占位日志,被 import 该模块的 20 多个调用方在 import 期污染日志。 + """ + import subprocess + import sys + from pathlib import Path + + repo_root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, "-c", "import src.utils.log_manager"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, output + for marker in ( + "这是一条聊天信息。", + "这是一条聊天警告。", + "这是一条模块信息。", + "这是一条模块错误!", + ): + assert marker not in output, marker diff --git a/tests/test_main_entry.py b/tests/test_main_entry.py index 0411d9d..0802e39 100644 --- a/tests/test_main_entry.py +++ b/tests/test_main_entry.py @@ -1,4 +1,151 @@ +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +import main + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# 冻结分支(main.py 顶层)只在 import 期跑一次,无法用 monkeypatch 触发, +# 因此用子进程伪造 ``sys.frozen`` / ``sys.executable`` 后重新 import。 +_FROZEN_PROBE = """ +import os, sys + +sys.frozen = True +sys.executable = {executable!r} +import main # noqa: F401 + +print("TIKTOKEN_CACHE_DIR=" + os.environ.get("TIKTOKEN_CACHE_DIR", "")) +""" + + +def _run_frozen_probe(executable: str, cache_dir: str | None = None) -> subprocess.CompletedProcess: + """在伪造的冻结环境里 import main,返回子进程结果。 + + ``cache_dir`` 为 ``None`` 时清除 ``TIKTOKEN_CACHE_DIR``;给值则预置为它—— + 预置那一路是区分「赋值」与 ``setdefault`` 的唯一办法:``setdefault`` 在变量 + 已存在时会保留用户那个值,而用户若把该变量设成空串,tiktoken 的 + ``read_file_cached`` 会直接关闭缓存,随包分发的 BPE 文件就被整个绕开。 + """ + env = dict(os.environ) + if cache_dir is None: + env.pop("TIKTOKEN_CACHE_DIR", None) + else: + env["TIKTOKEN_CACHE_DIR"] = cache_dir + return subprocess.run( + [sys.executable, "-c", _FROZEN_PROBE.format(executable=executable)], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + timeout=180, + check=False, + ) + + +# ``_resolve_version`` 在**模块导入期**执行,早于 ``main()`` 的任何 ``try``, +# 异常会直接终止进程。它自带两级兜底(VERSION.txt → pyproject.toml → "0.0.0"), +# 但原实现只覆盖了「文件不存在」——读取本身也会失败。 +# +# 实测 VERSION.txt 编码损坏(被编辑器写成 UTF-16、或被截断)时 ``read_text`` +# 抛 ``UnicodeDecodeError``,程序连横幅都打不出来就退出(exit code 1,消息是 +# 裸 traceback)。这与设计意图相反:兜底链的意义正是「读不到就降级」,而读不到 +# 的原因不止「不存在」。 +# +# 用子进程测:``VERSION = _resolve_version()`` 在 import 期求值,本进程里 +# 已经定值,monkeypatch 改不到。``resolve_app_root`` 取的是可执行文件所在目录 +# (源码运行时是仓库根),因此把仓库根当作 app_root、在其中放一个坏的 +# VERSION.txt 即可复现——但那会污染工作树,所以改为在子进程里替换 +# ``resolve_app_root`` 的返回值。 +_BAD_VERSION_PROBE = """ +import pathlib, tempfile + +root = pathlib.Path(tempfile.mkdtemp()) +(root / "VERSION.txt").write_bytes({payload!r}) +(root / "pyproject.toml").write_text( + '[project]' + chr(10) + 'name = "pyrag-kit"' + chr(10) + 'version = "9.9.9"' + chr(10), + encoding="utf-8", +) + +import main +# ``_resolve_version`` 读的是模块全局里的 ``resolve_app_root``,改它即可把 +# 兜底路径指向临时目录,避免污染工作树。 +main._resolve_version.__globals__["resolve_app_root"] = lambda: root +print("VERSION=" + main._resolve_version()) +""" + + +# ``pyproject.toml`` 形态异常时同样必须降级。``["project"]`` 与 +# ``["version"]`` 在下标形态不对时抛 ``TypeError``(``project = "x"`` -> +# ``string indices must be integers``;``project = 5`` -> ``'int' object is not +# subscriptable``),**不是** ``KeyError``——原实现的 ``except`` 元组里没有它, +# 而本函数在模块导入期执行,进程会以裸 traceback 退出。 +_MALFORMED_PYPROJECT_PROBE = """ +import pathlib, tempfile + +root = pathlib.Path(tempfile.mkdtemp()) +(root / "pyproject.toml").write_text({content!r}, encoding="utf-8") + import main +main._resolve_version.__globals__["resolve_app_root"] = lambda: root +print("VERSION=" + main._resolve_version()) +""" + + +@pytest.mark.parametrize( + ("label", "content"), + [ + ("project 是字符串", 'project = "string"'), + ("project 是列表", "project = [1, 2]"), + ("project 是整数", "project = 5"), + ("version 是列表", '[project]\nversion = ["1.4.0"]'), + ], + ids=["project-string", "project-list", "project-int", "version-list"], +) +def test_resolve_version_survives_malformed_pyproject(label, content): + """``pyproject.toml`` 形态异常时必须降级到 ``0.0.0``,不能让导入期抛异常。""" + result = subprocess.run( + [sys.executable, "-c", _MALFORMED_PYPROJECT_PROBE.format(content=content)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + assert result.returncode == 0, ( + f"{label}: 进程退出码 {result.returncode}\n{result.stderr[-400:]}" + ) + assert "VERSION=0.0.0" in result.stdout, f"{label}: 未降级到 0.0.0\n{result.stdout}" + + +@pytest.mark.parametrize( + ("label", "payload"), + [ + ("UTF-16 BOM 残留", b"\xff\xfe\x00bad"), + ("非法起始字节", b"\xffbad"), + ("截断的多字节序列", b"1.4.0\xe4\xb8"), + ], + ids=["utf16", "invalid-start", "truncated"], +) +def test_resolve_version_survives_undecodable_version_file(label, payload): + """VERSION.txt 无法解码时必须降级,不能让程序在导入期就退出。""" + result = subprocess.run( + [sys.executable, "-c", _BAD_VERSION_PROBE.format(payload=payload)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + assert result.returncode == 0, ( + f"{label}: 进程退出码 {result.returncode}\n{result.stderr[-400:]}" + ) + assert "VERSION=9.9.9" in result.stdout, f"{label}: 未降级到 pyproject.toml\n{result.stdout}" def test_run_cli_uses_smoke_test(monkeypatch): @@ -14,3 +161,176 @@ def fake_run_smoke_test(): assert result == 0 assert called["count"] == 1 + + +def test_frozen_with_bundled_tiktoken_cache_sets_env(tmp_path): + """包内缓存存在时应把它设为 ``TIKTOKEN_CACHE_DIR``。 + + 必须是赋值而不是 ``setdefault``:否则用户环境里一个空的 + ``TIKTOKEN_CACHE_DIR``(等于关闭缓存)会把随包分发的 BPE 文件整个绕开。 + """ + app_root = tmp_path / "PyRAG-Kit" + (app_root / "tiktoken_cache").mkdir(parents=True) + + result = _run_frozen_probe(str(app_root / "PyRAG-Kit")) + + assert result.returncode == 0, result.stderr + assert f"TIKTOKEN_CACHE_DIR={app_root / 'tiktoken_cache'}" in result.stdout + assert "未找到包内的分词编码缓存" not in result.stderr + + +def test_frozen_overrides_preexisting_empty_tiktoken_cache_dir(tmp_path): + """用户环境里已有的 ``TIKTOKEN_CACHE_DIR`` 不得把包内缓存挤掉。""" + app_root = tmp_path / "PyRAG-Kit" + (app_root / "tiktoken_cache").mkdir(parents=True) + + result = _run_frozen_probe(str(app_root / "PyRAG-Kit"), cache_dir="") + + assert result.returncode == 0, result.stderr + assert f"TIKTOKEN_CACHE_DIR={app_root / 'tiktoken_cache'}" in result.stdout + + +def test_frozen_without_bundled_tiktoken_cache_warns(tmp_path): + """冻结运行时若包内没有 BPE 缓存,必须显式警告。 + + 包内文件是发布包唯一的离线兜底;缺失时 tiktoken 会走 + ``requests.get(blobpath)``,而该调用**没有超时**,网络黑洞下会长时间挂起。 + 给出提示总好过让用户面对一个没有线索的卡顿。 + """ + result = _run_frozen_probe(str(tmp_path / "PyRAG-Kit")) + + assert result.returncode == 0, result.stderr + assert "未找到包内的分词编码缓存" in result.stderr + assert "TIKTOKEN_CACHE_DIR=" in result.stdout + + +# ── 回归:顶层错误边界不得被异常消息里的标记形态打断 ── +# +# ``console.print(str)`` 的字符串会被 rich 按标记解析。修复前 +# ``safe_exception_text()`` 的结果再次进入这个通道:异常消息里带 ``[/]`` 时 +# 连「报告错误」本身都抛 ``MarkupError``,三层兜底(内层 except、``main()`` +# 的 except、``run_cli`` 的 except)全部失效,程序以裸 traceback 退出。 +# 这条链路由一条含 ``[/]`` 的知识库文档触发,已端到端复现。 + + +def test_run_cli_survives_markup_like_exception(monkeypatch, capsys): + """异常消息含标记形态时,启动失败提示必须能打出来且退出码为 1。""" + + def failing_main(): + raise RuntimeError("bad tag [/] in message") + + monkeypatch.setattr(main, "main", failing_main) + + result = main.run_cli([]) + + captured = capsys.readouterr() + assert result == 1 + assert "bad tag [/] in message" in captured.out + + +def test_run_cli_survives_markup_like_config_error(monkeypatch, capsys): + """异常消息形如 rich 的闭合标记(``[/red]``)时同样不得二次抛错。""" + + def failing_main(): + raise ValueError("closing tag [/red] at position 3") + + monkeypatch.setattr(main, "main", failing_main) + + result = main.run_cli([]) + + captured = capsys.readouterr() + assert result == 1 + assert "closing tag [/red]" in captured.out + + +def test_smoke_test_survives_markup_like_path(monkeypatch, capsys): + """``log_path`` 含方括号时自检必须照常输出。 + + 该值来自 ``config.toml``,是用户可配置项;安装目录名同样由用户决定。 + 修复前它直接进 ``console.print(f"...")``,会被当作 rich 标记解析—— + ``[/]`` 抛 ``MarkupError``,而自检正是构建脚本用来判断产物好坏的入口。 + """ + from pathlib import Path + + class FakeSettings: + log_path = "/tmp/[kb]/logs" + + monkeypatch.setattr(main, "get_settings", lambda: FakeSettings()) + monkeypatch.setattr(main, "initialize_dependencies", lambda: None) + monkeypatch.setattr(main, "_assert_splitter_encoding_available", lambda: None) + monkeypatch.setattr(main, "resolve_app_root", lambda: Path("/tmp/[app]")) + + result = main.run_smoke_test() + + captured = capsys.readouterr() + assert result == 0 + assert "smoke test ok" in captured.out + assert "/tmp/[kb]/logs" in captured.out + + +# ── 回归:自检必须触达每个 provider 模块 ── +# +# 十三个 provider 模块只经 ``importlib.import_module(运行时字符串)`` 加载 +# (``src/providers/factory.py``),PyInstaller 的静态分析看不见它们,因此 +# ``HIDDEN_IMPORTS`` 是冻结包里能否找到它们的唯一依据。此前自检只走分片器与 +# 日志路径,**不实例化也不导入任何 provider**——漏收一个模块时 PyInstaller +# 不报错、``--validate`` 通过,用户在那个渠道上才拿到 ``ModuleNotFoundError``。 +# 这与 ``tiktoken_ext.openai_public`` 当初漏收是同一个失效模式。 + + +def test_smoke_test_asserts_every_provider_module_is_importable(): + """自检必须逐个导入注册表里的 provider 模块。""" + import main + from src.providers.factory import ModelProviderFactory + + # 正常环境下应当通过 + main._assert_provider_modules_importable() + + # 模块导入失败时必须报成带指引的 RuntimeError,而不是让 ImportError 穿出去 + import importlib + + real_import = importlib.import_module + + def failing_import(name, *args, **kwargs): + if name == "src.providers.local_hash": + raise ImportError("No module named 'src.providers.local_hash'") + return real_import(name, *args, **kwargs) + + original = importlib.import_module + importlib.import_module = failing_import + try: + with pytest.raises(RuntimeError, match="HIDDEN_IMPORTS"): + main._assert_provider_modules_importable() + finally: + importlib.import_module = original + + # 断言这条守卫确实覆盖了注册表里的每个模块,而不是只挑了几个 + assert len(ModelProviderFactory._provider_map) >= 13 + + +# ── 回归:分片器自检必须校验产出非空 ── +# +# ``_assert_splitter_encoding_available`` 是 ``--smoke-test`` 用来确认 tiktoken +# 编码在冻结环境里可用的守卫。原实现只调 ``splitter.split(...)`` 并丢弃返回值: +# 编码缺失导致分片器静默产出空列表时,自检照样报 "smoke test ok",构建脚本据 +# 此放行发布包。删除 ``if not chunks: raise`` 后全量 3790 条仍全绿(变异扫描 +# 确认),因此补这条反向用例。 + + +def test_splitter_self_check_rejects_empty_chunks(monkeypatch): + """分片器产出空列表时自检必须报错,而不是静默通过。""" + import src.etl.splitters.recursive_text_splitter as splitter_module + + class EmptySplitter: + def split(self, documents): + return [] + + monkeypatch.setattr(splitter_module, "RecursiveTextSplitter", EmptySplitter) + + with pytest.raises(RuntimeError, match="分片器自检未产出任何分片"): + main._assert_splitter_encoding_available() + + +def test_splitter_self_check_passes_in_real_environment(): + """正常环境下真实分片器必须产出分片(守卫的绿色一侧)。""" + main._assert_splitter_encoding_available() diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py index d6fd519..620cd0c 100644 --- a/tests/test_release_scripts.py +++ b/tests/test_release_scripts.py @@ -1,8 +1,12 @@ import subprocess +import sys +from pathlib import Path import pytest from scripts.build_binary_release import ( + APP_NAME, + APP_ROOT_FILES, PACKAGE_FILES, prepare_runtime_layout, stage_bundle, @@ -12,14 +16,146 @@ from scripts.extract_release_notes import extract_section +@pytest.fixture +def bpe_source(monkeypatch, tmp_path): + """给 BPE 预置一个自给的源文件,使测试不依赖宿主机缓存也不联网。 + + ``stage_tiktoken_cache`` 的源路径由 ``TIKTOKEN_CACHE_DIR`` 决定,缺失时 + 会联网下载——两者都会让单元测试依赖外部状态(宿主机恰好有缓存时绿、 + CI 上无缓存时红)。这里预置一份内容可辨的夹具文件并指向它。 + """ + cache_dir = tmp_path / "_bpe_source" + cache_dir.mkdir() + monkeypatch.setenv("TIKTOKEN_CACHE_DIR", str(cache_dir)) + monkeypatch.delenv("DATA_GYM_CACHE_DIR", raising=False) + monkeypatch.setattr( + "scripts.build_binary_release._download_tiktoken_blob", + lambda: pytest.fail("本测试不应联网下载 BPE"), + ) + return cache_dir + + def test_prepare_runtime_layout_creates_placeholder_directories(tmp_path): + """运行时目录必须建在冻结应用自己的根目录下。 + + 冻结时 ``resolve_app_root()`` 返回可执行文件所在目录,也就是 + ``bundle_root / APP_NAME``;默认相对路径(``knowledge_base``、 + ``data/kb``、``data/logs``)都解析到那里。建在 ``bundle_root`` 上会留下 + 一个应用永远不读的死目录,用户按 README 放文档只会拿到 + ``FileNotFoundError``。 + """ prepare_runtime_layout(tmp_path) - assert (tmp_path / "data" / "kb").is_dir() - assert (tmp_path / "data" / "logs").is_dir() - placeholder = tmp_path / "knowledge_base" / "README.md" + app_root = tmp_path / APP_NAME + assert (app_root / "data" / "kb").is_dir() + assert (app_root / "data" / "logs").is_dir() + placeholder = app_root / "knowledge_base" / "README.md" assert placeholder.is_file() assert "Markdown" in placeholder.read_text(encoding="utf-8") + # 包根不能残留同名死目录,否则用户会继续把文档放错位置。 + assert not (tmp_path / "knowledge_base").exists() + + +def _tiktoken_cache_key() -> str: + import hashlib + + from scripts.build_binary_release import TIKTOKEN_BLOB_URL + + return hashlib.sha1(TIKTOKEN_BLOB_URL.encode(), usedforsecurity=False).hexdigest() + + +def test_stage_tiktoken_cache_copies_verified_blob(tmp_path, monkeypatch, bpe_source): + """预置进包的 BPE 必须通过 sha256 校验,且落在 tiktoken 的查找键名下。""" + import hashlib + + from scripts.build_binary_release import ( + TIKTOKEN_BLOB_SHA256, + TIKTOKEN_CACHE_DIRNAME, + stage_tiktoken_cache, + ) + + payload = b"x" * 32 + monkeypatch.setattr( + "scripts.build_binary_release.TIKTOKEN_BLOB_SHA256", hashlib.sha256(payload).hexdigest() + ) + (bpe_source / _tiktoken_cache_key()).write_bytes(payload) + + app_root = tmp_path / "app" + stage_tiktoken_cache(app_root) + + staged = app_root / TIKTOKEN_CACHE_DIRNAME / _tiktoken_cache_key() + assert staged.read_bytes() == payload + # 键名必须与 tiktoken 的查找算法一致,否则运行时命中不到。 + assert staged.name == _tiktoken_cache_key() + assert TIKTOKEN_BLOB_SHA256 # 常量存在且非空 + + +def test_stage_tiktoken_cache_rejects_corrupted_blob(tmp_path, monkeypatch, bpe_source): + """内容与声明哈希不符时必须失败,坏数据不能进发布包。""" + from scripts.build_binary_release import stage_tiktoken_cache + + (bpe_source / _tiktoken_cache_key()).write_bytes(b"corrupted") + + with pytest.raises(RuntimeError, match="校验失败"): + stage_tiktoken_cache(tmp_path / "app") + + +def test_remove_runtime_residue_clears_logs_and_cache(tmp_path): + """自检产生的日志与缓存必须被清掉,占位目录本身要保留。""" + from scripts.build_binary_release import APP_NAME, CACHE_DIRNAME, remove_runtime_residue + + bundle_root = tmp_path / "PyRAG-Kit-1.4.0-linux-x64" + app_root = bundle_root / APP_NAME + logs = app_root / "data" / "logs" + logs.mkdir(parents=True) + (logs / "app_log_2026-09-24.log").write_text("build-time residue", encoding="utf-8") + cache = app_root / CACHE_DIRNAME + cache.mkdir() + (cache / "tmpfile").write_text("x", encoding="utf-8") + + remove_runtime_residue(bundle_root) + + assert list(logs.iterdir()) == [] + assert not cache.exists() + # 占位目录本身要保留,用户解压后应看到空的 knowledge_base/ 与 data/。 + assert logs.is_dir() + + +def test_bundle_version_matches_pyproject(tmp_path, monkeypatch): + """产物自报版本必须与 pyproject.toml 一致。 + + 版本号有两个消费方:发布包**文件名**来自 ``pyproject.toml``(构建脚本 + 读取),而横幅与自检输出来自 ``main.py``。构建脚本把版本写进包内 + ``VERSION.txt``,``main.py`` 优先读它——若读的位置不对(例如按 + ``__file__`` 找,冻结后会落到 ``_internal/``),就会静默退回兜底值, + 包名与横幅不一致而自检只 grep "smoke test ok" 时照样通过。 + """ + from scripts.build_binary_release import APP_NAME, load_version + + app_root = tmp_path / APP_NAME + app_root.mkdir(parents=True) + (app_root / "VERSION.txt").write_text("9.9.9\n", encoding="utf-8") + + project_root = Path(__file__).resolve().parent.parent + # 冻结时 resolve_app_root() 取可执行文件所在目录;源码模式取 __file__ 的 + # 祖父目录。这里在导入 main 之前把该函数打桩成目标目录,等价于冻结分支。 + script = ( + "import sys\n" + "sys.path.insert(0, sys.argv[1])\n" + "from pathlib import Path\n" + "import src.utils.config as cfg\n" + "cfg.resolve_app_root = lambda: Path(sys.argv[2])\n" + "import main\n" + "print(main.VERSION)\n" + ) + result = subprocess.run( + [sys.executable, "-c", script, str(project_root), str(app_root)], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == "9.9.9" + assert load_version() != "9.9.9" def test_extract_section_returns_version_content(): @@ -72,7 +208,9 @@ def test_validate_target_environment_rejects_cross_architecture(): validate_target_environment("macos-arm64", system="Darwin", machine="x86_64") -def test_stage_bundle_copies_package_files_including_subdirectories(monkeypatch, tmp_path): +def test_stage_bundle_copies_package_files_including_subdirectories( + monkeypatch, tmp_path, bpe_source +): """PACKAGE_FILES 含子目录路径(licenses/APACHE-2.0.txt)。 ``shutil.copy2`` 不会创建父目录,缺了 mkdir 会在打包时抛 @@ -91,6 +229,19 @@ def test_stage_bundle_copies_package_files_including_subdirectories(monkeypatch, source.parent.mkdir(parents=True, exist_ok=True) source.write_text("content", encoding="utf-8") + for relative in APP_ROOT_FILES: + source = tmp_path / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("content", encoding="utf-8") + + import hashlib + + payload = b"stub-bpe" + monkeypatch.setattr( + "scripts.build_binary_release.TIKTOKEN_BLOB_SHA256", hashlib.sha256(payload).hexdigest() + ) + (bpe_source / _tiktoken_cache_key()).write_bytes(payload) + bundle_root = stage_bundle("linux-x64", "1.4.0") assert bundle_root.name == "PyRAG-Kit-1.4.0-linux-x64" @@ -98,6 +249,11 @@ def test_stage_bundle_copies_package_files_including_subdirectories(monkeypatch, assert (bundle_root / relative).is_file(), relative # 子目录路径必须真的落在子目录里,而不是被拍平。 assert (bundle_root / "licenses" / "APACHE-2.0.txt").parent.name == "licenses" + # 配置示例必须落在 app 真正读取的位置(ROOT_DIR == bundle_root/APP_NAME), + # 放在包根会被静默忽略。 + for relative in APP_ROOT_FILES: + assert (bundle_root / APP_NAME / relative).is_file(), relative + assert not (bundle_root / relative).exists(), relative # ── 回归:ZIP 分支对悬空符号链接的确定行为 ── @@ -180,3 +336,165 @@ def test_find_dangling_symlinks_ignores_valid_links_and_files(tmp_path): (bundle_root / "dangling").symlink_to("nowhere.txt") assert [path.name for path in find_dangling_symlinks(bundle_root)] == ["dangling"] + + +# ── 回归:``HIDDEN_IMPORTS`` 必须覆盖 provider 注册表里的每个模块 ── +# +# 十三个 provider 模块**没有任何静态导入路径**——它们只经 +# ``importlib.import_module(provider_info["module"])`` 按运行时字符串加载 +# (``src/providers/factory.py``)。PyInstaller 的静态分析看不见这一层, +# 因此 ``HIDDEN_IMPORTS`` 是它们在冻结包里能否被找到的**唯一**依据。 +# +# 两个清单分处两个文件,各自演化:往注册表里加一个 provider 而忘了同步 +# ``HIDDEN_IMPORTS``,构建照常通过(PyInstaller 不报错)、``--validate`` 照常 +# 通过(自检不实例化任何 provider),用户在那个渠道上拿到的是 +# ``ModuleNotFoundError``。这与 ``tiktoken_ext.openai_public`` 当初漏收是同一个 +# 失效模式——只是那次被发现了,这次没有守卫。 + + +def test_hidden_imports_cover_every_registered_provider_module(): + """注册表里的每个模块都必须出现在 ``HIDDEN_IMPORTS`` 里。""" + from scripts.build_binary_release import HIDDEN_IMPORTS + from src.providers.factory import ModelProviderFactory + + registered = {info["module"] for info in ModelProviderFactory._provider_map.values()} + missing = sorted(registered - set(HIDDEN_IMPORTS)) + + assert not missing, ( + f"以下 provider 模块已注册但未列入 HIDDEN_IMPORTS,冻结包中将无法导入: {missing}" + ) + + +def test_hidden_imports_has_no_stale_provider_entries(): + """反向守卫:``HIDDEN_IMPORTS`` 不得残留已不存在的模块名。 + + 只查一个方向的话,注册表删掉一个 provider 后清单里的旧名字会一直留着, + 而 PyInstaller 对不存在的 hidden import 只是警告——清单会慢慢失真,等到 + 真需要它覆盖某个模块时已经没人信它了。 + """ + from scripts.build_binary_release import HIDDEN_IMPORTS + from src.providers.factory import ModelProviderFactory + + registered = {info["module"] for info in ModelProviderFactory._provider_map.values()} + stale = sorted( + name + for name in HIDDEN_IMPORTS + if name.startswith("src.providers.") and name not in registered + ) + + assert not stale, f"HIDDEN_IMPORTS 里的 provider 模块已不在注册表中: {stale}" + + +def test_registered_provider_modules_have_no_static_import_path(): + """确认这些模块确实只靠 ``importlib`` 加载——这正是需要 HIDDEN_IMPORTS 的原因。 + + 若哪天某个 provider 被静态导入了,这条用例会失败,提示该模块已不再依赖 + ``HIDDEN_IMPORTS``(清单可以相应精简)。这是对上面两条守卫前提的校验: + 前提不成立时它们守的是一个不存在的问题。 + """ + import re + import subprocess + + from src.providers.factory import ModelProviderFactory + + for module in sorted({info["module"] for info in ModelProviderFactory._provider_map.values()}): + # ``from X import Y`` 与 ``from .pkg import X`` 都算静态导入,与 + # ``import X`` 同罪——只 grep ``import {module}`` 会漏掉最常见的 + # from 形态,前提校验在它声称覆盖的路径上放空。 + patterns = [ + f"import {module}", + f"from {module} import", + f"from {module.rsplit('.', 1)[0]} import", + ] + result = subprocess.run( + ["grep", "-rnE", "|".join(patterns), "--include=*.py", "src", "scripts", "main.py"], + capture_output=True, + text=True, + check=False, + cwd=Path(__file__).resolve().parent.parent, + ) + # factory.py 里的是 ``"module": "src.providers.x"`` 字符串,不是 import 语句 + hits = [line for line in result.stdout.splitlines() if "factory.py" not in line] + assert not hits, f"{module} 出现了静态导入,HIDDEN_IMPORTS 不再是它唯一的发现途径: {hits}" + assert re.search(r"importlib\.import_module", Path("src/providers/factory.py").read_text()) + + +# ── 发布脚本四条判据的覆盖缺口 ── +# +# 下列判据在整套测试里从未执行(行覆盖 + 变异扫描双证;把它们的 ``raise`` 换成 +# ``pass`` 后全量 3790 条仍全绿)。删除后行为确实改变(实测,均经真实入口): +# * 102 未知发布目标 → ``expected is None`` 后解包,抛裸 +# ``TypeError: cannot unpack non-iterable NoneType object``; +# * 295 联网下载 BPE 失败 → **静默返回 None**,随后 ``sha256(None)`` 在更远处 +# 抛 ``TypeError``,与「构建环境无外网」这个真实原因无关; +# * 377 自检输出不含 "smoke test ok" → 消息退化成「自报版本与 pyproject.toml +# 的 1.4.0 不一致」——把「自检根本没跑通」说成「版本号写错了」,指向完全 +# 错误的排查方向; +# * 383 自检输出带**错误版本号** → **静默通过**,旧版本产物冒充新版本发布。 +def test_validate_target_environment_rejects_unknown_target(): + """未知发布目标必须报 ``ValueError``,不是解包 None 的裸 ``TypeError``。""" + with pytest.raises(ValueError, match="不支持的发布目标"): + validate_target_environment("bogus-target") + + +def test_download_tiktoken_blob_reports_network_failure(monkeypatch): + """联网下载失败必须报带上下文的 ``RuntimeError``,不能静默返回 None。""" + import urllib.error + import urllib.request + + from scripts.build_binary_release import _download_tiktoken_blob + + def failing_urlopen(*args, **kwargs): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(urllib.request, "urlopen", failing_urlopen) + + with pytest.raises(RuntimeError, match="无法获取 cl100k_base"): + _download_tiktoken_blob() + + +def test_validate_bundle_reports_missing_smoke_marker(monkeypatch, tmp_path): + """自检输出不含 "smoke test ok" 时必须点名这一条,不能报成版本不一致。""" + bundle_root = tmp_path / "bundle" + app_dir = bundle_root / "PyRAG-Kit" + app_dir.mkdir(parents=True) + executable = app_dir / "PyRAG-Kit" + executable.write_text("", encoding="utf-8") + + def fake_run(command, **kwargs): + return subprocess.CompletedProcess(command, 0, stdout="some other output\n", stderr="") + + monkeypatch.setattr("scripts.build_binary_release.subprocess.run", fake_run) + + with pytest.raises(RuntimeError, match="自检输出不符合预期"): + validate_bundle(bundle_root) + + +def test_validate_bundle_rejects_mismatched_version(monkeypatch, tmp_path): + """自检输出带错误版本号时必须拒绝,不能静默放行。""" + bundle_root = tmp_path / "bundle" + app_dir = bundle_root / "PyRAG-Kit" + app_dir.mkdir(parents=True) + executable = app_dir / "PyRAG-Kit" + executable.write_text("", encoding="utf-8") + + def fake_run(command, **kwargs): + return subprocess.CompletedProcess( + command, 0, stdout="PyRAG-Kit 0.0.1-wrong smoke test ok\n", stderr="" + ) + + monkeypatch.setattr("scripts.build_binary_release.subprocess.run", fake_run) + + with pytest.raises(RuntimeError, match="自报版本与 pyproject.toml"): + validate_bundle(bundle_root) + + +def test_extract_section_reports_missing_version(): + """请求的版本不在 CHANGELOG 中时必须报 ``ValueError``。 + + 删除守卫后 ``start_index`` 保持 ``None``,切片 ``lines[None:end]`` 会 + **静默返回整份 CHANGELOG 的开头**——发布说明里出现「更新日志」总标题与 + Unreleased 段,看起来像一份正常的文件。 + """ + with pytest.raises(ValueError, match="未找到版本"): + extract_section("99.99.99") diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..a5123a9 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,324 @@ +"""发布工作流的 shell 逻辑回归测试。 + +工作流里的校验步骤跑在 CI 上,本地看不见;一旦写错(例如 glob 匹配不到任何 +文件),循环体不执行、步骤照样退出 0,坏包会静默流到用户手里。这里把步骤的 +``run`` 脚本从 YAML 里取出来,用合成归档在本地真跑一遍。 +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml" + +# 这些用例把工作流里的 shell 脚本真跑一遍。GitHub 的 Windows runner 上 +# ``bash``/``tar``/``find`` 由 Git for Windows 提供,行为与 POSIX 不完全一致, +# 而本机无法验证——因此非 POSIX 平台直接跳过,宁可不覆盖也不要让一次发布 +# 因为测试环境的差异而失败。 +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="工作流脚本的本地复跑依赖 POSIX shell 与 coreutils,Windows 上无法验证", +) + +_HASH_TOOL = shutil.which("sha256sum") or shutil.which("shasum") + + +def _extract_run_step(job: str, step_name: str) -> str: + """从工作流 YAML 中取出某个步骤的 ``run: |`` 脚本文本。 + + 刻意不引入 YAML 解析依赖:只用缩进定位,取不到就报错——解析失败必须是 + 响亮的失败,否则测试会以为自己在校验一段它根本没读到的脚本。 + """ + lines = WORKFLOW.read_text(encoding="utf-8").splitlines() + job_header = f" {job}:" + try: + start = lines.index(job_header) + except ValueError: # pragma: no cover - 工作流结构被改动时的响亮失败 + raise AssertionError(f"未找到 job {job!r}(是否重命名了?)") from None + + step_header = f" - name: {step_name}" + offset = next( + (i for i, line in enumerate(lines[start:], start) if line == step_header), + None, + ) + assert offset is not None, f"job {job!r} 中未找到步骤 {step_name!r}" + + run_at = next( + (i for i, line in enumerate(lines[offset:], offset) if line.strip() == "run: |"), + None, + ) + assert run_at is not None, f"步骤 {step_name!r} 没有块状 run 脚本" + + indent = len(lines[run_at]) - len(lines[run_at].lstrip()) + body_indent = indent + 2 + body: list[str] = [] + for line in lines[run_at + 1 :]: + if line.strip() and (len(line) - len(line.lstrip())) < body_indent: + break + body.append(line[body_indent:] if len(line) > body_indent else "") + assert body, f"步骤 {step_name!r} 的 run 脚本为空" + return "\n".join(body) + "\n" + + +@pytest.fixture(scope="module") +def verify_script(tmp_path_factory: pytest.TempPathFactory) -> Path: + path = tmp_path_factory.mktemp("wf") / "verify.sh" + path.write_text(_extract_run_step("publish", "Verify archive contents"), encoding="utf-8") + return path + + +@pytest.fixture +def bash_env(tmp_path: Path) -> dict[str, str]: + """提供 ``python`` 可执行文件——CI 由 setup-python 保证,本地不一定有。""" + shim_dir = tmp_path / "shim" + shim_dir.mkdir() + shim = shim_dir / "python" + shim.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8") + shim.chmod(0o755) + + env = dict(os.environ) + env["PATH"] = f"{shim_dir}{os.pathsep}{env.get('PATH', '')}" + return env + + +def _run_verify(script: Path, case_dir: Path, env: dict[str, str]) -> subprocess.CompletedProcess: + """在 ``case_dir`` 下运行校验脚本(与 CI 一样,cwd 即工作区根)。""" + return subprocess.run( + ["bash", str(script)], + cwd=case_dir, + env=env, + capture_output=True, + text=True, + timeout=180, + check=False, + ) + + +def _make_tar(case_dir: Path, name: str, *, with_env: bool, with_cache: bool) -> Path: + """按 download-artifact 的嵌套布局造一个归档:release_artifacts//。""" + staging = case_dir / "staging" / name + (staging / "PyRAG-Kit" / "tiktoken_cache").mkdir(parents=True) + (staging / "PyRAG-Kit" / "tiktoken_cache" / "blob").write_text("bpe", encoding="utf-8") + (staging / "PyRAG-Kit" / "VERSION.txt").write_text("1.4.0\n", encoding="utf-8") + if with_env: + (staging / ".env").write_text("SECRET=x\n", encoding="utf-8") + + target_dir = case_dir / "release_artifacts" / "macos-x64" + target_dir.mkdir(parents=True) + archive = target_dir / f"{name}.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + if with_cache: + tar.add(staging, arcname=name) + else: + # 只收 VERSION.txt:模拟构建脚本漏掉 BPE 预置的产物。 + tar.add(staging / "PyRAG-Kit" / "VERSION.txt", arcname=f"{name}/PyRAG-Kit/VERSION.txt") + return archive + + +def _make_zip(case_dir: Path, name: str, *, with_env: bool) -> Path: + target_dir = case_dir / "release_artifacts" / "windows-x64" + target_dir.mkdir(parents=True) + archive = target_dir / f"{name}.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr(f"{name}/PyRAG-Kit/tiktoken_cache/blob", "bpe") + if with_env: + zf.writestr(f"{name}/.env", "SECRET=x\n") + return archive + + +def test_verify_step_accepts_a_clean_archive(tmp_path, verify_script, bash_env): + _make_tar(tmp_path, "PyRAG-Kit-1.4.0-macos-x64", with_env=False, with_cache=True) + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 0, result.stderr + assert "归档校验通过" in result.stdout + + +def test_verify_step_rejects_archives_that_contain_credentials(tmp_path, verify_script, bash_env): + _make_tar(tmp_path, "PyRAG-Kit-1.4.0-macos-x64", with_env=True, with_cache=True) + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 1 + assert "归档内含凭证文件" in result.stderr + assert ".env" in result.stderr + + +def test_verify_step_rejects_archives_without_the_offline_cache(tmp_path, verify_script, bash_env): + _make_tar(tmp_path, "PyRAG-Kit-1.4.0-macos-x64", with_env=False, with_cache=False) + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 1 + assert "缺少 tiktoken_cache" in result.stderr + + +def test_verify_step_detects_credentials_in_a_large_archive(tmp_path, verify_script, bash_env): + """归档列表超过管道缓冲区时,凭证检查必须仍然命中。 + + 原实现写的是 `printf ... | grep -q ...`:`grep -q` 命中即退出,上游 + `printf` 收到 SIGPIPE 后以 141 退出,`set -o pipefail` 让整条管道返回 141, + 而 `if` 只把 0 当成功——于是**命中被判成未命中**,含 `.env` 的包被放行发布。 + + 触发条件是列表超过管道缓冲区(Linux 上 64 KiB)。真实 v1.4.0 产物的列表 + 已经是 75–79 KB,因此这条检查自发布起就一直失效。这里的归档刻意造出足够 + 多的条目:小归档(其它用例)永远测不到这条路径。 + """ + staging = tmp_path / "staging" / "big" + cache_dir = staging / "PyRAG-Kit" / "tiktoken_cache" + cache_dir.mkdir(parents=True) + (cache_dir / "blob").write_text("bpe", encoding="utf-8") + (staging / "PyRAG-Kit" / "VERSION.txt").write_text("1.4.0\n", encoding="utf-8") + (staging / ".env").write_text("SECRET=x\n", encoding="utf-8") + # 让 namelist 超过 64 KiB:每条目约 40 字节,6000 条约 240 KB。 + filler = staging / "PyRAG-Kit" / "filler" + filler.mkdir() + for index in range(6000): + (filler / f"entry_{index:05d}.txt").write_text("x", encoding="utf-8") + + target_dir = tmp_path / "release_artifacts" / "macos-x64" + target_dir.mkdir(parents=True) + archive = target_dir / "PyRAG-Kit-1.4.0-macos-x64.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(staging, arcname="big") + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode != 0, result.stdout + result.stderr + assert "归档内含凭证文件" in result.stderr + + +def test_verify_step_accepts_a_large_archive_that_has_the_cache(tmp_path, verify_script, bash_env): + """同一形态的干净大归档必须通过——否则「凭证检查」可能是靠误报变红的。 + + 这条是上一条的反向守卫:去掉 `-q` 之后,`tiktoken_cache` 检查若仍用管道, + 会因 SIGPIPE 误报缺失,把正常包拦下。两条一起才能钉住「读完整输入」。 + """ + staging = tmp_path / "staging" / "big" + cache_dir = staging / "PyRAG-Kit" / "tiktoken_cache" + cache_dir.mkdir(parents=True) + (cache_dir / "blob").write_text("bpe", encoding="utf-8") + (staging / "PyRAG-Kit" / "VERSION.txt").write_text("1.4.0\n", encoding="utf-8") + filler = staging / "PyRAG-Kit" / "filler" + filler.mkdir() + for index in range(6000): + (filler / f"entry_{index:05d}.txt").write_text("x", encoding="utf-8") + + target_dir = tmp_path / "release_artifacts" / "macos-x64" + target_dir.mkdir(parents=True) + archive = target_dir / "PyRAG-Kit-1.4.0-macos-x64.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(staging, arcname="big") + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 0, result.stdout + result.stderr + assert "归档校验通过" in result.stdout + + +def test_verify_step_fails_when_no_archive_was_downloaded(tmp_path, verify_script, bash_env): + """下载步骤失败时不能静默通过——空集必须报错,否则整步等于没跑。""" + (tmp_path / "release_artifacts").mkdir() + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 1 + assert "未找到任何归档" in result.stderr + + +def test_verify_step_reads_the_nested_download_layout(tmp_path, verify_script, bash_env): + """归档在 ``release_artifacts//`` 下(download-artifact 的布局)。 + + 用 ``release_artifacts/*.tar.gz`` 这样的单层 glob 会一个都匹配不到, + 循环体不执行、fail 保持 0,整步静默通过——这正是本用例要钉住的行为。 + """ + archive = _make_tar(tmp_path, "PyRAG-Kit-1.4.0-macos-x64", with_env=False, with_cache=True) + assert archive.parent.parent.name == "release_artifacts" + assert not list((tmp_path / "release_artifacts").glob("*.tar.gz")), ( + "单层 glob 本就匹配不到,正是旧实现静默通过的原因" + ) + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 0, result.stderr + assert "归档校验通过(1 个)" in result.stdout + + +def test_verify_step_checks_zip_archives_too(tmp_path, verify_script, bash_env): + _make_zip(tmp_path, "PyRAG-Kit-1.4.0-windows-x64", with_env=True) + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 1 + assert "归档内含凭证文件" in result.stderr + + +def test_verify_step_accepts_a_clean_zip(tmp_path, verify_script, bash_env): + _make_zip(tmp_path, "PyRAG-Kit-1.4.0-windows-x64", with_env=False) + + result = _run_verify(verify_script, tmp_path, bash_env) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(_HASH_TOOL is None, reason="本机既无 sha256sum 也无 shasum") +def test_checksums_land_where_publish_looks_and_use_basenames(tmp_path, bash_env): + """校验和必须写进 ``release_artifacts/``,且条目只带文件名。 + + 两点都会让用户白拿一份对不上号的 SHA256SUMS:写到工作区根目录时 + publish 步骤按前缀查找、根本不会上传它;条目带 ``/`` 前缀时, + 用户把单个归档下载下来后 ``shasum -c`` 找不到文件。 + """ + archive = _make_tar(tmp_path, "PyRAG-Kit-1.4.0-macos-x64", with_env=False, with_cache=True) + script = tmp_path / "sums.sh" + script.write_text(_extract_run_step("publish", "Generate checksums"), encoding="utf-8") + + result = subprocess.run( + ["bash", str(script)], + cwd=tmp_path, + env=bash_env, + capture_output=True, + text=True, + timeout=180, + check=False, + ) + + assert result.returncode == 0, result.stderr + sums = tmp_path / "release_artifacts" / "SHA256SUMS.txt" + assert sums.is_file(), "校验和必须落在 publish 步骤查找的目录内" + + lines = [line for line in sums.read_text(encoding="utf-8").splitlines() if line.strip()] + assert len(lines) == 1 + digest, _, filename = lines[0].partition(" ") + assert filename == archive.name + assert "/" not in filename + + # 摘要必须与归档实际内容一致——文件名对了但摘要错了同样是坏产物。 + import hashlib + + assert digest == hashlib.sha256(archive.read_bytes()).hexdigest() + + # 用户视角:把归档与校验和放在同一目录,`shasum -c` 必须通过。 + user_dir = tmp_path / "download" + user_dir.mkdir() + shutil.copy(archive, user_dir / archive.name) + shutil.copy(sums, user_dir / "SHA256SUMS.txt") + check = subprocess.run( + [_HASH_TOOL, "-c", "SHA256SUMS.txt"], + cwd=user_dir, + capture_output=True, + text=True, + check=False, + ) + assert check.returncode == 0, check.stdout + check.stderr + assert "OK" in check.stdout diff --git a/tests/ui/test_config_menu.py b/tests/ui/test_config_menu.py new file mode 100644 index 0000000..8ae61a4 --- /dev/null +++ b/tests/ui/test_config_menu.py @@ -0,0 +1,1207 @@ +"""配置编辑器的上界必须与 `SessionConfig`/`Settings` 的守卫一致。 + +UI 的校验器如果只按单字段上界放行,用户设一个「字段合法、组合越界」的值后, +`__setitem__` 会在应用配置时抛 `ValueError`——而该异常落在 chat 主循环之外 +(主循环只捕获 `KeyboardInterrupt`/`EOFError`,外层 `try` 只有 `finally`), +会以裸 traceback 终止整个会话。 +""" + +from __future__ import annotations + +import pytest +import questionary + +from src.runtime.contracts import RetrievalMethod, SessionConfig, build_session_config +from src.ui.config_menu import _bounded_int_validator, _multiplier_ceiling, _top_k_ceiling +from src.utils.config import ( + _MAX_RETRIEVAL_CANDIDATES, + _MAX_RETRIEVAL_MULTIPLIER, + _MAX_RETRIEVAL_TOP_K, + Settings, +) + + +@pytest.fixture +def session_config() -> SessionConfig: + return build_session_config(Settings(_env_file=None)) + + +@pytest.mark.parametrize("multiplier", [1, 3, 20, 100]) +def test_top_k_ceiling_is_accepted_by_session_config(session_config, multiplier): + """UI 给出的 top_k 上界,配上当前倍率必须能被 ``SessionConfig`` 接受。""" + ceiling = _top_k_ceiling(multiplier) + + config = SessionConfig( + **{ + **session_config.to_dict(), + "top_k": ceiling, + "retrieval_candidate_multiplier": multiplier, + } + ) + + assert config["top_k"] == ceiling + + +@pytest.mark.parametrize("top_k", [1, 5, 100, 2000]) +def test_multiplier_ceiling_is_accepted_by_session_config(session_config, top_k): + """UI 给出的倍率上界,配上当前 top_k 必须能被 ``SessionConfig`` 接受。""" + ceiling = _multiplier_ceiling(top_k) + + config = SessionConfig( + **{ + **session_config.to_dict(), + "top_k": top_k, + "retrieval_candidate_multiplier": ceiling, + } + ) + + assert config["retrieval_candidate_multiplier"] == ceiling + + +def test_ceilings_never_exceed_the_individual_bounds(): + """上界不得高于字段自身的上界。""" + assert _top_k_ceiling(1) <= _MAX_RETRIEVAL_TOP_K + assert _multiplier_ceiling(1) <= _MAX_RETRIEVAL_MULTIPLIER + + +def test_ceilings_shrink_as_the_other_field_grows(): + """另一字段越大,本字段的上界越小——否则乘积会越界。""" + assert _top_k_ceiling(100) < _top_k_ceiling(1) + assert _multiplier_ceiling(2000) < _multiplier_ceiling(1) + + +def test_ceiling_stays_usable_when_the_other_field_is_at_its_bound(): + """另一字段取到上界时,本字段仍须至少能取到 1。 + + 取 0 会让 UI 校验器无条件拒绝,用户被卡在一个改不动的字段上。 + """ + assert _top_k_ceiling(_MAX_RETRIEVAL_CANDIDATES) >= 1 + assert _multiplier_ceiling(_MAX_RETRIEVAL_CANDIDATES) >= 1 + + +# ── 回归:输入框校验器不得把异常抛给 prompt_toolkit 的事件循环 ── +# +# questionary 的 ``build_validator`` 只把 ``ValidationError`` 转成「输入无效」 +# 提示;校验器抛出的其它异常会一路冒到 prompt_toolkit 的事件循环之外。用户按下 +# 回车后看到的是 ``Unhandled exception in event loop`` 加一段 traceback,输入框 +# 不再响应——而触发它的只是一个上标数字。 +# +# 根因是 ``text.isdigit()``:它对 128 个 Unicode 码位为真(``²`` ``①`` ``፩`` +# ``₅`` …),而 ``int()`` 对它们一律抛 ``ValueError``。实测(真实 PromptSession +# + 管道输入)输入 ``²`` 再回车,异常从 +# ``prompt_toolkit/shortcuts/prompt.py::_accept_input`` → +# ``buffer.validate_and_handle`` → ``questionary/prompts/common.py:571`` 抛出。 +# +# 全码位穷举:``isdigit()`` 为真的 788 个码位里 128 个 ``int()`` 抛错; +# ``isdecimal()`` 为真的 660 个码位 ``int()`` 全部可解析。 + + +@pytest.mark.parametrize( + "text", + ["²", "¹", "①", "፹", "₅", "⑴", "²²", "5²"], +) +def test_bounded_int_validator_does_not_raise_for_unparseable_digits(text): + """校验器对任何输入都必须返回布尔值,绝不能抛异常。""" + validate = _bounded_int_validator(_top_k_ceiling(3)) + + assert validate(text) is False + + +@pytest.mark.parametrize("text", ["٣", "12", "۷"]) +def test_bounded_int_validator_accepts_parseable_unicode_digits(text): + """收紧不能误伤:``int()`` 真能解析的 Unicode 十进制数字照常放行。""" + validate = _bounded_int_validator(20) + + assert validate(text) is True + + +def test_bounded_int_validator_keeps_the_original_bounds(): + """正常输入的接受/拒绝边界不得改变。""" + validate = _bounded_int_validator(5) + + assert validate("1") is True + assert validate("5") is True + assert validate("6") is False + assert validate("0") is False + assert validate("abc") is False + assert validate("") is False + + +def test_bounded_int_validator_returns_bool_not_truthy(): + """questionary 只把 ``False`` 当作「无效」;返回非布尔真值会被当成通过。 + + 见 ``questionary/prompts/common.py`` 的 ``_InputValidator``:``if verdict is + not True:``——返回 ``1`` 之类会被当成「不是 True」而报无效,返回 ``0`` 则 + 恰好等价于 False。这里要求严格返回 ``bool``,避免依赖这种巧合。 + """ + validate = _bounded_int_validator(5) + + for text in ["3", "9", "²", "abc", ""]: + assert type(validate(text)) is bool, f"{text!r} 返回了非布尔值" + + +def test_bounded_int_validator_survives_digit_strings_beyond_the_conversion_limit(): + """超长数字串必须被拒绝,而不是抛 ``ValueError``。 + + ``isdecimal()`` 为真不代表 ``int()`` 能解析:``"9" * 4301`` 是合法十进制整数, + 但位数超过 ``sys.get_int_max_str_digits()``(默认 4300),``int()`` 照样抛 + ``ValueError``。这类异常同样会冒到 prompt_toolkit 事件循环之外——把上标数字 + 换成「粘贴一长串数字」,收场完全一样。 + """ + import sys + + validate = _bounded_int_validator(_top_k_ceiling(3)) + + assert validate("9" * (sys.get_int_max_str_digits() + 1)) is False + assert validate("0" * 5000 + "1") is False + # 恰好在上限内且超过 ceiling 的值同样只是「被拒」,不是崩溃 + assert validate("9" * 100) is False + + +# ── 回归:菜单与提示里的取值必须可渲染 ── +# +# ``edit_retrieval_params`` 直接把 ``chat_config`` 的字段插进 questionary 的 +# 菜单项与提示串。``SessionConfig`` 是公开类、普通属性没有 ``__setattr__`` 守卫, +# 嵌入方(或携带超大整数的 pickle 快照)可以直写 ``cfg.top_k = 10**4301``, +# 随后 ``chat_config["top_k"]`` 就是这个值。裸插值会抛 +# ``ValueError: Exceeds the limit``——用户看到的是整数转换错误,而且异常落在 +# questionary 渲染阶段,菜单根本显示不出来。 +# +# 可达性已核实:这些取值全部经 ``chat_config[...]`` 读取,与 +# ``SessionConfig`` 的「属性直写可绕过 validator」是同一条路径 +# (见 ``_require_finite_number`` 的 docstring)。 +# 权重字段与 top_k/倍率同源:``SessionConfig`` 的属性没有 ``__setattr__`` +# 守卫,直写后菜单串、提示串、``default=`` 与 logger 四处都会裸插值。 +# 前三个在 questionary 渲染阶段抛 ``ValueError: Exceeds the limit``,菜单 +# 根本显示不出来;logger 那处在记录日志时抛。 +@pytest.mark.parametrize( + ("field", "label"), + [ + ("vector_weight", "向量"), + ("keyword_weight", "关键词"), + ], +) +def test_weight_renderings_survive_a_directly_written_value(field, label): + from src.utils.config import safe_repr + + huge = 10**4301 + + # 与 config_menu 里的拼装方式一致;裸插值在这里就会抛。 + menu = f"向量: {safe_repr(huge)} / 关键词: {safe_repr(huge)}" + prompt = f"输入新的向量权重 (0.0-1.0, 当前: {safe_repr(huge)}):" + default = safe_repr(huge) + + for text in (menu, prompt, default): + assert "Exceeds the limit" not in text + assert "位十进制" in text + assert label in menu + + +def test_weight_renderings_keep_small_values_verbatim(): + """收紧不能误伤:正常权重照旧原样显示,不加引号、不截断。""" + from src.utils.config import safe_repr + + assert safe_repr(0.7) == "0.7" + assert safe_repr(0.3) == "0.3" + assert f"向量: {safe_repr(0.7)} / 关键词: {safe_repr(0.3)}" == "向量: 0.7 / 关键词: 0.3" + + +def test_menu_labels_render_values_beyond_the_conversion_limit(): + """菜单项与提示串里的取值都要经 ``safe_repr``,超限整数照常显示。""" + from src.utils.config import safe_repr + + huge = 10**4301 + + # 与 config_menu 里的拼装方式一致;裸插值在这里就会抛。 + labels = [ + f"2. 检索数量 Top K (当前: {safe_repr(huge)})", + f"6. 候选过量招募倍率 (当前: {safe_repr(huge)})", + f"输入新的Top K值 (1-{safe_repr(_top_k_ceiling(3))}, 当前: {safe_repr(huge)}):", + f"输入候选过量招募倍率 (1-{safe_repr(_multiplier_ceiling(5))}, 当前: {safe_repr(huge)}):", + ] + + for label in labels: + assert "Exceeds the limit" not in label + assert "位十进制" in label + + +def test_menu_label_renderer_is_not_the_naive_interpolation(): + """反向守卫:裸插值确实会抛——否则上面那条断言证明不了什么。""" + huge = 10**4301 + + with pytest.raises(ValueError, match="Exceeds the limit"): + f"当前: {huge}" + + +def test_ceilings_stay_usable_across_every_legal_config_value(): + """在 ``SessionConfig`` 允许的取值范围内,两个上界都不得退化成 0。 + + 上界为 0 会让编辑器拒绝一切输入(区间 ``[1, 0]`` 为空),用户无从下手。 + 实测:合法范围内 ``top_k`` 上界最小 20、倍率上界最小 1,因此不会退化。 + + 被直写的越界倍率(如 ``10**4301``)确实会让上界算成 0,但**有意不修**: + 把上界钳到 1 反而允许用户提交一个乘法积越界的组合,使 ``__setitem__`` + 抛 ``ValueError`` 冲穿 chat 主循环——比「编辑器拒绝输入」更糟。此处只锁 + 「合法范围不退化为 0」这一真实不变量。 + """ + from src.utils.config import ( + _MAX_RETRIEVAL_MULTIPLIER, + _MAX_RETRIEVAL_TOP_K, + ) + + assert min(_top_k_ceiling(m) for m in range(1, _MAX_RETRIEVAL_MULTIPLIER + 1)) >= 1 + assert min(_multiplier_ceiling(k) for k in range(1, _MAX_RETRIEVAL_TOP_K + 1)) >= 1 + + +# ── 回归:模型菜单必须能对不可用的配置**取值**保持可用 ── +# +# `edit_model_params` 原先直接写 `list(chat_config["llm_configurations"].keys())`。 +# `SessionConfig._VALIDATORS` 明确不登记 `*_configurations`(「按原样写入,由 +# 使用方负责其内部结构」),嵌入方可以把它直写成 `5`,于是抛裸 +# `AttributeError: 'int' object has no attribute 'keys'`——不是 `ValueError`, +# 会以裸 traceback 终止会话。 +# +# 关键取舍:菜单**只判容器、不判取值**。`display_chat_config` 在上游读值时会 +# 直接拒绝形态坏掉的配置项,菜单是用户唯一还能把激活项切走的入口;如果这里 +# 也按取值判,配置一旦写坏就彻底没有修复路径了。 + + +class _Choice: + def __init__(self, value): + self._value = value + + def ask(self): + return self._value + + +def _fake_select(answers): + """返回一个按顺序吐答案的 ``questionary.select`` 替身。 + + 真实调用点把**菜单项**作为位置参数传入,替身必须收下(否则 + ``questionary.Choice`` 的求值路径根本没被覆盖到)。 + """ + queue = list(answers) + + def select(_prompt, *_args, **_kwargs): + return _Choice(queue.pop(0) if queue else None) + + return select + + +def test_edit_model_params_reports_non_mapping_llm_configurations(monkeypatch): + from src.ui import config_menu + + monkeypatch.setattr(config_menu.questionary, "select", _fake_select(["llm"])) + chat_config = { + "llm_configurations": 5, + "active_llm_configuration": "broken", + "active_rerank_configuration": "", + } + + with pytest.raises(ValueError, match="LLM配置"): + config_menu.edit_model_params(chat_config) + + +def test_edit_model_params_reports_non_mapping_rerank_configurations(monkeypatch): + from src.ui import config_menu + + monkeypatch.setattr(config_menu.questionary, "select", _fake_select(["rerank"])) + chat_config = { + "rerank_configurations": "x", + "active_llm_configuration": "", + "active_rerank_configuration": "broken", + } + + with pytest.raises(ValueError, match="Rerank配置"): + config_menu.edit_model_params(chat_config) + + +def test_edit_model_params_still_lists_configuration_keys(monkeypatch): + """收紧不能误伤:正常映射照常列出键并完成切换。""" + from src.ui import config_menu + + prompts: list[str] = [] + # 第一轮进 "llm" 分支、选到新的键;第二轮退出。主菜单必须真的退出, + # 否则 `while True` 会一直转(替身返回同一个答案就会死循环)。 + answers = iter(["llm", "b", "back"]) + + def select(prompt, choices=None, **_kwargs): + prompts.append(prompt) + if prompt == "选择新的LLM配置:": + # 调用点把配置键列表原样当候选传入 + assert [c.value if hasattr(c, "value") else c for c in choices] == ["a", "b"] + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "llm_configurations": {"a": object(), "b": object()}, + "active_llm_configuration": "a", + "active_rerank_configuration": "", + } + + assert config_menu.edit_model_params(chat_config) is True + assert chat_config["active_llm_configuration"] == "b" + assert "选择新的LLM配置:" in prompts + + +def test_edit_model_params_lists_unusable_details_without_reading_them(monkeypatch): + """取舍锁死:取值不可用时菜单仍要列出键,不能连修复入口一起断掉。""" + from src.ui import config_menu + + def select(prompt, choices=None, **_kwargs): + if prompt == "选择新的LLM配置:": + # 坏条目必须仍然出现在候选里——这是它能被切走的唯一入口 + assert [c.value if hasattr(c, "value") else c for c in choices] == ["broken"] + return _Choice("broken") + return _Choice("back") + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "llm_configurations": {"broken": None}, + "active_llm_configuration": "broken", + "active_rerank_configuration": "", + } + + assert config_menu.edit_model_params(chat_config) is False + assert chat_config["active_llm_configuration"] == "broken" + + +# ── 回归:模型菜单的候选项必须能交给 questionary ── +# +# `edit_model_params` 把 `_configuration_keys` 返回的**原始键**直接当候选传给 +# `questionary.select`。`SessionConfig` 的 `_VALIDATORS` 明确不登记 +# `*_configurations`(「按原样写入,由使用方负责其内部结构」),嵌入方或携带 +# 异常取值的 pickle 快照可以放进任意可哈希键。questionary 对每个候选调 +# `choice.get(...)`(`Choice` 对象才有该方法),非字符串键——`int`、`tuple`、 +# 乃至 `None`——一律抛裸 `AttributeError: 'int' object has no attribute 'get'`, +# 菜单根本显示不出来。位数超限的整数则更早在渲染候选标题时抛 +# `ValueError: Exceeds the limit`。 +# +# 同一处还有第二条:`default=current_llm` 传的是**原始**激活键,而候选若被 +# 渲染成字符串标题,questionary 会拿它去候选集合里找,找不到就抛 +# `ValueError: Invalid 'default' value passed`——激活项被删掉、或它本身是位数 +# 超限的整数时都会命中。 +# +# 修法:候选一律包成 `questionary.Choice(title=_render_value(key), value=key)`, +# `default` 传原始键(`Choice.value` 就是原始键,能对上),并把不在候选集合里的 +# `default` 收敛成 `None`(questionary 的「未作答」哨兵,合法)。 + + +@pytest.mark.parametrize( + "key", + [ + pytest.param(1, id="int"), + pytest.param((1, 2), id="tuple"), + pytest.param(10**4301, id="huge-int"), + ], +) +def test_edit_model_params_selects_over_non_string_configuration_keys(monkeypatch, key): + """非字符串的配置键必须能进候选,而不是让 questionary 抛裸 ``AttributeError``。""" + from src.ui import config_menu + + seen: list[list] = [] + answers = iter(["llm", None, "back"]) + + def select(prompt, choices=None, **_kwargs): + if prompt == "选择新的LLM配置:": + # 真实 questionary 走 ``Choice.build``:``Choice`` 原样返回、``str`` + # 包成 ``Choice(c, c)``,**其余类型**才落到 ``c.get(...)`` 上抛裸 + # ``AttributeError``。替身按同一契约校验。 + seen.append(list(choices)) + for choice in choices: + assert isinstance(choice, (str, questionary.Choice)), ( + f"候选不是 questionary 能接受的形态: {choice!r}" + ) + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "llm_configurations": {key: None}, + "active_llm_configuration": key, + "active_rerank_configuration": "", + } + + assert config_menu.edit_model_params(chat_config) is False + assert seen, "菜单必须真的构造过候选" + + +def test_edit_model_params_survives_stale_active_configuration(monkeypatch): + """激活项不在候选集合里时,``default`` 不得让 questionary 抛 ``ValueError``。""" + from src.ui import config_menu + + answers = iter(["llm", None, "back"]) + + def select(prompt, choices=None, default=None, **_kwargs): + if prompt == "选择新的LLM配置:": + # 收敛后 default 要么是候选里的原始键,要么是 ``None``(未作答哨兵) + values = [c.value for c in choices] + assert default is None or default in values, (default, values) + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "llm_configurations": {"a": None, "b": None}, + # 激活项已被删掉:不在候选里 + "active_llm_configuration": "removed", + "active_rerank_configuration": "", + } + + assert config_menu.edit_model_params(chat_config) is False + + +# ── 回归:融合策略的 ``default=`` 必须落在候选集合里 ── +# +# `edit_retrieval_params` 的融合策略分支把 `chat_config["hybrid_fusion_strategy"]` +# **原始**取值当 `questionary.select(default=...)`。候选只有 `"rrf"`/`"weighted"` +# 两个字面量,而 `SessionConfig` 的普通属性没有 `__setattr__` 守卫,嵌入方或携带 +# 异常取值的 pickle 快照可以直写该字段。实测三种收场都不在契约内: +# +# `"RRF"`/`" rrf "`(大小写/空白未归一) -> questionary 拿它去候选集合里 +# 找不到,抛 `ValueError: Invalid 'default' value passed`; +# 位数超限的整数 -> 抛 `ValueError: Exceeds the limit`; +# 非字符串(`1`/`None`/`["RRF"]`) -> 同样落到 `Invalid 'default'`。 +# +# 菜单是用户唯一还能改配置的入口(`display_chat_config` 在上游读值时会直接拒绝), +# 而异常落在 `edit_retrieval_params` 内、由 `launch_config_editor` 经 +# `asyncio.to_thread` 调用——调用点只接 `(KeyboardInterrupt, EOFError)`,会终止 +# 整个会话。 +# +# 修法:`default=` 走与 `_choice_default` 同一判据——取值经 `_coerce_fusion_strategy` +# 归一后若在候选集合里就用它,否则退回 `None`(questionary 的「未作答」哨兵)。 + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("RRF", id="uppercase"), + pytest.param(" rrf ", id="padded"), + pytest.param(1, id="int"), + pytest.param(10**4301, id="huge-int"), + pytest.param(None, id="none"), + ], +) +def test_fusion_default_survives_unnormalized_or_non_string_values(monkeypatch, value): + """融合策略的 ``default=`` 必须是候选集合里的取值或 ``None``。""" + from src.ui import config_menu + + seen: list = [] + answers = iter(["fusion", None, "back"]) + + def select(prompt, choices=None, default=None, **_kwargs): + if prompt == "选择新的混合融合策略:": + values = [c.value for c in choices] + seen.append(default) + assert default is None or default in values, (default, values) + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": value, + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert seen, "菜单必须真的构造过融合策略候选" + + +def test_fusion_default_keeps_normalized_value(monkeypatch): + """收紧不能误伤:规范的 ``"rrf"`` 照旧作为默认项选中。""" + from src.ui import config_menu + + seen: list = [] + answers = iter(["fusion", None, "back"]) + + def select(prompt, choices=None, default=None, **_kwargs): + if prompt == "选择新的混合融合策略:": + seen.append(default) + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert seen == ["rrf"] + + +def test_weight_validator_rejects_non_numeric_and_out_of_range(monkeypatch): + """权重校验器:非数值与越界返回 False,合法值返回 True。 + + 删除 ``except ValueError: return False`` 后,``float("abc")`` 的 + ``ValueError`` 冒泡到 questionary 的 ``buffer.validate_and_handle`` + ——输入一个非数字会让整个菜单崩溃,而校验器的契约是「返回 False + 表示输入无效」。 + """ + from src.ui import config_menu + + captured = {} + + def text(prompt, validate=None, default=None, **_kwargs): + if "向量权重" in prompt: + captured["validate"] = validate + return _Choice(None) + + monkeypatch.setattr(config_menu.questionary, "text", text) + monkeypatch.setattr(config_menu.questionary, "select", _fake_select(["weights", "back"])) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + config_menu.edit_retrieval_params(chat_config) + + validate = captured["validate"] + assert validate("0.5") is True + assert validate("0") is True + assert validate("1") is True + assert validate("1.5") is False + assert validate("-0.1") is False + assert validate("abc") is False + assert validate("") is False + + +def test_edit_retrieval_params_writes_rerank_toggle(monkeypatch): + """确认框返回布尔时写回 ``rerank_enabled``(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后判据反转:用户确认「启用」时不再写回配置,只有 + 「未作答」(``None``)才写入——开关永远不生效。 + """ + from src.ui import config_menu + + answers = iter(["rerank", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.questionary, "confirm", lambda *_a, **_k: _Choice(True)) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert chat_config["rerank_enabled"] is True + + +def test_edit_model_params_writes_selected_rerank_configuration(monkeypatch): + """选中新的 Rerank 配置时写回激活项(``is not None`` 不是 ``is None``)。 + + ``is None`` 变异后判据反转:用户选中的配置不再写回,激活项保持原值—— + 菜单显示切换成功而配置没变。 + """ + from src.ui import config_menu + + answers = iter(["rerank", "b", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + chat_config = { + "llm_configurations": {"a": object()}, + "rerank_configurations": {"a": object(), "b": object()}, + "active_llm_configuration": "a", + "active_rerank_configuration": "a", + } + + config_menu.edit_model_params(chat_config) + + assert chat_config["active_rerank_configuration"] == "b" + + +# --- face 12(== ↔ != 互换扫描)存活位点 ------------------------------------- + + +def test_weights_warning_shown_only_outside_hybrid_mode(monkeypatch): + """权重提示只在非混合检索模式出现(``!= HYBRID_SEARCH`` 不是 ``==``)。 + + ``!=``→``==`` 变异后判据反转:混合模式下反而警告「权重仅在混合检索生效」, + 非混合模式静默——提示与事实相反。 + """ + from src.ui import config_menu + + def _run(method): + printed = [] + answers = iter(["weights", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.questionary, "text", lambda *_a, **_k: _Choice(None)) + monkeypatch.setattr(config_menu.console, "print", lambda *a, **k: printed.append(str(a))) + chat_config = { + "retrieval_method": method, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + config_menu.edit_retrieval_params(chat_config) + return any("权重调整仅在" in message for message in printed) + + assert _run(RetrievalMethod.SEMANTIC_SEARCH) is True + assert _run(RetrievalMethod.HYBRID_SEARCH) is False + + +def test_fusion_warning_shown_only_outside_hybrid_mode(monkeypatch): + """融合策略提示同判据(``!=``→``==`` 变异后提示与事实相反)。""" + from src.ui import config_menu + + def _run(method): + printed = [] + answers = iter(["fusion", "rrf", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.console, "print", lambda *a, **k: printed.append(str(a))) + chat_config = { + "retrieval_method": method, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + config_menu.edit_retrieval_params(chat_config) + return any("融合策略仅在" in message for message in printed) + + assert _run(RetrievalMethod.SEMANTIC_SEARCH) is True + assert _run(RetrievalMethod.HYBRID_SEARCH) is False + + +def test_candidate_multiplier_selection_writes_value(monkeypatch): + """选中「候选过量招募倍率」时写回配置(``elif choice == ...`` 不是 ``!=``)。 + + ``==``→``!=`` 变异后分支判据反转:该选项被静默跳过(倍率保持原值), + 其余任何取值反而落入倍率编辑分支——实测变异体倍率停留在 3。 + """ + from src.ui import config_menu + + answers = iter(["candidate_multiplier", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.questionary, "text", lambda *_a, **_k: _Choice("5")) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert chat_config["retrieval_candidate_multiplier"] == 5 + + +def test_exit_choice_returns_from_editor_only_on_exit(monkeypatch): + """主菜单仅在选「保存并返回聊天」(或未作答)时退出(``== "exit"``)。 + + ``==``→``!=`` 变异后判据反转:任何非 exit 选项立即结束编辑器——实测 + 选择「1. 调整检索参数」时编辑函数从未被调用。 + """ + from src.ui import config_menu + + calls = [] + answers = iter(["1. 调整检索参数", "exit"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.console, "print", lambda *a, **k: None) + monkeypatch.setattr(config_menu, "edit_retrieval_params", lambda cfg: calls.append(cfg)) + + result = config_menu.launch_config_editor({}) + + assert len(calls) == 1 + assert result == (False, {}) + + +def test_model_switch_marks_llm_for_reload(monkeypatch): + """主菜单里选「切换模型」且模型确实改变时,必须返回 ``llm_needs_reload=True``。 + + ``llm_needs_reload = True`` 反向成 ``False`` 后,编辑器把「LLM 已更换」这件事 + 吞掉:``launch_config_editor`` 的返回值是调用方(``chat.core``)决定是否 + ``reload_llm()`` 的唯一依据,返回 False 时旧模型继续服务——用户在菜单里换了 + 模型,聊天却仍在用上一个。既有的主菜单用例只覆盖「调整检索参数」这条不触发 + 重载的分支。 + """ + from src.ui import config_menu + + answers = iter(["2. 切换模型", "exit"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.console, "print", lambda *a, **k: None) + # ``edit_model_params`` 返回「模型是否改变」——这里让它报告已改变。 + monkeypatch.setattr(config_menu, "edit_model_params", lambda _cfg: True) + + result = config_menu.launch_config_editor({}) + + assert result[0] is True, "切换模型后必须标记需要重载 LLM" + + +def test_retrieval_menu_loops_until_back_is_chosen(monkeypatch): + """``edit_retrieval_params`` 的循环必须真的循环(``while True``)。 + + 反向成 ``while False`` 后循环体一次都不执行:菜单直接返回,任何编辑都无效 + ——用户改完 Top K 再返回主菜单,配置一个字没变,而界面上没有任何提示。 + 既有的检索菜单用例只走「进入一次即 back」的路径,从不需要第二次迭代。 + """ + from src.ui import config_menu + + answers = iter(["top_k", "back", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.questionary, "text", lambda *_a, **_k: _Choice("7")) + monkeypatch.setattr(config_menu.console, "print", lambda *a, **k: None) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert chat_config["top_k"] == 7, "循环未执行,编辑没有落盘" + + +# ── 回归:权重编辑必须写回补数(``keyword = 1 - vector``,不是 ``1 + vector``)── +# +# ``arith`` 变异面扫出的缺口:``edit_retrieval_params`` 的 ``weights`` 分支把 +# ``new_keyword_weight = 1.0 - new_vector_weight`` 写回配置,而既有的 weights +# 用例一律让 ``questionary.text`` 返回 ``_Choice(None)``(空输入 → 不写回), +# 从未提交过真实权重值——把 ``-`` 变异成 ``+`` 后整套测试仍全绿,而用户把向量 +# 权重设成 0.2 时关键词权重会变成 1.2(越过 ``SessionConfig`` 的 0..1 上界)。 +def test_weights_selection_writes_complement_to_keyword_weight(monkeypatch): + """提交向量权重 0.2 时,关键词权重写回 ``0.8``(补数),不是 ``1.2``。""" + from src.ui import config_menu + + answers = iter(["weights", "back"]) + + def _select(*_args, **_kwargs): + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.questionary, "text", lambda *_a, **_k: _Choice("0.2")) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert chat_config["vector_weight"] == 0.2 + assert chat_config["keyword_weight"] == 0.8 + + +def test_weights_edge_zero_vector_writes_keyword_first(monkeypatch): + """向量权重改 0 时先写 keyword 侧——组合守卫按中间态判,固定先写 + vector 会拒绝合法终态 (0.0, 1.0)。 + + 实测复现:默认 (1.0, 0.0) 会话里用户把向量权重改成 0(切纯关键词检索), + 原顺序报「vector_weight 与 keyword_weight 不能同时为 0」——终态完全合法, + 中间态 (0.0, 0.0) 撞上了双 0 守卫。两个边界方向都要断言。 + """ + from src.ui import config_menu + + cases = [ + # (初始 vector, 初始 keyword, 输入, 期望 (vector, keyword)) + (1.0, 0.0, "0", (0.0, 1.0)), # 默认组合切纯关键词——缺陷方向 + (0.0, 1.0, "1", (1.0, 0.0)), # 反向:纯关键词切回 + (0.5, 0.5, "0.3", (0.3, 0.7)), # 常规路径不回归 + ] + for initial_vector, initial_keyword, answer, expected in cases: + answers = iter(["weights", "back"]) + + def _select(prompt, *, _answers=answers, **_kwargs): + return _Choice(next(_answers)) + + def _text(prompt, *, _answer=answer, **_kwargs): + return _Choice(_answer) + + monkeypatch.setattr(config_menu.questionary, "select", _select) + monkeypatch.setattr(config_menu.questionary, "text", _text) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": initial_vector, + "keyword_weight": initial_keyword, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert (chat_config["vector_weight"], chat_config["keyword_weight"]) == expected, ( + f"初始 ({initial_vector}, {initial_keyword}) 输入 {answer}" + ) + + +def test_menu_shows_unknown_for_invalid_retrieval_method(monkeypatch): + """非法 ``retrieval_method`` 的菜单项显示「未知」,与 Rerank 项同一契约。 + + 实测:直写 ``retrieval_method = "bogus"`` 后打开菜单,修复前第一项渲染成 + ``1. 检索模式 (当前: None)``——裸插值把 ``None`` 直接丢给了用户,而下一项 + ``_rerank_menu_label`` 对同一类非法取值刻意显示「未知」。两个相邻菜单项 + 必须对同一契约给出同一种显示。 + """ + seen: list = [] + answers = iter([None, "back"]) + + def select(_prompt, choices=None, default=None, **_kwargs): + for choice in choices or []: + seen.append(choice.title if hasattr(choice, "title") else str(choice)) + return _Choice(next(answers)) + + from src.ui import config_menu + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": "bogus", + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + method_item = next(item for item in seen if "检索模式" in str(item)) + assert "未知" in str(method_item) + assert "None" not in str(method_item) + + +def test_method_submenu_default_is_valid_when_method_is_invalid(monkeypatch): + """非法 ``retrieval_method`` 时 method 子菜单的 ``default`` 必须收敛。 + + 「未知」占位串传给 ``questionary.select(default=...)`` 会在**构造期**抛 + ``ValueError: Invalid 'default' value``(实测逐字复现),整个编辑器退出 + ——method 分支是唯一能写 ``retrieval_method`` 的入口,恰是该字段唯一还能 + 被修回合法值的路径。替身校验 ``default`` 必须在候选值集合内或为 ``None``。 + """ + from src.ui import config_menu + + seen_defaults: list = [] + answers = iter(["method", None, "back"]) + + def select(_prompt, choices=None, default=None, **_kwargs): + if _prompt == "选择新的检索模式:": + seen_defaults.append(default) + choice_values = {c.value for c in choices} + if default is not None and default not in choice_values: + raise ValueError( + f"Invalid `default` value passed. The value (`{default}`) " + "does not exist in the set of choices." + ) + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": "bogus", + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert seen_defaults == [None] # 非法时传 None,不预选 + + +def test_method_submenu_default_keeps_valid_method(monkeypatch): + """合法 method 照旧预选,收紧不能误伤。""" + from src.ui import config_menu + from src.utils.config import RetrievalMethod + + seen_defaults: list = [] + answers = iter(["method", None, "back"]) + + def select(_prompt, choices=None, default=None, **_kwargs): + if _prompt == "选择新的检索模式:": + seen_defaults.append(default) + return _Choice(next(answers)) + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + assert seen_defaults == [RetrievalMethod.HYBRID_SEARCH.value] + + +def test_menu_shows_weighted_verbatim(monkeypatch): + """收紧不能误伤:合法默认 ``weighted`` 照常显示,不得被「未知」吞掉。 + + 显示白名单曾误写 ``("rrf", "weight")``——合法默认 ``weighted`` 在菜单 + 显示「未知」(实测复现)。合法字面量以 ``SessionConfig._validate_fusion_strategy`` + 与 questionary Choice value 为准。 + """ + seen: list = [] + answers = iter([None, "back"]) + + def select(_prompt, choices=None, default=None, **_kwargs): + for choice in choices or []: + seen.append(choice.title if hasattr(choice, "title") else str(choice)) + return _Choice(next(answers)) + + from src.ui import config_menu + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "weighted", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + fusion_item = next(item for item in seen if "融合策略" in str(item)) + assert "weighted" in str(fusion_item) + assert "未知" not in str(fusion_item) + + +def test_menu_shows_unknown_for_invalid_fusion_strategy(monkeypatch): + """非法 ``hybrid_fusion_strategy``(如直写 ``None``)的菜单项显示「未知」。 + + 修复前 ``_render_value(...).upper()`` 让 ``None`` 渲染成 ``当前: NONE``—— + 与 method/rerank 项对同一类非法取值统一的「未知」不一致(第三轮统一了 + 两处,本项漏网)。 + """ + seen: list = [] + answers = iter([None, "back"]) + + def select(_prompt, choices=None, default=None, **_kwargs): + for choice in choices or []: + seen.append(choice.title if hasattr(choice, "title") else str(choice)) + return _Choice(next(answers)) + + from src.ui import config_menu + + monkeypatch.setattr(config_menu.questionary, "select", select) + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": None, + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + } + + config_menu.edit_retrieval_params(chat_config) + + fusion_item = next(item for item in seen if "融合策略" in str(item)) + assert "未知" in str(fusion_item) + assert "NONE" not in str(fusion_item) + + +def test_editor_converges_eof_to_save_and_return(monkeypatch): + """Ctrl+D/EOF 在任意编辑器层收敛为「按原样保存返回」,不终止聊天会话。 + + ``questionary`` 的 ``.ask()`` 只捕 ``KeyboardInterrupt``(Ctrl+C → None → + 退一层);EOF 抛 ``EOFError`` 直接穿透——它经 ``asyncio.to_thread`` 到达 + 聊天主循环的 ``except (KeyboardInterrupt, EOFError): break``,在最深的 + Top K 输入框按一次 Ctrl+D 就终止整个会话(实测复现)。 + """ + from src.ui import config_menu + from src.utils.config import RetrievalMethod + + class EOFAsk: + def __init__(self, *_args, **_kwargs): + pass + + def ask(self): + raise EOFError() + + monkeypatch.setattr(config_menu.questionary, "select", EOFAsk) + monkeypatch.setattr(config_menu.questionary, "Separator", lambda *a, **k: object()) + + chat_config = { + "retrieval_method": RetrievalMethod.HYBRID_SEARCH, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 3, + "top_k": 5, + "vector_weight": 0.5, + "keyword_weight": 0.5, + "rerank_enabled": False, + "active_llm_configuration": "old", + "llm_configurations": {"old": object()}, + } + + llm_needs_reload, returned = config_menu.launch_config_editor(chat_config) + + assert llm_needs_reload is False + assert returned is chat_config # 配置按原样返回(未生效任何改动) + + +def test_editor_eof_reverts_partial_edits(monkeypatch): + """EOF 前已确认写回的编辑必须被还原——「按原样保存返回」的承诺要成立。 + + 编辑器全程原地修改 ``chat_config``(``/config`` 入口随后把它应用到 + bot)。EOF 前成功写入的每一项原本都会留在字典里随返回生效,且 reload + 标志只在 ``edit_model_params`` 正常走完才置位——若不还原,EOF 前切换过 + LLM 的残留键会以「已切换但未重载」的形态继续服务(实测复现)。 + """ + from src.ui import config_menu + + _EOF_SENTINEL = object() + + # 主菜单 → 检索参数 → top_k → 输入 7(写回)→ EOF。 + # 替身必须按调用序出队:恒返回同一值的替身会让子菜单循环永远拿到 + # "top_k" 而无限旋转(实测挂死),与真实交互序列不符。 + sequence = [ + "1. 调整检索参数", # 主菜单 + "top_k", # 检索参数子菜单 + "7", # Top K 输入框(text) + _EOF_SENTINEL, # 下一层任何 .ask() 抛 EOFError + ] + + class SequenceAsk: + def __init__(self, *_args, **_kwargs): + pass + + def ask(self): + value = sequence.pop(0) + if value is _EOF_SENTINEL: + raise EOFError() + return value + + monkeypatch.setattr(config_menu.questionary, "select", SequenceAsk) + monkeypatch.setattr(config_menu.questionary, "text", SequenceAsk) + monkeypatch.setattr(config_menu.questionary, "Separator", lambda *a, **k: object()) + + chat_config = { + "top_k": 5, + "retrieval_method": "vector", + "rerank_enabled": True, + "vector_weight": 0.7, + "keyword_weight": 0.3, + "hybrid_fusion_strategy": "rrf", + "retrieval_candidate_multiplier": 5, + "rerank_configurations": {}, + "llm_configurations": {}, + "active_llm_configuration": "a", + "active_rerank_configuration": "b", + } + + llm_needs_reload, returned = config_menu.launch_config_editor(chat_config) + + assert llm_needs_reload is False + assert returned is chat_config + assert chat_config["top_k"] == 5 # EOF 前写入的 7 已还原 + assert set(chat_config) == { + "top_k", + "retrieval_method", + "rerank_enabled", + "vector_weight", + "keyword_weight", + "hybrid_fusion_strategy", + "retrieval_candidate_multiplier", + "rerank_configurations", + "llm_configurations", + "active_llm_configuration", + "active_rerank_configuration", + } # 键集合不变(无残留、无丢失) + + +def test_editor_keeps_reload_flag_through_normal_exit(monkeypatch): + """正常退出路径的重载标记不受 EOF 收敛重构影响。""" + + from src.ui import config_menu + + # 第一次 select 返回「切换模型」(edit_model_params 被替换、返回 True), + # 第二次返回 exit 正常保存。 + counter = {"n": 0} + + class SequenceAsk: + def __init__(self, *_args, **_kwargs): + pass + + def ask(self): + counter["n"] += 1 + if counter["n"] == 1: + return "2. 切换模型" + return "exit" + + monkeypatch.setattr(config_menu.questionary, "select", SequenceAsk) + monkeypatch.setattr(config_menu.questionary, "Separator", lambda *a, **k: object()) + monkeypatch.setattr(config_menu, "edit_model_params", lambda _cfg: True) + + chat_config = { + "active_llm_configuration": "old", + "llm_configurations": {"old": object()}, + } + llm_needs_reload, returned = config_menu.launch_config_editor(chat_config) + + assert llm_needs_reload is True + assert returned is chat_config